OLD | NEW |
1 # coding=utf8 | 1 # coding=utf8 |
2 # Copyright (c) 2011 The Chromium Authors. All rights reserved. | 2 # Copyright (c) 2011 The Chromium Authors. All rights reserved. |
3 # Use of this source code is governed by a BSD-style license that can be | 3 # Use of this source code is governed by a BSD-style license that can be |
4 # found in the LICENSE file. | 4 # found in the LICENSE file. |
5 """Utility functions to handle patches.""" | 5 """Utility functions to handle patches.""" |
6 | 6 |
| 7 import posixpath |
| 8 import os |
7 import re | 9 import re |
8 | 10 |
9 | 11 |
10 class UnsupportedPatchFormat(Exception): | 12 class UnsupportedPatchFormat(Exception): |
11 def __init__(self, filename, status): | 13 def __init__(self, filename, status): |
12 super(UnsupportedPatchFormat, self).__init__(filename, status) | 14 super(UnsupportedPatchFormat, self).__init__(filename, status) |
13 self.filename = filename | 15 self.filename = filename |
14 self.status = status | 16 self.status = status |
15 | 17 |
16 def __str__(self): | 18 def __str__(self): |
17 out = 'Can\'t process patch for file %s.' % self.filename | 19 out = 'Can\'t process patch for file %s.' % self.filename |
18 if self.status: | 20 if self.status: |
19 out += '\n%s' % self.status | 21 out += '\n%s' % self.status |
20 return out | 22 return out |
21 | 23 |
22 | 24 |
23 class FilePatchBase(object): | 25 class FilePatchBase(object): |
24 """Defines a single file being modified.""" | 26 """Defines a single file being modified. |
| 27 |
| 28 '/' is always used instead of os.sep for consistency. |
| 29 """ |
25 is_delete = False | 30 is_delete = False |
26 is_binary = False | 31 is_binary = False |
27 | 32 |
| 33 def __init__(self, filename): |
| 34 self.filename = None |
| 35 self._set_filename(filename) |
| 36 |
| 37 def _set_filename(self, filename): |
| 38 self.filename = filename.replace('\\', '/') |
| 39 # Blacklist a few characters for simplicity. |
| 40 for i in ('%', '$', '..', '\'', '"'): |
| 41 if i in self.filename: |
| 42 self._fail('Can\'t use \'%s\' in filename.' % i) |
| 43 for i in ('/', 'CON', 'COM'): |
| 44 if self.filename.startswith(i): |
| 45 self._fail('Filename can\'t start with \'%s\'.' % i) |
| 46 |
28 def get(self): | 47 def get(self): |
29 raise NotImplementedError('Nothing to grab') | 48 raise NotImplementedError('Nothing to grab') |
30 | 49 |
| 50 def set_relpath(self, relpath): |
| 51 if not relpath: |
| 52 return |
| 53 relpath = relpath.replace('\\', '/') |
| 54 if relpath[0] == '/': |
| 55 self._fail('Relative path starts with %s' % relpath[0]) |
| 56 self._set_filename(posixpath.join(relpath, self.filename)) |
| 57 |
| 58 def _fail(self, msg): |
| 59 raise UnsupportedPatchFormat(self.filename, msg) |
| 60 |
31 | 61 |
32 class FilePatchDelete(FilePatchBase): | 62 class FilePatchDelete(FilePatchBase): |
33 """Deletes a file.""" | 63 """Deletes a file.""" |
34 is_delete = True | 64 is_delete = True |
35 | 65 |
36 def __init__(self, filename, is_binary): | 66 def __init__(self, filename, is_binary): |
37 super(FilePatchDelete, self).__init__() | 67 super(FilePatchDelete, self).__init__(filename) |
38 self.filename = filename | |
39 self.is_binary = is_binary | 68 self.is_binary = is_binary |
40 | 69 |
41 def get(self): | 70 def get(self): |
42 raise NotImplementedError('Nothing to grab') | 71 raise NotImplementedError('Nothing to grab') |
43 | 72 |
44 | 73 |
45 class FilePatchBinary(FilePatchBase): | 74 class FilePatchBinary(FilePatchBase): |
46 """Content of a new binary file.""" | 75 """Content of a new binary file.""" |
47 is_binary = True | 76 is_binary = True |
48 | 77 |
49 def __init__(self, filename, data, svn_properties): | 78 def __init__(self, filename, data, svn_properties): |
50 super(FilePatchBinary, self).__init__() | 79 super(FilePatchBinary, self).__init__(filename) |
51 self.filename = filename | |
52 self.data = data | 80 self.data = data |
53 self.svn_properties = svn_properties or [] | 81 self.svn_properties = svn_properties or [] |
54 | 82 |
55 def get(self): | 83 def get(self): |
56 return self.data | 84 return self.data |
57 | 85 |
58 | 86 |
59 class FilePatchDiff(FilePatchBase): | 87 class FilePatchDiff(FilePatchBase): |
60 """Patch for a single file.""" | 88 """Patch for a single file.""" |
61 | 89 |
62 def __init__(self, filename, diff, svn_properties): | 90 def __init__(self, filename, diff, svn_properties): |
63 super(FilePatchDiff, self).__init__() | 91 super(FilePatchDiff, self).__init__(filename) |
64 self.filename = filename | 92 self.diff_header, self.diff_hunks = self._split_header(diff) |
65 self.diff = diff | |
66 self.svn_properties = svn_properties or [] | 93 self.svn_properties = svn_properties or [] |
67 self.is_git_diff = self._is_git_diff(diff) | 94 self.is_git_diff = self._is_git_diff_header(self.diff_header) |
| 95 self.patchlevel = 0 |
68 if self.is_git_diff: | 96 if self.is_git_diff: |
69 self.patchlevel = 1 | 97 self._verify_git_header() |
70 self._verify_git_patch(filename, diff) | |
71 assert not svn_properties | 98 assert not svn_properties |
72 else: | 99 else: |
73 self.patchlevel = 0 | 100 self._verify_svn_header() |
74 self._verify_svn_patch(filename, diff) | |
75 | 101 |
76 def get(self): | 102 def get(self): |
77 return self.diff | 103 return self.diff_header + self.diff_hunks |
| 104 |
| 105 def set_relpath(self, relpath): |
| 106 old_filename = self.filename |
| 107 super(FilePatchDiff, self).set_relpath(relpath) |
| 108 # Update the header too. |
| 109 self.diff_header = self.diff_header.replace(old_filename, self.filename) |
| 110 |
| 111 def _split_header(self, diff): |
| 112 """Splits a diff in two: the header and the hunks.""" |
| 113 header = [] |
| 114 hunks = diff.splitlines(True) |
| 115 while hunks: |
| 116 header.append(hunks.pop(0)) |
| 117 if header[-1].startswith('--- '): |
| 118 break |
| 119 else: |
| 120 # Some diff may not have a ---/+++ set like a git rename with no change or |
| 121 # a svn diff with only property change. |
| 122 pass |
| 123 |
| 124 if hunks: |
| 125 if not hunks[0].startswith('+++ '): |
| 126 self._fail('Inconsistent header') |
| 127 header.append(hunks.pop(0)) |
| 128 if hunks: |
| 129 if not hunks[0].startswith('@@ '): |
| 130 self._fail('Inconsistent hunk header') |
| 131 |
| 132 # Mangle any \\ in the header to /. |
| 133 header_lines = ('Index:', 'diff', 'copy', 'rename', '+++', '---') |
| 134 basename = os.path.basename(self.filename) |
| 135 for i in xrange(len(header)): |
| 136 if (header[i].split(' ', 1)[0] in header_lines or |
| 137 header[i].endswith(basename)): |
| 138 header[i] = header[i].replace('\\', '/') |
| 139 return ''.join(header), ''.join(hunks) |
78 | 140 |
79 @staticmethod | 141 @staticmethod |
80 def _is_git_diff(diff): | 142 def _is_git_diff_header(diff_header): |
81 """Returns True if the diff for a single files was generated with gt. | 143 """Returns True if the diff for a single files was generated with git.""" |
82 | |
83 Expects the following format: | |
84 | |
85 Index: <filename> | |
86 diff --git a/<filename> b/<filename> | |
87 <filemode changes> | |
88 <index> | |
89 --- <filename> | |
90 +++ <filename> | |
91 <hunks> | |
92 | |
93 Index: is a rietveld specific line. | |
94 """ | |
95 # Delete: http://codereview.chromium.org/download/issue6368055_22_29.diff | 144 # Delete: http://codereview.chromium.org/download/issue6368055_22_29.diff |
96 # Rename partial change: | 145 # Rename partial change: |
97 # http://codereview.chromium.org/download/issue6250123_3013_6010.diff | 146 # http://codereview.chromium.org/download/issue6250123_3013_6010.diff |
98 # Rename no change: | 147 # Rename no change: |
99 # http://codereview.chromium.org/download/issue6287022_3001_4010.diff | 148 # http://codereview.chromium.org/download/issue6287022_3001_4010.diff |
100 return any(l.startswith('diff --git') for l in diff.splitlines()[:3]) | 149 return any(l.startswith('diff --git') for l in diff_header.splitlines()) |
101 | 150 |
102 @staticmethod | 151 def mangle(self, string): |
103 def _verify_git_patch(filename, diff): | 152 """Mangle a file path.""" |
104 lines = diff.splitlines() | 153 return '/'.join(string.replace('\\', '/').split('/')[self.patchlevel:]) |
105 # First fine the git diff header: | 154 |
| 155 def _verify_git_header(self): |
| 156 """Sanity checks the header. |
| 157 |
| 158 Expects the following format: |
| 159 |
| 160 <garbagge> |
| 161 diff --git (|a/)<filename> (|b/)<filename> |
| 162 <similarity> |
| 163 <filemode changes> |
| 164 <index> |
| 165 <copy|rename from> |
| 166 <copy|rename to> |
| 167 --- <filename> |
| 168 +++ <filename> |
| 169 |
| 170 Everything is optional except the diff --git line. |
| 171 """ |
| 172 lines = self.diff_header.splitlines() |
| 173 |
| 174 # Verify the diff --git line. |
| 175 old = None |
| 176 new = None |
106 while lines: | 177 while lines: |
107 line = lines.pop(0) | 178 match = re.match(r'^diff \-\-git (.*?) (.*)$', lines.pop(0)) |
108 match = re.match(r'^diff \-\-git a\/(.*?) b\/(.*)$', line) | 179 if not match: |
109 if match: | 180 continue |
110 a = match.group(1) | 181 old = match.group(1).replace('\\', '/') |
111 b = match.group(2) | 182 new = match.group(2).replace('\\', '/') |
112 if a != filename and a != 'dev/null': | 183 if old.startswith('a/') and new.startswith('b/'): |
113 raise UnsupportedPatchFormat( | 184 self.patchlevel = 1 |
114 filename, 'Unexpected git diff input name.') | 185 old = old[2:] |
115 if b != filename and b != 'dev/null': | 186 new = new[2:] |
116 raise UnsupportedPatchFormat( | 187 # The rename is about the new file so the old file can be anything. |
117 filename, 'Unexpected git diff output name.') | 188 if new not in (self.filename, 'dev/null'): |
118 if a == 'dev/null' and b == 'dev/null': | 189 self._fail('Unexpected git diff output name %s.' % new) |
119 raise UnsupportedPatchFormat( | 190 if old == 'dev/null' and new == 'dev/null': |
120 filename, 'Unexpected /dev/null git diff.') | 191 self._fail('Unexpected /dev/null git diff.') |
| 192 break |
| 193 |
| 194 if not old or not new: |
| 195 self._fail('Unexpected git diff; couldn\'t find git header.') |
| 196 |
| 197 # Handle these: |
| 198 # rename from <> |
| 199 # rename to <> |
| 200 # copy from <> |
| 201 # copy to <> |
| 202 while lines: |
| 203 if lines[0].startswith('--- '): |
121 break | 204 break |
| 205 match = re.match(r'^(rename|copy) from (.+)$', lines.pop(0)) |
| 206 if not match: |
| 207 continue |
| 208 if old != match.group(2): |
| 209 self._fail('Unexpected git diff input name for %s.' % match.group(1)) |
| 210 if not lines: |
| 211 self._fail('Missing git diff output name for %s.' % match.group(1)) |
| 212 match = re.match(r'^(rename|copy) to (.+)$', lines.pop(0)) |
| 213 if not match: |
| 214 self._fail('Missing git diff output name for %s.' % match.group(1)) |
| 215 if new != match.group(2): |
| 216 self._fail('Unexpected git diff output name for %s.' % match.group(1)) |
| 217 |
| 218 # Handle ---/+++ |
| 219 while lines: |
| 220 match = re.match(r'^--- (.*)$', lines.pop(0)) |
| 221 if not match: |
| 222 continue |
| 223 if old != self.mangle(match.group(1)) and match.group(1) != '/dev/null': |
| 224 self._fail('Unexpected git diff: %s != %s.' % (old, match.group(1))) |
| 225 if not lines: |
| 226 self._fail('Missing git diff output name.') |
| 227 match = re.match(r'^\+\+\+ (.*)$', lines.pop(0)) |
| 228 if not match: |
| 229 self._fail('Unexpected git diff: --- not following +++.') |
| 230 if new != self.mangle(match.group(1)) and '/dev/null' != match.group(1): |
| 231 self._fail('Unexpected git diff: %s != %s.' % (new, match.group(1))) |
| 232 assert not lines, '_split_header() is broken' |
| 233 break |
| 234 |
| 235 def _verify_svn_header(self): |
| 236 """Sanity checks the header. |
| 237 |
| 238 A svn diff can contain only property changes, in that case there will be no |
| 239 proper header. To make things worse, this property change header is |
| 240 localized. |
| 241 """ |
| 242 lines = self.diff_header.splitlines() |
| 243 while lines: |
| 244 match = re.match(r'^--- ([^\t]+).*$', lines.pop(0)) |
| 245 if not match: |
| 246 continue |
| 247 if match.group(1) not in (self.filename, '/dev/null'): |
| 248 self._fail('Unexpected diff: %s.' % match.group(1)) |
| 249 match = re.match(r'^\+\+\+ ([^\t]+).*$', lines.pop(0)) |
| 250 if not match: |
| 251 self._fail('Unexpected diff: --- not following +++.') |
| 252 if match.group(1) not in (self.filename, '/dev/null'): |
| 253 self._fail('Unexpected diff: %s.' % match.group(1)) |
| 254 assert not lines, '_split_header() is broken' |
| 255 break |
122 else: | 256 else: |
123 raise UnsupportedPatchFormat( | 257 # Cheap check to make sure the file name is at least mentioned in the |
124 filename, 'Unexpected git diff; couldn\'t find git header.') | 258 # 'diff' header. That the only remaining invariant. |
125 | 259 if not self.filename in self.diff_header: |
126 while lines: | 260 self._fail('Diff seems corrupted.') |
127 line = lines.pop(0) | |
128 match = re.match(r'^--- a/(.*)$', line) | |
129 if match: | |
130 if a != match.group(1): | |
131 raise UnsupportedPatchFormat( | |
132 filename, 'Unexpected git diff: %s != %s.' % (a, match.group(1))) | |
133 match = re.match(r'^\+\+\+ b/(.*)$', lines.pop(0)) | |
134 if not match: | |
135 raise UnsupportedPatchFormat( | |
136 filename, 'Unexpected git diff: --- not following +++.') | |
137 if b != match.group(1): | |
138 raise UnsupportedPatchFormat( | |
139 filename, 'Unexpected git diff: %s != %s.' % (b, match.group(1))) | |
140 break | |
141 # Don't fail if the patch header is not found, the diff could be a | |
142 # file-mode-change-only diff. | |
143 | |
144 @staticmethod | |
145 def _verify_svn_patch(filename, diff): | |
146 lines = diff.splitlines() | |
147 while lines: | |
148 line = lines.pop(0) | |
149 match = re.match(r'^--- ([^\t]+).*$', line) | |
150 if match: | |
151 if match.group(1) not in (filename, '/dev/null'): | |
152 raise UnsupportedPatchFormat( | |
153 filename, | |
154 'Unexpected diff: %s != %s.' % (filename, match.group(1))) | |
155 # Grab next line. | |
156 line2 = lines.pop(0) | |
157 match = re.match(r'^\+\+\+ ([^\t]+).*$', line2) | |
158 if not match: | |
159 raise UnsupportedPatchFormat( | |
160 filename, | |
161 'Unexpected diff: --- not following +++.:\n%s\n%s' % ( | |
162 line, line2)) | |
163 if match.group(1) not in (filename, '/dev/null'): | |
164 raise UnsupportedPatchFormat( | |
165 filename, | |
166 'Unexpected diff: %s != %s.' % (filename, match.group(1))) | |
167 break | |
168 # Don't fail if the patch header is not found, the diff could be a | |
169 # svn-property-change-only diff. | |
170 | 261 |
171 | 262 |
172 class PatchSet(object): | 263 class PatchSet(object): |
173 """A list of FilePatch* objects.""" | 264 """A list of FilePatch* objects.""" |
174 | 265 |
175 def __init__(self, patches): | 266 def __init__(self, patches): |
176 self.patches = patches | 267 self.patches = patches |
177 | 268 |
| 269 def set_relpath(self, relpath): |
| 270 """Used to offset the patch into a subdirectory.""" |
| 271 for patch in self.patches: |
| 272 patch.set_relpath(relpath) |
| 273 |
178 def __iter__(self): | 274 def __iter__(self): |
179 for patch in self.patches: | 275 for patch in self.patches: |
180 yield patch | 276 yield patch |
181 | 277 |
182 @property | 278 @property |
183 def filenames(self): | 279 def filenames(self): |
184 return [p.filename for p in self.patches] | 280 return [p.filename for p in self.patches] |
OLD | NEW |