remote-hg: add simple mail test
[git] / contrib / remote-helpers / git-remote-hg
1 #!/usr/bin/env python
2 #
3 # Copyright (c) 2012 Felipe Contreras
4 #
5
6 # Inspired by Rocco Rutte's hg-fast-export
7
8 # Just copy to your ~/bin, or anywhere in your $PATH.
9 # Then you can clone with:
10 # git clone hg::/path/to/mercurial/repo/
11
12 from mercurial import hg, ui, bookmarks, context, util, encoding, node, error
13
14 import re
15 import sys
16 import os
17 import json
18 import shutil
19 import subprocess
20 import urllib
21
22 #
23 # If you want to switch to hg-git compatibility mode:
24 # git config --global remote-hg.hg-git-compat true
25 #
26 # If you are not in hg-git-compat mode and want to disable the tracking of
27 # named branches:
28 # git config --global remote-hg.track-branches false
29 #
30 # If you don't want to force pushes (and thus risk creating new remote heads):
31 # git config --global remote-hg.force-push false
32 #
33 # git:
34 # Sensible defaults for git.
35 # hg bookmarks are exported as git branches, hg branches are prefixed
36 # with 'branches/', HEAD is a special case.
37 #
38 # hg:
39 # Emulate hg-git.
40 # Only hg bookmarks are exported as git branches.
41 # Commits are modified to preserve hg information and allow bidirectionality.
42 #
43
44 NAME_RE = re.compile('^([^<>]+)')
45 AUTHOR_RE = re.compile('^([^<>]+?)? ?<([^<>]*)>$')
46 AUTHOR_HG_RE = re.compile('^(.*?) ?<(.*?)(?:>(.+)?)?$')
47 RAW_AUTHOR_RE = re.compile('^(\w+) (?:(.+)? )?<(.*)> (\d+) ([+-]\d+)')
48
49 def die(msg, *args):
50     sys.stderr.write('ERROR: %s\n' % (msg % args))
51     sys.exit(1)
52
53 def warn(msg, *args):
54     sys.stderr.write('WARNING: %s\n' % (msg % args))
55
56 def gitmode(flags):
57     return 'l' in flags and '120000' or 'x' in flags and '100755' or '100644'
58
59 def gittz(tz):
60     return '%+03d%02d' % (-tz / 3600, -tz % 3600 / 60)
61
62 def hgmode(mode):
63     m = { '100755': 'x', '120000': 'l' }
64     return m.get(mode, '')
65
66 def hghex(node):
67     return hg.node.hex(node)
68
69 def get_config(config):
70     cmd = ['git', 'config', '--get', config]
71     process = subprocess.Popen(cmd, stdout=subprocess.PIPE)
72     output, _ = process.communicate()
73     return output
74
75 class Marks:
76
77     def __init__(self, path):
78         self.path = path
79         self.tips = {}
80         self.marks = {}
81         self.rev_marks = {}
82         self.last_mark = 0
83
84         self.load()
85
86     def load(self):
87         if not os.path.exists(self.path):
88             return
89
90         tmp = json.load(open(self.path))
91
92         self.tips = tmp['tips']
93         self.marks = tmp['marks']
94         self.last_mark = tmp['last-mark']
95
96         for rev, mark in self.marks.iteritems():
97             self.rev_marks[mark] = int(rev)
98
99     def dict(self):
100         return { 'tips': self.tips, 'marks': self.marks, 'last-mark' : self.last_mark }
101
102     def store(self):
103         json.dump(self.dict(), open(self.path, 'w'))
104
105     def __str__(self):
106         return str(self.dict())
107
108     def from_rev(self, rev):
109         return self.marks[str(rev)]
110
111     def to_rev(self, mark):
112         return self.rev_marks[mark]
113
114     def get_mark(self, rev):
115         self.last_mark += 1
116         self.marks[str(rev)] = self.last_mark
117         return self.last_mark
118
119     def new_mark(self, rev, mark):
120         self.marks[str(rev)] = mark
121         self.rev_marks[mark] = rev
122         self.last_mark = mark
123
124     def is_marked(self, rev):
125         return self.marks.has_key(str(rev))
126
127     def get_tip(self, branch):
128         return self.tips.get(branch, 0)
129
130     def set_tip(self, branch, tip):
131         self.tips[branch] = tip
132
133 class Parser:
134
135     def __init__(self, repo):
136         self.repo = repo
137         self.line = self.get_line()
138
139     def get_line(self):
140         return sys.stdin.readline().strip()
141
142     def __getitem__(self, i):
143         return self.line.split()[i]
144
145     def check(self, word):
146         return self.line.startswith(word)
147
148     def each_block(self, separator):
149         while self.line != separator:
150             yield self.line
151             self.line = self.get_line()
152
153     def __iter__(self):
154         return self.each_block('')
155
156     def next(self):
157         self.line = self.get_line()
158         if self.line == 'done':
159             self.line = None
160
161     def get_mark(self):
162         i = self.line.index(':') + 1
163         return int(self.line[i:])
164
165     def get_data(self):
166         if not self.check('data'):
167             return None
168         i = self.line.index(' ') + 1
169         size = int(self.line[i:])
170         return sys.stdin.read(size)
171
172     def get_author(self):
173         global bad_mail
174
175         ex = None
176         m = RAW_AUTHOR_RE.match(self.line)
177         if not m:
178             return None
179         _, name, email, date, tz = m.groups()
180         if name and 'ext:' in name:
181             m = re.match('^(.+?) ext:\((.+)\)$', name)
182             if m:
183                 name = m.group(1)
184                 ex = urllib.unquote(m.group(2))
185
186         if email != bad_mail:
187             if name:
188                 user = '%s <%s>' % (name, email)
189             else:
190                 user = '<%s>' % (email)
191         else:
192             user = name
193
194         if ex:
195             user += ex
196
197         tz = int(tz)
198         tz = ((tz / 100) * 3600) + ((tz % 100) * 60)
199         return (user, int(date), -tz)
200
201 def export_file(fc):
202     d = fc.data()
203     print "M %s inline %s" % (gitmode(fc.flags()), fc.path())
204     print "data %d" % len(d)
205     print d
206
207 def get_filechanges(repo, ctx, parent):
208     modified = set()
209     added = set()
210     removed = set()
211
212     cur = ctx.manifest()
213     prev = repo[parent].manifest().copy()
214
215     for fn in cur:
216         if fn in prev:
217             if (cur.flags(fn) != prev.flags(fn) or cur[fn] != prev[fn]):
218                 modified.add(fn)
219             del prev[fn]
220         else:
221             added.add(fn)
222     removed |= set(prev.keys())
223
224     return added | modified, removed
225
226 def fixup_user_git(user):
227     name = mail = None
228     user = user.replace('"', '')
229     m = AUTHOR_RE.match(user)
230     if m:
231         name = m.group(1)
232         mail = m.group(2).strip()
233     else:
234         m = NAME_RE.match(user)
235         if m:
236             name = m.group(1).strip()
237     return (name, mail)
238
239 def fixup_user_hg(user):
240     def sanitize(name):
241         # stole this from hg-git
242         return re.sub('[<>\n]', '?', name.lstrip('< ').rstrip('> '))
243
244     m = AUTHOR_HG_RE.match(user)
245     if m:
246         name = sanitize(m.group(1))
247         mail = sanitize(m.group(2))
248         ex = m.group(3)
249         if ex:
250             name += ' ext:(' + urllib.quote(ex) + ')'
251     else:
252         name = sanitize(user)
253         if '@' in user:
254             mail = name
255         else:
256             mail = None
257
258     return (name, mail)
259
260 def fixup_user(user):
261     global mode, bad_mail
262
263     if mode == 'git':
264         name, mail = fixup_user_git(user)
265     else:
266         name, mail = fixup_user_hg(user)
267
268     if not name:
269         name = bad_name
270     if not mail:
271         mail = bad_mail
272
273     return '%s <%s>' % (name, mail)
274
275 def get_repo(url, alias):
276     global dirname, peer
277
278     myui = ui.ui()
279     myui.setconfig('ui', 'interactive', 'off')
280     myui.fout = sys.stderr
281
282     if hg.islocal(url):
283         repo = hg.repository(myui, url)
284     else:
285         local_path = os.path.join(dirname, 'clone')
286         if not os.path.exists(local_path):
287             try:
288                 peer, dstpeer = hg.clone(myui, {}, url, local_path, update=True, pull=True)
289             except:
290                 die('Repository error')
291             repo = dstpeer.local()
292         else:
293             repo = hg.repository(myui, local_path)
294             try:
295                 peer = hg.peer(myui, {}, url)
296             except:
297                 die('Repository error')
298             repo.pull(peer, heads=None, force=True)
299
300     return repo
301
302 def rev_to_mark(rev):
303     global marks
304     return marks.from_rev(rev)
305
306 def mark_to_rev(mark):
307     global marks
308     return marks.to_rev(mark)
309
310 def export_ref(repo, name, kind, head):
311     global prefix, marks, mode
312
313     ename = '%s/%s' % (kind, name)
314     tip = marks.get_tip(ename)
315
316     # mercurial takes too much time checking this
317     if tip and tip == head.rev():
318         # nothing to do
319         return
320     revs = xrange(tip, head.rev() + 1)
321     count = 0
322
323     revs = [rev for rev in revs if not marks.is_marked(rev)]
324
325     for rev in revs:
326
327         c = repo[rev]
328         (manifest, user, (time, tz), files, desc, extra) = repo.changelog.read(c.node())
329         rev_branch = extra['branch']
330
331         author = "%s %d %s" % (fixup_user(user), time, gittz(tz))
332         if 'committer' in extra:
333             user, time, tz = extra['committer'].rsplit(' ', 2)
334             committer = "%s %s %s" % (user, time, gittz(int(tz)))
335         else:
336             committer = author
337
338         parents = [p for p in repo.changelog.parentrevs(rev) if p >= 0]
339
340         if len(parents) == 0:
341             modified = c.manifest().keys()
342             removed = []
343         else:
344             modified, removed = get_filechanges(repo, c, parents[0])
345
346         if mode == 'hg':
347             extra_msg = ''
348
349             if rev_branch != 'default':
350                 extra_msg += 'branch : %s\n' % rev_branch
351
352             renames = []
353             for f in c.files():
354                 if f not in c.manifest():
355                     continue
356                 rename = c.filectx(f).renamed()
357                 if rename:
358                     renames.append((rename[0], f))
359
360             for e in renames:
361                 extra_msg += "rename : %s => %s\n" % e
362
363             for key, value in extra.iteritems():
364                 if key in ('author', 'committer', 'encoding', 'message', 'branch', 'hg-git'):
365                     continue
366                 else:
367                     extra_msg += "extra : %s : %s\n" % (key, urllib.quote(value))
368
369             desc += '\n'
370             if extra_msg:
371                 desc += '\n--HG--\n' + extra_msg
372
373         if len(parents) == 0 and rev:
374             print 'reset %s/%s' % (prefix, ename)
375
376         print "commit %s/%s" % (prefix, ename)
377         print "mark :%d" % (marks.get_mark(rev))
378         print "author %s" % (author)
379         print "committer %s" % (committer)
380         print "data %d" % (len(desc))
381         print desc
382
383         if len(parents) > 0:
384             print "from :%s" % (rev_to_mark(parents[0]))
385             if len(parents) > 1:
386                 print "merge :%s" % (rev_to_mark(parents[1]))
387
388         for f in modified:
389             export_file(c.filectx(f))
390         for f in removed:
391             print "D %s" % (f)
392         print
393
394         count += 1
395         if (count % 100 == 0):
396             print "progress revision %d '%s' (%d/%d)" % (rev, name, count, len(revs))
397             print "#############################################################"
398
399     # make sure the ref is updated
400     print "reset %s/%s" % (prefix, ename)
401     print "from :%u" % rev_to_mark(rev)
402     print
403
404     marks.set_tip(ename, rev)
405
406 def export_tag(repo, tag):
407     export_ref(repo, tag, 'tags', repo[tag])
408
409 def export_bookmark(repo, bmark):
410     head = bmarks[bmark]
411     export_ref(repo, bmark, 'bookmarks', head)
412
413 def export_branch(repo, branch):
414     tip = get_branch_tip(repo, branch)
415     head = repo[tip]
416     export_ref(repo, branch, 'branches', head)
417
418 def export_head(repo):
419     global g_head
420     export_ref(repo, g_head[0], 'bookmarks', g_head[1])
421
422 def do_capabilities(parser):
423     global prefix, dirname
424
425     print "import"
426     print "export"
427     print "refspec refs/heads/branches/*:%s/branches/*" % prefix
428     print "refspec refs/heads/*:%s/bookmarks/*" % prefix
429     print "refspec refs/tags/*:%s/tags/*" % prefix
430
431     path = os.path.join(dirname, 'marks-git')
432
433     if os.path.exists(path):
434         print "*import-marks %s" % path
435     print "*export-marks %s" % path
436
437     print
438
439 def get_branch_tip(repo, branch):
440     global branches
441
442     heads = branches.get(branch, None)
443     if not heads:
444         return None
445
446     # verify there's only one head
447     if (len(heads) > 1):
448         warn("Branch '%s' has more than one head, consider merging" % branch)
449         # older versions of mercurial don't have this
450         if hasattr(repo, "branchtip"):
451             return repo.branchtip(branch)
452
453     return heads[0]
454
455 def list_head(repo, cur):
456     global g_head, bmarks
457
458     head = bookmarks.readcurrent(repo)
459     if head:
460         node = repo[head]
461     else:
462         # fake bookmark from current branch
463         head = cur
464         node = repo['.']
465         if not node:
466             node = repo['tip']
467         if not node:
468             return
469         if head == 'default':
470             head = 'master'
471         bmarks[head] = node
472
473     print "@refs/heads/%s HEAD" % head
474     g_head = (head, node)
475
476 def do_list(parser):
477     global branches, bmarks, mode, track_branches
478
479     repo = parser.repo
480     for bmark, node in bookmarks.listbookmarks(repo).iteritems():
481         bmarks[bmark] = repo[node]
482
483     cur = repo.dirstate.branch()
484
485     list_head(repo, cur)
486
487     if track_branches:
488         for branch in repo.branchmap():
489             heads = repo.branchheads(branch)
490             if len(heads):
491                 branches[branch] = heads
492
493         for branch in branches:
494             print "? refs/heads/branches/%s" % branch
495
496     for bmark in bmarks:
497         print "? refs/heads/%s" % bmark
498
499     for tag, node in repo.tagslist():
500         if tag == 'tip':
501             continue
502         print "? refs/tags/%s" % tag
503
504     print
505
506 def do_import(parser):
507     repo = parser.repo
508
509     path = os.path.join(dirname, 'marks-git')
510
511     print "feature done"
512     if os.path.exists(path):
513         print "feature import-marks=%s" % path
514     print "feature export-marks=%s" % path
515     sys.stdout.flush()
516
517     tmp = encoding.encoding
518     encoding.encoding = 'utf-8'
519
520     # lets get all the import lines
521     while parser.check('import'):
522         ref = parser[1]
523
524         if (ref == 'HEAD'):
525             export_head(repo)
526         elif ref.startswith('refs/heads/branches/'):
527             branch = ref[len('refs/heads/branches/'):]
528             export_branch(repo, branch)
529         elif ref.startswith('refs/heads/'):
530             bmark = ref[len('refs/heads/'):]
531             export_bookmark(repo, bmark)
532         elif ref.startswith('refs/tags/'):
533             tag = ref[len('refs/tags/'):]
534             export_tag(repo, tag)
535
536         parser.next()
537
538     encoding.encoding = tmp
539
540     print 'done'
541
542 def parse_blob(parser):
543     global blob_marks
544
545     parser.next()
546     mark = parser.get_mark()
547     parser.next()
548     data = parser.get_data()
549     blob_marks[mark] = data
550     parser.next()
551
552 def get_merge_files(repo, p1, p2, files):
553     for e in repo[p1].files():
554         if e not in files:
555             if e not in repo[p1].manifest():
556                 continue
557             f = { 'ctx' : repo[p1][e] }
558             files[e] = f
559
560 def parse_commit(parser):
561     global marks, blob_marks, parsed_refs
562     global mode
563
564     from_mark = merge_mark = None
565
566     ref = parser[1]
567     parser.next()
568
569     commit_mark = parser.get_mark()
570     parser.next()
571     author = parser.get_author()
572     parser.next()
573     committer = parser.get_author()
574     parser.next()
575     data = parser.get_data()
576     parser.next()
577     if parser.check('from'):
578         from_mark = parser.get_mark()
579         parser.next()
580     if parser.check('merge'):
581         merge_mark = parser.get_mark()
582         parser.next()
583         if parser.check('merge'):
584             die('octopus merges are not supported yet')
585
586     files = {}
587
588     for line in parser:
589         if parser.check('M'):
590             t, m, mark_ref, path = line.split(' ', 3)
591             mark = int(mark_ref[1:])
592             f = { 'mode' : hgmode(m), 'data' : blob_marks[mark] }
593         elif parser.check('D'):
594             t, path = line.split(' ', 1)
595             f = { 'deleted' : True }
596         else:
597             die('Unknown file command: %s' % line)
598         files[path] = f
599
600     def getfilectx(repo, memctx, f):
601         of = files[f]
602         if 'deleted' in of:
603             raise IOError
604         if 'ctx' in of:
605             return of['ctx']
606         is_exec = of['mode'] == 'x'
607         is_link = of['mode'] == 'l'
608         rename = of.get('rename', None)
609         return context.memfilectx(f, of['data'],
610                 is_link, is_exec, rename)
611
612     repo = parser.repo
613
614     user, date, tz = author
615     extra = {}
616
617     if committer != author:
618         extra['committer'] = "%s %u %u" % committer
619
620     if from_mark:
621         p1 = repo.changelog.node(mark_to_rev(from_mark))
622     else:
623         p1 = '\0' * 20
624
625     if merge_mark:
626         p2 = repo.changelog.node(mark_to_rev(merge_mark))
627     else:
628         p2 = '\0' * 20
629
630     #
631     # If files changed from any of the parents, hg wants to know, but in git if
632     # nothing changed from the first parent, nothing changed.
633     #
634     if merge_mark:
635         get_merge_files(repo, p1, p2, files)
636
637     # Check if the ref is supposed to be a named branch
638     if ref.startswith('refs/heads/branches/'):
639         extra['branch'] = ref[len('refs/heads/branches/'):]
640
641     if mode == 'hg':
642         i = data.find('\n--HG--\n')
643         if i >= 0:
644             tmp = data[i + len('\n--HG--\n'):].strip()
645             for k, v in [e.split(' : ', 1) for e in tmp.split('\n')]:
646                 if k == 'rename':
647                     old, new = v.split(' => ', 1)
648                     files[new]['rename'] = old
649                 elif k == 'branch':
650                     extra[k] = v
651                 elif k == 'extra':
652                     ek, ev = v.split(' : ', 1)
653                     extra[ek] = urllib.unquote(ev)
654             data = data[:i]
655
656     ctx = context.memctx(repo, (p1, p2), data,
657             files.keys(), getfilectx,
658             user, (date, tz), extra)
659
660     tmp = encoding.encoding
661     encoding.encoding = 'utf-8'
662
663     node = repo.commitctx(ctx)
664
665     encoding.encoding = tmp
666
667     rev = repo[node].rev()
668
669     parsed_refs[ref] = node
670     marks.new_mark(rev, commit_mark)
671
672 def parse_reset(parser):
673     global parsed_refs
674
675     ref = parser[1]
676     parser.next()
677     # ugh
678     if parser.check('commit'):
679         parse_commit(parser)
680         return
681     if not parser.check('from'):
682         return
683     from_mark = parser.get_mark()
684     parser.next()
685
686     node = parser.repo.changelog.node(mark_to_rev(from_mark))
687     parsed_refs[ref] = node
688
689 def parse_tag(parser):
690     name = parser[1]
691     parser.next()
692     from_mark = parser.get_mark()
693     parser.next()
694     tagger = parser.get_author()
695     parser.next()
696     data = parser.get_data()
697     parser.next()
698
699     # nothing to do
700
701 def do_export(parser):
702     global parsed_refs, bmarks, peer
703
704     p_bmarks = []
705
706     parser.next()
707
708     for line in parser.each_block('done'):
709         if parser.check('blob'):
710             parse_blob(parser)
711         elif parser.check('commit'):
712             parse_commit(parser)
713         elif parser.check('reset'):
714             parse_reset(parser)
715         elif parser.check('tag'):
716             parse_tag(parser)
717         elif parser.check('feature'):
718             pass
719         else:
720             die('unhandled export command: %s' % line)
721
722     for ref, node in parsed_refs.iteritems():
723         if ref.startswith('refs/heads/branches'):
724             print "ok %s" % ref
725         elif ref.startswith('refs/heads/'):
726             bmark = ref[len('refs/heads/'):]
727             p_bmarks.append((bmark, node))
728             continue
729         elif ref.startswith('refs/tags/'):
730             tag = ref[len('refs/tags/'):]
731             if mode == 'git':
732                 msg = 'Added tag %s for changeset %s' % (tag, hghex(node[:6]));
733                 parser.repo.tag([tag], node, msg, False, None, {})
734             else:
735                 parser.repo.tag([tag], node, None, True, None, {})
736             print "ok %s" % ref
737         else:
738             # transport-helper/fast-export bugs
739             continue
740
741     if peer:
742         parser.repo.push(peer, force=force_push)
743
744     # handle bookmarks
745     for bmark, node in p_bmarks:
746         ref = 'refs/heads/' + bmark
747         new = hghex(node)
748
749         if bmark in bmarks:
750             old = bmarks[bmark].hex()
751         else:
752             old = ''
753
754         if bmark == 'master' and 'master' not in parser.repo._bookmarks:
755             # fake bookmark
756             pass
757         elif bookmarks.pushbookmark(parser.repo, bmark, old, new):
758             # updated locally
759             pass
760         else:
761             print "error %s" % ref
762             continue
763
764         if peer:
765             if not peer.pushkey('bookmarks', bmark, old, new):
766                 print "error %s" % ref
767                 continue
768
769         print "ok %s" % ref
770
771     print
772
773 def fix_path(alias, repo, orig_url):
774     repo_url = util.url(repo.url())
775     url = util.url(orig_url)
776     if str(url) == str(repo_url):
777         return
778     cmd = ['git', 'config', 'remote.%s.url' % alias, "hg::%s" % repo_url]
779     subprocess.call(cmd)
780
781 def main(args):
782     global prefix, dirname, branches, bmarks
783     global marks, blob_marks, parsed_refs
784     global peer, mode, bad_mail, bad_name
785     global track_branches, force_push
786
787     alias = args[1]
788     url = args[2]
789     peer = None
790
791     hg_git_compat = False
792     track_branches = True
793     force_push = True
794
795     try:
796         if get_config('remote-hg.hg-git-compat') == 'true\n':
797             hg_git_compat = True
798             track_branches = False
799         if get_config('remote-hg.track-branches') == 'false\n':
800             track_branches = False
801         if get_config('remote-hg.force-push') == 'false\n':
802             force_push = False
803     except subprocess.CalledProcessError:
804         pass
805
806     if hg_git_compat:
807         mode = 'hg'
808         bad_mail = 'none@none'
809         bad_name = ''
810     else:
811         mode = 'git'
812         bad_mail = 'unknown'
813         bad_name = 'Unknown'
814
815     if alias[4:] == url:
816         is_tmp = True
817         alias = util.sha1(alias).hexdigest()
818     else:
819         is_tmp = False
820
821     gitdir = os.environ['GIT_DIR']
822     dirname = os.path.join(gitdir, 'hg', alias)
823     branches = {}
824     bmarks = {}
825     blob_marks = {}
826     parsed_refs = {}
827
828     repo = get_repo(url, alias)
829     prefix = 'refs/hg/%s' % alias
830
831     if not is_tmp:
832         fix_path(alias, peer or repo, url)
833
834     if not os.path.exists(dirname):
835         os.makedirs(dirname)
836
837     marks_path = os.path.join(dirname, 'marks-hg')
838     marks = Marks(marks_path)
839
840     parser = Parser(repo)
841     for line in parser:
842         if parser.check('capabilities'):
843             do_capabilities(parser)
844         elif parser.check('list'):
845             do_list(parser)
846         elif parser.check('import'):
847             do_import(parser)
848         elif parser.check('export'):
849             do_export(parser)
850         else:
851             die('unhandled command: %s' % line)
852         sys.stdout.flush()
853
854     if not is_tmp:
855         marks.store()
856     else:
857         shutil.rmtree(dirname)
858
859 sys.exit(main(sys.argv))