hg-fast-export: Support branches without parents
[python/fast-export.git] / hg-fast-export.py
1 #!/usr/bin/env python
2
3 # Copyright (c) 2007, 2008 Rocco Rutte <pdmef@gmx.net> and others.
4 # License: MIT <http://www.opensource.org/licenses/mit-license.php>
5
6 from mercurial import repo,hg,cmdutil,util,ui,revlog,node
7 from hg2git import setup_repo,fixup_user,get_branch,get_changeset
8 from hg2git import load_cache,save_cache,get_git_sha1,set_default_branch,set_origin_name
9 from tempfile import mkstemp
10 from optparse import OptionParser
11 import re
12 import sys
13 import os
14
15 # silly regex to catch Signed-off-by lines in log message
16 sob_re=re.compile('^Signed-[Oo]ff-[Bb]y: (.+)$')
17 # insert 'checkpoint' command after this many commits or none at all if 0
18 cfg_checkpoint_count=0
19 # write some progress message every this many file contents written
20 cfg_export_boundary=1000
21
22 def gitmode(flags):
23   return 'l' in flags and '120000' or 'x' in flags and '100755' or '100644'
24
25 def wr(msg=''):
26   if msg == None:
27     msg = ''
28   print msg
29   #map(lambda x: sys.stderr.write('\t[%s]\n' % x),msg.split('\n'))
30
31 def checkpoint(count):
32   count=count+1
33   if cfg_checkpoint_count>0 and count%cfg_checkpoint_count==0:
34     sys.stderr.write("Checkpoint after %d commits\n" % count)
35     wr('checkpoint')
36     wr()
37   return count
38
39 def get_parent_mark(parent,marks):
40   """Get the mark for some parent.
41   If we saw it in the current session, return :%d syntax and
42   otherwise the SHA1 from the cache."""
43   return marks.get(str(parent),':%d' % (parent+1))
44
45 def file_mismatch(f1,f2):
46   """See if two revisions of a file are not equal."""
47   return node.hex(f1)!=node.hex(f2)
48
49 def split_dict(dleft,dright,l=[],c=[],r=[],match=file_mismatch):
50   """Loop over our repository and find all changed and missing files."""
51   for left in dleft.keys():
52     right=dright.get(left,None)
53     if right==None:
54       # we have the file but our parent hasn't: add to left set
55       l.append(left)
56     elif match(dleft[left],right):
57       # we have it but checksums mismatch: add to center set
58       c.append(left)
59   for right in dright.keys():
60     left=dleft.get(right,None)
61     if left==None:
62       # if parent has file but we don't: add to right set
63       r.append(right)
64     # change is already handled when comparing child against parent
65   return l,c,r
66
67 def get_filechanges(repo,revision,parents,mleft):
68   """Given some repository and revision, find all changed/deleted files."""
69   l,c,r=[],[],[]
70   for p in parents:
71     if p<0: continue
72     mright=repo.changectx(p).manifest()
73     l,c,r=split_dict(mleft,mright,l,c,r)
74   l.sort()
75   c.sort()
76   r.sort()
77   return l,c,r
78
79 def get_author(logmessage,committer,authors):
80   """As git distincts between author and committer of a patch, try to
81   extract author by detecting Signed-off-by lines.
82
83   This walks from the end of the log message towards the top skipping
84   empty lines. Upon the first non-empty line, it walks all Signed-off-by
85   lines upwards to find the first one. For that (if found), it extracts
86   authorship information the usual way (authors table, cleaning, etc.)
87
88   If no Signed-off-by line is found, this defaults to the committer.
89
90   This may sound stupid (and it somehow is), but in log messages we
91   accidentially may have lines in the middle starting with
92   "Signed-off-by: foo" and thus matching our detection regex. Prevent
93   that."""
94
95   loglines=logmessage.split('\n')
96   i=len(loglines)
97   # from tail walk to top skipping empty lines
98   while i>=0:
99     i-=1
100     if len(loglines[i].strip())==0: continue
101     break
102   if i>=0:
103     # walk further upwards to find first sob line, store in 'first'
104     first=None
105     while i>=0:
106       m=sob_re.match(loglines[i])
107       if m==None: break
108       first=m
109       i-=1
110     # if the last non-empty line matches our Signed-Off-by regex: extract username
111     if first!=None:
112       r=fixup_user(first.group(1),authors)
113       return r
114   return committer
115
116 def export_file_contents(ctx,manifest,files):
117   count=0
118   max=len(files)
119   for file in files:
120     # Skip .hgtags files. They only get us in trouble.
121     if file == ".hgtags":
122       sys.stderr.write('Skip %s\n' % (file))
123       continue
124     d=ctx.filectx(file).data()
125     wr('M %s inline %s' % (gitmode(manifest.flags(file)),file))
126     wr('data %d' % len(d)) # had some trouble with size()
127     wr(d)
128     count+=1
129     if count%cfg_export_boundary==0:
130       sys.stderr.write('Exported %d/%d files\n' % (count,max))
131   if max>cfg_export_boundary:
132     sys.stderr.write('Exported %d/%d files\n' % (count,max))
133
134 def is_merge(parents):
135   c=0
136   for parent in parents:
137     if parent>=0:
138       c+=1
139   return c>1
140
141 def sanitize_name(name,what="branch"):
142   """Sanitize input roughly according to git-check-ref-format(1)"""
143
144   def dot(name):
145     if name[0] == '.': return '_'+name[1:]
146     return name
147
148   n=name
149   p=re.compile('([[ ~^:?*]|\.\.)')
150   n=p.sub('_', n)
151   if n[-1] == '/': n=n[:-1]+'_'
152   n='/'.join(map(dot,n.split('/')))
153   p=re.compile('_+')
154   n=p.sub('_', n)
155
156   if n!=name:
157     sys.stderr.write('Warning: sanitized %s [%s] to [%s]\n' % (what,name,n))
158   return n
159
160 def export_commit(ui,repo,revision,marks,mapping,heads,last,max,count,authors,sob,brmap):
161   def get_branchname(name):
162     if brmap.has_key(name):
163       return brmap[name]
164     n=sanitize_name(name)
165     brmap[name]=n
166     return n
167
168   (revnode,_,user,(time,timezone),files,desc,branch,_)=get_changeset(ui,repo,revision,authors)
169   parents=repo.changelog.parentrevs(revision)
170
171   branch=get_branchname(branch)
172
173   wr('commit refs/heads/%s' % branch)
174   wr('mark :%d' % (revision+1))
175   if sob:
176     wr('author %s %d %s' % (get_author(desc,user,authors),time,timezone))
177   wr('committer %s %d %s' % (user,time,timezone))
178   wr('data %d' % (len(desc)+1)) # wtf?
179   wr(desc)
180   wr()
181
182   pidx1, pidx2 = 0, 1
183   if parents[0] < parents[1]:
184     pidx1, pidx2 = 1, 0
185
186   full_rev=False
187   if revision==0: full_rev=True
188
189   src=heads.get(branch,'')
190   link=''
191   if src!='':
192     # if we have a cached head, this is an incremental import: initialize it
193     # and kill reference so we won't init it again
194     wr('from %s' % src)
195     heads[branch]=''
196     sys.stderr.write('%s: Initializing to parent [%s]\n' %
197         (branch,src))
198     link=src # avoid making a merge commit for incremental import
199   elif link=='' and not heads.has_key(branch) and revision>0:
200     if parents[0]>=0:
201       # newly created branch with parent: connect to parent
202       tmp=get_parent_mark(parents[0],marks)
203       wr('from %s' % tmp)
204       sys.stderr.write('%s: Link new branch to parent [%s]\n' %
205           (branch,tmp))
206       link=tmp # avoid making a merge commit for branch fork
207     else:
208       # newly created branch without parent: feed full revision
209       full_rev=True
210   elif last.get(branch,revision) != parents[pidx1] and parents[pidx1] > 0 and revision > 0:
211     pm=get_parent_mark(parents[pidx1],marks)
212     sys.stderr.write('%s: Placing commit [r%d] in branch [%s] on top of [r%d]\n' %
213         (branch,revision,branch,parents[pidx1]));
214     wr('from %s' % pm)
215
216   if parents[pidx2] > 0:
217     pm=get_parent_mark(parents[pidx2],marks)
218     sys.stderr.write('%s: Merging with parent [%s] from [r%d]\n' %
219         (branch,pm,parents[pidx2]))
220     wr('merge %s' % pm)
221
222   last[branch]=revision
223   heads[branch]=''
224   # we need this later to write out tags
225   marks[str(revision)]=':%d'%(revision+1)
226
227   ctx=repo.changectx(str(revision))
228   man=ctx.manifest()
229   added,changed,removed,type=[],[],[],''
230
231   if full_rev:
232     # first revision: feed in full manifest
233     added=man.keys()
234     added.sort()
235     type='full'
236   elif is_merge(parents):
237     # later merge revision: feed in changed manifest
238     # for many files comparing checksums is expensive so only do it for
239     # merges where we really need it due to hg's revlog logic
240     added,changed,removed=get_filechanges(repo,revision,parents,man)
241     type='thorough delta'
242   else:
243     # later non-merge revision: feed in changed manifest
244     # if we have exactly one parent, just take the changes from the
245     # manifest without expensively comparing checksums
246     f=repo.status(repo.lookup(parents[0]),revnode)[:3]
247     added,changed,removed=f[1],f[0],f[2]
248     type='simple delta'
249
250   sys.stderr.write('%s: Exporting %s revision %d/%d with %d/%d/%d added/changed/removed files\n' %
251       (branch,type,revision+1,max,len(added),len(changed),len(removed)))
252
253   map(lambda r: wr('D %s' % r),removed)
254   export_file_contents(ctx,man,added)
255   export_file_contents(ctx,man,changed)
256   wr()
257
258   return checkpoint(count)
259
260 def export_tags(ui,repo,marks_cache,mapping_cache,count,authors):
261   l=repo.tagslist()
262   for tag,node in l:
263     tag=sanitize_name(tag,"tag")
264     # ignore latest revision
265     if tag=='tip': continue
266     # ignore tags to nodes that are missing (ie, 'in the future')
267     if node.encode('hex_codec') not in mapping_cache:
268       sys.stderr.write('Tag %s refers to unseen node %s\n' % (tag, node.encode('hex_codec')))
269       continue
270
271     rev=int(mapping_cache[node.encode('hex_codec')])
272
273     ref=marks_cache.get(str(rev),':%d' % (rev))
274     if ref==None:
275       sys.stderr.write('Failed to find reference for creating tag'
276           ' %s at r%d\n' % (tag,rev))
277       continue
278     sys.stderr.write('Exporting tag [%s] at [hg r%d] [git %s]\n' % (tag,rev,ref))
279     wr('reset refs/tags/%s' % tag)
280     wr('from %s' % ref)
281     wr()
282     count=checkpoint(count)
283   return count
284
285 def load_authors(filename):
286   cache={}
287   if not os.path.exists(filename):
288     return cache
289   f=open(filename,'r')
290   l=0
291   lre=re.compile('^([^=]+)[ ]*=[ ]*(.+)$')
292   for line in f.readlines():
293     l+=1
294     m=lre.match(line)
295     if m==None:
296       sys.stderr.write('Invalid file format in [%s], line %d\n' % (filename,l))
297       continue
298     # put key:value in cache, key without ^:
299     cache[m.group(1).strip()]=m.group(2).strip()
300   f.close()
301   sys.stderr.write('Loaded %d authors\n' % l)
302   return cache
303
304 def verify_heads(ui,repo,cache,force):
305   branches=repo.branchtags()
306   l=[(-repo.changelog.rev(n), n, t) for t, n in branches.items()]
307   l.sort()
308
309   # get list of hg's branches to verify, don't take all git has
310   for _,_,b in l:
311     b=get_branch(b)
312     sha1=get_git_sha1(b)
313     c=cache.get(b)
314     if sha1!=None and c!=None:
315       sys.stderr.write('Verifying branch [%s]\n' % b)
316     if sha1!=c:
317       sys.stderr.write('Error: Branch [%s] modified outside hg-fast-export:'
318         '\n%s (repo) != %s (cache)\n' % (b,sha1,c))
319       if not force: return False
320
321   # verify that branch has exactly one head
322   t={}
323   for h in repo.heads():
324     (_,_,_,_,_,_,branch,_)=get_changeset(ui,repo,h)
325     if t.get(branch,False):
326       sys.stderr.write('Error: repository has at least one unnamed head: hg r%s\n' %
327           repo.changelog.rev(h))
328       if not force: return False
329     t[branch]=True
330
331   return True
332
333 def mangle_mark(mark):
334   return str(int(mark)-1)
335
336 def hg2git(repourl,m,marksfile,mappingfile,headsfile,tipfile,authors={},sob=False,force=False):
337   _max=int(m)
338
339   marks_cache=load_cache(marksfile,mangle_mark)
340   mapping_cache=load_cache(mappingfile)
341   heads_cache=load_cache(headsfile)
342   state_cache=load_cache(tipfile)
343
344   ui,repo=setup_repo(repourl)
345
346   if not verify_heads(ui,repo,heads_cache,force):
347     return 1
348
349   try:
350     tip=repo.changelog.count()
351   except AttributeError:
352     tip=len(repo)
353
354   min=int(state_cache.get('tip',0))
355   max=_max
356   if _max<0 or max>tip:
357     max=tip
358
359   for rev in range(0,max):
360         (revnode,_,_,_,_,_,_,_)=get_changeset(ui,repo,rev,authors)
361         mapping_cache[revnode.encode('hex_codec')] = str(rev)
362
363
364   c=0
365   last={}
366   brmap={}
367   for rev in range(min,max):
368     c=export_commit(ui,repo,rev,marks_cache,mapping_cache,heads_cache,last,max,c,authors,sob,brmap)
369
370   state_cache['tip']=max
371   state_cache['repo']=repourl
372   save_cache(tipfile,state_cache)
373   save_cache(mappingfile,mapping_cache)
374
375   c=export_tags(ui,repo,marks_cache,mapping_cache,c,authors)
376
377   sys.stderr.write('Issued %d commands\n' % c)
378
379   return 0
380
381 if __name__=='__main__':
382   def bail(parser,opt):
383     sys.stderr.write('Error: No %s option given\n' % opt)
384     parser.print_help()
385     sys.exit(2)
386
387   parser=OptionParser()
388
389   parser.add_option("-m","--max",type="int",dest="max",
390       help="Maximum hg revision to import")
391   parser.add_option("--mapping",dest="mappingfile",
392       help="File to read last run's hg-to-git SHA1 mapping")
393   parser.add_option("--marks",dest="marksfile",
394       help="File to read git-fast-import's marks from")
395   parser.add_option("--heads",dest="headsfile",
396       help="File to read last run's git heads from")
397   parser.add_option("--status",dest="statusfile",
398       help="File to read status from")
399   parser.add_option("-r","--repo",dest="repourl",
400       help="URL of repo to import")
401   parser.add_option("-s",action="store_true",dest="sob",
402       default=False,help="Enable parsing Signed-off-by lines")
403   parser.add_option("-A","--authors",dest="authorfile",
404       help="Read authormap from AUTHORFILE")
405   parser.add_option("-f","--force",action="store_true",dest="force",
406       default=False,help="Ignore validation errors by force")
407   parser.add_option("-M","--default-branch",dest="default_branch",
408       help="Set the default branch")
409   parser.add_option("-o","--origin",dest="origin_name",
410       help="use <name> as namespace to track upstream")
411
412   (options,args)=parser.parse_args()
413
414   m=-1
415   if options.max!=None: m=options.max
416
417   if options.marksfile==None: bail(parser,'--marks')
418   if options.mappingfile==None: bail(parser,'--mapping')
419   if options.headsfile==None: bail(parser,'--heads')
420   if options.statusfile==None: bail(parser,'--status')
421   if options.repourl==None: bail(parser,'--repo')
422
423   a={}
424   if options.authorfile!=None:
425     a=load_authors(options.authorfile)
426
427   if options.default_branch!=None:
428     set_default_branch(options.default_branch)
429
430   if options.origin_name!=None:
431     set_origin_name(options.origin_name)
432
433   sys.exit(hg2git(options.repourl,m,options.marksfile,options.mappingfile,options.headsfile,
434     options.statusfile,authors=a,sob=options.sob,force=options.force))