hgext/shelve.py
changeset 19854 49d4919d21c2
child 19855 a3b285882724
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/hgext/shelve.py	Thu Aug 29 09:22:13 2013 -0700
@@ -0,0 +1,607 @@
+# shelve.py - save/restore working directory state
+#
+# Copyright 2013 Facebook, Inc.
+#
+# This software may be used and distributed according to the terms of the
+# GNU General Public License version 2 or any later version.
+
+"""save and restore changes to the working directory
+
+The "hg shelve" command saves changes made to the working directory
+and reverts those changes, resetting the working directory to a clean
+state.
+
+Later on, the "hg unshelve" command restores the changes saved by "hg
+shelve". Changes can be restored even after updating to a different
+parent, in which case Mercurial's merge machinery will resolve any
+conflicts if necessary.
+
+You can have more than one shelved change outstanding at a time; each
+shelved change has a distinct name. For details, see the help for "hg
+shelve".
+"""
+
+try:
+    import cPickle as pickle
+    pickle.dump # import now
+except ImportError:
+    import pickle
+from mercurial.i18n import _
+from mercurial.node import nullid
+from mercurial import changegroup, cmdutil, scmutil, phases
+from mercurial import error, hg, mdiff, merge, patch, repair, util
+from mercurial import templatefilters
+from mercurial import lock as lockmod
+import errno
+
+cmdtable = {}
+command = cmdutil.command(cmdtable)
+testedwith = 'internal'
+
+class shelvedfile(object):
+    """Handles common functions on shelve files (.hg/.files/.patch) using
+    the vfs layer"""
+    def __init__(self, repo, name, filetype=None):
+        self.repo = repo
+        self.name = name
+        self.vfs = scmutil.vfs(repo.join('shelved'))
+        if filetype:
+            self.fname = name + '.' + filetype
+        else:
+            self.fname = name
+
+    def exists(self):
+        return self.vfs.exists(self.fname)
+
+    def filename(self):
+        return self.vfs.join(self.fname)
+
+    def unlink(self):
+        util.unlink(self.filename())
+
+    def stat(self):
+        return self.vfs.stat(self.fname)
+
+    def opener(self, mode='rb'):
+        try:
+            return self.vfs(self.fname, mode)
+        except IOError, err:
+            if err.errno != errno.ENOENT:
+                raise
+            if mode[0] in 'wa':
+                try:
+                    self.vfs.mkdir()
+                    return self.vfs(self.fname, mode)
+                except IOError, err:
+                    if err.errno != errno.EEXIST:
+                        raise
+            elif mode[0] == 'r':
+                raise util.Abort(_("shelved change '%s' not found") %
+                                 self.name)
+
+class shelvedstate(object):
+    """Handles saving and restoring a shelved state. Ensures that different
+    versions of a shelved state are possible and handles them appropriate"""
+    _version = 1
+    _filename = 'shelvedstate'
+
+    @classmethod
+    def load(cls, repo):
+        fp = repo.opener(cls._filename)
+        (version, name, parents, stripnodes) = pickle.load(fp)
+
+        if version != cls._version:
+            raise util.Abort(_('this version of shelve is incompatible '
+                               'with the version used in this repo'))
+
+        obj = cls()
+        obj.name = name
+        obj.parents = parents
+        obj.stripnodes = stripnodes
+
+        return obj
+
+    @classmethod
+    def save(cls, repo, name, stripnodes):
+        fp = repo.opener(cls._filename, 'wb')
+        pickle.dump((cls._version, name,
+                     repo.dirstate.parents(),
+                     stripnodes), fp)
+        fp.close()
+
+    @staticmethod
+    def clear(repo):
+        util.unlinkpath(repo.join('shelvedstate'), ignoremissing=True)
+
+def createcmd(ui, repo, pats, opts):
+    def publicancestors(ctx):
+        """Compute the heads of the public ancestors of a commit.
+
+        Much faster than the revset heads(ancestors(ctx) - draft())"""
+        seen = set()
+        visit = util.deque()
+        visit.append(ctx)
+        while visit:
+            ctx = visit.popleft()
+            for parent in ctx.parents():
+                rev = parent.rev()
+                if rev not in seen:
+                    seen.add(rev)
+                    if parent.mutable():
+                        visit.append(parent)
+                    else:
+                        yield parent.node()
+
+    wctx = repo[None]
+    parents = wctx.parents()
+    if len(parents) > 1:
+        raise util.Abort(_('cannot shelve while merging'))
+    parent = parents[0]
+
+    # we never need the user, so we use a generic user for all shelve operations
+    user = 'shelve@localhost'
+    label = repo._bookmarkcurrent or parent.branch() or 'default'
+
+    # slashes aren't allowed in filenames, therefore we rename it
+    origlabel, label = label, label.replace('/', '_')
+
+    def gennames():
+        yield label
+        for i in xrange(1, 100):
+            yield '%s-%02d' % (label, i)
+
+    shelvedfiles = []
+
+    def commitfunc(ui, repo, message, match, opts):
+        # check modified, added, removed, deleted only
+        for flist in repo.status(match=match)[:4]:
+            shelvedfiles.extend(flist)
+        return repo.commit(message, user, opts.get('date'), match)
+
+    if parent.node() != nullid:
+        desc = parent.description().split('\n', 1)[0]
+        desc = _('shelved from %s (%s): %s') % (label, str(parent)[:8], desc)
+    else:
+        desc = '(empty repository)'
+
+    if not opts['message']:
+        opts['message'] = desc
+
+    name = opts['name']
+
+    wlock = lock = tr = None
+    try:
+        wlock = repo.wlock()
+        lock = repo.lock()
+
+        # use an uncommited transaction to generate the bundle to avoid
+        # pull races. ensure we don't print the abort message to stderr.
+        tr = repo.transaction('commit', report=lambda x: None)
+
+        if name:
+            if shelvedfile(repo, name, 'hg').exists():
+                raise util.Abort(_("a shelved change named '%s' already exists")
+                                 % name)
+        else:
+            for n in gennames():
+                if not shelvedfile(repo, n, 'hg').exists():
+                    name = n
+                    break
+            else:
+                raise util.Abort(_("too many shelved changes named '%s'") %
+                                 label)
+
+        # ensure we are not creating a subdirectory or a hidden file
+        if '/' in name or '\\' in name:
+            raise util.Abort(_('shelved change names may not contain slashes'))
+        if name.startswith('.'):
+            raise util.Abort(_("shelved change names may not start with '.'"))
+
+        node = cmdutil.commit(ui, repo, commitfunc, pats, opts)
+
+        if not node:
+            stat = repo.status(match=scmutil.match(repo[None], pats, opts))
+            if stat[3]:
+                ui.status(_("nothing changed (%d missing files, see "
+                            "'hg status')\n") % len(stat[3]))
+            else:
+                ui.status(_("nothing changed\n"))
+            return 1
+
+        phases.retractboundary(repo, phases.secret, [node])
+
+        fp = shelvedfile(repo, name, 'files').opener('wb')
+        fp.write('\0'.join(shelvedfiles))
+
+        bases = list(publicancestors(repo[node]))
+        cg = repo.changegroupsubset(bases, [node], 'shelve')
+        changegroup.writebundle(cg, shelvedfile(repo, name, 'hg').filename(),
+                                'HG10UN')
+        cmdutil.export(repo, [node],
+                       fp=shelvedfile(repo, name, 'patch').opener('wb'),
+                       opts=mdiff.diffopts(git=True))
+
+        if ui.formatted():
+            desc = util.ellipsis(desc, ui.termwidth())
+        ui.status(desc + '\n')
+        ui.status(_('shelved as %s\n') % name)
+        hg.update(repo, parent.node())
+    finally:
+        if tr:
+            tr.abort()
+        lockmod.release(lock, wlock)
+
+def cleanupcmd(ui, repo):
+    wlock = None
+    try:
+        wlock = repo.wlock()
+        for (name, _) in repo.vfs.readdir('shelved'):
+            suffix = name.rsplit('.', 1)[-1]
+            if suffix in ('hg', 'files', 'patch'):
+                shelvedfile(repo, name).unlink()
+    finally:
+        lockmod.release(wlock)
+
+def deletecmd(ui, repo, pats):
+    if not pats:
+        raise util.Abort(_('no shelved changes specified!'))
+    wlock = None
+    try:
+        wlock = repo.wlock()
+        try:
+            for name in pats:
+                for suffix in 'hg files patch'.split():
+                    shelvedfile(repo, name, suffix).unlink()
+        except OSError, err:
+            if err.errno != errno.ENOENT:
+                raise
+            raise util.Abort(_("shelved change '%s' not found") % name)
+    finally:
+        lockmod.release(wlock)
+
+def listshelves(repo):
+    try:
+        names = repo.vfs.readdir('shelved')
+    except OSError, err:
+        if err.errno != errno.ENOENT:
+            raise
+        return []
+    info = []
+    for (name, _) in names:
+        pfx, sfx = name.rsplit('.', 1)
+        if not pfx or sfx != 'patch':
+            continue
+        st = shelvedfile(repo, name).stat()
+        info.append((st.st_mtime, shelvedfile(repo, pfx).filename()))
+    return sorted(info, reverse=True)
+
+def listcmd(ui, repo, pats, opts):
+    pats = set(pats)
+    width = 80
+    if not ui.plain():
+        width = ui.termwidth()
+    namelabel = 'shelve.newest'
+    for mtime, name in listshelves(repo):
+        sname = util.split(name)[1]
+        if pats and sname not in pats:
+            continue
+        ui.write(sname, label=namelabel)
+        namelabel = 'shelve.name'
+        if ui.quiet:
+            ui.write('\n')
+            continue
+        ui.write(' ' * (16 - len(sname)))
+        used = 16
+        age = '[%s]' % templatefilters.age(util.makedate(mtime))
+        ui.write(age, label='shelve.age')
+        ui.write(' ' * (18 - len(age)))
+        used += 18
+        fp = open(name + '.patch', 'rb')
+        try:
+            while True:
+                line = fp.readline()
+                if not line:
+                    break
+                if not line.startswith('#'):
+                    desc = line.rstrip()
+                    if ui.formatted():
+                        desc = util.ellipsis(desc, width - used)
+                    ui.write(desc)
+                    break
+            ui.write('\n')
+            if not (opts['patch'] or opts['stat']):
+                continue
+            difflines = fp.readlines()
+            if opts['patch']:
+                for chunk, label in patch.difflabel(iter, difflines):
+                    ui.write(chunk, label=label)
+            if opts['stat']:
+                for chunk, label in patch.diffstatui(difflines, width=width,
+                                                     git=True):
+                    ui.write(chunk, label=label)
+        finally:
+            fp.close()
+
+def readshelvedfiles(repo, basename):
+    fp = shelvedfile(repo, basename, 'files').opener()
+    return fp.read().split('\0')
+
+def checkparents(repo, state):
+    if state.parents != repo.dirstate.parents():
+        raise util.Abort(_('working directory parents do not match unshelve '
+                           'state'))
+
+def unshelveabort(ui, repo, state, opts):
+    wlock = repo.wlock()
+    lock = None
+    try:
+        checkparents(repo, state)
+        lock = repo.lock()
+        merge.mergestate(repo).reset()
+        if opts['keep']:
+            repo.setparents(repo.dirstate.parents()[0])
+        else:
+            revertfiles = readshelvedfiles(repo, state.name)
+            wctx = repo.parents()[0]
+            cmdutil.revert(ui, repo, wctx, [wctx.node(), nullid],
+                           *revertfiles, no_backup=True)
+            # fix up the weird dirstate states the merge left behind
+            mf = wctx.manifest()
+            dirstate = repo.dirstate
+            for f in revertfiles:
+                if f in mf:
+                    dirstate.normallookup(f)
+                else:
+                    dirstate.drop(f)
+            dirstate._pl = (wctx.node(), nullid)
+            dirstate._dirty = True
+        repair.strip(ui, repo, state.stripnodes, backup='none', topic='shelve')
+        shelvedstate.clear(repo)
+        ui.warn(_("unshelve of '%s' aborted\n") % state.name)
+    finally:
+        lockmod.release(lock, wlock)
+
+def unshelvecleanup(ui, repo, name, opts):
+    if not opts['keep']:
+        for filetype in 'hg files patch'.split():
+            shelvedfile(repo, name, filetype).unlink()
+
+def finishmerge(ui, repo, ms, stripnodes, name, opts):
+    # Reset the working dir so it's no longer in a merge state.
+    dirstate = repo.dirstate
+    for f in ms:
+        if dirstate[f] == 'm':
+            dirstate.normallookup(f)
+    dirstate._pl = (dirstate._pl[0], nullid)
+    dirstate._dirty = dirstate._dirtypl = True
+    shelvedstate.clear(repo)
+
+def unshelvecontinue(ui, repo, state, opts):
+    # We're finishing off a merge. First parent is our original
+    # parent, second is the temporary "fake" commit we're unshelving.
+    wlock = repo.wlock()
+    lock = None
+    try:
+        checkparents(repo, state)
+        ms = merge.mergestate(repo)
+        if [f for f in ms if ms[f] == 'u']:
+            raise util.Abort(
+                _("unresolved conflicts, can't continue"),
+                hint=_("see 'hg resolve', then 'hg unshelve --continue'"))
+        finishmerge(ui, repo, ms, state.stripnodes, state.name, opts)
+        lock = repo.lock()
+        repair.strip(ui, repo, state.stripnodes, backup='none', topic='shelve')
+        unshelvecleanup(ui, repo, state.name, opts)
+        ui.status(_("unshelve of '%s' complete\n") % state.name)
+    finally:
+        lockmod.release(lock, wlock)
+
+@command('unshelve',
+         [('a', 'abort', None,
+           _('abort an incomplete unshelve operation')),
+          ('c', 'continue', None,
+           _('continue an incomplete unshelve operation')),
+          ('', 'keep', None,
+           _('keep shelve after unshelving'))],
+         _('hg unshelve [SHELVED]'))
+def unshelve(ui, repo, *shelved, **opts):
+    """restore a shelved change to the working directory
+
+    This command accepts an optional name of a shelved change to
+    restore. If none is given, the most recent shelved change is used.
+
+    If a shelved change is applied successfully, the bundle that
+    contains the shelved changes is deleted afterwards.
+
+    Since you can restore a shelved change on top of an arbitrary
+    commit, it is possible that unshelving will result in a conflict
+    between your changes and the commits you are unshelving onto. If
+    this occurs, you must resolve the conflict, then use
+    ``--continue`` to complete the unshelve operation. (The bundle
+    will not be deleted until you successfully complete the unshelve.)
+
+    (Alternatively, you can use ``--abort`` to abandon an unshelve
+    that causes a conflict. This reverts the unshelved changes, and
+    does not delete the bundle.)
+    """
+    abortf = opts['abort']
+    continuef = opts['continue']
+    if not abortf and not continuef:
+        cmdutil.checkunfinished(repo)
+
+    if abortf or continuef:
+        if abortf and continuef:
+            raise util.Abort(_('cannot use both abort and continue'))
+        if shelved:
+            raise util.Abort(_('cannot combine abort/continue with '
+                               'naming a shelved change'))
+
+        try:
+            state = shelvedstate.load(repo)
+        except IOError, err:
+            if err.errno != errno.ENOENT:
+                raise
+            raise util.Abort(_('no unshelve operation underway'))
+
+        if abortf:
+            return unshelveabort(ui, repo, state, opts)
+        elif continuef:
+            return unshelvecontinue(ui, repo, state, opts)
+    elif len(shelved) > 1:
+        raise util.Abort(_('can only unshelve one change at a time'))
+    elif not shelved:
+        shelved = listshelves(repo)
+        if not shelved:
+            raise util.Abort(_('no shelved changes to apply!'))
+        basename = util.split(shelved[0][1])[1]
+        ui.status(_("unshelving change '%s'\n") % basename)
+    else:
+        basename = shelved[0]
+
+    shelvedfiles = readshelvedfiles(repo, basename)
+
+    m, a, r, d = repo.status()[:4]
+    unsafe = set(m + a + r + d).intersection(shelvedfiles)
+    if unsafe:
+        ui.warn(_('the following shelved files have been modified:\n'))
+        for f in sorted(unsafe):
+            ui.warn('  %s\n' % f)
+        ui.warn(_('you must commit, revert, or shelve your changes before you '
+                  'can proceed\n'))
+        raise util.Abort(_('cannot unshelve due to local changes\n'))
+
+    wlock = lock = tr = None
+    try:
+        lock = repo.lock()
+
+        tr = repo.transaction('unshelve', report=lambda x: None)
+        oldtiprev = len(repo)
+        try:
+            fp = shelvedfile(repo, basename, 'hg').opener()
+            gen = changegroup.readbundle(fp, fp.name)
+            repo.addchangegroup(gen, 'unshelve', 'bundle:' + fp.name)
+            nodes = [ctx.node() for ctx in repo.set('%d:', oldtiprev)]
+            phases.retractboundary(repo, phases.secret, nodes)
+            tr.close()
+        finally:
+            fp.close()
+
+        tip = repo['tip']
+        wctx = repo['.']
+        ancestor = tip.ancestor(wctx)
+
+        wlock = repo.wlock()
+
+        if ancestor.node() != wctx.node():
+            conflicts = hg.merge(repo, tip.node(), force=True, remind=False)
+            ms = merge.mergestate(repo)
+            stripnodes = [repo.changelog.node(rev)
+                          for rev in xrange(oldtiprev, len(repo))]
+            if conflicts:
+                shelvedstate.save(repo, basename, stripnodes)
+                # Fix up the dirstate entries of files from the second
+                # parent as if we were not merging, except for those
+                # with unresolved conflicts.
+                parents = repo.parents()
+                revertfiles = set(parents[1].files()).difference(ms)
+                cmdutil.revert(ui, repo, parents[1],
+                               (parents[0].node(), nullid),
+                               *revertfiles, no_backup=True)
+                raise error.InterventionRequired(
+                    _("unresolved conflicts (see 'hg resolve', then "
+                      "'hg unshelve --continue')"))
+            finishmerge(ui, repo, ms, stripnodes, basename, opts)
+        else:
+            parent = tip.parents()[0]
+            hg.update(repo, parent.node())
+            cmdutil.revert(ui, repo, tip, repo.dirstate.parents(), *tip.files(),
+                           no_backup=True)
+
+        prevquiet = ui.quiet
+        ui.quiet = True
+        try:
+            repo.rollback(force=True)
+        finally:
+            ui.quiet = prevquiet
+
+        unshelvecleanup(ui, repo, basename, opts)
+    finally:
+        if tr:
+            tr.release()
+        lockmod.release(lock, wlock)
+
+@command('shelve',
+         [('A', 'addremove', None,
+           _('mark new/missing files as added/removed before shelving')),
+          ('', 'cleanup', None,
+           _('delete all shelved changes')),
+          ('', 'date', '',
+           _('shelve with the specified commit date'), _('DATE')),
+          ('d', 'delete', None,
+           _('delete the named shelved change(s)')),
+          ('l', 'list', None,
+           _('list current shelves')),
+          ('m', 'message', '',
+           _('use text as shelve message'), _('TEXT')),
+          ('n', 'name', '',
+           _('use the given name for the shelved commit'), _('NAME')),
+          ('p', 'patch', None,
+           _('show patch')),
+          ('', 'stat', None,
+           _('output diffstat-style summary of changes'))],
+         _('hg shelve'))
+def shelvecmd(ui, repo, *pats, **opts):
+    '''save and set aside changes from the working directory
+
+    Shelving takes files that "hg status" reports as not clean, saves
+    the modifications to a bundle (a shelved change), and reverts the
+    files so that their state in the working directory becomes clean.
+
+    To restore these changes to the working directory, using "hg
+    unshelve"; this will work even if you switch to a different
+    commit.
+
+    When no files are specified, "hg shelve" saves all not-clean
+    files. If specific files or directories are named, only changes to
+    those files are shelved.
+
+    Each shelved change has a name that makes it easier to find later.
+    The name of a shelved change defaults to being based on the active
+    bookmark, or if there is no active bookmark, the current named
+    branch.  To specify a different name, use ``--name``.
+
+    To see a list of existing shelved changes, use the ``--list``
+    option. For each shelved change, this will print its name, age,
+    and description; use ``--patch`` or ``--stat`` for more details.
+
+    To delete specific shelved changes, use ``--delete``. To delete
+    all shelved changes, use ``--cleanup``.
+    '''
+    cmdutil.checkunfinished(repo)
+
+    def checkopt(opt, incompatible):
+        if opts[opt]:
+            for i in incompatible.split():
+                if opts[i]:
+                    raise util.Abort(_("options '--%s' and '--%s' may not be "
+                                       "used together") % (opt, i))
+            return True
+    if checkopt('cleanup', 'addremove delete list message name patch stat'):
+        if pats:
+            raise util.Abort(_("cannot specify names when using '--cleanup'"))
+        return cleanupcmd(ui, repo)
+    elif checkopt('delete', 'addremove cleanup list message name patch stat'):
+        return deletecmd(ui, repo, pats)
+    elif checkopt('list', 'addremove cleanup delete message name'):
+        return listcmd(ui, repo, pats, opts)
+    else:
+        for i in ('patch', 'stat'):
+            if opts[i]:
+                raise util.Abort(_("option '--%s' may not be "
+                                   "used when shelving a change") % (i,))
+        return createcmd(ui, repo, pats, opts)
+
+def extsetup(ui):
+    cmdutil.unfinishedstates.append(
+        [shelvedstate._filename, False, True, _('unshelve already in progress'),
+         _("use 'hg unshelve --continue' or 'hg unshelve --abort'")])