Submitter | Bryan O'Sullivan |
---|---|
Date | May 28, 2013, 11:28 p.m. |
Message ID | <a87ee920b333192898e8.1369783737@australite.thefacebook.com> |
Download | mbox | patch |
Permalink | /patch/1672/ |
State | Superseded, archived |
Commit | 49d4919d21c2fe79957d160d64acd048bf6a2e7b |
Headers | show |
Comments
On May 28, 2013, at 7:28 PM, Bryan O'Sullivan <bos@serpentine.com> wrote: > # HG changeset patch > # User Bryan O'Sullivan <bryano@fb.com> > # Date 1369783721 25200 > # Tue May 28 16:28:41 2013 -0700 > # Node ID a87ee920b333192898e8ff84e30ddb1ebac515b6 > # Parent 11fce4dc68f060e96cc06cc88da72e2c9da1022b > shelve: add a shelve extension to save/restore working changes One code style question. I wonder if we shouldn't take a step back and have some central registry of things that could be in-progress, since an in-progress histedit, merge, rebase, or graft should abort this, and not all of those will necessarily look like a merge (and the abort message could be confusing). > > This extension saves shelved changes using a temporary draft commit, > and bundles all draft ancestors of the temporary commit. This > strategy makes it possible to use Mercurial's merge machinery to > resolve conflicts if necessary when unshelving, even when the > destination commit or its ancestors have been amended, squashed, > or evolved. > > Although this extension shares its name and some functionality with > the third party hgshelve extension, it has little else in common. > Notably, the hgshelve extension shelves changes as unified diffs, > which makes conflict resolution a matter of finding .rej files and > cleaning up the mess by hand. > > We do not yet allow hunk-level choosing of changes to record. > Compared to the hgshelve extension, this is a small regression in > usability, but we hope to integrate that at a later point, once the > record machinery becomes more reusable and robust. > > diff --git a/hgext/color.py b/hgext/color.py > --- a/hgext/color.py > +++ b/hgext/color.py > @@ -63,6 +63,10 @@ Default effects may be overridden from y > rebase.rebased = blue > rebase.remaining = red bold > > + shelve.age = cyan > + shelve.newest = green bold > + shelve.name = blue bold > + > histedit.remaining = red bold > > The available effects in terminfo mode are 'blink', 'bold', 'dim', > @@ -260,6 +264,9 @@ except ImportError: > 'rebase.remaining': 'red bold', > 'resolve.resolved': 'green bold', > 'resolve.unresolved': 'red bold', > + 'shelve.age': 'cyan', > + 'shelve.newest': 'green bold', > + 'shelve.name': 'blue bold', > 'status.added': 'green bold', > 'status.clean': 'none', > 'status.copied': 'none', > diff --git a/hgext/shelve.py b/hgext/shelve.py > new file mode 100644 > --- /dev/null > +++ b/hgext/shelve.py > @@ -0,0 +1,552 @@ > +# 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". > +''' > + > +from mercurial.i18n import _ > +from mercurial.node import nullid > +from mercurial import changegroup, cmdutil, commands, extensions, pvec, scmutil > +from mercurial import error, hg, mdiff, merge, node, patch, repair, util > +from mercurial import templatefilters > +from mercurial import lock as lockmod > +import errno, os, time > + > +cmdtable = {} > +command = cmdutil.command(cmdtable) > +testedwith = 'internal' > + > +def shelvedfilename(repo, name, filetype): > + return os.path.join(repo.join('shelved'), name + '.' + filetype) > + > +def shelvedfile(repo, name, filetype, mode='rb'): > + '''Open a file used for storing data associated with a shelved change.''' > + try: > + return open(shelvedfilename(repo, name, filetype), mode) > + except IOError, err: > + if err.errno == errno.ENOENT: > + if mode[0] in 'wa': > + try: > + repo.vfs.mkdir(repo.join('shelved')) > + return open(shelvedfilename(repo, name, filetype), mode) > + except IOError, err: > + if err.errno != errno.EEXIST: > + raise > + elif mode[0] =='r': > + raise util.Abort(_("shelved change '%s' not found") % name) > + raise > + > +class shelvedstate(object): > + @classmethod > + def load(cls, repo): > + fp = repo.opener('shelvedstate') > + try: > + lines = fp.read().splitlines() > + finally: > + fp.close() > + lines.reverse() > + > + version = lines.pop() > + obj = cls() > + obj.name = lines.pop() > + obj.parents = [node.bin(n) for n in lines.pop().split()] > + obj.stripnodes = [node.bin(n) for n in lines] > + return obj > + > + @staticmethod > + def save(repo, name, oldtiprev): > + fp = repo.opener('shelvedstate', 'wb') > + fp.write('1\n') > + fp.write(name + '\n') > + fp.write(' '.join(node.hex(n) for n in repo.dirstate.parents()) + '\n') > + # save revs that need to be stripped when we are done > + for rev in xrange(oldtiprev, len(repo)): > + fp.write(node.hex(repo.changelog.node(rev)) + '\n') > + fp.close() > + > + @staticmethod > + def clear(repo): > + util.unlinkpath(repo.join('shelvedstate'), ignoremissing=True) > + > +def createcmd(ui, repo, pats, opts): > + def publicancestors(ctx): why is this function nested? > + '''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() > + > + try: > + shelvedstate.load(repo) > + raise util.Abort(_('unshelve already in progress')) > + except IOError, err: > + if err.errno != errno.ENOENT: > + raise > + > + wctx = repo[None] > + parents = wctx.parents() > + if len(parents) > 1: > + raise util.Abort(_('cannot shelve while merging')) > + parent = parents[0] > + if parent.node() == nullid: > + raise util.Abort(_('cannot shelve - repo has no history')) > + > + try: > + user = repo.ui.username() > + except util.Abort: > + user = 'shelve@localhost' > + > + label = repo._bookmarkcurrent or parent.branch() > + > + def gennames(): > + yield label > + for i in xrange(1, 100): > + yield '%s-%02d' % (label, i) > + > + shelvedfiles = [] > + > + def commitfunc(ui, repo, message, match, opts): > + for flist in repo.status(match=match)[:4]: > + shelvedfiles.extend(flist) > + return repo.commit(message, user, opts.get('date'), match) > + > + desc = parent.description().split('\n', 1)[0] > + desc = _('shelved from %s (%s): %s') % (label, str(parent)[:8], desc) > + > + if not opts['message']: > + opts['message'] = desc > + > + name = opts['name'] > + 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 '.'")) > + if name: > + if os.path.exists(shelvedfilename(repo, name, 'hg')): > + raise util.Abort(_("a shelved change named '%s' already exists") > + % name) > + > + wlock = lock = None > + try: > + wlock = repo.wlock() > + lock = repo.lock() > + > + if not name: > + for name in gennames(): > + if not os.path.exists(shelvedfilename(repo, name, 'hg')): > + break > + else: > + raise util.Abort(_("too many shelved changes named '%s'") % > + label) > + > + 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 > + > + shelvedfile(repo, name, 'files', 'wb').write('\0'.join(shelvedfiles)) > + > + bases = list(publicancestors(repo[node])) > + cg = repo.changegroupsubset(bases, [node], 'shelve') > + changegroup.writebundle(cg, shelvedfilename(repo, name, 'hg'), > + 'HG10UN') > + cmdutil.export(repo, [node], > + fp=shelvedfile(repo, name, 'patch', '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()) > + repair.strip(ui, repo, [node], backup='none', topic='shelve') > + finally: > + lockmod.release(lock, wlock) > + > +def cleanupcmd(ui, repo): > + path = repo.join('shelved') > + wlock = None > + try: > + wlock = repo.wlock() > + for name in os.listdir(path): > + suffix = name.rsplit('.', 1)[-1] > + if suffix in ('hg', 'files', 'patch'): > + os.unlink(os.path.join(path, name)) > + 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(): > + os.unlink(shelvedfilename(repo, name, suffix)) > + 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): > + path = repo.join('shelved') > + try: > + names = os.listdir(path) > + 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 = os.lstat(os.path.join(path, name)) > + info.append((st.st_mtime, os.path.join(path, pfx))) > + 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 = os.path.basename(name) > + 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): > + return shelvedfile(repo, basename, 'files').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(): > + os.unlink(shelvedfilename(repo, name, filetype)) > + > +def unshelvecontinue(ui, repo, state, opts): > + # We're finishing off a merge. First parent is our original > + # parent, second is the 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'")) > + 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 > + lock = repo.lock() > + repair.strip(ui, repo, state.stripnodes, backup='none', topic='shelve') > + shelvedstate.clear(repo) > + 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, > + _('save shelved change'))], > + _('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 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 = os.path.basename(shelved[0][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 = None > + try: > + lock = repo.lock() > + > + oldtiprev = len(repo) > + try: > + fp = shelvedfile(repo, basename, 'hg') > + gen = changegroup.readbundle(fp, fp.name) > + modheads = repo.addchangegroup(gen, 'unshelve', 'bundle:' + fp.name) > + 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) > + if conflicts: > + cl = repo.changelog > + shelvedstate.save(repo, basename, oldtiprev) > + # Fix up the dirstate entries of files from the second > + # parent as if we were not merging, except for those > + # with unresolved conflicts. > + ms = merge.mergestate(repo) > + 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')")) > + else: > + parent = tip.parents()[0] > + hg.update(repo, parent.node()) > + cmdutil.revert(ui, repo, tip, repo.dirstate.parents(), *tip.files(), > + no_backup=True) > + #repair.strip(ui, repo, [tip.node()], backup='none', topic='shelve') > + > + try: > + prevquiet = ui.quiet > + ui.quiet = True > + repo.rollback(force=True) > + finally: > + ui.quiet = prevquiet > + > + unshelvecleanup(ui, repo, basename, opts) > + finally: > + 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``. > + ''' > + 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) > diff --git a/tests/run-tests.py b/tests/run-tests.py > --- a/tests/run-tests.py > +++ b/tests/run-tests.py > @@ -889,6 +889,7 @@ def runone(options, test): > hgrc.write('[defaults]\n') > hgrc.write('backout = -d "0 0"\n') > hgrc.write('commit = -d "0 0"\n') > + hgrc.write('shelve = --date "0 0"\n') > hgrc.write('tag = -d "0 0"\n') > if options.inotify: > hgrc.write('[extensions]\n') > diff --git a/tests/test-shelve.t b/tests/test-shelve.t > new file mode 100644 > --- /dev/null > +++ b/tests/test-shelve.t > @@ -0,0 +1,354 @@ > + $ echo "[extensions]" >> $HGRCPATH > + $ echo "shelve=" >> $HGRCPATH > + $ echo "[defaults]" >> $HGRCPATH > + $ echo "diff = --nodates --git" >> $HGRCPATH > + > + $ hg init repo > + $ cd repo > + $ mkdir a b > + $ echo a > a/a > + $ echo b > b/b > + $ echo c > c > + $ echo d > d > + $ echo x > x > + $ hg addremove -q > + > +shelving in an empty repo should bail > + > + $ hg shelve > + abort: cannot shelve - repo has no history > + [255] > + > + $ hg commit -q -m 'initial commit' > + > + $ hg shelve > + nothing changed > + [1] > + > +shelve a change that we will delete later > + > + $ echo a >> a/a > + $ hg shelve > + shelved from default (cc01e2b0): initial commit > + shelved as default > + 1 files updated, 0 files merged, 0 files removed, 0 files unresolved > + > +set up some more complex changes to shelve > + > + $ echo a >> a/a > + $ hg mv b b.rename > + moving b/b to b.rename/b > + $ hg cp c c.copy > + $ hg status -C > + M a/a > + A b.rename/b > + b/b > + A c.copy > + c > + R b/b > + > +prevent some foot-shooting > + > + $ hg shelve -n foo/bar > + abort: shelved change names may not contain slashes > + [255] > + $ hg shelve -n .baz > + abort: shelved change names may not start with '.' > + [255] > + > +the common case - no options or filenames > + > + $ hg shelve > + shelved from default (cc01e2b0): initial commit > + shelved as default-01 > + 2 files updated, 0 files merged, 2 files removed, 0 files unresolved > + $ hg status -C > + > +ensure that our shelved changes exist > + > + $ hg shelve -l > + default-01 [*] shelved from default (cc01e2b0): initial commit (glob) > + default [*] shelved from default (cc01e2b0): initial commit (glob) > + > + $ hg shelve -l -p default > + default [*] shelved from default (cc01e2b0): initial commit (glob) > + > + diff --git a/a/a b/a/a > + --- a/a/a > + +++ b/a/a > + @@ -1,1 +1,2 @@ > + a > + +a > + > +delete our older shelved change > + > + $ hg shelve -d default > + > +local edits should prevent a shelved change from applying > + > + $ echo e>>a/a > + $ hg unshelve > + unshelving change 'default-01' > + the following shelved files have been modified: > + a/a > + you must commit, revert, or shelve your changes before you can proceed > + abort: cannot unshelve due to local changes > + > + [255] > + > + $ hg revert -C a/a > + > +apply it and make sure our state is as expected > + > + $ hg unshelve > + unshelving change 'default-01' > + adding changesets > + adding manifests > + adding file changes > + added 1 changesets with 3 changes to 7 files > + 0 files updated, 0 files merged, 0 files removed, 0 files unresolved > + $ hg status -C > + M a/a > + A b.rename/b > + b/b > + A c.copy > + c > + R b/b > + $ hg shelve -l > + > + $ hg unshelve > + abort: no shelved changes to apply! > + [255] > + $ hg unshelve foo > + abort: shelved change 'foo' not found > + [255] > + > +named shelves, specific filenames, and "commit messages" should all work > + > + $ hg status -C > + M a/a > + A b.rename/b > + b/b > + A c.copy > + c > + R b/b > + $ hg shelve -q -n wibble -m wat a > + > +expect "a" to no longer be present, but status otherwise unchanged > + > + $ hg status -C > + A b.rename/b > + b/b > + A c.copy > + c > + R b/b > + $ hg shelve -l --stat > + wibble [*] wat (glob) > + a/a | 1 + > + 1 files changed, 1 insertions(+), 0 deletions(-) > + > +and now "a/a" should reappear > + > + $ hg unshelve -q wibble > + $ hg status -C > + M a/a > + A b.rename/b > + b/b > + A c.copy > + c > + R b/b > + > +cause unshelving to result in a merge with 'a' conflicting > + > + $ hg shelve -q > + $ echo c>>a/a > + $ hg commit -m second > + $ hg tip --template '{files}\n' > + a/a > + > +add an unrelated change that should be preserved > + > + $ mkdir foo > + $ echo foo > foo/foo > + $ hg add foo/foo > + > +force a conflicted merge to occur > + > + $ hg unshelve > + unshelving change 'default' > + adding changesets > + adding manifests > + adding file changes > + added 1 changesets with 3 changes to 7 files (+1 heads) > + merging a/a > + warning: conflicts during merge. > + merging a/a incomplete! (edit conflicts, then use 'hg resolve --mark') > + 2 files updated, 0 files merged, 1 files removed, 1 files unresolved > + use 'hg resolve' to retry unresolved file merges or 'hg update -C .' to abandon > + unresolved conflicts (see 'hg resolve', then 'hg unshelve --continue') > + [1] > + > +ensure that we have a merge with unresolved conflicts > + > + $ hg heads -q > + 2:99fa200422e2 > + 1:71743bbd8fc8 > + $ hg parents -q > + 1:71743bbd8fc8 > + 2:99fa200422e2 > + $ hg status > + M a/a > + M b.rename/b > + M c.copy > + A foo/foo > + R b/b > + ? a/a.orig > + $ hg diff > + diff --git a/a/a b/a/a > + --- a/a/a > + +++ b/a/a > + @@ -1,2 +1,6 @@ > + a > + +<<<<<<< local > + c > + +======= > + +a > + +>>>>>>> other > + diff --git a/b.rename/b b/b.rename/b > + --- /dev/null > + +++ b/b.rename/b > + @@ -0,0 +1,1 @@ > + +b > + diff --git a/b/b b/b/b > + deleted file mode 100644 > + --- a/b/b > + +++ /dev/null > + @@ -1,1 +0,0 @@ > + -b > + diff --git a/c.copy b/c.copy > + --- /dev/null > + +++ b/c.copy > + @@ -0,0 +1,1 @@ > + +c > + diff --git a/foo/foo b/foo/foo > + new file mode 100644 > + --- /dev/null > + +++ b/foo/foo > + @@ -0,0 +1,1 @@ > + +foo > + $ hg resolve -l > + U a/a > + > + $ hg shelve > + abort: unshelve already in progress > + [255] > + > +abort the unshelve and be happy > + > + $ hg status > + M a/a > + M b.rename/b > + M c.copy > + A foo/foo > + R b/b > + ? a/a.orig > + $ hg unshelve -a > + unshelve of 'default' aborted > + $ hg heads -q > + 1:71743bbd8fc8 > + $ hg parents > + changeset: 1:71743bbd8fc8 > + tag: tip > + user: test > + date: Thu Jan 01 00:00:00 1970 +0000 > + summary: second > + > + $ hg resolve -l > + $ hg status > + A foo/foo > + ? a/a.orig > + > +try to continue with no unshelve underway > + > + $ hg unshelve -c > + abort: no unshelve operation underway > + [255] > + $ hg status > + A foo/foo > + ? a/a.orig > + > +redo the unshelve to get a conflict > + > + $ hg unshelve -q > + warning: conflicts during merge. > + merging a/a incomplete! (edit conflicts, then use 'hg resolve --mark') > + unresolved conflicts (see 'hg resolve', then 'hg unshelve --continue') > + [1] > + > +attempt to continue > + > + $ hg unshelve -c > + abort: unresolved conflicts, can't continue > + (see 'hg resolve', then 'hg unshelve --continue') > + [255] > + > + $ hg revert -r . a/a > + $ hg resolve -m a/a > + > + $ hg unshelve -c > + unshelve of 'default' complete > + > +ensure the repo is as we hope > + > + $ hg parents > + changeset: 1:71743bbd8fc8 > + tag: tip > + user: test > + date: Thu Jan 01 00:00:00 1970 +0000 > + summary: second > + > + $ hg heads -q > + 1:71743bbd8fc8 > + > + $ hg status -C > + M a/a > + M b.rename/b > + b/b > + M c.copy > + c > + A foo/foo > + R b/b > + ? a/a.orig > + > +there should be no shelves left > + > + $ hg shelve -l > + > + $ hg commit -m whee a/a > + > +#if execbit > + > +ensure that metadata-only changes are shelved > + > + $ chmod +x a/a > + $ hg shelve -q -n execbit a/a > + $ hg status a/a > + $ hg unshelve -q execbit > + $ hg status a/a > + M a/a > + $ hg revert a/a > + > +#endif > + > +#if symlink > + > + $ rm a/a > + $ ln -s foo a/a > + $ hg shelve -q -n symlink a/a > + $ hg status a/a > + $ hg unshelve -q symlink > + $ hg status a/a > + M a/a > + $ hg revert a/a > + > +#endif > _______________________________________________ > Mercurial-devel mailing list > Mercurial-devel@selenic.com > http://selenic.com/mailman/listinfo/mercurial-devel
On Tue, May 28, 2013 at 08:20:05PM -0400, Augie Fackler wrote: > > On May 28, 2013, at 7:28 PM, Bryan O'Sullivan <bos@serpentine.com> wrote: > > > # HG changeset patch > > # User Bryan O'Sullivan <bryano@fb.com> > > # Date 1369783721 25200 > > # Tue May 28 16:28:41 2013 -0700 > > # Node ID a87ee920b333192898e8ff84e30ddb1ebac515b6 > > # Parent 11fce4dc68f060e96cc06cc88da72e2c9da1022b > > shelve: add a shelve extension to save/restore working changes Sound very similar to the "stash" extension from idan K. > One code style question. > > I wonder if we shouldn't take a step back and have some central > registry of things that could be in-progress, since an in-progress > histedit, merge, rebase, or graft should abort this, and not all of > those will necessarily look like a merge (and the abort message could > be confusing). Ho yes, we should. I have more and more multi step operation and a generic mecanism to prevent mistake would be very helpful. In an ideal future we could even have a consistent experience with global "hg continue" and "hg abort". > > This extension saves shelved changes using a temporary draft commit, > > and bundles all draft ancestors of the temporary commit. This > > strategy makes it possible to use Mercurial's merge machinery to > > resolve conflicts if necessary when unshelving, even when the > > destination commit or its ancestors have been amended, squashed, > > or evolved. hu, clever!
On Tue, May 28, 2013 at 4:28 PM, Bryan O'Sullivan <bos@serpentine.com>wrote: > This extension saves shelved changes using a temporary draft commit, > and bundles all draft ancestors of the temporary commit. > I should clarify: we create a draft commit, bundle it and its draft ancestors, and then strip the temporary commit. That means that the shelved commit is not present in history; does not interfere with mq; but still uses normal Mercurial machinery (including support for merges and conflicts!) instead of something ad hoc like patches.
On Tue, May 28, 2013 at 5:20 PM, Augie Fackler <raf@durin42.com> wrote: > I wonder if we shouldn't take a step back and have some central registry > of things that could be in-progress, since an in-progress histedit, merge, > rebase, or graft should abort this, and not all of those will necessarily > look like a merge (and the abort message could be confusing). > Definitely. I found myself thinking the same thing on Monday, in fact. > +def createcmd(ui, repo, pats, opts): > > + def publicancestors(ctx): > > why is this function nested? It started out life as a top-level function with an underscore prefix, but it's only used inside createcmd, so I moved it in there.
On Wed, May 29, 2013 at 2:28 AM, Bryan O'Sullivan <bos@serpentine.com> wrote: > > # HG changeset patch > # User Bryan O'Sullivan <bryano@fb.com> > # Date 1369783721 25200 > # Tue May 28 16:28:41 2013 -0700 > # Node ID a87ee920b333192898e8ff84e30ddb1ebac515b6 > # Parent 11fce4dc68f060e96cc06cc88da72e2c9da1022b > shelve: add a shelve extension to save/restore working changes > > This extension saves shelved changes using a temporary draft commit, > and bundles all draft ancestors of the temporary commit. This > strategy makes it possible to use Mercurial's merge machinery to > resolve conflicts if necessary when unshelving, even when the > destination commit or its ancestors have been amended, squashed, > or evolved. Nice, this seems to deal with the issues I had with my implementation. Are you stripping the temporary commit and its bundled ancestors when an unshelve succeeds? > > Although this extension shares its name and some functionality with > the third party hgshelve extension, it has little else in common. > Notably, the hgshelve extension shelves changes as unified diffs, > which makes conflict resolution a matter of finding .rej files and > cleaning up the mess by hand. Won't the name conflict cause issues for existing users of hgshelve? Why not call it stash? > > We do not yet allow hunk-level choosing of changes to record. > Compared to the hgshelve extension, this is a small regression in > usability, but we hope to integrate that at a later point, once the > record machinery becomes more reusable and robust. > > diff --git a/hgext/color.py b/hgext/color.py > --- a/hgext/color.py > +++ b/hgext/color.py > @@ -63,6 +63,10 @@ Default effects may be overridden from y > rebase.rebased = blue > rebase.remaining = red bold > > + shelve.age = cyan > + shelve.newest = green bold > + shelve.name = blue bold > + > histedit.remaining = red bold > > The available effects in terminfo mode are 'blink', 'bold', 'dim', > @@ -260,6 +264,9 @@ except ImportError: > 'rebase.remaining': 'red bold', > 'resolve.resolved': 'green bold', > 'resolve.unresolved': 'red bold', > + 'shelve.age': 'cyan', > + 'shelve.newest': 'green bold', > + 'shelve.name': 'blue bold', > 'status.added': 'green bold', > 'status.clean': 'none', > 'status.copied': 'none', > diff --git a/hgext/shelve.py b/hgext/shelve.py > new file mode 100644 > --- /dev/null > +++ b/hgext/shelve.py > @@ -0,0 +1,552 @@ > +# shelve.py - save/restore working directory state Does this file pass tests? There are some unused imports and commented code here. [..] > + > +apply it and make sure our state is as expected > + > + $ hg unshelve > + unshelving change 'default-01' > + adding changesets > + adding manifests > + adding file changes > + added 1 changesets with 3 changes to 7 files > + 0 files updated, 0 files merged, 0 files removed, 0 files unresolved Shouldn't it say 1 files updated here? > + $ hg status -C > + M a/a > + A b.rename/b > + b/b > + A c.copy > + c > + R b/b > + $ hg shelve -l
On Thu, May 30, 2013 at 6:36 AM, Idan Kamara <idankk86@gmail.com> wrote: > > Are you stripping the temporary commit and its bundled ancestors when an > unshelve succeeds? > Yep. > Won't the name conflict cause issues for existing users of hgshelve? Why > not call it stash? > The extensions have different names, so you won't accidentally get one if you have the other enabled. I don't see a problem with them providing the same command names, since I can't imagine someone wanting to use both. > Does this file pass tests? There are some unused imports and > commented code here. > It passes the new shelve test, but needs a tiny bit of tweaking to pass the check-code tests. > > + $ hg unshelve > > + unshelving change 'default-01' > > + adding changesets > > + adding manifests > > + adding file changes > > + added 1 changesets with 3 changes to 7 files > > + 0 files updated, 0 files merged, 0 files removed, 0 files unresolved > > Shouldn't it say 1 files updated here? > For this particular test, the change being unshelved makes the same change to a file as the commit it's being unshelved onto - so the result should indeed be no changes.
Patch
diff --git a/hgext/color.py b/hgext/color.py --- a/hgext/color.py +++ b/hgext/color.py @@ -63,6 +63,10 @@ Default effects may be overridden from y rebase.rebased = blue rebase.remaining = red bold + shelve.age = cyan + shelve.newest = green bold + shelve.name = blue bold + histedit.remaining = red bold The available effects in terminfo mode are 'blink', 'bold', 'dim', @@ -260,6 +264,9 @@ except ImportError: 'rebase.remaining': 'red bold', 'resolve.resolved': 'green bold', 'resolve.unresolved': 'red bold', + 'shelve.age': 'cyan', + 'shelve.newest': 'green bold', + 'shelve.name': 'blue bold', 'status.added': 'green bold', 'status.clean': 'none', 'status.copied': 'none', diff --git a/hgext/shelve.py b/hgext/shelve.py new file mode 100644 --- /dev/null +++ b/hgext/shelve.py @@ -0,0 +1,552 @@ +# 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". +''' + +from mercurial.i18n import _ +from mercurial.node import nullid +from mercurial import changegroup, cmdutil, commands, extensions, pvec, scmutil +from mercurial import error, hg, mdiff, merge, node, patch, repair, util +from mercurial import templatefilters +from mercurial import lock as lockmod +import errno, os, time + +cmdtable = {} +command = cmdutil.command(cmdtable) +testedwith = 'internal' + +def shelvedfilename(repo, name, filetype): + return os.path.join(repo.join('shelved'), name + '.' + filetype) + +def shelvedfile(repo, name, filetype, mode='rb'): + '''Open a file used for storing data associated with a shelved change.''' + try: + return open(shelvedfilename(repo, name, filetype), mode) + except IOError, err: + if err.errno == errno.ENOENT: + if mode[0] in 'wa': + try: + repo.vfs.mkdir(repo.join('shelved')) + return open(shelvedfilename(repo, name, filetype), mode) + except IOError, err: + if err.errno != errno.EEXIST: + raise + elif mode[0] =='r': + raise util.Abort(_("shelved change '%s' not found") % name) + raise + +class shelvedstate(object): + @classmethod + def load(cls, repo): + fp = repo.opener('shelvedstate') + try: + lines = fp.read().splitlines() + finally: + fp.close() + lines.reverse() + + version = lines.pop() + obj = cls() + obj.name = lines.pop() + obj.parents = [node.bin(n) for n in lines.pop().split()] + obj.stripnodes = [node.bin(n) for n in lines] + return obj + + @staticmethod + def save(repo, name, oldtiprev): + fp = repo.opener('shelvedstate', 'wb') + fp.write('1\n') + fp.write(name + '\n') + fp.write(' '.join(node.hex(n) for n in repo.dirstate.parents()) + '\n') + # save revs that need to be stripped when we are done + for rev in xrange(oldtiprev, len(repo)): + fp.write(node.hex(repo.changelog.node(rev)) + '\n') + 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() + + try: + shelvedstate.load(repo) + raise util.Abort(_('unshelve already in progress')) + except IOError, err: + if err.errno != errno.ENOENT: + raise + + wctx = repo[None] + parents = wctx.parents() + if len(parents) > 1: + raise util.Abort(_('cannot shelve while merging')) + parent = parents[0] + if parent.node() == nullid: + raise util.Abort(_('cannot shelve - repo has no history')) + + try: + user = repo.ui.username() + except util.Abort: + user = 'shelve@localhost' + + label = repo._bookmarkcurrent or parent.branch() + + def gennames(): + yield label + for i in xrange(1, 100): + yield '%s-%02d' % (label, i) + + shelvedfiles = [] + + def commitfunc(ui, repo, message, match, opts): + for flist in repo.status(match=match)[:4]: + shelvedfiles.extend(flist) + return repo.commit(message, user, opts.get('date'), match) + + desc = parent.description().split('\n', 1)[0] + desc = _('shelved from %s (%s): %s') % (label, str(parent)[:8], desc) + + if not opts['message']: + opts['message'] = desc + + name = opts['name'] + 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 '.'")) + if name: + if os.path.exists(shelvedfilename(repo, name, 'hg')): + raise util.Abort(_("a shelved change named '%s' already exists") + % name) + + wlock = lock = None + try: + wlock = repo.wlock() + lock = repo.lock() + + if not name: + for name in gennames(): + if not os.path.exists(shelvedfilename(repo, name, 'hg')): + break + else: + raise util.Abort(_("too many shelved changes named '%s'") % + label) + + 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 + + shelvedfile(repo, name, 'files', 'wb').write('\0'.join(shelvedfiles)) + + bases = list(publicancestors(repo[node])) + cg = repo.changegroupsubset(bases, [node], 'shelve') + changegroup.writebundle(cg, shelvedfilename(repo, name, 'hg'), + 'HG10UN') + cmdutil.export(repo, [node], + fp=shelvedfile(repo, name, 'patch', '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()) + repair.strip(ui, repo, [node], backup='none', topic='shelve') + finally: + lockmod.release(lock, wlock) + +def cleanupcmd(ui, repo): + path = repo.join('shelved') + wlock = None + try: + wlock = repo.wlock() + for name in os.listdir(path): + suffix = name.rsplit('.', 1)[-1] + if suffix in ('hg', 'files', 'patch'): + os.unlink(os.path.join(path, name)) + 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(): + os.unlink(shelvedfilename(repo, name, suffix)) + 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): + path = repo.join('shelved') + try: + names = os.listdir(path) + 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 = os.lstat(os.path.join(path, name)) + info.append((st.st_mtime, os.path.join(path, pfx))) + 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 = os.path.basename(name) + 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): + return shelvedfile(repo, basename, 'files').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(): + os.unlink(shelvedfilename(repo, name, filetype)) + +def unshelvecontinue(ui, repo, state, opts): + # We're finishing off a merge. First parent is our original + # parent, second is the 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'")) + 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 + lock = repo.lock() + repair.strip(ui, repo, state.stripnodes, backup='none', topic='shelve') + shelvedstate.clear(repo) + 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, + _('save shelved change'))], + _('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 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 = os.path.basename(shelved[0][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 = None + try: + lock = repo.lock() + + oldtiprev = len(repo) + try: + fp = shelvedfile(repo, basename, 'hg') + gen = changegroup.readbundle(fp, fp.name) + modheads = repo.addchangegroup(gen, 'unshelve', 'bundle:' + fp.name) + 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) + if conflicts: + cl = repo.changelog + shelvedstate.save(repo, basename, oldtiprev) + # Fix up the dirstate entries of files from the second + # parent as if we were not merging, except for those + # with unresolved conflicts. + ms = merge.mergestate(repo) + 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')")) + else: + parent = tip.parents()[0] + hg.update(repo, parent.node()) + cmdutil.revert(ui, repo, tip, repo.dirstate.parents(), *tip.files(), + no_backup=True) + #repair.strip(ui, repo, [tip.node()], backup='none', topic='shelve') + + try: + prevquiet = ui.quiet + ui.quiet = True + repo.rollback(force=True) + finally: + ui.quiet = prevquiet + + unshelvecleanup(ui, repo, basename, opts) + finally: + 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``. + ''' + 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) diff --git a/tests/run-tests.py b/tests/run-tests.py --- a/tests/run-tests.py +++ b/tests/run-tests.py @@ -889,6 +889,7 @@ def runone(options, test): hgrc.write('[defaults]\n') hgrc.write('backout = -d "0 0"\n') hgrc.write('commit = -d "0 0"\n') + hgrc.write('shelve = --date "0 0"\n') hgrc.write('tag = -d "0 0"\n') if options.inotify: hgrc.write('[extensions]\n') diff --git a/tests/test-shelve.t b/tests/test-shelve.t new file mode 100644 --- /dev/null +++ b/tests/test-shelve.t @@ -0,0 +1,354 @@ + $ echo "[extensions]" >> $HGRCPATH + $ echo "shelve=" >> $HGRCPATH + $ echo "[defaults]" >> $HGRCPATH + $ echo "diff = --nodates --git" >> $HGRCPATH + + $ hg init repo + $ cd repo + $ mkdir a b + $ echo a > a/a + $ echo b > b/b + $ echo c > c + $ echo d > d + $ echo x > x + $ hg addremove -q + +shelving in an empty repo should bail + + $ hg shelve + abort: cannot shelve - repo has no history + [255] + + $ hg commit -q -m 'initial commit' + + $ hg shelve + nothing changed + [1] + +shelve a change that we will delete later + + $ echo a >> a/a + $ hg shelve + shelved from default (cc01e2b0): initial commit + shelved as default + 1 files updated, 0 files merged, 0 files removed, 0 files unresolved + +set up some more complex changes to shelve + + $ echo a >> a/a + $ hg mv b b.rename + moving b/b to b.rename/b + $ hg cp c c.copy + $ hg status -C + M a/a + A b.rename/b + b/b + A c.copy + c + R b/b + +prevent some foot-shooting + + $ hg shelve -n foo/bar + abort: shelved change names may not contain slashes + [255] + $ hg shelve -n .baz + abort: shelved change names may not start with '.' + [255] + +the common case - no options or filenames + + $ hg shelve + shelved from default (cc01e2b0): initial commit + shelved as default-01 + 2 files updated, 0 files merged, 2 files removed, 0 files unresolved + $ hg status -C + +ensure that our shelved changes exist + + $ hg shelve -l + default-01 [*] shelved from default (cc01e2b0): initial commit (glob) + default [*] shelved from default (cc01e2b0): initial commit (glob) + + $ hg shelve -l -p default + default [*] shelved from default (cc01e2b0): initial commit (glob) + + diff --git a/a/a b/a/a + --- a/a/a + +++ b/a/a + @@ -1,1 +1,2 @@ + a + +a + +delete our older shelved change + + $ hg shelve -d default + +local edits should prevent a shelved change from applying + + $ echo e>>a/a + $ hg unshelve + unshelving change 'default-01' + the following shelved files have been modified: + a/a + you must commit, revert, or shelve your changes before you can proceed + abort: cannot unshelve due to local changes + + [255] + + $ hg revert -C a/a + +apply it and make sure our state is as expected + + $ hg unshelve + unshelving change 'default-01' + adding changesets + adding manifests + adding file changes + added 1 changesets with 3 changes to 7 files + 0 files updated, 0 files merged, 0 files removed, 0 files unresolved + $ hg status -C + M a/a + A b.rename/b + b/b + A c.copy + c + R b/b + $ hg shelve -l + + $ hg unshelve + abort: no shelved changes to apply! + [255] + $ hg unshelve foo + abort: shelved change 'foo' not found + [255] + +named shelves, specific filenames, and "commit messages" should all work + + $ hg status -C + M a/a + A b.rename/b + b/b + A c.copy + c + R b/b + $ hg shelve -q -n wibble -m wat a + +expect "a" to no longer be present, but status otherwise unchanged + + $ hg status -C + A b.rename/b + b/b + A c.copy + c + R b/b + $ hg shelve -l --stat + wibble [*] wat (glob) + a/a | 1 + + 1 files changed, 1 insertions(+), 0 deletions(-) + +and now "a/a" should reappear + + $ hg unshelve -q wibble + $ hg status -C + M a/a + A b.rename/b + b/b + A c.copy + c + R b/b + +cause unshelving to result in a merge with 'a' conflicting + + $ hg shelve -q + $ echo c>>a/a + $ hg commit -m second + $ hg tip --template '{files}\n' + a/a + +add an unrelated change that should be preserved + + $ mkdir foo + $ echo foo > foo/foo + $ hg add foo/foo + +force a conflicted merge to occur + + $ hg unshelve + unshelving change 'default' + adding changesets + adding manifests + adding file changes + added 1 changesets with 3 changes to 7 files (+1 heads) + merging a/a + warning: conflicts during merge. + merging a/a incomplete! (edit conflicts, then use 'hg resolve --mark') + 2 files updated, 0 files merged, 1 files removed, 1 files unresolved + use 'hg resolve' to retry unresolved file merges or 'hg update -C .' to abandon + unresolved conflicts (see 'hg resolve', then 'hg unshelve --continue') + [1] + +ensure that we have a merge with unresolved conflicts + + $ hg heads -q + 2:99fa200422e2 + 1:71743bbd8fc8 + $ hg parents -q + 1:71743bbd8fc8 + 2:99fa200422e2 + $ hg status + M a/a + M b.rename/b + M c.copy + A foo/foo + R b/b + ? a/a.orig + $ hg diff + diff --git a/a/a b/a/a + --- a/a/a + +++ b/a/a + @@ -1,2 +1,6 @@ + a + +<<<<<<< local + c + +======= + +a + +>>>>>>> other + diff --git a/b.rename/b b/b.rename/b + --- /dev/null + +++ b/b.rename/b + @@ -0,0 +1,1 @@ + +b + diff --git a/b/b b/b/b + deleted file mode 100644 + --- a/b/b + +++ /dev/null + @@ -1,1 +0,0 @@ + -b + diff --git a/c.copy b/c.copy + --- /dev/null + +++ b/c.copy + @@ -0,0 +1,1 @@ + +c + diff --git a/foo/foo b/foo/foo + new file mode 100644 + --- /dev/null + +++ b/foo/foo + @@ -0,0 +1,1 @@ + +foo + $ hg resolve -l + U a/a + + $ hg shelve + abort: unshelve already in progress + [255] + +abort the unshelve and be happy + + $ hg status + M a/a + M b.rename/b + M c.copy + A foo/foo + R b/b + ? a/a.orig + $ hg unshelve -a + unshelve of 'default' aborted + $ hg heads -q + 1:71743bbd8fc8 + $ hg parents + changeset: 1:71743bbd8fc8 + tag: tip + user: test + date: Thu Jan 01 00:00:00 1970 +0000 + summary: second + + $ hg resolve -l + $ hg status + A foo/foo + ? a/a.orig + +try to continue with no unshelve underway + + $ hg unshelve -c + abort: no unshelve operation underway + [255] + $ hg status + A foo/foo + ? a/a.orig + +redo the unshelve to get a conflict + + $ hg unshelve -q + warning: conflicts during merge. + merging a/a incomplete! (edit conflicts, then use 'hg resolve --mark') + unresolved conflicts (see 'hg resolve', then 'hg unshelve --continue') + [1] + +attempt to continue + + $ hg unshelve -c + abort: unresolved conflicts, can't continue + (see 'hg resolve', then 'hg unshelve --continue') + [255] + + $ hg revert -r . a/a + $ hg resolve -m a/a + + $ hg unshelve -c + unshelve of 'default' complete + +ensure the repo is as we hope + + $ hg parents + changeset: 1:71743bbd8fc8 + tag: tip + user: test + date: Thu Jan 01 00:00:00 1970 +0000 + summary: second + + $ hg heads -q + 1:71743bbd8fc8 + + $ hg status -C + M a/a + M b.rename/b + b/b + M c.copy + c + A foo/foo + R b/b + ? a/a.orig + +there should be no shelves left + + $ hg shelve -l + + $ hg commit -m whee a/a + +#if execbit + +ensure that metadata-only changes are shelved + + $ chmod +x a/a + $ hg shelve -q -n execbit a/a + $ hg status a/a + $ hg unshelve -q execbit + $ hg status a/a + M a/a + $ hg revert a/a + +#endif + +#if symlink + + $ rm a/a + $ ln -s foo a/a + $ hg shelve -q -n symlink a/a + $ hg status a/a + $ hg unshelve -q symlink + $ hg status a/a + M a/a + $ hg revert a/a + +#endif