git-remote-hg: improve sanitation of local repo urls
[git] / git_remote_helpers / fastimport / commands.py
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, write to the Free Software
15 # Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
16
17 """Import command classes."""
18
19 import os
20
21 # There is a bug in git 1.5.4.3 and older by which unquoting a string consumes
22 # one extra character. Set this variable to True to work-around it. It only
23 # happens when renaming a file whose name contains spaces and/or quotes, and
24 # the symptom is:
25 #   % git-fast-import
26 #   fatal: Missing space after source: R "file 1.txt" file 2.txt
27 # http://git.kernel.org/?p=git/git.git;a=commit;h=c8744d6a8b27115503565041566d97c21e722584
28 GIT_FAST_IMPORT_NEEDS_EXTRA_SPACE_AFTER_QUOTE = False
29
30
31 # Lists of command names
32 COMMAND_NAMES = ['blob', 'checkpoint', 'commit', 'feature', 'progress',
33     'reset', 'tag']
34 FILE_COMMAND_NAMES = ['filemodify', 'filedelete', 'filecopy', 'filerename',
35     'filedeleteall']
36
37
38 # Feature names
39 MULTIPLE_AUTHORS_FEATURE = "multiple-authors"
40 COMMIT_PROPERTIES_FEATURE = "commit-properties"
41 EMPTY_DIRS_FEATURE = "empty-directories"
42 FEATURE_NAMES = [
43     MULTIPLE_AUTHORS_FEATURE,
44     COMMIT_PROPERTIES_FEATURE,
45     EMPTY_DIRS_FEATURE,
46     ]
47
48
49 # for classes with no meaningful __str__()
50 def _simplerepr(self):
51     return "<%s at 0x%x>" % (self.__class__.__name__, id(self))
52
53 # classes that define __str__() should use this instead
54 def _detailrepr(self):
55     return ("<%s at 0x%x: %s>"
56             % (self.__class__.__name__, id(self), str(self)))
57
58
59 class ImportCommand(object):
60     """Base class for import commands."""
61
62     def __init__(self, name):
63         self.name = name
64         # List of field names not to display
65         self._binary = []
66
67     __repr__ = _simplerepr
68
69     def format(self):
70         """Format this command as a fastimport dump fragment.
71
72         Returns a (possibly multiline) string that, if seen in a
73         fastimport stream, would parse to an equivalent command object.
74         """
75         raise NotImplementedError("abstract method")
76
77     def dump_str(self, names=None, child_lists=None, verbose=False):
78         """Dump fields as a string.
79
80         :param names: the list of fields to include or
81             None for all public fields
82         :param child_lists: dictionary of child command names to
83             fields for that child command to include
84         :param verbose: if True, prefix each line with the command class and
85             display fields as a dictionary; if False, dump just the field
86             values with tabs between them
87         """
88         interesting = {}
89         if names is None:
90             fields = [k for k in self.__dict__.keys() if not k.startswith('_')]
91         else:
92             fields = names
93         for field in fields:
94             value = self.__dict__.get(field)
95             if field in self._binary and value is not None:
96                 value = '(...)'
97             interesting[field] = value
98         if verbose:
99             return "%s: %s" % (self.__class__.__name__, interesting)
100         else:
101             return "\t".join([repr(interesting[k]) for k in fields])
102
103
104 class _MarkMixin(object):
105     """mixin for fastimport commands with a mark: blob, commit."""
106     def __init__(self, mark, location):
107         self.mark= mark
108         self.location = location
109
110         # Provide a unique id in case the mark is missing
111         if mark is None:
112             self.id = '%s@%d' % (os.path.basename(location[0]), location[1])
113         else:
114             self.id = ':%s' % mark
115
116     def __str__(self):
117         return self.id
118
119     __repr__ = _detailrepr
120
121
122 class BlobCommand(ImportCommand, _MarkMixin):
123
124     def __init__(self, mark, data, location):
125         ImportCommand.__init__(self, 'blob')
126         _MarkMixin.__init__(self, mark, location)
127         self.data = data
128         self._binary = ['data']
129
130     def format(self):
131         if self.mark is None:
132             mark_line = ""
133         else:
134             mark_line = "\nmark :%s" % self.mark
135         return "blob%s\ndata %d\n%s" % (mark_line, len(self.data), self.data)
136
137
138 class CheckpointCommand(ImportCommand):
139
140     def __init__(self):
141         ImportCommand.__init__(self, 'checkpoint')
142
143     def format(self):
144         return "checkpoint"
145
146
147 class CommitCommand(ImportCommand, _MarkMixin):
148
149     def __init__(self, ref, mark, author, committer, message, from_,
150         merges, file_cmds, location=None, more_authors=None, properties=None):
151         ImportCommand.__init__(self, 'commit')
152         _MarkMixin.__init__(self, mark, location)
153         self.ref = ref
154         self.author = author
155         self.committer = committer
156         self.message = message
157         self.from_ = from_
158         self.merges = merges
159         self.file_cmds = file_cmds
160         self.more_authors = more_authors
161         self.properties = properties
162         self._binary = ['file_cmds']
163
164     def format(self, use_features=True, include_file_contents=True):
165         if self.mark is None:
166             mark_line = ""
167         else:
168             mark_line = "\nmark :%s" % self.mark
169         if self.author is None:
170             author_section = ""
171         else:
172             author_section = "\nauthor %s" % format_who_when(self.author)
173             if use_features and self.more_authors:
174                 for author in self.more_authors:
175                     author_section += "\nauthor %s" % format_who_when(author)
176         committer = "committer %s" % format_who_when(self.committer)
177         if self.message is None:
178             msg_section = ""
179         else:
180             msg = self.message.encode('utf8')
181             msg_section = "\ndata %d\n%s" % (len(msg), msg)
182         if self.from_ is None:
183             from_line = ""
184         else:
185             from_line = "\nfrom %s" % self.from_
186         if self.merges is None:
187             merge_lines = ""
188         else:
189             merge_lines = "".join(["\nmerge %s" % (m,)
190                 for m in self.merges])
191         if use_features and self.properties:
192             property_lines = []
193             for name in sorted(self.properties):
194                 value = self.properties[name]
195                 property_lines.append("\n" + format_property(name, value))
196             properties_section = "".join(property_lines)
197         else:
198             properties_section = ""
199         if self.file_cmds is None:
200             filecommands = ""
201         else:
202             if include_file_contents:
203                 format_str = "\n%r"
204             else:
205                 format_str = "\n%s"
206             filecommands = "".join(
207                 ["\n" + fc.format() for fc in self.file_cmds])
208         return "commit %s%s%s\n%s%s%s%s%s%s" % (self.ref, mark_line,
209             author_section, committer, msg_section, from_line, merge_lines,
210             properties_section, filecommands)
211
212     def dump_str(self, names=None, child_lists=None, verbose=False):
213         result = [ImportCommand.dump_str(self, names, verbose=verbose)]
214         for f in self.file_cmds:
215             if child_lists is None:
216                 continue
217             try:
218                 child_names = child_lists[f.name]
219             except KeyError:
220                 continue
221             result.append("\t%s" % f.dump_str(child_names, verbose=verbose))
222         return '\n'.join(result)
223
224
225 class FeatureCommand(ImportCommand):
226
227     def __init__(self, feature_name, value=None, location=None):
228         ImportCommand.__init__(self, 'feature')
229         self.feature_name = feature_name
230         self.value = value
231         self.location = location
232
233     def format(self):
234         if self.value is None:
235             value_text = ""
236         else:
237             value_text = "=%s" % self.value
238         return "feature %s%s" % (self.feature_name, value_text)
239
240
241 class ProgressCommand(ImportCommand):
242
243     def __init__(self, message):
244         ImportCommand.__init__(self, 'progress')
245         self.message = message
246
247     def format(self):
248         return "progress %s" % (self.message,)
249
250
251 class ResetCommand(ImportCommand):
252
253     def __init__(self, ref, from_):
254         ImportCommand.__init__(self, 'reset')
255         self.ref = ref
256         self.from_ = from_
257
258     def format(self):
259         if self.from_ is None:
260             from_line = ""
261         else:
262             # According to git-fast-import(1), the extra LF is optional here;
263             # however, versions of git up to 1.5.4.3 had a bug by which the LF
264             # was needed. Always emit it, since it doesn't hurt and maintains
265             # compatibility with older versions.
266             # http://git.kernel.org/?p=git/git.git;a=commit;h=655e8515f279c01f525745d443f509f97cd805ab
267             from_line = "\nfrom %s\n" % self.from_
268         return "reset %s%s" % (self.ref, from_line)
269
270
271 class TagCommand(ImportCommand):
272
273     def __init__(self, id, from_, tagger, message):
274         ImportCommand.__init__(self, 'tag')
275         self.id = id
276         self.from_ = from_
277         self.tagger = tagger
278         self.message = message
279
280     def __str__(self):
281         return self.id
282
283     __repr__ = _detailrepr
284
285     def format(self):
286         if self.from_ is None:
287             from_line = ""
288         else:
289             from_line = "\nfrom %s" % self.from_
290         if self.tagger is None:
291             tagger_line = ""
292         else:
293             tagger_line = "\ntagger %s" % format_who_when(self.tagger)
294         if self.message is None:
295             msg_section = ""
296         else:
297             msg = self.message.encode('utf8')
298             msg_section = "\ndata %d\n%s" % (len(msg), msg)
299         return "tag %s%s%s%s" % (self.id, from_line, tagger_line, msg_section)
300
301
302 class FileCommand(ImportCommand):
303     """Base class for file commands."""
304     pass
305
306
307 class FileModifyCommand(FileCommand):
308
309     def __init__(self, path, mode, dataref, data):
310         # Either dataref or data should be null
311         FileCommand.__init__(self, 'filemodify')
312         self.path = check_path(path)
313         self.mode = mode
314         self.dataref = dataref
315         self.data = data
316         self._binary = ['data']
317
318     def __str__(self):
319         return self.path
320
321     __repr__ = _detailrepr
322
323     def format(self, include_file_contents=True):
324         datastr = ""
325         if self.dataref is None:
326             dataref = "inline"
327             if include_file_contents:
328                 datastr = "\ndata %d\n%s" % (len(self.data), self.data)
329         else:
330             dataref = "%s" % (self.dataref,)
331         path = format_path(self.path)
332         return "M %s %s %s%s" % (self.mode, dataref, path, datastr)
333
334     def is_regular(self):
335         """Return true if this is a regular file (mode 644)."""
336         return self.mode.endswith("644")
337
338     def is_executable(self):
339         """Return true if this is an executable file (mode 755)."""
340         return self.mode.endswith("755")
341
342     def is_symlink(self):
343         """Return true if this is a symlink (mode 120000)."""
344         return self.mode == "120000"
345
346     def is_gitlink(self):
347         """Return true if this is a gitlink (mode 160000)."""
348         return self.mode == "160000"
349
350
351 class FileDeleteCommand(FileCommand):
352
353     def __init__(self, path):
354         FileCommand.__init__(self, 'filedelete')
355         self.path = check_path(path)
356
357     def __str__(self):
358         return self.path
359
360     __repr__ = _detailrepr
361
362     def format(self):
363         return "D %s" % (format_path(self.path),)
364
365
366 class FileCopyCommand(FileCommand):
367
368     def __init__(self, src_path, dest_path):
369         FileCommand.__init__(self, 'filecopy')
370         self.src_path = check_path(src_path)
371         self.dest_path = check_path(dest_path)
372
373     def __str__(self):
374         return "%s -> %s" % (self.src_path, self.dest_path)
375
376     __repr__ = _detailrepr
377
378     def format(self):
379         return "C %s %s" % (
380             format_path(self.src_path, quote_spaces=True),
381             format_path(self.dest_path))
382
383
384 class FileRenameCommand(FileCommand):
385
386     def __init__(self, old_path, new_path):
387         FileCommand.__init__(self, 'filerename')
388         self.old_path = check_path(old_path)
389         self.new_path = check_path(new_path)
390
391     def __str__(self):
392         return "%s -> %s" % (self.old_path, self.new_path)
393
394     __repr__ = _detailrepr
395
396     def format(self):
397         return "R %s %s" % (
398             format_path(self.old_path, quote_spaces=True),
399             format_path(self.new_path))
400
401
402 class FileDeleteAllCommand(FileCommand):
403
404     def __init__(self):
405         FileCommand.__init__(self, 'filedeleteall')
406
407     def format(self):
408         return "deleteall"
409
410
411 def check_path(path):
412     """Check that a path is legal.
413
414     :return: the path if all is OK
415     :raise ValueError: if the path is illegal
416     """
417     if path is None or path == '':
418         raise ValueError("illegal path '%s'" % path)
419     return path
420
421
422 def format_path(p, quote_spaces=False):
423     """Format a path in utf8, quoting it if necessary."""
424     if '\n' in p:
425         import re
426         p = re.sub('\n', '\\n', p)
427         quote = True
428     else:
429         quote = p[0] == '"' or (quote_spaces and ' ' in p)
430     if quote:
431         extra = GIT_FAST_IMPORT_NEEDS_EXTRA_SPACE_AFTER_QUOTE and ' ' or ''
432         p = '"%s"%s' % (p, extra)
433     return p.encode('utf8')
434
435
436 def format_who_when(fields):
437     """Format a tuple of name,email,secs-since-epoch,utc-offset-secs as a string."""
438     offset = fields[3]
439     if offset < 0:
440         offset_sign = '-'
441         offset = abs(offset)
442     else:
443         offset_sign = '+'
444     offset_hours = offset / 3600
445     offset_minutes = offset / 60 - offset_hours * 60
446     offset_str = "%s%02d%02d" % (offset_sign, offset_hours, offset_minutes)
447     name = fields[0]
448     if name == '':
449         sep = ''
450     else:
451         sep = ' '
452     if isinstance(name, unicode):
453         name = name.encode('utf8')
454     email = fields[1]
455     if isinstance(email, unicode):
456         email = email.encode('utf8')
457     result = "%s%s<%s> %d %s" % (name, sep, email, fields[2], offset_str)
458     return result
459
460
461 def format_property(name, value):
462     """Format the name and value (both unicode) of a property as a string."""
463     utf8_name = name.encode('utf8')
464     if value is not None:
465         utf8_value = value.encode('utf8')
466         result = "property %s %d %s" % (utf8_name, len(utf8_value), utf8_value)
467     else:
468         result = "property %s" % (utf8_name,)
469     return result