diff --git a/mercurial/bundle2.py b/mercurial/bundle2.py
--- a/mercurial/bundle2.py
+++ b/mercurial/bundle2.py
@@ -158,6 +158,7 @@ from . import (
     changegroup,
     error,
     obsolete,
+    phases,
     pushkey,
     pycompat,
     tags,
@@ -178,6 +179,8 @@ urlreq = util.urlreq
 _fpayloadsize = '>i'
 _fpartparamcount = '>BB'
 
+_fphasesentry = '>i20s'
+
 preferedchunksize = 4096
 
 _parttypeforbidden = re.compile('[^a-zA-Z0-9_:-]')
@@ -1387,6 +1390,14 @@ def _addpartsfromopts(ui, repo, bundler,
         obsmarkers = repo.obsstore.relevantmarkers(outgoing.missing)
         buildobsmarkerspart(bundler, obsmarkers)
 
+    if opts.get('phases', False):
+        headsbyphase = phases.subsetphaseheads(repo, outgoing.missing)
+        phasedata = []
+        for phase in phases.allphases:
+            for head in headsbyphase[phase]:
+                phasedata.append(_pack(_fphasesentry, phase, head))
+        bundler.newpart('phase-heads', data=''.join(phasedata))
+
 def addparttagsfnodescache(repo, bundler, outgoing):
     # we include the tags fnode cache for the bundle changeset
     # (as an optional parts)
@@ -1721,6 +1732,29 @@ def handlepushkey(op, inpart):
                 kwargs[key] = inpart.params[key]
         raise error.PushkeyFailed(partid=str(inpart.id), **kwargs)
 
+def _readphaseheads(inpart):
+    headsbyphase = [[] for i in phases.allphases]
+    entrysize = struct.calcsize(_fphasesentry)
+    while True:
+        entry = inpart.read(entrysize)
+        if len(entry) < entrysize:
+            if entry:
+                raise error.Abort(_('bad phase-heads bundle part'))
+            break
+        phase, node = struct.unpack(_fphasesentry, entry)
+        headsbyphase[phase].append(node)
+    return headsbyphase
+
+@parthandler('phase-heads')
+def handlephases(op, inpart):
+    """apply phases from bundle part to repo"""
+    headsbyphase = _readphaseheads(inpart)
+    addednodes = []
+    for entry in op.records['changegroup']:
+        addednodes.extend(entry['addednodes'])
+    phases.updatephases(op.repo.unfiltered(), op.gettransaction(), headsbyphase,
+                        addednodes)
+
 @parthandler('reply:pushkey', ('return', 'in-reply-to'))
 def handlepushkeyreply(op, inpart):
     """retrieve the result of a pushkey request"""
diff --git a/mercurial/commands.py b/mercurial/commands.py
--- a/mercurial/commands.py
+++ b/mercurial/commands.py
@@ -1230,6 +1230,8 @@ def bundle(ui, repo, fname, dest=None, *
     contentopts = {'cg.version': cgversion}
     if repo.ui.configbool('experimental', 'evolution.bundle-obsmarker', False):
         contentopts['obsolescence'] = True
+    if repo.ui.configbool('experimental', 'bundle-phases', False):
+        contentopts['phases'] = True
     bundle2.writenewbundle(ui, repo, 'bundle', fname, bversion, outgoing,
                            contentopts, compression=bcompression,
                            compopts=compopts)
diff --git a/mercurial/debugcommands.py b/mercurial/debugcommands.py
--- a/mercurial/debugcommands.py
+++ b/mercurial/debugcommands.py
@@ -311,6 +311,15 @@ def _debugobsmarkers(ui, part, indent=0,
             cmdutil.showmarker(fm, m)
         fm.end()
 
+def _debugphaseheads(ui, data, indent=0):
+    """display version and markers contained in 'data'"""
+    indent_string = ' ' * indent
+    headsbyphase = bundle2._readphaseheads(data)
+    for phase in phases.allphases:
+        for head in headsbyphase[phase]:
+            ui.write(indent_string)
+            ui.write('%s %s\n' % (hex(head), phases.phasenames[phase]))
+
 def _debugbundle2(ui, gen, all=None, **opts):
     """lists the contents of a bundle2"""
     if not isinstance(gen, bundle2.unbundle20):
@@ -327,6 +336,8 @@ def _debugbundle2(ui, gen, all=None, **o
             _debugchangegroup(ui, cg, all=all, indent=4, **opts)
         if part.type == 'obsmarkers':
             _debugobsmarkers(ui, part, indent=4, **opts)
+        if part.type == 'phase-heads':
+            _debugphaseheads(ui, part, indent=4)
 
 @command('debugbundle',
         [('a', 'all', None, _('show all details')),
diff --git a/mercurial/phases.py b/mercurial/phases.py
--- a/mercurial/phases.py
+++ b/mercurial/phases.py
@@ -430,6 +430,32 @@ def pushphase(repo, nhex, oldphasestr, n
         else:
             return False
 
+def subsetphaseheads(repo, subset):
+    """Finds the phase heads for a subset of a history
+
+    Returns a list indexed by phase number where each item is a list of phase
+    head nodes.
+    """
+    cl = repo.changelog
+
+    headsbyphase = [[] for i in allphases]
+    # No need to keep track of secret phase; any heads in the subset that
+    # are not mentioned are implicitly secret.
+    for phase in allphases[:-1]:
+        revset = "heads(%%ln & %s())" % phasenames[phase]
+        headsbyphase[phase] = [cl.node(r) for r in repo.revs(revset, subset)]
+    return headsbyphase
+
+def updatephases(repo, tr, headsbyphase, addednodes):
+    """Updates the repo with the given phase heads"""
+    # First make all the added revisions secret because changegroup.apply()
+    # currently sets the phase to draft.
+    retractboundary(repo, tr, secret, addednodes)
+
+    # Now advance phase boundaries of all but secret phase
+    for phase in allphases[:-1]:
+        advanceboundary(repo, tr, phase, headsbyphase[phase])
+
 def analyzeremotephases(repo, subset, roots):
     """Compute phases heads and root in a subset of node from root dict
 
diff --git a/tests/test-bundle-phases.t b/tests/test-bundle-phases.t
new file mode 100644
--- /dev/null
+++ b/tests/test-bundle-phases.t
@@ -0,0 +1,259 @@
+  $ cat >> $HGRCPATH <<EOF
+  > [experimental]
+  > bundle-phases=yes
+  > [extensions]
+  > strip=
+  > drawdag=$TESTDIR/drawdag.py
+  > EOF
+
+Set up repo with linear history
+  $ hg init linear
+  $ cd linear
+  $ hg debugdrawdag <<'EOF'
+  > E
+  > |
+  > D
+  > |
+  > C
+  > |
+  > B
+  > |
+  > A
+  > EOF
+  $ hg phase --public A
+  $ hg phase --force --secret D
+  $ hg log -G -T '{desc} {phase}\n'
+  o  E secret
+  |
+  o  D secret
+  |
+  o  C draft
+  |
+  o  B draft
+  |
+  o  A public
+  
+Phases are restored when unbundling
+  $ hg bundle --base B -r E bundle
+  3 changesets found
+  $ hg debugbundle bundle
+  Stream params: sortdict([('Compression', 'BZ')])
+  changegroup -- "sortdict([('version', '02'), ('nbchanges', '3')])"
+      26805aba1e600a82e93661149f2313866a221a7b
+      f585351a92f85104bff7c284233c338b10eb1df7
+      9bc730a19041f9ec7cb33c626e811aa233efb18c
+  phase-heads -- 'sortdict()'
+      26805aba1e600a82e93661149f2313866a221a7b draft
+  $ hg strip --no-backup C
+  $ hg unbundle -q bundle
+  $ rm bundle
+  $ hg log -G -T '{desc} {phase}\n'
+  o  E secret
+  |
+  o  D secret
+  |
+  o  C draft
+  |
+  o  B draft
+  |
+  o  A public
+  
+Root revision's phase is preserved
+  $ hg bundle -a bundle
+  5 changesets found
+  $ hg strip --no-backup A
+  $ hg unbundle -q bundle
+  $ rm bundle
+  $ hg log -G -T '{desc} {phase}\n'
+  o  E secret
+  |
+  o  D secret
+  |
+  o  C draft
+  |
+  o  B draft
+  |
+  o  A public
+  
+Completely public history can be restored
+  $ hg phase --public E
+  $ hg bundle -a bundle
+  5 changesets found
+  $ hg strip --no-backup A
+  $ hg unbundle -q bundle
+  $ rm bundle
+  $ hg log -G -T '{desc} {phase}\n'
+  o  E public
+  |
+  o  D public
+  |
+  o  C public
+  |
+  o  B public
+  |
+  o  A public
+  
+Direct transition from public to secret can be restored
+  $ hg phase --secret --force D
+  $ hg bundle -a bundle
+  5 changesets found
+  $ hg strip --no-backup A
+  $ hg unbundle -q bundle
+  $ rm bundle
+  $ hg log -G -T '{desc} {phase}\n'
+  o  E secret
+  |
+  o  D secret
+  |
+  o  C public
+  |
+  o  B public
+  |
+  o  A public
+  
+Revisions within bundle preserve their phase even if parent changes its phase
+  $ hg phase --draft --force B
+  $ hg bundle --base B -r E bundle
+  3 changesets found
+  $ hg strip --no-backup C
+  $ hg phase --public B
+  $ hg unbundle -q bundle
+  $ rm bundle
+  $ hg log -G -T '{desc} {phase}\n'
+  o  E secret
+  |
+  o  D secret
+  |
+  o  C draft
+  |
+  o  B public
+  |
+  o  A public
+  
+Phase of ancestors of stripped node get advanced to accommodate child
+  $ hg bundle --base B -r E bundle
+  3 changesets found
+  $ hg strip --no-backup C
+  $ hg phase --force --secret B
+  $ hg unbundle -q bundle
+  $ rm bundle
+  $ hg log -G -T '{desc} {phase}\n'
+  o  E secret
+  |
+  o  D secret
+  |
+  o  C draft
+  |
+  o  B draft
+  |
+  o  A public
+  
+Unbundling advances phases of changesets even if they were already in the repo.
+To test that, create a bundle of everything in draft phase and then unbundle
+to see that secret becomes draft, but public remains public.
+  $ hg phase --draft --force A
+  $ hg phase --draft E
+  $ hg bundle -a bundle
+  5 changesets found
+  $ hg phase --public A
+  $ hg phase --secret --force E
+  $ hg unbundle -q bundle
+  $ rm bundle
+  $ hg log -G -T '{desc} {phase}\n'
+  o  E draft
+  |
+  o  D draft
+  |
+  o  C draft
+  |
+  o  B draft
+  |
+  o  A public
+  
+  $ cd ..
+
+Set up repo with non-linear history
+  $ hg init non-linear
+  $ cd non-linear
+  $ hg debugdrawdag <<'EOF'
+  > D E
+  > |\|
+  > B C
+  > |/
+  > A
+  > EOF
+  $ hg phase --public C
+  $ hg phase --force --secret B
+  $ hg log -G -T '{node|short} {desc} {phase}\n'
+  o  03ca77807e91 E draft
+  |
+  | o  215e7b0814e1 D secret
+  |/|
+  o |  dc0947a82db8 C public
+  | |
+  | o  112478962961 B secret
+  |/
+  o  426bada5c675 A public
+  
+
+Restore bundle of entire repo
+  $ hg bundle -a bundle
+  5 changesets found
+  $ hg debugbundle bundle
+  Stream params: sortdict([('Compression', 'BZ')])
+  changegroup -- "sortdict([('version', '02'), ('nbchanges', '5')])"
+      426bada5c67598ca65036d57d9e4b64b0c1ce7a0
+      112478962961147124edd43549aedd1a335e44bf
+      dc0947a82db884575bb76ea10ac97b08536bfa03
+      215e7b0814e1cac8e2614e7284f2a5dc266b4323
+      03ca77807e919db8807c3749086dc36fb478cac0
+  phase-heads -- 'sortdict()'
+      dc0947a82db884575bb76ea10ac97b08536bfa03 public
+      03ca77807e919db8807c3749086dc36fb478cac0 draft
+  $ hg strip --no-backup A
+  $ hg unbundle -q bundle
+  $ rm bundle
+  $ hg log -G -T '{node|short} {desc} {phase}\n'
+  o  03ca77807e91 E draft
+  |
+  | o  215e7b0814e1 D secret
+  |/|
+  o |  dc0947a82db8 C public
+  | |
+  | o  112478962961 B secret
+  |/
+  o  426bada5c675 A public
+  
+
+  $ hg bundle --base 'A + C' -r D bundle
+  2 changesets found
+  $ hg debugbundle bundle
+  Stream params: sortdict([('Compression', 'BZ')])
+  changegroup -- "sortdict([('version', '02'), ('nbchanges', '2')])"
+      112478962961147124edd43549aedd1a335e44bf
+      215e7b0814e1cac8e2614e7284f2a5dc266b4323
+  phase-heads -- 'sortdict()'
+  $ rm bundle
+
+  $ hg bundle --base A -r D bundle
+  3 changesets found
+  $ hg debugbundle bundle
+  Stream params: sortdict([('Compression', 'BZ')])
+  changegroup -- "sortdict([('version', '02'), ('nbchanges', '3')])"
+      112478962961147124edd43549aedd1a335e44bf
+      dc0947a82db884575bb76ea10ac97b08536bfa03
+      215e7b0814e1cac8e2614e7284f2a5dc266b4323
+  phase-heads -- 'sortdict()'
+      dc0947a82db884575bb76ea10ac97b08536bfa03 public
+  $ rm bundle
+
+  $ hg bundle --base 'B + C' -r 'D + E' bundle
+  2 changesets found
+  $ hg debugbundle bundle
+  Stream params: sortdict([('Compression', 'BZ')])
+  changegroup -- "sortdict([('version', '02'), ('nbchanges', '2')])"
+      215e7b0814e1cac8e2614e7284f2a5dc266b4323
+      03ca77807e919db8807c3749086dc36fb478cac0
+  phase-heads -- 'sortdict()'
+      03ca77807e919db8807c3749086dc36fb478cac0 draft
+  $ rm bundle