|
86
|
1 # Copyright (C) 2008 Canonical Ltd |
|
|
2 # |
|
|
3 # This program is free software; you can redistribute it and/or modify |
|
|
4 # it under the terms of the GNU General Public License as published by |
|
|
5 # the Free Software Foundation; either version 2 of the License, or |
|
|
6 # (at your option) any later version. |
|
|
7 # |
|
|
8 # This program is distributed in the hope that it will be useful, |
|
|
9 # but WITHOUT ANY WARRANTY; without even the implied warranty of |
|
|
10 # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
|
|
11 # GNU General Public License for more details. |
|
|
12 # |
|
|
13 # You should have received a copy of the GNU General Public License |
|
|
14 # along with this program. If not, see <http://www.gnu.org/licenses/>. |
|
|
15 |
|
|
16 """fast-import command classes. |
|
|
17 |
|
|
18 These objects are used by the parser to represent the content of |
|
|
19 a fast-import stream. |
|
|
20 """ |
|
|
21 from __future__ import division |
|
|
22 |
|
|
23 import re |
|
|
24 import stat |
|
|
25 import sys |
|
|
26 |
|
|
27 from fastimport.helpers import ( |
|
|
28 newobject as object, |
|
|
29 utf8_bytes_string, |
|
|
30 repr_bytes, |
|
|
31 ) |
|
|
32 |
|
|
33 |
|
|
34 # There is a bug in git 1.5.4.3 and older by which unquoting a string consumes |
|
|
35 # one extra character. Set this variable to True to work-around it. It only |
|
|
36 # happens when renaming a file whose name contains spaces and/or quotes, and |
|
|
37 # the symptom is: |
|
|
38 # % git-fast-import |
|
|
39 # fatal: Missing space after source: R "file 1.txt" file 2.txt |
|
|
40 # http://git.kernel.org/?p=git/git.git;a=commit;h=c8744d6a8b27115503565041566d97c21e722584 |
|
|
41 GIT_FAST_IMPORT_NEEDS_EXTRA_SPACE_AFTER_QUOTE = False |
|
|
42 |
|
|
43 |
|
|
44 # Lists of command names |
|
|
45 COMMAND_NAMES = [b'blob', b'checkpoint', b'commit', b'feature', b'progress', |
|
|
46 b'reset', b'tag'] |
|
|
47 FILE_COMMAND_NAMES = [b'filemodify', b'filedelete', b'filecopy', b'filerename', |
|
|
48 b'filedeleteall'] |
|
|
49 |
|
|
50 # Feature names |
|
|
51 MULTIPLE_AUTHORS_FEATURE = b'multiple-authors' |
|
|
52 COMMIT_PROPERTIES_FEATURE = b'commit-properties' |
|
|
53 EMPTY_DIRS_FEATURE = b'empty-directories' |
|
|
54 FEATURE_NAMES = [ |
|
|
55 MULTIPLE_AUTHORS_FEATURE, |
|
|
56 COMMIT_PROPERTIES_FEATURE, |
|
|
57 EMPTY_DIRS_FEATURE, |
|
|
58 ] |
|
|
59 |
|
|
60 |
|
|
61 class ImportCommand(object): |
|
|
62 """Base class for import commands.""" |
|
|
63 |
|
|
64 def __init__(self, name): |
|
|
65 self.name = name |
|
|
66 # List of field names not to display |
|
|
67 self._binary = [] |
|
|
68 |
|
|
69 def __str__(self): |
|
|
70 return repr(self) |
|
|
71 |
|
|
72 def __repr__(self): |
|
|
73 if sys.version_info[0] == 2: |
|
|
74 return self.__bytes__() |
|
|
75 else: |
|
|
76 return bytes(self).decode('utf8') |
|
|
77 |
|
|
78 def __bytes__(self): |
|
|
79 raise NotImplementedError( |
|
|
80 'An implementation of __bytes__ is required' |
|
|
81 ) |
|
|
82 |
|
|
83 def dump_str(self, names=None, child_lists=None, verbose=False): |
|
|
84 """Dump fields as a string. |
|
|
85 |
|
|
86 For debugging. |
|
|
87 |
|
|
88 :param names: the list of fields to include or |
|
|
89 None for all public fields |
|
|
90 :param child_lists: dictionary of child command names to |
|
|
91 fields for that child command to include |
|
|
92 :param verbose: if True, prefix each line with the command class and |
|
|
93 display fields as a dictionary; if False, dump just the field |
|
|
94 values with tabs between them |
|
|
95 """ |
|
|
96 interesting = {} |
|
|
97 if names is None: |
|
|
98 fields = [ |
|
|
99 k for k in list(self.__dict__.keys()) |
|
|
100 if not k.startswith(b'_') |
|
|
101 ] |
|
|
102 else: |
|
|
103 fields = names |
|
|
104 for field in fields: |
|
|
105 value = self.__dict__.get(field) |
|
|
106 if field in self._binary and value is not None: |
|
|
107 value = b'(...)' |
|
|
108 interesting[field] = value |
|
|
109 if verbose: |
|
|
110 return "%s: %s" % (self.__class__.__name__, interesting) |
|
|
111 else: |
|
|
112 return "\t".join([repr(interesting[k]) for k in fields]) |
|
|
113 |
|
|
114 |
|
|
115 class BlobCommand(ImportCommand): |
|
|
116 |
|
|
117 def __init__(self, mark, data, lineno=0): |
|
|
118 ImportCommand.__init__(self, b'blob') |
|
|
119 self.mark = mark |
|
|
120 self.data = data |
|
|
121 self.lineno = lineno |
|
|
122 # Provide a unique id in case the mark is missing |
|
|
123 if mark is None: |
|
|
124 self.id = b'@' + ("%d" % lineno).encode('utf-8') |
|
|
125 else: |
|
|
126 self.id = b':' + mark |
|
|
127 self._binary = [b'data'] |
|
|
128 |
|
|
129 def __bytes__(self): |
|
|
130 if self.mark is None: |
|
|
131 mark_line = b'' |
|
|
132 else: |
|
|
133 mark_line = b"\nmark :" + self.mark |
|
|
134 return (b'blob' + mark_line + b'\n' + |
|
|
135 ('data %d\n' % len(self.data)).encode('utf-8') + self.data) |
|
|
136 |
|
|
137 |
|
|
138 class CheckpointCommand(ImportCommand): |
|
|
139 |
|
|
140 def __init__(self): |
|
|
141 ImportCommand.__init__(self, b'checkpoint') |
|
|
142 |
|
|
143 def __bytes__(self): |
|
|
144 return b'checkpoint' |
|
|
145 |
|
|
146 |
|
|
147 class CommitCommand(ImportCommand): |
|
|
148 |
|
|
149 def __init__(self, ref, mark, author, committer, message, from_, |
|
|
150 merges, file_iter, lineno=0, more_authors=None, properties=None): |
|
|
151 ImportCommand.__init__(self, b'commit') |
|
|
152 self.ref = ref |
|
|
153 self.mark = mark |
|
|
154 self.author = author |
|
|
155 self.committer = committer |
|
|
156 self.message = message |
|
|
157 self.from_ = from_ |
|
|
158 self.merges = merges |
|
|
159 self.file_iter = file_iter |
|
|
160 self.more_authors = more_authors |
|
|
161 self.properties = properties |
|
|
162 self.lineno = lineno |
|
|
163 self._binary = [b'file_iter'] |
|
|
164 # Provide a unique id in case the mark is missing |
|
|
165 if self.mark is None: |
|
|
166 self.id = b'@' + ('%d' % lineno).encode('utf-8') |
|
|
167 else: |
|
|
168 if isinstance(self.mark, (int)): |
|
|
169 self.id = b':' + str(self.mark).encode('utf-8') |
|
|
170 else: |
|
|
171 self.id = b':' + self.mark |
|
|
172 |
|
|
173 def copy(self, **kwargs): |
|
|
174 if not isinstance(self.file_iter, list): |
|
|
175 self.file_iter = list(self.file_iter) |
|
|
176 |
|
|
177 fields = dict( |
|
|
178 (key, value) |
|
|
179 for key, value in self.__dict__.items() |
|
|
180 if key not in ('id', 'name') |
|
|
181 if not key.startswith('_') |
|
|
182 ) |
|
|
183 |
|
|
184 fields.update(kwargs) |
|
|
185 |
|
|
186 return CommitCommand(**fields) |
|
|
187 |
|
|
188 def __bytes__(self): |
|
|
189 return self.to_string(include_file_contents=True) |
|
|
190 |
|
|
191 |
|
|
192 def to_string(self, use_features=True, include_file_contents=False): |
|
|
193 """ |
|
|
194 @todo the name to_string is ambiguous since the method actually |
|
|
195 returns bytes. |
|
|
196 """ |
|
|
197 if self.mark is None: |
|
|
198 mark_line = b'' |
|
|
199 else: |
|
|
200 if isinstance(self.mark, (int)): |
|
|
201 mark_line = b'\nmark :' + str(self.mark).encode('utf-8') |
|
|
202 else: |
|
|
203 mark_line = b'\nmark :' + self.mark |
|
|
204 |
|
|
205 if self.author is None: |
|
|
206 author_section = b'' |
|
|
207 else: |
|
|
208 author_section = b'\nauthor ' + format_who_when(self.author) |
|
|
209 if use_features and self.more_authors: |
|
|
210 for author in self.more_authors: |
|
|
211 author_section += b'\nauthor ' + format_who_when(author) |
|
|
212 |
|
|
213 committer = b'committer ' + format_who_when(self.committer) |
|
|
214 |
|
|
215 if self.message is None: |
|
|
216 msg_section = b'' |
|
|
217 else: |
|
|
218 msg = self.message |
|
|
219 msg_section = ('\ndata %d\n' % len(msg)).encode('ascii') + msg |
|
|
220 if self.from_ is None: |
|
|
221 from_line = b'' |
|
|
222 else: |
|
|
223 from_line = b'\nfrom ' + self.from_ |
|
|
224 if self.merges is None: |
|
|
225 merge_lines = b'' |
|
|
226 else: |
|
|
227 merge_lines = b''.join([b'\nmerge ' + m |
|
|
228 for m in self.merges]) |
|
|
229 if use_features and self.properties: |
|
|
230 property_lines = [] |
|
|
231 for name in sorted(self.properties): |
|
|
232 value = self.properties[name] |
|
|
233 property_lines.append(b'\n' + format_property(name, value)) |
|
|
234 properties_section = b''.join(property_lines) |
|
|
235 else: |
|
|
236 properties_section = b'' |
|
|
237 if self.file_iter is None: |
|
|
238 filecommands = b'' |
|
|
239 else: |
|
|
240 if include_file_contents: |
|
|
241 filecommands = b''.join([b'\n' + repr_bytes(c) |
|
|
242 for c in self.iter_files()]) |
|
|
243 else: |
|
|
244 filecommands = b''.join([b'\n' + str(c) |
|
|
245 for c in self.iter_files()]) |
|
|
246 return b''.join([ |
|
|
247 b'commit ', |
|
|
248 self.ref, |
|
|
249 mark_line, |
|
|
250 author_section + b'\n', |
|
|
251 committer, |
|
|
252 msg_section, |
|
|
253 from_line, |
|
|
254 merge_lines, |
|
|
255 properties_section, |
|
|
256 filecommands]) |
|
|
257 |
|
|
258 def dump_str(self, names=None, child_lists=None, verbose=False): |
|
|
259 result = [ImportCommand.dump_str(self, names, verbose=verbose)] |
|
|
260 for f in self.iter_files(): |
|
|
261 if child_lists is None: |
|
|
262 continue |
|
|
263 try: |
|
|
264 child_names = child_lists[f.name] |
|
|
265 except KeyError: |
|
|
266 continue |
|
|
267 result.append('\t%s' % f.dump_str(child_names, verbose=verbose)) |
|
|
268 return '\n'.join(result) |
|
|
269 |
|
|
270 def iter_files(self): |
|
|
271 """Iterate over files.""" |
|
|
272 # file_iter may be a callable or an iterator |
|
|
273 if callable(self.file_iter): |
|
|
274 return self.file_iter() |
|
|
275 return iter(self.file_iter) |
|
|
276 |
|
|
277 |
|
|
278 class FeatureCommand(ImportCommand): |
|
|
279 |
|
|
280 def __init__(self, feature_name, value=None, lineno=0): |
|
|
281 ImportCommand.__init__(self, b'feature') |
|
|
282 self.feature_name = feature_name |
|
|
283 self.value = value |
|
|
284 self.lineno = lineno |
|
|
285 |
|
|
286 def __bytes__(self): |
|
|
287 if self.value is None: |
|
|
288 value_text = b'' |
|
|
289 else: |
|
|
290 value_text = b'=' + self.value |
|
|
291 return b'feature ' + self.feature_name + value_text |
|
|
292 |
|
|
293 |
|
|
294 class ProgressCommand(ImportCommand): |
|
|
295 |
|
|
296 def __init__(self, message): |
|
|
297 ImportCommand.__init__(self, b'progress') |
|
|
298 self.message = message |
|
|
299 |
|
|
300 def __bytes__(self): |
|
|
301 return b'progress ' + self.message |
|
|
302 |
|
|
303 |
|
|
304 class ResetCommand(ImportCommand): |
|
|
305 |
|
|
306 def __init__(self, ref, from_): |
|
|
307 ImportCommand.__init__(self, b'reset') |
|
|
308 self.ref = ref |
|
|
309 self.from_ = from_ |
|
|
310 |
|
|
311 def __bytes__(self): |
|
|
312 if self.from_ is None: |
|
|
313 from_line = b'' |
|
|
314 else: |
|
|
315 # According to git-fast-import(1), the extra LF is optional here; |
|
|
316 # however, versions of git up to 1.5.4.3 had a bug by which the LF |
|
|
317 # was needed. Always emit it, since it doesn't hurt and maintains |
|
|
318 # compatibility with older versions. |
|
|
319 # http://git.kernel.org/?p=git/git.git;a=commit;h=655e8515f279c01f525745d443f509f97cd805ab |
|
|
320 from_line = b'\nfrom ' + self.from_ + b'\n' |
|
|
321 return b'reset ' + self.ref + from_line |
|
|
322 |
|
|
323 |
|
|
324 class TagCommand(ImportCommand): |
|
|
325 |
|
|
326 def __init__(self, id, from_, tagger, message): |
|
|
327 ImportCommand.__init__(self, b'tag') |
|
|
328 self.id = id |
|
|
329 self.from_ = from_ |
|
|
330 self.tagger = tagger |
|
|
331 self.message = message |
|
|
332 |
|
|
333 def __bytes__(self): |
|
|
334 if self.from_ is None: |
|
|
335 from_line = b'' |
|
|
336 else: |
|
|
337 from_line = b'\nfrom ' + self.from_ |
|
|
338 if self.tagger is None: |
|
|
339 tagger_line = b'' |
|
|
340 else: |
|
|
341 tagger_line = b'\ntagger ' + format_who_when(self.tagger) |
|
|
342 if self.message is None: |
|
|
343 msg_section = b'' |
|
|
344 else: |
|
|
345 msg = self.message |
|
|
346 msg_section = ('\ndata %d\n' % len(msg)).encode('ascii') + msg |
|
|
347 return b'tag ' + self.id + from_line + tagger_line + msg_section |
|
|
348 |
|
|
349 |
|
|
350 class FileCommand(ImportCommand): |
|
|
351 """Base class for file commands.""" |
|
|
352 pass |
|
|
353 |
|
|
354 |
|
|
355 class FileModifyCommand(FileCommand): |
|
|
356 |
|
|
357 def __init__(self, path, mode, dataref, data): |
|
|
358 # Either dataref or data should be null |
|
|
359 FileCommand.__init__(self, b'filemodify') |
|
|
360 self.path = check_path(path) |
|
|
361 self.mode = mode |
|
|
362 self.dataref = dataref |
|
|
363 self.data = data |
|
|
364 self._binary = [b'data'] |
|
|
365 |
|
|
366 def __bytes__(self): |
|
|
367 return self.to_string(include_file_contents=True) |
|
|
368 |
|
|
369 def __str__(self): |
|
|
370 return self.to_string(include_file_contents=False) |
|
|
371 |
|
|
372 def _format_mode(self, mode): |
|
|
373 if mode in (0o755, 0o100755): |
|
|
374 return b'755' |
|
|
375 elif mode in (0o644, 0o100644): |
|
|
376 return b'644' |
|
|
377 elif mode == 0o40000: |
|
|
378 return b'040000' |
|
|
379 elif mode == 0o120000: |
|
|
380 return b'120000' |
|
|
381 elif mode == 0o160000: |
|
|
382 return b'160000' |
|
|
383 else: |
|
|
384 raise AssertionError('Unknown mode %o' % mode) |
|
|
385 |
|
|
386 def to_string(self, include_file_contents=False): |
|
|
387 datastr = b'' |
|
|
388 if stat.S_ISDIR(self.mode): |
|
|
389 dataref = b'-' |
|
|
390 elif self.dataref is None: |
|
|
391 dataref = b'inline' |
|
|
392 if include_file_contents: |
|
|
393 datastr = ('\ndata %d\n' % len(self.data)).encode('ascii') + self.data |
|
|
394 else: |
|
|
395 dataref = self.dataref |
|
|
396 path = format_path(self.path) |
|
|
397 |
|
|
398 return b' '.join( |
|
|
399 [b'M', self._format_mode(self.mode), dataref, path + datastr]) |
|
|
400 |
|
|
401 |
|
|
402 class FileDeleteCommand(FileCommand): |
|
|
403 |
|
|
404 def __init__(self, path): |
|
|
405 FileCommand.__init__(self, b'filedelete') |
|
|
406 self.path = check_path(path) |
|
|
407 |
|
|
408 def __bytes__(self): |
|
|
409 return b' '.join([b'D', format_path(self.path)]) |
|
|
410 |
|
|
411 |
|
|
412 class FileCopyCommand(FileCommand): |
|
|
413 |
|
|
414 def __init__(self, src_path, dest_path): |
|
|
415 FileCommand.__init__(self, b'filecopy') |
|
|
416 self.src_path = check_path(src_path) |
|
|
417 self.dest_path = check_path(dest_path) |
|
|
418 |
|
|
419 def __bytes__(self): |
|
|
420 return b' '.join([b'C', |
|
|
421 format_path(self.src_path, quote_spaces=True), |
|
|
422 format_path(self.dest_path)]) |
|
|
423 |
|
|
424 |
|
|
425 class FileRenameCommand(FileCommand): |
|
|
426 |
|
|
427 def __init__(self, old_path, new_path): |
|
|
428 FileCommand.__init__(self, b'filerename') |
|
|
429 self.old_path = check_path(old_path) |
|
|
430 self.new_path = check_path(new_path) |
|
|
431 |
|
|
432 def __bytes__(self): |
|
|
433 return b' '.join([ |
|
|
434 b'R', |
|
|
435 format_path(self.old_path, quote_spaces=True), |
|
|
436 format_path(self.new_path)] |
|
|
437 ) |
|
|
438 |
|
|
439 |
|
|
440 class FileDeleteAllCommand(FileCommand): |
|
|
441 |
|
|
442 def __init__(self): |
|
|
443 FileCommand.__init__(self, b'filedeleteall') |
|
|
444 |
|
|
445 def __bytes__(self): |
|
|
446 return b'deleteall' |
|
|
447 |
|
|
448 |
|
|
449 class NoteModifyCommand(FileCommand): |
|
|
450 |
|
|
451 def __init__(self, from_, data): |
|
|
452 super(NoteModifyCommand, self).__init__(b'notemodify') |
|
|
453 self.from_ = from_ |
|
|
454 self.data = data |
|
|
455 self._binary = ['data'] |
|
|
456 |
|
|
457 def __bytes__(self): |
|
|
458 return (b'N inline :' + self.from_ + |
|
|
459 ('\ndata %d\n'% len(self.data)).encode('ascii') + self.data) |
|
|
460 |
|
|
461 |
|
|
462 def check_path(path): |
|
|
463 """Check that a path is legal. |
|
|
464 |
|
|
465 :return: the path if all is OK |
|
|
466 :raise ValueError: if the path is illegal |
|
|
467 """ |
|
|
468 if path is None or path == b'' or path.startswith(b'/'): |
|
|
469 raise ValueError("illegal path '%s'" % path) |
|
|
470 |
|
|
471 if ( |
|
|
472 (sys.version_info[0] >= 3 and not isinstance(path, bytes)) and |
|
|
473 (sys.version_info[0] == 2 and not isinstance(path, str)) |
|
|
474 ): |
|
|
475 raise TypeError("illegale type for path '%r'" % path) |
|
|
476 |
|
|
477 return path |
|
|
478 |
|
|
479 |
|
|
480 def format_path(p, quote_spaces=False): |
|
|
481 """Format a path in utf8, quoting it if necessary.""" |
|
|
482 if b'\n' in p: |
|
|
483 p = re.sub(b'\n', b'\\n', p) |
|
|
484 quote = True |
|
|
485 else: |
|
|
486 quote = p[0] == b'"' or (quote_spaces and b' ' in p) |
|
|
487 if quote: |
|
|
488 extra = GIT_FAST_IMPORT_NEEDS_EXTRA_SPACE_AFTER_QUOTE and b' ' or b'' |
|
|
489 p = b'"' + p + b'"' + extra |
|
|
490 return p |
|
|
491 |
|
|
492 |
|
|
493 def format_who_when(fields): |
|
|
494 """Format a tuple of name,email,secs-since-epoch,utc-offset-secs as a string.""" |
|
|
495 offset = fields[3] |
|
|
496 if offset < 0: |
|
|
497 offset_sign = b'-' |
|
|
498 offset = abs(offset) |
|
|
499 else: |
|
|
500 offset_sign = b'+' |
|
|
501 offset_hours = offset // 3600 |
|
|
502 offset_minutes = offset // 60 - offset_hours * 60 |
|
|
503 offset_str = offset_sign + ('%02d%02d' % (offset_hours, offset_minutes)).encode('ascii') |
|
|
504 name = fields[0] |
|
|
505 |
|
|
506 if name == b'': |
|
|
507 sep = b'' |
|
|
508 else: |
|
|
509 sep = b' ' |
|
|
510 |
|
|
511 name = utf8_bytes_string(name) |
|
|
512 |
|
|
513 email = fields[1] |
|
|
514 |
|
|
515 email = utf8_bytes_string(email) |
|
|
516 |
|
|
517 return b''.join((name, sep, b'<', email, b'> ', ("%d" % fields[2]).encode('ascii'), b' ', offset_str)) |
|
|
518 |
|
|
519 |
|
|
520 def format_property(name, value): |
|
|
521 """Format the name and value (both unicode) of a property as a string.""" |
|
|
522 result = b'' |
|
|
523 utf8_name = utf8_bytes_string(name) |
|
|
524 |
|
|
525 result = b'property ' + utf8_name |
|
|
526 if value is not None: |
|
|
527 utf8_value = utf8_bytes_string(value) |
|
|
528 result += b' ' + ('%d' % len(utf8_value)).encode('ascii') + b' ' + utf8_value |
|
|
529 |
|
|
530 return result |