Submitter | David Soria Parra |
---|---|
Date | Sept. 17, 2013, 3:55 p.m. |
Message ID | <f533657af87051e0a7a3.1379433318@achird.localdomain> |
Download | mbox | patch |
Permalink | /patch/2508/ |
State | Superseded, archived |
Commit | 49d4919d21c2fe79957d160d64acd048bf6a2e7b |
Headers | show |
Comments
On 09/17/2013 05:55 PM, David Soria Parra wrote: > # HG changeset patch > # User David Soria Parra<dsp@experimentalworks.net> > # Date 1377793333 25200 > # Thu Aug 29 09:22:13 2013 -0700 > # Node ID f533657af87051e0a7a3d6ffc30a9dd7d2415d5a > # Parent 4732ba61dd562a85f2517a634b67f49ea3229f2e > shelve: add a shelve extension to save/restore working changes > > This extension saves shelved changes using a temporary draft commit, > and bundles the temporary commit and its draft ancestors, then > strips them. > > This strategy makes it possible to use Mercurial's bundle and merge > machinery to resolve conflicts if necessary when unshelving, even > when the destination commit or its ancestors have been amended, > squashed, or evolved. (Once a change has been unshelved, its > associated unbundled commits are either rolled back or stripped.) > > Storing the shelved change as a bundle also avoids the difficulty > that hidden commits would cause, of making it impossible to amend > the parent if it is a draft commits (a common scenario). > > 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 > conflict markers, 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. Note: have you checked the Sean Farley progress on this topic ? > > diff --git a/hgext/color.py b/hgext/color.py > --- a/hgext/color.py > +++ b/hgext/color.py > @@ -63,6 +63,10 @@ > 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', > @@ -259,6 +263,9 @@ > '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,587 @@ > +# 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, 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 > + > +cmdtable = {} > +command = cmdutil.command(cmdtable) > +testedwith = 'internal' > + > +class shelvedfile(object): > + 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: > + 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) > + raise small nitch: I prefer if err.errno != errno.ENOENT: raise big chunk of code over the current if err.errno == errno.ENOENT: big chunk of code raise The first one is actually used more often in the rest of the file. > + > +class shelvedstate(object): This class could use a few line of documentation explaining its goal and the disc format used. > + _version = '1' > + > + @classmethod > + def load(cls, repo): > + fp = repo.opener('shelvedstate') > + try: > + lines = fp.read().splitlines() > + finally: > + fp.close() > + lines.reverse() > + > + version = lines.pop() > + if version != cls._version: > + raise util.Abort(_('this version of shelve is incompatible ' > + 'with the version used in this repo')) > + 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 I had some terrible experience with attribute that appears within some other code. Can we have a __init__ that initialises all known attribute (possibility to None is that help)? > + > + @classmethod > + def save(cls, repo, name, stripnodes): > + fp = repo.opener('shelvedstate', 'wb') > + fp.write(cls._version + '\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 n in stripnodes: > + fp.write(node.hex(n) + '\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')) Don't we just get a nice a shinny API to check multi-step operation ? > + 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')) Why is the lack of history an issue for shelve ? > + > + try: > + user = repo.ui.username() > + except util.Abort: > + user = 'shelve@localhost' elsewhere in Mercurial, the usename is a strong requirement ? Why isn't it the case here ? (and additional question, why shelve have no -u argument ?) > + > + label = repo._bookmarkcurrent or parent.branch() > + > + # 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) > + > + 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'] > + > + wlock = lock = None > + try: > + wlock = repo.wlock() > + lock = repo.lock() > + > + 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) > + > + 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 '.'")) Is that for filename validity ? I believe there is other constrains out there (like no ":" on Mac etc). Do we have a generic function in core to handle that ? > + > + node = cmdutil.commit(ui, repo, commitfunc, pats, opts) Question: could we use a non-commited transaction to avoid the strip and a possible race condition with pull ? > + > + 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 > + > + 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()) > + repair.strip(ui, repo, [node], backup='none', topic='shelve') > + finally: > + 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, > + _('save shelved change'))], This help text is a bit obscur. What about: "do not delete the 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. Note: The latest unshelved changed could be kept under a special name. That would ease undoing error. > + > + 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 = 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')) really ? It very very impractical that local changes prevent unshelving. It that a temporary technical limitiation or a real UI choice ? As we allows it for file not impacted by the shelve this seems inconsistent. We should either allow it or deny it all the time. I would prefer to allow it at all time and we have the technologie for it (see merge --force) > + wlock = lock = None > + try: > + lock = repo.lock() > + > + oldtiprev = len(repo) > + try: > + fp = shelvedfile(repo, basename, 'hg').opener() > + gen = changegroup.readbundle(fp, fp.name) > + repo.addchangegroup(gen, 'unshelve', 'bundle:' + fp.name) > + finally: > + fp.close() Could we force the unbundled content as secret to prevent pull race ? > + > + 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) > + > + 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 > @@ -341,6 +341,7 @@ > 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-commandserver.py.out b/tests/test-commandserver.py.out > --- a/tests/test-commandserver.py.out > +++ b/tests/test-commandserver.py.out > @@ -73,6 +73,7 @@ > bundle.mainreporoot=$TESTTMP > defaults.backout=-d "0 0" > defaults.commit=-d "0 0" > +defaults.shelve=--date "0 0" > defaults.tag=-d "0 0" > ui.slash=True > ui.interactive=False > @@ -81,6 +82,7 @@ > runcommand -R foo showconfig ui defaults > defaults.backout=-d "0 0" > defaults.commit=-d "0 0" > +defaults.shelve=--date "0 0" > defaults.tag=-d "0 0" > ui.slash=True > ui.interactive=False > 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,401 @@ > + $ 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] > + > +create another commit > + > + $ echo n> n > + $ hg add n > + $ hg commit n -m second > + > +shelve a change that we will delete later > + > + $ echo a>> a/a > + $ hg shelve > + shelved from default (bb4fec6d): second > + shelved as default > + 1 files updated, 0 files merged, 0 files removed, 0 files unresolved note: the "update" line is confusing. I can see why it is here but we should probably improves that in the future. > + > +set up some more complex changes to shelve > + > + $ echo a>> a/a > + $ hg mv b b.rename > + moving b/b to b.rename/b (glob) > + $ 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 (bb4fec6d): second > + 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 (bb4fec6d): second (glob) > + default [*] shelved from default (bb4fec6d): second (glob) > + > + $ hg shelve -l -p default > + default [*] shelved from default (bb4fec6d): second (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 8 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 8 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 > + 3:da6db56b46f7 > + 2:ceefc37abe1e > + $ hg parents -q > + 2:ceefc37abe1e > + 3:da6db56b46f7 > + $ 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 > + 2:ceefc37abe1e > + $ hg parents > + changeset: 2:ceefc37abe1e > + 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: 2:ceefc37abe1e > + tag: tip > + user: test > + date: Thu Jan 01 00:00:00 1970 +0000 > + summary: second > + > + $ hg heads -q > + 2:ceefc37abe1e > + > + $ 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 > + > +set up another conflict between a commit and a shelved change > + > + $ hg revert -q -C -a > + $ echo a>> a/a > + $ hg shelve -q > + $ echo x>> a/a > + $ hg ci -m 'create conflict' > + $ hg add foo/foo > + > +if we resolve a conflict while unshelving, the unshelve should succeed > + > + $ HGMERGE=true hg unshelve > + unshelving change 'default' > + adding changesets > + adding manifests > + adding file changes > + added 1 changesets with 1 changes to 6 files (+1 heads) > + merging a/a > + 0 files updated, 1 files merged, 0 files removed, 0 files unresolved > + $ hg parents -q > + 4:be7e79683c99 > + $ hg shelve -l > + $ hg status > + M a/a > + A foo/foo > + $ cat a/a > + a > + c > + x > + > +test cleanup > + > + $ hg shelve > + shelved from default (be7e7968): create conflict > + shelved as default > + 0 files updated, 0 files merged, 1 files removed, 0 files unresolved > + $ hg shelve --list > + default [*] shelved from default (be7e7968): create conflict (glob) > + $ hg shelve --cleanup > + $ hg shelve --list > _______________________________________________ > Mercurial-devel mailing list > Mercurial-devel@selenic.com > http://selenic.com/mailman/listinfo/mercurial-devel
On 09/18/2013 08:04 PM, Pierre-Yves David wrote: >> + >> + try: >> + user = repo.ui.username() >> + except util.Abort: >> + user = 'shelve@localhost' > > elsewhere in Mercurial, the usename is a strong requirement ? Why isn't > it the case here ? > (and additional question, why shelve have no -u argument ?) Changing the username isn't useful for the user unless we intentionally want to be able to have users merge the bundle into their repository as a commit. atm it's only a temporary storage and the username will never make it's way into the repository. We use --user only for commits that can make it into the repository (mq, etc). I think a fallback using a dummy user is okay for shelve in order to avoid confusion ("why do i need a proper user setup for shelving, what does the --user option do anyway in shelve?") David
Patch
diff --git a/hgext/color.py b/hgext/color.py --- a/hgext/color.py +++ b/hgext/color.py @@ -63,6 +63,10 @@ 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', @@ -259,6 +263,9 @@ '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,587 @@ +# 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, 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 + +cmdtable = {} +command = cmdutil.command(cmdtable) +testedwith = 'internal' + +class shelvedfile(object): + 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: + 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) + raise + +class shelvedstate(object): + _version = '1' + + @classmethod + def load(cls, repo): + fp = repo.opener('shelvedstate') + try: + lines = fp.read().splitlines() + finally: + fp.close() + lines.reverse() + + version = lines.pop() + if version != cls._version: + raise util.Abort(_('this version of shelve is incompatible ' + 'with the version used in this repo')) + 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 + + @classmethod + def save(cls, repo, name, stripnodes): + fp = repo.opener('shelvedstate', 'wb') + fp.write(cls._version + '\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 n in stripnodes: + fp.write(node.hex(n) + '\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() + + # 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) + + 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'] + + wlock = lock = None + try: + wlock = repo.wlock() + lock = repo.lock() + + 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) + + 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 + + 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()) + repair.strip(ui, repo, [node], backup='none', topic='shelve') + finally: + 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, + _('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 = 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 = None + try: + lock = repo.lock() + + oldtiprev = len(repo) + try: + fp = shelvedfile(repo, basename, 'hg').opener() + gen = changegroup.readbundle(fp, fp.name) + 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, 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) + + 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 @@ -341,6 +341,7 @@ 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-commandserver.py.out b/tests/test-commandserver.py.out --- a/tests/test-commandserver.py.out +++ b/tests/test-commandserver.py.out @@ -73,6 +73,7 @@ bundle.mainreporoot=$TESTTMP defaults.backout=-d "0 0" defaults.commit=-d "0 0" +defaults.shelve=--date "0 0" defaults.tag=-d "0 0" ui.slash=True ui.interactive=False @@ -81,6 +82,7 @@ runcommand -R foo showconfig ui defaults defaults.backout=-d "0 0" defaults.commit=-d "0 0" +defaults.shelve=--date "0 0" defaults.tag=-d "0 0" ui.slash=True ui.interactive=False 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,401 @@ + $ 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] + +create another commit + + $ echo n > n + $ hg add n + $ hg commit n -m second + +shelve a change that we will delete later + + $ echo a >> a/a + $ hg shelve + shelved from default (bb4fec6d): second + 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 (glob) + $ 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 (bb4fec6d): second + 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 (bb4fec6d): second (glob) + default [*] shelved from default (bb4fec6d): second (glob) + + $ hg shelve -l -p default + default [*] shelved from default (bb4fec6d): second (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 8 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 8 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 + 3:da6db56b46f7 + 2:ceefc37abe1e + $ hg parents -q + 2:ceefc37abe1e + 3:da6db56b46f7 + $ 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 + 2:ceefc37abe1e + $ hg parents + changeset: 2:ceefc37abe1e + 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: 2:ceefc37abe1e + tag: tip + user: test + date: Thu Jan 01 00:00:00 1970 +0000 + summary: second + + $ hg heads -q + 2:ceefc37abe1e + + $ 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 + +set up another conflict between a commit and a shelved change + + $ hg revert -q -C -a + $ echo a >> a/a + $ hg shelve -q + $ echo x >> a/a + $ hg ci -m 'create conflict' + $ hg add foo/foo + +if we resolve a conflict while unshelving, the unshelve should succeed + + $ HGMERGE=true hg unshelve + unshelving change 'default' + adding changesets + adding manifests + adding file changes + added 1 changesets with 1 changes to 6 files (+1 heads) + merging a/a + 0 files updated, 1 files merged, 0 files removed, 0 files unresolved + $ hg parents -q + 4:be7e79683c99 + $ hg shelve -l + $ hg status + M a/a + A foo/foo + $ cat a/a + a + c + x + +test cleanup + + $ hg shelve + shelved from default (be7e7968): create conflict + shelved as default + 0 files updated, 0 files merged, 1 files removed, 0 files unresolved + $ hg shelve --list + default [*] shelved from default (be7e7968): create conflict (glob) + $ hg shelve --cleanup + $ hg shelve --list