git_handler.py 67 KB
Newer Older
1
2
3
4
5
6
7
import collections
import itertools
import os
import urllib
import re
import cStringIO
import StringIO
Kevin Bullock's avatar
Kevin Bullock committed
8

9
10
from dulwich.errors import HangupException, GitProtocolError
from dulwich.objects import Blob, Commit, Tag, Tree, parse_timezone
Kevin Bullock's avatar
Kevin Bullock committed
11
from dulwich.pack import create_delta, apply_delta
12
from dulwich.repo import Repo, check_ref_format
13
from dulwich import client
14
from dulwich import config as dul_config
15
from dulwich import diff_tree
Kevin Bullock's avatar
Kevin Bullock committed
16
17
18

from mercurial.i18n import _
from mercurial.node import hex, bin, nullid
19
from mercurial import (
20
    bookmarks,
21
22
    commands,
    context,
23
    encoding,
24
    error,
25
    phases,
26
27
    util as hgutil,
)
Kevin Bullock's avatar
Kevin Bullock committed
28

29
import _ssh
30
import compat
31
import git2hg
32
import hg2git
33
import util
Brendan Cully's avatar
Brendan Cully committed
34
from overlay import overlayrepo
Kevin Bullock's avatar
Kevin Bullock committed
35

36

Gregory Szorc's avatar
Gregory Szorc committed
37
38
RE_GIT_AUTHOR = re.compile('^(.*?) ?\<(.*?)(?:\>(.*))?$')

39
40
RE_GIT_SANITIZE_AUTHOR = re.compile('[<>\n]')

41
42
RE_GIT_AUTHOR_EXTRA = re.compile('^(.*?)\ ext:\((.*)\) <(.*)\>$')

43
44
RE_GIT_EXTRA_KEY = re.compile('GIT([0-9]*)-(.*)')

45
46
47
48
49
50
51
# Test for git:// and git+ssh:// URI.
# Support several URL forms, including separating the
# host and path with either a / or : (sepr)
RE_GIT_URI = re.compile(
    r'^(?P<scheme>git([+]ssh)?://)(?P<host>.*?)(:(?P<port>\d+))?'
    r'(?P<sepr>[:/])(?P<path>.*)$')

52
53
54
RE_NEWLINES = re.compile('[\r\n]')
RE_GIT_PROGRESS = re.compile('\((\d+)/(\d+)\)')

55
56
RE_AUTHOR_FILE = re.compile('\s*=\s*')

57
58
59
60
61
62
63
# mercurial.utils.dateutil functions were in mercurial.util in Mercurial < 4.6
try:
    from mercurial.utils import dateutil
    dateutil.parsedate
except ImportError:
    dateutil = hgutil

64
65
CALLBACK_BUFFER = ''

Sean Farley's avatar
Sean Farley committed
66

67
68
69
70
71
class GitProgress(object):
    """convert git server progress strings into mercurial progress"""
    def __init__(self, ui):
        self.ui = ui

72
        self._progress = None
73
74
75
76
77
        self.msgbuf = ''

    def progress(self, msg):
        # 'Counting objects: 33640, done.\n'
        # 'Compressing objects:   0% (1/9955)   \r
78
        msgs = RE_NEWLINES.split(self.msgbuf + msg)
79
80
81
82
83
84
85
86
87
88
        self.msgbuf = msgs.pop()

        for msg in msgs:
            td = msg.split(':', 1)
            data = td.pop()
            if not td:
                self.flush(data)
                continue
            topic = td[0]

89
            m = RE_GIT_PROGRESS.search(data)
90
            if m:
91
                if self._progress and self._progress.topic != topic:
92
                    self.flush()
93
94
                if not self._progress:
                    self._progress = compat.makeprogress(self.ui, topic)
95
96

                pos, total = map(int, m.group(1, 2))
97
                self._progress.update(pos, total=total)
98
99
100
101
            else:
                self.flush(msg)

    def flush(self, msg=None):
102
103
104
105
        if self._progress is None:
            return
        self._progress.complete()
        self._progress = None
106
107
108
        if msg:
            self.ui.note(msg + '\n')

Sean Farley's avatar
Sean Farley committed
109

Kevin Bullock's avatar
Kevin Bullock committed
110
class GitHandler(object):
111
    map_file = 'git-mapfile'
112
    remote_refs_file = 'git-remote-refs'
113
    tags_file = 'git-tags'
Kevin Bullock's avatar
Kevin Bullock committed
114
115
116
117

    def __init__(self, dest_repo, ui):
        self.repo = dest_repo
        self.ui = ui
118
        self.vfs = self.repo.vfs
Kevin Bullock's avatar
Kevin Bullock committed
119

120
121
        # Mercurial >= 3.3:  repo.shared()
        if dest_repo.sharedpath != dest_repo.path:
122
            self.vfs = compat.hgvfs(dest_repo.sharedpath)
Kevin Bullock's avatar
Kevin Bullock committed
123
        if compat.config(ui, 'bool', 'git', 'intree'):
Durham Goode's avatar
Durham Goode committed
124
            self.gitdir = self.repo.wvfs.join('.git')
Kevin Bullock's avatar
Kevin Bullock committed
125
        else:
126
            self.gitdir = self.vfs.join('git')
Kevin Bullock's avatar
Kevin Bullock committed
127

128
129
        self.init_author_file()

Kevin Bullock's avatar
Kevin Bullock committed
130
131
        self.paths = ui.configitems('paths')

132
133
        self.branch_bookmark_suffix = compat.config(
            ui, 'string', 'git', 'branch_bookmark_suffix')
134

135
136
        self._map_git_real = None
        self._map_hg_real = None
durin42's avatar
durin42 committed
137
        self.load_tags()
138
        self._remote_refs = None
durin42's avatar
durin42 committed
139
140
141

    @property
    def _map_git(self):
142
143
144
        if self._map_git_real is None:
            self.load_map()
        return self._map_git_real
durin42's avatar
durin42 committed
145
146
147

    @property
    def _map_hg(self):
148
149
150
        if self._map_hg_real is None:
            self.load_map()
        return self._map_hg_real
Kevin Bullock's avatar
Kevin Bullock committed
151

152
153
154
155
156
157
    @property
    def remote_refs(self):
        if self._remote_refs is None:
            self.load_remote_refs()
        return self._remote_refs

158
159
    @hgutil.propertycache
    def git(self):
160
161
162
163
164
165
166
        # Dulwich is going to try and join unicode ref names against
        # the repository path to try and read unpacked refs. This
        # doesn't match hg's bytes-only view of filesystems, we just
        # have to cope with that. As a workaround, try decoding our
        # (bytes) path to the repo in hg's active encoding and hope
        # for the best.
        gitpath = self.gitdir.decode(encoding.encoding, encoding.encodingmode)
167
        # make the git data directory
Augie Fackler's avatar
Augie Fackler committed
168
        if os.path.exists(self.gitdir):
169
            return Repo(gitpath)
Augie Fackler's avatar
Augie Fackler committed
170
        else:
Kevin Bullock's avatar
Kevin Bullock committed
171
            os.mkdir(self.gitdir)
172
            return Repo.init_bare(gitpath)
173

174
175
    def init_author_file(self):
        self.author_map = {}
Kevin Bullock's avatar
Kevin Bullock committed
176
177
178
        authors_path = compat.config(self.ui, 'string', 'git', 'authors')
        if authors_path:
            with open(self.repo.wvfs.join(authors_path)) as f:
179
180
181
182
                for line in f:
                    line = line.strip()
                    if not line or line.startswith('#'):
                        continue
183
                    from_, to = RE_AUTHOR_FILE.split(line, 2)
184
185
                    self.author_map[from_] = to

186
    # FILE LOAD AND SAVE METHODS
Kevin Bullock's avatar
Kevin Bullock committed
187
188
189
190
191
192
193
194
195
196
197
198

    def map_set(self, gitsha, hgsha):
        self._map_git[gitsha] = hgsha
        self._map_hg[hgsha] = gitsha

    def map_hg_get(self, gitsha):
        return self._map_git.get(gitsha)

    def map_git_get(self, hgsha):
        return self._map_hg.get(hgsha)

    def load_map(self):
199
200
        map_git_real = {}
        map_hg_real = {}
201
202
        if os.path.exists(self.vfs.join(self.map_file)):
            for line in self.vfs(self.map_file):
203
204
205
206
207
208
209
                # format is <40 hex digits> <40 hex digits>\n
                if len(line) != 82:
                    raise ValueError(
                        _('corrupt mapfile: incorrect line length %d') %
                        len(line))
                gitsha = line[:40]
                hgsha = line[41:81]
210
211
212
213
                map_git_real[gitsha] = hgsha
                map_hg_real[hgsha] = gitsha
        self._map_git_real = map_git_real
        self._map_hg_real = map_hg_real
Kevin Bullock's avatar
Kevin Bullock committed
214

215
    def save_map(self, map_file):
216
217
        wlock = self.repo.wlock()
        try:
218
            file = self.vfs(map_file, 'w+', atomictemp=True)
219
220
221
222
223
224
225
226
227
            map_hg = self._map_hg
            buf = cStringIO.StringIO()
            bwrite = buf.write
            for hgsha, gitsha in map_hg.iteritems():
                bwrite("%s %s\n" % (gitsha, hgsha))
            file.write(buf.getvalue())
            buf.close()
            # If this complains, atomictempfile no longer has close
            file.close()
228
229
        finally:
            wlock.release()
Kevin Bullock's avatar
Kevin Bullock committed
230
231
232

    def load_tags(self):
        self.tags = {}
233
234
        if os.path.exists(self.vfs.join(self.tags_file)):
            for line in self.vfs(self.tags_file):
Kevin Bullock's avatar
Kevin Bullock committed
235
236
237
238
                sha, name = line.strip().split(' ', 1)
                self.tags[name] = sha

    def save_tags(self):
239
        file = self.vfs(self.tags_file, 'w+', atomictemp=True)
Kevin Bullock's avatar
Kevin Bullock committed
240
241
242
        for name, sha in sorted(self.tags.iteritems()):
            if not self.repo.tagtype(name) == 'global':
                file.write("%s %s\n" % (sha, name))
243
244
        # If this complains, atomictempfile no longer has close
        file.close()
Kevin Bullock's avatar
Kevin Bullock committed
245

246
247
    def load_remote_refs(self):
        self._remote_refs = {}
248
249
250
251
252
        refdir = os.path.join(self.git.path, 'refs', 'remotes')

        paths = self.paths
        # if paths are set, we should still check 'default'
        if not paths:
253
            paths = (('default', None),)
254
255
256
257
258
259
260
261
262
263
264
265
266
267

        # we avoid using dulwich's refs method because it is incredibly slow;
        # on a repo with a few hundred branches and a few thousand tags,
        # dulwich took about 200ms
        for p in paths:
            remotedir = os.path.join(refdir, p[0])
            for root, dirs, files in os.walk(remotedir):
                for f in files:
                    try:
                        ref = root.replace(refdir + os.sep, '') + '/'
                        node = open(os.path.join(root, f)).read().strip()
                        self._remote_refs[ref + f] = bin(self._map_git[node])
                    except (KeyError, IOError):
                        pass
268

269
    # END FILE LOAD AND SAVE METHODS
Kevin Bullock's avatar
Kevin Bullock committed
270

271
    # COMMANDS METHODS
Kevin Bullock's avatar
Kevin Bullock committed
272
273

    def import_commits(self, remote_name):
274
        refs = self.git.refs.as_dict()
275
276
        filteredrefs = self.filter_min_date(refs)
        self.import_git_objects(remote_name, filteredrefs)
277
        self.update_hg_bookmarks(refs)
278
        self.save_map(self.map_file)
Kevin Bullock's avatar
Kevin Bullock committed
279
280

    def fetch(self, remote, heads):
281
        result = self.fetch_pack(remote, heads)
Kevin Bullock's avatar
Kevin Bullock committed
282
283
        remote_name = self.remote_name(remote)

284
285
286
        # if remote returns a symref for HEAD, then let's store that
        rhead = None
        rnode = None
287
288
        oldheads = self.repo.changelog.heads()
        imported = 0
289
290
291
        if result.refs:
            filteredrefs = self.filter_min_date(self.filter_refs(result.refs,
                                                                 heads))
292
            imported = self.import_git_objects(remote_name, filteredrefs)
293
294
            self.import_tags(result.refs)
            self.update_hg_bookmarks(result.refs)
295
296

            try:
297
                symref = result.symrefs['HEAD']
298
299
300
                if symref.startswith('refs/heads'):
                    rhead = symref.replace('refs/heads/', '')

301
                rnode = result.refs['refs/heads/%s' % rhead]
302
303
304
305
306
307
308
                rnode = self._map_git[rnode]
                rnode = self.repo[rnode].node()
            except KeyError:
                # if there is any error make sure to clear the variables
                rhead = None
                rnode = None

Kevin Bullock's avatar
Kevin Bullock committed
309
            if remote_name:
310
                self.update_remote_branches(remote_name, result.refs)
311
            elif not self.git.refs.as_dict('refs/remotes/'):
Kevin Bullock's avatar
Kevin Bullock committed
312
                # intial cloning
313
                self.update_remote_branches('default', result.refs)
314

315
                # "Activate" a tipmost bookmark.
316
317
                bms = self.repo['tip'].bookmarks()

318
319
320
321
322
                # override the 'tipmost' behavior if we know the remote HEAD
                if rnode:
                    # make sure the bookmark exists; at the point the remote
                    # branches has already been set up
                    suffix = self.branch_bookmark_suffix or ''
323
324
                    changes = [(rhead + suffix, rnode)]
                    util.updatebookmarks(self.repo, changes)
325
326
                    bms = [rhead + suffix]

327
                if bms:
328
                    bookmarks.activate(self.repo, bms[0])
329

330
        self.save_map(self.map_file)
Kevin Bullock's avatar
Kevin Bullock committed
331

332
333
334
        # also mark public any branches the user specified
        blist = [self.repo[branch].node() for branch in
                 self.ui.configlist('git', 'public')]
335
        if rnode and self.ui.configbool('hggit', 'usephases'):
336
337
338
            blist.append(rnode)

        if blist:
339
340
341
            lock = self.repo.lock()
            try:
                tr = self.repo.transaction("phase")
342
343
                phases.advanceboundary(self.repo, tr, phases.public,
                                       blist)
344
345
346
347
348
349
                tr.close()
            finally:
                if tr is not None:
                    tr.release()
                lock.release()

350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
        if imported == 0:
            return 0

        # code taken from localrepo.py:addchangegroup
        dh = 0
        if oldheads:
            heads = self.repo.changelog.heads()
            dh = len(heads) - len(oldheads)
            for h in heads:
                if h not in oldheads and self.repo[h].closesbranch():
                    dh -= 1

        if dh < 0:
            return dh - 1
        else:
            return dh + 1
366

Kevin Bullock's avatar
Kevin Bullock committed
367
368
369
370
371
372
    def export_commits(self):
        try:
            self.export_git_objects()
            self.export_hg_tags()
            self.update_references()
        finally:
373
            self.save_map(self.map_file)
Kevin Bullock's avatar
Kevin Bullock committed
374
375
376
377
378
379

    def get_refs(self, remote):
        self.export_commits()
        client, path = self.get_transport_and_path(remote)
        old_refs = {}
        new_refs = {}
380

Kevin Bullock's avatar
Kevin Bullock committed
381
382
        def changed(refs):
            old_refs.update(refs)
383
384
            exportable = self.get_exportable()
            new_refs.update(self.get_changed_refs(refs, exportable, True))
385
            return refs  # always return the same refs to make the send a no-op
Kevin Bullock's avatar
Kevin Bullock committed
386
387

        try:
388
            client.send_pack(path, changed, lambda have, want: [])
Kevin Bullock's avatar
Kevin Bullock committed
389
390
391
392

            changed_refs = [ref for ref, sha in new_refs.iteritems()
                            if sha != old_refs.get(ref)]
            new = [bin(self.map_hg_get(new_refs[ref])) for ref in changed_refs]
393
394
395
396
397
            old = {}
            for r in old_refs:
                old_ref = self.map_hg_get(old_refs[r])
                if old_ref:
                    old[bin(old_ref)] = 1
Kevin Bullock's avatar
Kevin Bullock committed
398
399

            return old, new
400
        except (HangupException, GitProtocolError), e:
401
            raise error.Abort(_("git remote error: ") + str(e))
Kevin Bullock's avatar
Kevin Bullock committed
402
403
404

    def push(self, remote, revs, force):
        self.export_commits()
405
        old_refs, new_refs = self.upload_pack(remote, revs, force)
Kevin Bullock's avatar
Kevin Bullock committed
406
407
        remote_name = self.remote_name(remote)

408
        if remote_name and new_refs:
409
            for ref, new_sha in sorted(new_refs.iteritems()):
410
411
412
413
                old_sha = old_refs.get(ref)
                if old_sha is None:
                    if self.ui.verbose:
                        self.ui.note("adding reference %s::%s => GIT:%s\n" %
414
                                     (remote_name, ref, new_sha[0:8]))
415
416
417
418
419
                    else:
                        self.ui.status("adding reference %s\n" % ref)
                elif new_sha != old_sha:
                    if self.ui.verbose:
                        self.ui.note("updating reference %s::%s => GIT:%s\n" %
420
                                     (remote_name, ref, new_sha[0:8]))
421
422
                    else:
                        self.ui.status("updating reference %s\n" % ref)
423
                else:
424
                    self.ui.debug("unchanged reference %s::%s => GIT:%s\n" %
425
                                  (remote_name, ref, new_sha[0:8]))
Kevin Bullock's avatar
Kevin Bullock committed
426

427
428
            self.update_remote_branches(remote_name, new_refs)
        if old_refs == new_refs:
429
            self.ui.status(_("no changes found\n"))
430
431
432
433
434
435
436
437
            ret = None
        elif len(new_refs) > len(old_refs):
            ret = 1 + (len(new_refs) - len(old_refs))
        elif len(old_refs) > len(new_refs):
            ret = -1 - (len(new_refs) - len(old_refs))
        else:
            ret = 1
        return ret
Kevin Bullock's avatar
Kevin Bullock committed
438
439

    def clear(self):
440
        mapfile = self.vfs.join(self.map_file)
Kevin Bullock's avatar
Kevin Bullock committed
441
442
443
444
445
446
447
448
449
450
        if os.path.exists(self.gitdir):
            for root, dirs, files in os.walk(self.gitdir, topdown=False):
                for name in files:
                    os.remove(os.path.join(root, name))
                for name in dirs:
                    os.rmdir(os.path.join(root, name))
            os.rmdir(self.gitdir)
        if os.path.exists(mapfile):
            os.remove(mapfile)

Brendan Cully's avatar
Brendan Cully committed
451
452
453
    # incoming support
    def getremotechanges(self, remote, revs):
        self.export_commits()
454
        result = self.fetch_pack(remote.path, revs)
Brendan Cully's avatar
Brendan Cully committed
455
456
457
458
459
460

        # refs contains all remote refs. Prune to only those requested.
        if revs:
            reqrefs = {}
            for rev in revs:
                for n in ('refs/heads/' + rev, 'refs/tags/' + rev):
461
462
                    if n in result.refs:
                        reqrefs[n] = result.refs[n]
Brendan Cully's avatar
Brendan Cully committed
463
        else:
464
            reqrefs = result.refs
Brendan Cully's avatar
Brendan Cully committed
465

466
        commits = [bin(c) for c in self.get_git_incoming(reqrefs).commits]
Brendan Cully's avatar
Brendan Cully committed
467

468
        b = overlayrepo(self, commits, result.refs)
Brendan Cully's avatar
Brendan Cully committed
469
470
471

        return (b, commits, lambda: None)

472
    # CHANGESET CONVERSION METHODS
Kevin Bullock's avatar
Kevin Bullock committed
473
474

    def export_git_objects(self):
475
        self.ui.note(_("finding hg commits to export\n"))
476
477
        repo = self.repo
        clnode = repo.changelog.node
478

479
        nodes = (clnode(n) for n in repo)
480
        to_export = (repo[node] for node in nodes if not hex(node) in
481
                     self._map_hg)
482
483
484
485
486

        todo_total = len(repo) - len(self._map_hg)
        topic = 'find commits to export'
        unit = 'commits'

487
488
489
490
491
492
493
        with compat.makeprogress(repo.ui, topic, unit, todo_total) as progress:
            export = []
            for ctx in to_export:
                item = hex(ctx.node())
                progress.increment(item=item, total=todo_total)
                if ctx.extra().get('hg-git', None) != 'octopus':
                    export.append(ctx)
494

495
496
497
            total = len(export)
            if not total:
                return
498
499

        self.ui.note(_("exporting hg objects to git\n"))
500
501
502

        # By only exporting deltas, the assertion is that all previous objects
        # for all other changesets are already present in the Git repository.
503
504
505
506
        # This assertion is necessary to prevent redundant work. Here, nodes,
        # and therefore export, is in topological order. By definition,
        # export[0]'s parents must be present in Git, so we start the
        # incremental exporter from there.
507
        pctx = export[0].p1()
508
509
510
511
512
513
514
515
        pnode = pctx.node()
        if pnode == nullid:
            gitcommit = None
        else:
            gitsha = self._map_hg[hex(pnode)]
            try:
                gitcommit = self.git[gitsha]
            except KeyError:
516
517
                raise error.Abort(_('Parent SHA-1 not present in Git'
                                    'repo: %s' % gitsha))
518
519
520

        exporter = hg2git.IncrementalChangesetExporter(
            self.repo, pctx, self.git.object_store, gitcommit)
521

522
        mapsavefreq = compat.config(self.ui, 'int', 'hggit',
523
                                    'mapsavefrequency')
524
525
526
527
528
529
530
        with compat.makeprogress(self.ui, 'exporting', total=total) as progress:
            for i, ctx in enumerate(export):
                progress.update(i, total=total)
                self.export_hg_commit(ctx.node(), exporter)
                if mapsavefreq and i % mapsavefreq == 0:
                    self.ui.debug(_("saving mapfile\n"))
                    self.save_map(self.map_file)
531

532
533
534
535
536
    def set_commiter_from_author(self, commit):
        commit.committer = commit.author
        commit.commit_time = commit.author_time
        commit.commit_timezone = commit.author_timezone

Kevin Bullock's avatar
Kevin Bullock committed
537
538
539
    # convert this commit into git objects
    # go through the manifest, convert all blobs/trees we don't have
    # write the commit object (with metadata info)
540
    def export_hg_commit(self, rev, exporter):
541
        self.ui.note(_("converting revision %s\n") % hex(rev))
Kevin Bullock's avatar
Kevin Bullock committed
542
543
544

        oldenc = self.swap_out_encoding()

545
        ctx = self.repo[rev]
Kevin Bullock's avatar
Kevin Bullock committed
546
547
548
549
550
        extra = ctx.extra()

        commit = Commit()

        (time, timezone) = ctx.date()
551
552
553
554
555
556
        # work around to bad timezone offets - dulwich does not handle
        # sub minute based timezones. In the one known case, it was a
        # manual edit that led to the unusual value. Based on that,
        # there is no reason to round one way or the other, so do the
        # simplest and round down.
        timezone -= (timezone % 60)
Kevin Bullock's avatar
Kevin Bullock committed
557
558
559
560
561
        commit.author = self.get_git_author(ctx)
        commit.author_time = int(time)
        commit.author_timezone = -timezone

        if 'committer' in extra:
562
563
564
565
566
            try:
                # fixup timezone
                (name, timestamp, timezone) = extra['committer'].rsplit(' ', 2)
                commit.committer = name
                commit.commit_time = timestamp
Kevin Bullock's avatar
Kevin Bullock committed
567

568
569
570
571
572
573
574
575
576
577
                # work around a timezone format change
                if int(timezone) % 60 != 0:  # pragma: no cover
                    timezone = parse_timezone(timezone)
                    # Newer versions of Dulwich return a tuple here
                    if isinstance(timezone, tuple):
                        timezone, neg_utc = timezone
                        commit._commit_timezone_neg_utc = neg_utc
                else:
                    timezone = -int(timezone)
                commit.commit_timezone = timezone
578
            except:    # extra is essentially user-supplied, we must be careful
579
                self.set_commiter_from_author(commit)
Kevin Bullock's avatar
Kevin Bullock committed
580
        else:
581
            self.set_commiter_from_author(commit)
Kevin Bullock's avatar
Kevin Bullock committed
582
583
584
585
586
587

        commit.parents = []
        for parent in self.get_git_parents(ctx):
            hgsha = hex(parent.node())
            git_sha = self.map_git_get(hgsha)
            if git_sha:
588
                if git_sha not in self.git.object_store:
589
590
                    raise error.Abort(_('Parent SHA-1 not present in Git'
                                        'repo: %s' % git_sha))
591

Kevin Bullock's avatar
Kevin Bullock committed
592
593
                commit.parents.append(git_sha)

594
595
        commit.message, extra = self.get_git_message_and_extra(ctx)
        commit.extra.extend(extra)
Kevin Bullock's avatar
Kevin Bullock committed
596
597
598

        if 'encoding' in extra:
            commit.encoding = extra['encoding']
599
600
        if 'gpgsig' in extra:
            commit.gpgsig = extra['gpgsig']
Kevin Bullock's avatar
Kevin Bullock committed
601

602
        for obj, nodeid in exporter.update_changeset(ctx):
603
604
            if obj.id not in self.git.object_store:
                self.git.object_store.add_object(obj)
605
606
607

        tree_sha = exporter.root_tree_sha

608
        if tree_sha not in self.git.object_store:
609
610
            raise error.Abort(_('Tree SHA-1 not present in Git repo: %s' %
                                tree_sha))
611

Kevin Bullock's avatar
Kevin Bullock committed
612
613
        commit.tree = tree_sha

614
615
        if commit.id not in self.git.object_store:
            self.git.object_store.add_object(commit)
Kevin Bullock's avatar
Kevin Bullock committed
616
617
618
619
620
        self.map_set(commit.id, ctx.hex())

        self.swap_out_encoding(oldenc)
        return commit.id

621
    def get_valid_git_username_email(self, name):
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
        r"""Sanitize usernames and emails to fit git's restrictions.

        The following is taken from the man page of git's fast-import
        command:

            [...] Likewise LF means one (and only one) linefeed [...]

            committer
                The committer command indicates who made this commit,
                and when they made it.

                Here <name> is the person's display name (for example
                "Com M Itter") and <email> is the person's email address
                ("cm@example.com[1]"). LT and GT are the literal
                less-than (\x3c) and greater-than (\x3e) symbols. These
                are required to delimit the email address from the other
                fields in the line. Note that <name> and <email> are
                free-form and may contain any sequence of bytes, except
                LT, GT and LF. <name> is typically UTF-8 encoded.

        Accordingly, this function makes sure that there are none of the
        characters <, >, or \n in any string which will be used for
        a git username or email. Before this, it first removes left
        angle brackets and spaces from the beginning, and right angle
        brackets and spaces from the end, of this string, to convert
        such things as " <john@doe.com> " to "john@doe.com" for
        convenience.

        TESTS:

652
        >>> from collections import namedtuple
653
        >>> from mercurial.ui import ui
Sean Farley's avatar
Sean Farley committed
654
        >>> mockrepo = namedtuple('localrepo', ['vfs', 'sharedpath', 'path'])
655
        >>> mockrepo.vfs = ''
Sean Farley's avatar
Sean Farley committed
656
657
        >>> mockrepo.sharedpath = ''
        >>> mockrepo.path = ''
658
        >>> g = GitHandler(mockrepo, ui()).get_valid_git_username_email
659
660
661
662
663
664
665
666
667
668
669
        >>> g('John Doe')
        'John Doe'
        >>> g('john@doe.com')
        'john@doe.com'
        >>> g(' <john@doe.com> ')
        'john@doe.com'
        >>> g('    <random<\n<garbage\n>  > > ')
        'random???garbage?'
        >>> g('Typo in hgrc >but.hg-git@handles.it.gracefully>')
        'Typo in hgrc ?but.hg-git@handles.it.gracefully'
        """
670
        return RE_GIT_SANITIZE_AUTHOR.sub('?', name.lstrip('< ').rstrip('> '))
671

Kevin Bullock's avatar
Kevin Bullock committed
672
673
674
675
    def get_git_author(self, ctx):
        # hg authors might not have emails
        author = ctx.user()

676
        # see if a translation exists
Gregory Szorc's avatar
Gregory Szorc committed
677
        author = self.author_map.get(author, author)
678

Kevin Bullock's avatar
Kevin Bullock committed
679
        # check for git author pattern compliance
Gregory Szorc's avatar
Gregory Szorc committed
680
        a = RE_GIT_AUTHOR.match(author)
Kevin Bullock's avatar
Kevin Bullock committed
681
682

        if a:
683
684
            name = self.get_valid_git_username_email(a.group(1))
            email = self.get_valid_git_username_email(a.group(2))
685
            if a.group(3) is not None and len(a.group(3)) != 0:
Kevin Bullock's avatar
Kevin Bullock committed
686
                name += ' ext:(' + urllib.quote(a.group(3)) + ')'
687
688
689
            author = '%s <%s>' \
                     % (self.get_valid_git_username_email(name),
                        self.get_valid_git_username_email(email))
690
        elif '@' in author:
691
692
693
            author = '%s <%s>' \
                     % (self.get_valid_git_username_email(author),
                        self.get_valid_git_username_email(author))
Kevin Bullock's avatar
Kevin Bullock committed
694
        else:
695
            author = self.get_valid_git_username_email(author) + ' <none@none>'
Kevin Bullock's avatar
Kevin Bullock committed
696
697

        if 'author' in ctx.extra():
698
            author = "".join(apply_delta(author, ctx.extra()['author']))
Kevin Bullock's avatar
Kevin Bullock committed
699
700
701
702
703

        return author

    def get_git_parents(self, ctx):
        def is_octopus_part(ctx):
704
705
            olist = ('octopus', 'octopus-done')
            return ctx.extra().get('hg-git', None) in olist
Kevin Bullock's avatar
Kevin Bullock committed
706
707
708
709
710
711
712

        parents = []
        if ctx.extra().get('hg-git', None) == 'octopus-done':
            # implode octopus parents
            part = ctx
            while is_octopus_part(part):
                (p1, p2) = part.parents()
713
                assert ctx.extra().get('hg-git', None) != 'octopus'
Kevin Bullock's avatar
Kevin Bullock committed
714
715
716
717
718
719
720
721
                parents.append(p1)
                part = p2
            parents.append(p2)
        else:
            parents = ctx.parents()

        return parents

722
    def get_git_message_and_extra(self, ctx):
Kevin Bullock's avatar
Kevin Bullock committed
723
724
725
726
        extra = ctx.extra()

        message = ctx.description() + "\n"
        if 'message' in extra:
727
            message = "".join(apply_delta(message, extra['message']))
Kevin Bullock's avatar
Kevin Bullock committed
728
729

        # HG EXTRA INFORMATION
730
731

        # test only -- do not document this!
732
733
        extra_in_message = compat.config(self.ui, 'bool', 'git',
                                         'debugextrainmessage')
Kevin Bullock's avatar
Kevin Bullock committed
734
        extra_message = ''
735
        git_extra = []
736
        if ctx.branch() != 'default':
737
            # we always store the branch in the extra message
Kevin Bullock's avatar
Kevin Bullock committed
738
739
            extra_message += "branch : " + ctx.branch() + "\n"

740
741
742
743
744
745
746
747
748
749
750
751
752
        # Git native extra items always come first, followed by hg renames,
        # followed by hg extra keys
        git_extraitems = []
        for key, value in extra.items():
            m = RE_GIT_EXTRA_KEY.match(key)
            if m is not None:
                git_extraitems.append((int(m.group(1)), m.group(2), value))
                del extra[key]

        git_extraitems.sort()
        for i, field, value in git_extraitems:
            git_extra.append((urllib.unquote(field), urllib.unquote(value)))

753
754
755
756
757
758
759
760
        if extra.get('hg-git-rename-source', None) != 'git':
            renames = []
            for f in ctx.files():
                if f not in ctx.manifest():
                    continue
                rename = ctx.filectx(f).renamed()
                if rename:
                    renames.append((rename[0], f))
Kevin Bullock's avatar
Kevin Bullock committed
761

762
763
764
765
766
767
768
769
770
            if renames:
                for oldfile, newfile in renames:
                    if extra_in_message:
                        extra_message += ("rename : " + oldfile + " => " +
                                          newfile + "\n")
                    else:
                        spec = '%s:%s' % (urllib.quote(oldfile),
                                          urllib.quote(newfile))
                        git_extra.append(('HG:rename', spec))
Kevin Bullock's avatar
Kevin Bullock committed
771

772
        # hg extra items always go at the end
773
774
775
        extraitems = extra.items()
        extraitems.sort()
        for key, value in extraitems:
776
777
            if key in ('author', 'committer', 'encoding', 'message', 'branch',
                       'hg-git', 'hg-git-rename-source'):
Kevin Bullock's avatar
Kevin Bullock committed
778
779
                continue
            else:
780
781
782
783
784
785
786
                if extra_in_message:
                    extra_message += ("extra : " + key + " : " +
                                      urllib.quote(value) + "\n")
                else:
                    spec = '%s:%s' % (urllib.quote(key),
                                      urllib.quote(value))
                    git_extra.append(('HG:extra', spec))
Kevin Bullock's avatar
Kevin Bullock committed
787

788
        if extra_message:
Kevin Bullock's avatar
Kevin Bullock committed
789
790
            message += "\n--HG--\n" + extra_message

791
792
        if (extra.get('hg-git-rename-source', None) != 'git' and not
            extra_in_message and not git_extra and extra_message == ''):
793
794
795
796
797
            # We need to store this if no other metadata is stored. This
            # indicates that when reimporting the commit into Mercurial we'll
            # know not to detect renames.
            git_extra.append(('HG:rename-source', 'hg'))

798
        return message, git_extra
Kevin Bullock's avatar
Kevin Bullock committed
799

800
    def get_git_incoming(self, refs):
801
        return git2hg.find_incoming(self.git.object_store, self._map_git, refs)
Brendan Cully's avatar
Brendan Cully committed
802

803
    def import_git_objects(self, remote_name, refs):
804
805
806
        result = self.get_git_incoming(refs)
        commits = result.commits
        commit_cache = result.commit_cache
Kevin Bullock's avatar
Kevin Bullock committed
807
808
        # import each of the commits, oldest first
        total = len(commits)
Brendan Cully's avatar
Brendan Cully committed
809
810
        if total:
            self.ui.status(_("importing git objects into hg\n"))
811
812
        else:
            self.ui.status(_("no changes found\n"))
Brendan Cully's avatar
Brendan Cully committed
813

814
        mapsavefreq = compat.config(self.ui, 'int', 'hggit',
815
                                    'mapsavefrequency')
816
817
818
819
820
821
822
823
        with compat.makeprogress(self.ui, 'importing', unit='commits', total=total) as progress:
            for i, csha in enumerate(commits):
                progress.update(i)
                commit = commit_cache[csha]
                self.import_git_commit(commit)
                if mapsavefreq and i % mapsavefreq == 0:
                    self.ui.debug(_("saving mapfile\n"))
                    self.save_map(self.map_file)
Kevin Bullock's avatar
Kevin Bullock committed
824

825
        # TODO if the tags cache is used, remove any dangling tag references
826
        return total
827

Kevin Bullock's avatar
Kevin Bullock committed
828
829
830
    def import_git_commit(self, commit):
        self.ui.debug(_("importing: %s\n") % commit.id)

831
        detect_renames = False
832
        (strip_message, hg_renames,
833
         hg_branch, extra) = git2hg.extract_hg_metadata(
834
             commit.message, commit.extra)
835
        if hg_renames is None:
836
            detect_renames = True
837
838
839
840
841
            # We have to store this unconditionally, even if there are no
            # renames detected from Git. This is because we export an extra
            # 'HG:rename-source' Git parameter when this isn't set, which will
            # break bidirectionality.
            extra['hg-git-rename-source'] = 'git'
842
843
        else:
            renames = hg_renames
Kevin Bullock's avatar
Kevin Bullock committed
844

845
846
        gparents = map(self.map_hg_get, commit.parents)

847
848
        for parent in gparents:
            if parent not in self.repo:
849
850
                raise error.Abort(_('you appear to have run strip - '
                                    'please run hg git-cleanup'))
851

852
        # get a list of the changed, added, removed files and gitlinks
853
854
855
856
        files, gitlinks, git_renames = self.get_files_changed(commit,
                                                              detect_renames)
        if detect_renames:
            renames = git_renames
Kevin Bullock's avatar
Kevin Bullock committed
857

858
859
        git_commit_tree = self.git[commit.tree]

860
861
862
863
864
865
866
867
868
        # Analyze hgsubstate and build an updated version using SHAs from
        # gitlinks. Order of application:
        # - preexisting .hgsubstate in git tree
        # - .hgsubstate from hg parent
        # - changes in gitlinks
        hgsubstate = util.parse_hgsubstate(
            self.git_file_readlines(git_commit_tree, '.hgsubstate'))
        parentsubdata = ''
        if gparents:
869
            p1ctx = self.repo[gparents[0]]
870
            if '.hgsubstate' in p1ctx:
871
872
                parentsubdata = p1ctx.filectx('.hgsubstate').data()
                parentsubdata = parentsubdata.splitlines()
873
874
875
876
877
878
879
                parentsubstate = util.parse_hgsubstate(parentsubdata)
                for path, sha in parentsubstate.iteritems():
                    hgsubstate[path] = sha
        for path, sha in gitlinks.iteritems():
            if sha is None:
                hgsubstate.pop(path, None)
            else:
880
                hgsubstate[path] = sha
881
        # in case .hgsubstate wasn't among changed files
882
883
884
885
886
        # force its inclusion if it wasn't already deleted
        hgsubdeleted = files.get('.hgsubstate')
        if hgsubdeleted:
            hgsubdeleted = hgsubdeleted[0]
        if hgsubdeleted or (not hgsubstate and parentsubdata):
887
888
889
            files['.hgsubstate'] = True, None, None
        elif util.serialize_hgsubstate(hgsubstate) != parentsubdata:
            files['.hgsubstate'] = False, 0100644, None
890
891
892
893

        # Analyze .hgsub and merge with .gitmodules
        hgsub = None
        gitmodules = self.parse_gitmodules(git_commit_tree)
894
        if gitmodules:
895
896
            hgsub = util.parse_hgsub(self.git_file_readlines(git_commit_tree,
                                                             '.hgsub'))
897
898
899
            for (sm_path, sm_url, sm_name) in gitmodules:
                hgsub[sm_path] = '[git]' + sm_url
            files['.hgsub'] = (False, 0100644, None)
900
901
        elif (commit.parents and '.gitmodules' in
              self.git[self.git[commit.parents[0]].tree]):
902
903
904
905
906
            # no .gitmodules in this commit, however present in the parent
            # mark its hg counterpart as deleted (assuming .hgsub is there
            # due to the same import_git_commit process
            files['.hgsub'] = (True, 0100644, None)

Kevin Bullock's avatar
Kevin Bullock committed
907
908
909
910
911
912
913
914
915
916
917
        date = (commit.author_time, -commit.author_timezone)
        text = strip_message

        origtext = text
        try:
            text.decode('utf-8')
        except UnicodeDecodeError:
            text = self.decode_guess(text, commit.encoding)

        text = '\n'.join([l.rstrip() for l in text.splitlines()]).strip('\n')
        if text + '\n' != origtext:
918
            extra['message'] = create_delta(text + '\n', origtext)
Kevin Bullock's avatar
Kevin Bullock committed
919
920
921
922
923

        author = commit.author

        # convert extra data back to the end
        if ' ext:' in commit.author:
924
            m = RE_GIT_AUTHOR_EXTRA.match(commit.author)
Kevin Bullock's avatar
Kevin Bullock committed
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
            if m:
                name = m.group(1)
                ex = urllib.unquote(m.group(2))
                email = m.group(3)
                author = name + ' <' + email + '>' + ex

        if ' <none@none>' in commit.author:
            author = commit.author[:-12]

        try:
            author.decode('utf-8')
        except UnicodeDecodeError:
            origauthor = author
            author = self.decode_guess(author, commit.encoding)
            extra['author'] = create_delta(author, origauthor)

        oldenc = self.swap_out_encoding()

943
944
945
946
947
948
949
950
951
952
        def findconvergedfiles(p1, p2):
            # If any files have the same contents in both parents of a merge
            # (and are therefore not reported as changed by Git) but are at
            # different file revisions in Mercurial (because they arrived at
            # those contents in different ways), we need to include them in
            # the list of changed files so that Mercurial can join up their
            # filelog histories (same as if the merge was done in Mercurial to
            # begin with).
            if p2 == nullid:
                return []
953
954
            manifest1 = self.repo[p1].manifest()
            manifest2 = self.repo[p2].manifest()
955
956
            return [path for path, node1 in manifest1.iteritems() if path not
                    in files and manifest2.get(path, node1) != node1]
957

Kevin Bullock's avatar
Kevin Bullock committed
958
        def getfilectx(repo, memctx, f):
959
            info = files.get(f)
960
            if info is not None:
961
962
963
                # it's a file reported as modified from Git
                delete, mode, sha = info
                if delete:
964
                    return None
965

966
                if not sha:  # indicates there's no git counterpart
967
968
969
970
971
972
973
974
                    e = ''
                    copied_path = None
                    if '.hgsubstate' == f:
                        data = util.serialize_hgsubstate(hgsubstate)
                    elif '.hgsub' == f:
                        data = util.serialize_hgsub(hgsub)
                else:
                    data = self.git[sha].data
975
                    copied_path = renames.get(f)
976
                    e = self.convert_git_int_mode(mode)
977
978
979
980
981
            else:
                # it's a converged file
                fc = context.filectx(self.repo, f, changeid=memctx.p1().rev())
                data = fc.data()
                e = fc.flags()
982
983
984
985
                copied_path = None
                copied = fc.renamed()
                if copied:
                    copied_path = copied[0]
986

987
988
989
            return compat.memfilectx(self.repo, memctx, f, data,
                                     islink='l' in e,
                                     isexec='x' in e,
990
                                     copysource=copied_path)
Kevin Bullock's avatar
Kevin Bullock committed
991
992
993
994
995
996
997

        p1, p2 = (nullid, nullid)
        octopus = False

        if len(gparents) > 1:
            # merge, possibly octopus
            def commit_octopus(p1, p2):
998
999
1000
                ctx = context.memctx(self.repo, (p1, p2), text, list(files) +
                                     findconvergedfiles(p1, p2), getfilectx,
                                     author, date, {'hg-git': 'octopus'})
1001
1002
                # See comment below about setting substate to None.
                ctx.substate = None
Kevin Bullock's avatar
Kevin Bullock committed
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
                return hex(self.repo.commitctx(ctx))

            octopus = len(gparents) > 2
            p2 = gparents.pop()
            p1 = gparents.pop()
            while len(gparents) > 0:
                p2 = commit_octopus(p1, p2)
                p1 = gparents.pop()
        else:
            if gparents:
                p1 = gparents.pop()

        # if named branch, add to extra
        if hg_branch:
            extra['branch'] = hg_branch
1018
1019
        else:
            extra['branch'] = 'default'
Kevin Bullock's avatar
Kevin Bullock committed
1020
1021
1022

        # if committer is different than author, add it to extra
        if commit.author != commit.committer \
1023
1024
           or commit.author_time != commit.commit_time \
           or commit.author_timezone != commit.commit_timezone:
1025
1026
            extra['committer'] = "%s %d %d" % (
                commit.committer, commit.commit_time, -commit.commit_timezone)
Kevin Bullock's avatar
Kevin Bullock committed
1027
1028
1029

        if commit.encoding:
            extra['encoding'] = commit.encoding
1030
1031
        if commit.gpgsig:
            extra['gpgsig'] = commit.gpgsig
Kevin Bullock's avatar
Kevin Bullock committed
1032
1033

        if octopus:
1034
            extra['hg-git'] = 'octopus-done'
Kevin Bullock's avatar
Kevin Bullock committed
1035

1036
1037
1038
        ctx = context.memctx(self.repo, (p1, p2), text,
                             list(files) + findconvergedfiles(p1, p2),
                             getfilectx, author, date, extra)
1039
1040
1041
1042
        # Starting Mercurial commit d2743be1bb06, memctx imports from
        # committablectx. This means that it has a 'substate' property that
        # contains the subrepo state. Ordinarily, Mercurial expects the subrepo
        # to be present while making a new commit -- since hg-git is importing
1043
1044
        # purely in-memory commits without backing stores for the subrepos,
        # that won't work. Forcibly set the substate to None so that there's no
1045
1046
        # attempt to read subrepos.
        ctx.substate = None
Kevin Bullock's avatar
Kevin Bullock committed
1047
1048
1049
1050
1051
1052
1053
1054
        node = self.repo.commitctx(ctx)

        self.swap_out_encoding(oldenc)

        # save changeset to mapping file
        cs = hex(node)
        self.map_set(commit.id, cs)

1055
    # PACK UPLOADING AND FETCHING
Kevin Bullock's avatar
Kevin Bullock committed
1056
1057
1058

    def upload_pack(self, remote, revs, force):
        client, path = self.get_transport_and_path(remote)
1059
        old_refs = {}
1060
1061
        change_totals = {}

Kevin Bullock's avatar
Kevin Bullock committed
1062
        def changed(refs):
1063
            self.ui.status(_("searching for changes\n"))
1064
            old_refs.update(refs)
1065
1066
1067
1068
1069
1070
1071
            all_exportable = self.get_exportable()
            if revs is None:
                exportable = all_exportable
            else:
                exportable = {}
                for rev in (hex(r) for r in revs):
                    if rev not in all_exportable:
1072
1073
1074
                        raise error.Abort("revision %s cannot be pushed since"
                                          " it doesn't have a bookmark" %
                                          self.repo[rev])
1075
1076
                    exportable[rev] = all_exportable[rev]
            return self.get_changed_refs(refs, exportable, force)
Kevin Bullock's avatar
Kevin Bullock committed
1077

1078
        def genpack(have, want, progress=None, ofs_delta=True):
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
            commits = []
            for mo in self.git.object_store.find_missing_objects(have, want):
                (sha, name) = mo
                o = self.git.object_store[sha]
                t = type(o)
                change_totals[t] = change_totals.get(t, 0) + 1
                if isinstance(o, Commit):
                    commits.append(sha)
            commit_count = len(commits)
            self.ui.note(_("%d commits found\n") % commit_count)
            if commit_count > 0:
                self.ui.debug(_("list of commits:\n"))
                for commit in commits:
                    self.ui.debug("%s\n" % commit)
                self.ui.status(_("adding objects\n"))
1094
1095
            return self.git.object_store.generate_pack_data(
                have, want, progress=progress, ofs_delta=ofs_delta)
1096

1097
        def callback(remote_info):
1098
1099
1100
1101
1102
1103
1104
1105
1106
            # dulwich (perhaps git?) wraps remote output at a fixed width but
            # signifies the end of transmission with a double new line
            global CALLBACK_BUFFER
            if remote_info and not remote_info.endswith('\n\n'):
                CALLBACK_BUFFER += remote_info
                return

            remote_info = CALLBACK_BUFFER + remote_info
            CALLBACK_BUFFER = ''
1107
            if not remote_info:
1108
1109
1110
                remote_info = '\n'

            for line in remote_info[:-1].split('\n'):
1111
1112
                self.ui.status(_("remote: %s\n") % line)

Kevin Bullock's avatar
Kevin Bullock committed
1113
        try:
1114
1115
            new_refs = client.send_pack(path, changed, genpack,
                                        progress=callback)
1116
1117
1118
1119
1120
1121
            if len(change_totals) > 0:
                self.ui.status(_("added %d commits with %d trees"
                                 " and %d blobs\n") %
                               (change_totals.get(Commit, 0),
                                change_totals.get(Tree, 0),
                                change_totals.get(Blob, 0)))
1122
            return old_refs, new_refs
1123
        except (HangupException, GitProtocolError), e:
1124
            raise error.Abort(_("git remote error: ") + str(e))
Kevin Bullock's avatar
Kevin Bullock committed
1125

1126
    def get_changed_refs(self, refs, exportable, force):
Kevin Bullock's avatar
Kevin Bullock committed
1127
1128
        new_refs = refs.copy()

1129
1130
        # The remote repo is empty and the local one doesn't have
        # bookmarks/tags
1131
1132
1133
1134
1135
1136
        #
        # (older dulwich versions return the proto-level
        # capabilities^{} key when the dict should have been
        # empty. That check can probably be removed at some point in
        # the future.)
        if not refs or refs.keys()[0] == 'capabilities^{}':
1137
            if not exportable:
1138
1139
                tip = self.repo.lookup('tip')
                if tip != nullid:
1140
1141
                    if 'capabilities^{}' in new_refs:
                        del new_refs['capabilities^{}']
1142
                    tip = hex(tip)
1143
1144
                    commands.bookmark(self.ui, self.repo, 'master',
                                      rev=tip, force=True)
1145
                    bookmarks.activate(self.repo, 'master')
1146
                    new_refs['refs/heads/master'] = self.map_git_get(tip)
Kevin Bullock's avatar
Kevin Bullock committed
1147

1148
1149
        # mapped nodes might be hidden
        unfiltered = self.repo.unfiltered()
1150
        for rev, rev_refs in exportable.iteritems():
Kevin Bullock's avatar
Kevin Bullock committed
1151
            ctx = self.repo[rev]
1152
            if not rev_refs: