Merge lp:~blr/launchpad/bug-1472045-demangle-inlinecomment-mail into lp:launchpad

Proposed by Kit Randel
Status: Merged
Approved by: Kit Randel
Approved revision: no longer in the source branch.
Merged at revision: 17614
Proposed branch: lp:~blr/launchpad/bug-1472045-demangle-inlinecomment-mail
Merge into: lp:launchpad
Diff against target: 735 lines (+611/-18)
3 files modified
lib/lp/code/mail/codereviewcomment.py (+1/-1)
lib/lp/code/mail/patches.py (+513/-0)
lib/lp/code/mail/tests/test_codereviewcomment.py (+97/-17)
To merge this branch: bzr merge lp:~blr/launchpad/bug-1472045-demangle-inlinecomment-mail
Reviewer Review Type Date Requested Status
William Grant code Approve
Review via email: mp+264105@code.launchpad.net

Commit message

Ensure blank lines and git dirty headers (diff, index) are added to dirty_head.

To post a comment you must log in.
Revision history for this message
Kit Randel (blr) wrote :

While I believe this does resolve the issue (the blankline before the dirty header is discarded by bzrlib.patches.parse), it does make the assumption that our diffs will continue to be rendered consistently in the form:

=== dirty header
--- patch header
+++ patch header
@ hunk header @
text
text
text

=== dirty header
etc.

I tested this branch against a very large real-world diff (elmo's) and it appeared to be behaving.

Colin, you suggested an approach which may work better for differing cases (git?), however I didn't entirely understand and would need to discuss it with you further. Shall we land this in the meantime to get things functional and look at refactoring? I can't say I love this code.

Revision history for this message
Kit Randel (blr) wrote :

Reverted the blank line handling in codereviewcomment, will attempt to handle in patches.

Revision history for this message
Kit Randel (blr) wrote :

Have cloned a local copy of bzrlib.patches with support for parsing dirty headers from git, and respecting blank lines preceding dirty headers.

Have added an additional test which ensures blank lines within hunks are still handled appropriately.

While this code could (and perhaps should) be refactored to handle all general cases of dirty headers, this does appear to work and will fix the broken behaviour in production.

Revision history for this message
William Grant (wgrant) :
review: Approve (code)

Preview Diff

[H/L] Next/Prev Comment, [J/K] Next/Prev File, [N/P] Next/Prev Hunk
1=== modified file 'lib/lp/code/mail/codereviewcomment.py'
2--- lib/lp/code/mail/codereviewcomment.py 2015-07-07 05:32:11 +0000
3+++ lib/lp/code/mail/codereviewcomment.py 2015-07-09 05:40:58 +0000
4@@ -10,7 +10,6 @@
5 'CodeReviewCommentMailer',
6 ]
7
8-from bzrlib import patches
9 from zope.component import getUtility
10 from zope.security.proxy import removeSecurityProxy
11
12@@ -21,6 +20,7 @@
13 from lp.code.interfaces.codereviewinlinecomment import (
14 ICodeReviewInlineCommentSet,
15 )
16+from lp.code.mail import patches
17 from lp.code.mail.branchmergeproposal import BMPMailer
18 from lp.services.mail.sendmail import (
19 append_footer,
20
21=== added file 'lib/lp/code/mail/patches.py'
22--- lib/lp/code/mail/patches.py 1970-01-01 00:00:00 +0000
23+++ lib/lp/code/mail/patches.py 2015-07-09 05:40:58 +0000
24@@ -0,0 +1,513 @@
25+# This file was cloned from bzr-2.6.0-lp-3 (bzrlib.patches) and
26+# customised for LP.
27+#
28+# Copyright (C) 2005-2010 Aaron Bentley, Canonical Ltd
29+# <aaron.bentley@utoronto.ca>
30+#
31+# This program is free software; you can redistribute it and/or modify
32+# it under the terms of the GNU General Public License as published by
33+# the Free Software Foundation; either version 2 of the License, or
34+# (at your option) any later version.
35+#
36+# This program is distributed in the hope that it will be useful,
37+# but WITHOUT ANY WARRANTY; without even the implied warranty of
38+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
39+# GNU General Public License for more details.
40+#
41+# You should have received a copy of the GNU General Public License
42+# along with this program; if not, write to the Free Software
43+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
44+
45+from __future__ import absolute_import
46+
47+from bzrlib.errors import (
48+ BinaryFiles,
49+ MalformedHunkHeader,
50+ MalformedLine,
51+ MalformedPatchHeader,
52+ PatchConflict,
53+ PatchSyntax,
54+ )
55+
56+import re
57+
58+
59+binary_files_re = 'Binary files (.*) and (.*) differ\n'
60+
61+
62+def get_patch_names(iter_lines):
63+ line = iter_lines.next()
64+ try:
65+ match = re.match(binary_files_re, line)
66+ if match is not None:
67+ raise BinaryFiles(match.group(1), match.group(2))
68+ if not line.startswith("--- "):
69+ raise MalformedPatchHeader("No orig name", line)
70+ else:
71+ orig_name = line[4:].rstrip("\n")
72+ except StopIteration:
73+ raise MalformedPatchHeader("No orig line", "")
74+ try:
75+ line = iter_lines.next()
76+ if not line.startswith("+++ "):
77+ raise PatchSyntax("No mod name")
78+ else:
79+ mod_name = line[4:].rstrip("\n")
80+ except StopIteration:
81+ raise MalformedPatchHeader("No mod line", "")
82+ return (orig_name, mod_name)
83+
84+
85+def parse_range(textrange):
86+ """Parse a patch range, handling the "1" special-case
87+
88+ :param textrange: The text to parse
89+ :type textrange: str
90+ :return: the position and range, as a tuple
91+ :rtype: (int, int)
92+ """
93+ tmp = textrange.split(',')
94+ if len(tmp) == 1:
95+ pos = tmp[0]
96+ range = "1"
97+ else:
98+ (pos, range) = tmp
99+ pos = int(pos)
100+ range = int(range)
101+ return (pos, range)
102+
103+
104+def hunk_from_header(line):
105+ import re
106+ matches = re.match(r'\@\@ ([^@]*) \@\@( (.*))?\n', line)
107+ if matches is None:
108+ raise MalformedHunkHeader("Does not match format.", line)
109+ try:
110+ (orig, mod) = matches.group(1).split(" ")
111+ except (ValueError, IndexError), e:
112+ raise MalformedHunkHeader(str(e), line)
113+ if not orig.startswith('-') or not mod.startswith('+'):
114+ raise MalformedHunkHeader("Positions don't start with + or -.", line)
115+ try:
116+ (orig_pos, orig_range) = parse_range(orig[1:])
117+ (mod_pos, mod_range) = parse_range(mod[1:])
118+ except (ValueError, IndexError), e:
119+ raise MalformedHunkHeader(str(e), line)
120+ if mod_range < 0 or orig_range < 0:
121+ raise MalformedHunkHeader("Hunk range is negative", line)
122+ tail = matches.group(3)
123+ return Hunk(orig_pos, orig_range, mod_pos, mod_range, tail)
124+
125+
126+class HunkLine:
127+ def __init__(self, contents):
128+ self.contents = contents
129+
130+ def get_str(self, leadchar):
131+ if self.contents == "\n" and leadchar == " " and False:
132+ return "\n"
133+ if not self.contents.endswith('\n'):
134+ terminator = '\n' + NO_NL
135+ else:
136+ terminator = ''
137+ return leadchar + self.contents + terminator
138+
139+
140+class ContextLine(HunkLine):
141+ def __init__(self, contents):
142+ HunkLine.__init__(self, contents)
143+
144+ def __str__(self):
145+ return self.get_str(" ")
146+
147+
148+class InsertLine(HunkLine):
149+ def __init__(self, contents):
150+ HunkLine.__init__(self, contents)
151+
152+ def __str__(self):
153+ return self.get_str("+")
154+
155+
156+class RemoveLine(HunkLine):
157+ def __init__(self, contents):
158+ HunkLine.__init__(self, contents)
159+
160+ def __str__(self):
161+ return self.get_str("-")
162+
163+NO_NL = '\\ No newline at end of file\n'
164+__pychecker__ = "no-returnvalues"
165+
166+
167+def parse_line(line):
168+ if line.startswith("\n"):
169+ return ContextLine(line)
170+ elif line.startswith(" "):
171+ return ContextLine(line[1:])
172+ elif line.startswith("+"):
173+ return InsertLine(line[1:])
174+ elif line.startswith("-"):
175+ return RemoveLine(line[1:])
176+ else:
177+ raise MalformedLine("Unknown line type", line)
178+__pychecker__ = ""
179+
180+
181+class Hunk:
182+ def __init__(self, orig_pos, orig_range, mod_pos, mod_range, tail=None):
183+ self.orig_pos = orig_pos
184+ self.orig_range = orig_range
185+ self.mod_pos = mod_pos
186+ self.mod_range = mod_range
187+ self.tail = tail
188+ self.lines = []
189+
190+ def get_header(self):
191+ if self.tail is None:
192+ tail_str = ''
193+ else:
194+ tail_str = ' ' + self.tail
195+ return "@@ -%s +%s @@%s\n" % (self.range_str(self.orig_pos,
196+ self.orig_range),
197+ self.range_str(self.mod_pos,
198+ self.mod_range),
199+ tail_str)
200+
201+ def range_str(self, pos, range):
202+ """Return a file range, special-casing for 1-line files.
203+
204+ :param pos: The position in the file
205+ :type pos: int
206+ :range: The range in the file
207+ :type range: int
208+ :return: a string in the format 1,4 except when range == pos == 1
209+ """
210+ if range == 1:
211+ return "%i" % pos
212+ else:
213+ return "%i,%i" % (pos, range)
214+
215+ def __str__(self):
216+ lines = [self.get_header()]
217+ for line in self.lines:
218+ lines.append(str(line))
219+ return "".join(lines)
220+
221+ def shift_to_mod(self, pos):
222+ if pos < self.orig_pos - 1:
223+ return 0
224+ elif pos > self.orig_pos + self.orig_range:
225+ return self.mod_range - self.orig_range
226+ else:
227+ return self.shift_to_mod_lines(pos)
228+
229+ def shift_to_mod_lines(self, pos):
230+ position = self.orig_pos - 1
231+ shift = 0
232+ for line in self.lines:
233+ if isinstance(line, InsertLine):
234+ shift += 1
235+ elif isinstance(line, RemoveLine):
236+ if position == pos:
237+ return None
238+ shift -= 1
239+ position += 1
240+ elif isinstance(line, ContextLine):
241+ position += 1
242+ if position > pos:
243+ break
244+ return shift
245+
246+
247+def iter_hunks(iter_lines, allow_dirty=False):
248+ '''
249+ :arg iter_lines: iterable of lines to parse for hunks
250+ :kwarg allow_dirty: If True, when we encounter something that is not
251+ a hunk header when we're looking for one, assume the rest of the lines
252+ are not part of the patch (comments or other junk). Default False
253+ '''
254+ hunk = None
255+ for line in iter_lines:
256+ if line == "\n":
257+ if hunk is not None:
258+ yield hunk
259+ hunk = None
260+ continue
261+ if hunk is not None:
262+ yield hunk
263+ try:
264+ hunk = hunk_from_header(line)
265+ except MalformedHunkHeader:
266+ if allow_dirty:
267+ # If the line isn't a hunk header, then we've reached the end
268+ # of this patch and there's "junk" at the end. Ignore the
269+ # rest of this patch.
270+ return
271+ raise
272+ orig_size = 0
273+ mod_size = 0
274+ while orig_size < hunk.orig_range or mod_size < hunk.mod_range:
275+ hunk_line = parse_line(iter_lines.next())
276+ hunk.lines.append(hunk_line)
277+ if isinstance(hunk_line, (RemoveLine, ContextLine)):
278+ orig_size += 1
279+ if isinstance(hunk_line, (InsertLine, ContextLine)):
280+ mod_size += 1
281+ if hunk is not None:
282+ yield hunk
283+
284+
285+class BinaryPatch(object):
286+ def __init__(self, oldname, newname):
287+ self.oldname = oldname
288+ self.newname = newname
289+
290+ def __str__(self):
291+ return 'Binary files %s and %s differ\n' % (self.oldname, self.newname)
292+
293+
294+class Patch(BinaryPatch):
295+
296+ def __init__(self, oldname, newname):
297+ BinaryPatch.__init__(self, oldname, newname)
298+ self.hunks = []
299+
300+ def __str__(self):
301+ ret = self.get_header()
302+ ret += "".join([str(h) for h in self.hunks])
303+ return ret
304+
305+ def get_header(self):
306+ return "--- %s\n+++ %s\n" % (self.oldname, self.newname)
307+
308+ def stats_values(self):
309+ """Calculate the number of inserts and removes."""
310+ removes = 0
311+ inserts = 0
312+ for hunk in self.hunks:
313+ for line in hunk.lines:
314+ if isinstance(line, InsertLine):
315+ inserts += 1
316+ elif isinstance(line, RemoveLine):
317+ removes += 1
318+ return (inserts, removes, len(self.hunks))
319+
320+ def stats_str(self):
321+ """Return a string of patch statistics"""
322+ return "%i inserts, %i removes in %i hunks" % \
323+ self.stats_values()
324+
325+ def pos_in_mod(self, position):
326+ newpos = position
327+ for hunk in self.hunks:
328+ shift = hunk.shift_to_mod(position)
329+ if shift is None:
330+ return None
331+ newpos += shift
332+ return newpos
333+
334+ def iter_inserted(self):
335+ """Iteraties through inserted lines
336+
337+ :return: Pair of line number, line
338+ :rtype: iterator of (int, InsertLine)
339+ """
340+ for hunk in self.hunks:
341+ pos = hunk.mod_pos - 1
342+ for line in hunk.lines:
343+ if isinstance(line, InsertLine):
344+ yield (pos, line)
345+ pos += 1
346+ if isinstance(line, ContextLine):
347+ pos += 1
348+
349+
350+def parse_patch(iter_lines, allow_dirty=False):
351+ '''
352+ :arg iter_lines: iterable of lines to parse
353+ :kwarg allow_dirty: If True, allow the patch to have trailing junk.
354+ Default False
355+ '''
356+ iter_lines = iter_lines_handle_nl(iter_lines)
357+ try:
358+ (orig_name, mod_name) = get_patch_names(iter_lines)
359+ except BinaryFiles, e:
360+ return BinaryPatch(e.orig_name, e.mod_name)
361+ else:
362+ patch = Patch(orig_name, mod_name)
363+ for hunk in iter_hunks(iter_lines, allow_dirty):
364+ patch.hunks.append(hunk)
365+ return patch
366+
367+
368+def iter_file_patch(iter_lines, allow_dirty=False, keep_dirty=False):
369+ '''
370+ :arg iter_lines: iterable of lines to parse for patches
371+ :kwarg allow_dirty: If True, allow comments and other non-patch text
372+ before the first patch. Note that the algorithm here can only find
373+ such text before any patches have been found. Comments after the
374+ first patch are stripped away in iter_hunks() if it is also passed
375+ allow_dirty=True. Default False.
376+ '''
377+ # FIXME: Docstring is not quite true. We allow certain comments no
378+ # matter what, If they startwith '===', '***', or '#' Someone should
379+ # reexamine this logic and decide if we should include those in
380+ # allow_dirty or restrict those to only being before the patch is found
381+ # (as allow_dirty does).
382+ regex = re.compile(binary_files_re)
383+ saved_lines = []
384+ dirty_head = []
385+ orig_range = 0
386+ beginning = True
387+
388+ dirty_headers = ('=== ', 'diff ', 'index ')
389+ for line in iter_lines:
390+ # preserve bzr modified/added headers and blank lines
391+ if line.startswith(dirty_headers) or not line.strip('\n'):
392+ if len(saved_lines) > 0:
393+ if keep_dirty and len(dirty_head) > 0:
394+ yield {'saved_lines': saved_lines,
395+ 'dirty_head': dirty_head}
396+ dirty_head = []
397+ else:
398+ yield saved_lines
399+ saved_lines = []
400+ dirty_head.append(line)
401+ continue
402+ if line.startswith('*** '):
403+ continue
404+ if line.startswith('#'):
405+ continue
406+ elif orig_range > 0:
407+ if line.startswith('-') or line.startswith(' '):
408+ orig_range -= 1
409+ elif line.startswith('--- ') or regex.match(line):
410+ if allow_dirty and beginning:
411+ # Patches can have "junk" at the beginning
412+ # Stripping junk from the end of patches is handled when we
413+ # parse the patch
414+ beginning = False
415+ elif len(saved_lines) > 0:
416+ if keep_dirty and len(dirty_head) > 0:
417+ yield {'saved_lines': saved_lines,
418+ 'dirty_head': dirty_head}
419+ dirty_head = []
420+ else:
421+ yield saved_lines
422+ saved_lines = []
423+ elif line.startswith('@@'):
424+ hunk = hunk_from_header(line)
425+ orig_range = hunk.orig_range
426+ saved_lines.append(line)
427+ if len(saved_lines) > 0:
428+ if keep_dirty and len(dirty_head) > 0:
429+ yield {'saved_lines': saved_lines,
430+ 'dirty_head': dirty_head}
431+ else:
432+ yield saved_lines
433+
434+
435+def iter_lines_handle_nl(iter_lines):
436+ """
437+ Iterates through lines, ensuring that lines that originally had no
438+ terminating \n are produced without one. This transformation may be
439+ applied at any point up until hunk line parsing, and is safe to apply
440+ repeatedly.
441+ """
442+ last_line = None
443+ for line in iter_lines:
444+ if line == NO_NL:
445+ if not last_line.endswith('\n'):
446+ raise AssertionError()
447+ last_line = last_line[:-1]
448+ line = None
449+ if last_line is not None:
450+ yield last_line
451+ last_line = line
452+ if last_line is not None:
453+ yield last_line
454+
455+
456+def parse_patches(iter_lines, allow_dirty=False, keep_dirty=False):
457+ '''
458+ :arg iter_lines: iterable of lines to parse for patches
459+ :kwarg allow_dirty: If True, allow text that's not part of the patch at
460+ selected places. This includes comments before and after a patch
461+ for instance. Default False.
462+ :kwarg keep_dirty: If True, returns a dict of patches with dirty headers.
463+ Default False.
464+ '''
465+ patches = []
466+ for patch_lines in iter_file_patch(iter_lines, allow_dirty, keep_dirty):
467+ if 'dirty_head' in patch_lines:
468+ patches.append({'patch': parse_patch(
469+ patch_lines['saved_lines'], allow_dirty),
470+ 'dirty_head': patch_lines['dirty_head']})
471+ else:
472+ patches.append(parse_patch(patch_lines, allow_dirty))
473+ return patches
474+
475+
476+def difference_index(atext, btext):
477+ """Find the indext of the first character that differs between two texts
478+
479+ :param atext: The first text
480+ :type atext: str
481+ :param btext: The second text
482+ :type str: str
483+ :return: The index, or None if there are no differences within the range
484+ :rtype: int or NoneType
485+ """
486+ length = len(atext)
487+ if len(btext) < length:
488+ length = len(btext)
489+ for i in range(length):
490+ if atext[i] != btext[i]:
491+ return i
492+ return None
493+
494+
495+def iter_patched(orig_lines, patch_lines):
496+ """Iterate through a series of lines with a patch applied.
497+ This handles a single file, and does exact, not fuzzy patching.
498+ """
499+ patch_lines = iter_lines_handle_nl(iter(patch_lines))
500+ get_patch_names(patch_lines)
501+ return iter_patched_from_hunks(orig_lines, iter_hunks(patch_lines))
502+
503+
504+def iter_patched_from_hunks(orig_lines, hunks):
505+ """Iterate through a series of lines with a patch applied.
506+ This handles a single file, and does exact, not fuzzy patching.
507+
508+ :param orig_lines: The unpatched lines.
509+ :param hunks: An iterable of Hunk instances.
510+ """
511+ seen_patch = []
512+ line_no = 1
513+ if orig_lines is not None:
514+ orig_lines = iter(orig_lines)
515+ for hunk in hunks:
516+ while line_no < hunk.orig_pos:
517+ orig_line = orig_lines.next()
518+ yield orig_line
519+ line_no += 1
520+ for hunk_line in hunk.lines:
521+ seen_patch.append(str(hunk_line))
522+ if isinstance(hunk_line, InsertLine):
523+ yield hunk_line.contents
524+ elif isinstance(hunk_line, (ContextLine, RemoveLine)):
525+ orig_line = orig_lines.next()
526+ if orig_line != hunk_line.contents:
527+ raise PatchConflict(line_no, orig_line,
528+ "".join(seen_patch))
529+ if isinstance(hunk_line, ContextLine):
530+ yield orig_line
531+ else:
532+ if not isinstance(hunk_line, RemoveLine):
533+ raise AssertionError(hunk_line)
534+ line_no += 1
535+ if orig_lines is not None:
536+ for line in orig_lines:
537+ yield line
538
539=== modified file 'lib/lp/code/mail/tests/test_codereviewcomment.py'
540--- lib/lp/code/mail/tests/test_codereviewcomment.py 2015-07-07 05:32:11 +0000
541+++ lib/lp/code/mail/tests/test_codereviewcomment.py 2015-07-09 05:40:58 +0000
542@@ -3,7 +3,6 @@
543
544 """Test CodeReviewComment emailing functionality."""
545
546-
547 import testtools
548 import transaction
549 from zope.component import getUtility
550@@ -382,9 +381,9 @@
551
552 diff_text = (
553 "=== added directory 'foo/bar'\n"
554- "=== modified file 'foo/bar/baz.py'\n"
555- "--- bar\t2009-08-26 15:53:34.000000000 -0400\n"
556- "+++ bar\t1969-12-31 19:00:00.000000000 -0500\n"
557+ "=== modified file 'foo/bar/bar.py'\n"
558+ "--- bar.py\t2009-08-26 15:53:34.000000000 -0400\n"
559+ "+++ bar.py\t1969-12-31 19:00:00.000000000 -0500\n"
560 "@@ -1,3 +0,0 @@\n"
561 "-\xc3\xa5\n"
562 "-b\n"
563@@ -404,7 +403,35 @@
564 "-b\n"
565 " c\n"
566 "+d\n"
567- "+e\n")
568+ "+e\n"
569+ "\n"
570+ "=== modified file 'fulango.py'\n"
571+ "--- fulano.py\t2014-08-26 15:53:34.000000000 -0400\n"
572+ "+++ fulano.py\t2015-12-31 19:00:00.000000000 -0500\n"
573+ "@@ -1,3 +1,4 @@\n"
574+ " a\n"
575+ "-fulano\n"
576+ " c\n"
577+ "+mengano\n"
578+ "+zutano\n")
579+
580+ git_diff_text = (
581+ "diff --git a/foo b/foo\n"
582+ "index 5716ca5..7601807 100644\n"
583+ "--- a/foo\n"
584+ "+++ b/foo\n"
585+ "@@ -1 +1 @@\n"
586+ "-bar\n"
587+ "+baz\n"
588+ "diff --git a/fulano b/fulano\n"
589+ "index 5716ca5..7601807 100644\n"
590+ "--- a/fulano\n"
591+ "+++ b/fulano\n"
592+ "@@ -1,3 +1,3 @@\n"
593+ " fulano\n"
594+ " \n"
595+ "-mengano\n"
596+ "+zutano\n")
597
598 binary_diff_text = (
599 "=== added file 'lib/canonical/launchpad/images/foo.png'\n"
600@@ -412,9 +439,10 @@
601 "1970-01-01 00:00:00 +0000 and "
602 "lib/canonical/launchpad/images/foo.png\t"
603 "2015-06-21 22:07:50 +0000 differ\n"
604- "=== modified file 'foo/bar/baz.py'\n"
605- "--- bar\t2009-08-26 15:53:34.000000000 -0400\n"
606- "+++ bar\t1969-12-31 19:00:00.000000000 -0500\n"
607+ "\n"
608+ "=== modified file 'foo/bar/bar.py'\n"
609+ "--- bar.py\t2009-08-26 15:53:34.000000000 -0400\n"
610+ "+++ bar.py\t1969-12-31 19:00:00.000000000 -0500\n"
611 "@@ -1,3 +0,0 @@\n"
612 "-a\n"
613 "-b\n"
614@@ -443,7 +471,7 @@
615
616 def test_binary_patch_in_diff(self):
617 # Binary patches with comments are handled appropriately.
618- comments = {'1': 'Updated the png', '2': 'foo', '8': 'bar'}
619+ comments = {'1': 'Updated the png', '2': 'foo', '9': 'bar'}
620 section = self.getSection(comments, diff_text=self.binary_diff_text)
621 self.assertEqual(
622 map(unicode, [
623@@ -458,9 +486,10 @@
624 "",
625 "foo",
626 "",
627- "> === modified file 'foo/bar/baz.py'",
628- "> --- bar\t2009-08-26 15:53:34.000000000 -0400",
629- "> +++ bar\t1969-12-31 19:00:00.000000000 -0500",
630+ "> ",
631+ "> === modified file 'foo/bar/bar.py'",
632+ "> --- bar.py\t2009-08-26 15:53:34.000000000 -0400",
633+ "> +++ bar.py\t1969-12-31 19:00:00.000000000 -0500",
634 "> @@ -1,3 +0,0 @@",
635 "> -a",
636 "> -b",
637@@ -468,7 +497,7 @@
638 "bar",
639 "",
640 "> -c"]),
641- section.splitlines()[4:22])
642+ section.splitlines()[4:23])
643
644 def test_single_line_comment(self):
645 # The inline comments are correctly contextualized in the diff.
646@@ -476,12 +505,44 @@
647 comments = {'4': '\u03b4\u03bf\u03ba\u03b9\u03bc\u03ae'}
648 self.assertEqual(
649 map(unicode, [
650- '> +++ bar\t1969-12-31 19:00:00.000000000 -0500',
651+ '> +++ bar.py\t1969-12-31 19:00:00.000000000 -0500',
652 '',
653 '\u03b4\u03bf\u03ba\u03b9\u03bc\u03ae',
654 '']),
655 self.getSection(comments).splitlines()[7:11])
656
657+ def test_comments_in_git_diff(self):
658+ comments = {'1': 'foo', '5': 'bar', '15': 'baz'}
659+ section = self.getSection(comments, diff_text=self.git_diff_text)
660+ self.assertEqual(
661+ map(unicode, [
662+ "> diff --git a/foo b/foo",
663+ "",
664+ "foo",
665+ "",
666+ "> index 5716ca5..7601807 100644",
667+ "> --- a/foo",
668+ "> +++ b/foo",
669+ "> @@ -1 +1 @@",
670+ "",
671+ "bar",
672+ "",
673+ "> -bar",
674+ "> +baz",
675+ "> diff --git a/fulano b/fulano",
676+ "> index 5716ca5..7601807 100644",
677+ "> --- a/fulano",
678+ "> +++ b/fulano",
679+ "> @@ -1,3 +1,3 @@",
680+ "> fulano",
681+ "> ",
682+ "> -mengano",
683+ "",
684+ "baz",
685+ "",
686+ "> +zutano"]),
687+ section.splitlines()[4:29])
688+
689 def test_commentless_hunks_ignored(self):
690 # Hunks without inline comments are not returned in the diff text.
691 comments = {'16': 'A comment', '21': 'Another comment'}
692@@ -556,13 +617,32 @@
693 '> +b']),
694 self.getSection(comments).splitlines()[4:12])
695
696+ def test_comment_in_patch_after_linebreak(self):
697+ comments = {'31': 'que?'}
698+ self.assertEqual(
699+ map(unicode, [
700+ "> ",
701+ "> === modified file 'fulango.py'",
702+ "> --- fulano.py\t2014-08-26 15:53:34.000000000 -0400",
703+ "> +++ fulano.py\t2015-12-31 19:00:00.000000000 -0500",
704+ "> @@ -1,3 +1,4 @@",
705+ "> a",
706+ "> -fulano",
707+ "",
708+ "que?",
709+ "",
710+ "> c",
711+ "> +mengano",
712+ "> +zutano"]),
713+ self.getSection(comments).splitlines()[4:17])
714+
715 def test_multi_line_comment(self):
716 # Inline comments with multiple lines are rendered appropriately.
717 comments = {'4': 'Foo\nBar'}
718 self.assertEqual(
719 map(unicode, [
720- '> --- bar\t2009-08-26 15:53:34.000000000 -0400',
721- '> +++ bar\t1969-12-31 19:00:00.000000000 -0500',
722+ '> --- bar.py\t2009-08-26 15:53:34.000000000 -0400',
723+ '> +++ bar.py\t1969-12-31 19:00:00.000000000 -0500',
724 '',
725 'Foo',
726 'Bar',
727@@ -573,7 +653,7 @@
728 # Multiple inline comments are redered appropriately.
729 comments = {'4': 'Foo', '5': 'Bar'}
730 self.assertEqual(
731- ['> +++ bar\t1969-12-31 19:00:00.000000000 -0500',
732+ ['> +++ bar.py\t1969-12-31 19:00:00.000000000 -0500',
733 '',
734 'Foo',
735 '',