[PATCH 2 of 2 RFCv2] commands: introduce stash command

Idan Kamara idankk86 at gmail.com
Thu Feb 14 18:15:23 CST 2013


# HG changeset patch
# User Idan Kamara <idankk86 at gmail.com>
# Date 1360886982 -7200
# Node ID 06dd5eda17402c1e89a1aa212e340b146394fad8
# Parent  d4c029076cf2213ad680a53dfd32b0886b2b7be0
commands: introduce stash command

Stashes are unnamed (by default) and can be listed using --list and inspected
with -s/--show.

Referring to stashes is done either by index (as shown in --list) or name (if
one was given). If neither is specified, the most recent stash is chosen.

A stash is saved as a regular secret commit in the repository and is identified
by a bookmark under a special namespace '.hg/stash/'.  It is hidden from the
user and isn't exchanged with other repositories. When a stash is popped or
deleted, it is stripped from the repository or marked obsolete if obsolete is
enabled.

It's currently not possible to stash when an mq patch is applied. Unless mq
sometime in the future stops the use of strip (unlikely, mainly due to lack of
interest in touching it), this won't change.

Notes:

- --list output could be more friendly
- short option of --list is taken by -l of --log-message, slightly annoying and
  could perhaps be better off with a different name (or drop --log-message from
  options?)
- command option validation is pretty nasty, can it be improved somehow?
- there's a bug in the added test that I haven't been able to solve yet, any help
  will be appreciated

Other feedback is also welcome!

diff --git a/hgext/mq.py b/hgext/mq.py
--- a/hgext/mq.py
+++ b/hgext/mq.py
@@ -3536,6 +3536,11 @@
         ui.note(_("mq:     (empty queue)\n"))
     return r
 
+def stash(orig, ui, repo, *args, **kwargs):
+    if repo.mq.applied:
+        raise util.Abort(_('cannot stash with applied patches'))
+    return orig(ui, repo, *args, **kwargs)
+
 def revsetmq(repo, subset, x):
     """``mq()``
     Changesets managed by MQ.
@@ -3554,6 +3559,7 @@
 
     extensions.wrapcommand(commands.table, 'import', mqimport)
     extensions.wrapcommand(commands.table, 'summary', summary)
+    extensions.wrapcommand(commands.table, 'stash', stash)
 
     entry = extensions.wrapcommand(commands.table, 'init', mqinit)
     entry[1].extend(mqopt)
diff --git a/mercurial/commands.py b/mercurial/commands.py
--- a/mercurial/commands.py
+++ b/mercurial/commands.py
@@ -17,7 +17,7 @@
 import minirst, revset, fileset
 import dagparser, context, simplemerge, graphmod
 import random, setdiscovery, treediscovery, dagutil, pvec, localrepo
-import phases, obsolete
+import phases, obsolete, stash as stashmod
 
 table = {}
 
@@ -5366,6 +5366,135 @@
                      ui.configsource(section, name, untrusted))
             ui.write('%s=%s\n' % (sectname, value))
 
+ at command("stash",
+        [('', 'list', False, _('list all stashes')), # -l taken by logfile
+         ('f', 'force', False, _('unstash with dirty working dir')),
+         ('a', 'apply', False, _('apply the named or most recent stash')),
+         ('p', 'pop', False,
+             _('apply and pop the named or most recent stash')),
+         ('s', 'show', False,
+             _('show patch of the named or most recent stash')),
+         ('d', 'delete', False, _('delete the named or most recent stash')),
+         ('A', 'addremove', None,
+             _('mark new/missing files as added/removed before stashing')),
+         ('m', 'message', '', _('use text as stash message'), _('TEXT'))]
+         + commitopts + walkopts + mergetoolopts,
+         _('hg stash [--list|-a|-p|-d] [-f] [NAME]'))
+def stash(ui, repo, name=None, pop=False, apply=False, delete=False,
+          show=False, **opts):
+    """stash all changes in the working directory
+
+    Saves the state of the working directory so it can be restored at a later
+    time.
+
+    Stashes are unnamed (by default) and can be listed using --list and
+    inspected with -s/--show.
+
+    Referring to stashes is done either by index (as shown in --list) or name
+    (if one was given). If neither is specified, the most recent stash is
+    chosen.
+
+    A stash is saved as a regular secret commit in the repository and is
+    identified by a bookmark under a special namespace ``.hg/stash/``.  It is
+    hidden from the user and isn't exchanged with other repositories. When
+    a stash is popped or deleted, it is removed from the repository.
+
+    .. container:: verbose
+
+      Examples:
+
+      - stash changes in a named stash::
+
+          hg stash wip
+
+      - unstash (but don't delete) a named stash::
+
+          hg stash -a wip
+
+      - unstash and remove previous stash::
+
+          hg stash --pop
+
+      - show previous stash::
+
+          hg stash -p
+
+      - list all stashes
+
+          hg stash --list
+    """
+
+    # check command line options
+    l = []
+    if opts.get('list'):
+        l.append('-l/--list')
+    if pop:
+        l.append('-p/--pop')
+    if delete:
+        l.append('-d/--delete')
+
+    if len(l) > 1:
+        raise util.Abort(_('can specify one of: -l/--list, -p/--pop or'
+                           '-d/--delete'))
+
+    if opts.get('force') and not (pop or apply):
+        raise util.Abort(_('-f is only valid with -p/--pop or -a/--apply'))
+
+    if l:
+        if opts.get('addremove'):
+            raise util.Abort(_('cannot specify -A/--addremove with %s') % l[0])
+        for opt in commitopts + walkopts:
+            if opts.get(opt[1]):
+                raise util.Abort(_('cannot specify %s with %s') % (opt[1],
+                                                                   l[0]))
+        if opts.get('delete') or opts.get('list'):
+            for opt in mergetoolopts:
+                if opts.get(opt[1]):
+                    raise util.Abort(_('cannot specify %s with %s') % (opt[1],
+                                                                       l[0]))
+
+    repo = repo.unfiltered()
+
+    if opts.get('list'):
+        for i, (name, ctx) in enumerate(sorted(stashmod.list_(repo).iteritems(),
+                                key=lambda k: k[1].rev())):
+            name = name[len(stashmod.stashprefix):]
+            if name.startswith('.'):
+                name = '<unnamed>'
+            ui.write("%d %s@%d:%s - %s\n" % (i, name, ctx.p1().rev(),
+                     short(ctx.p1().node()), ctx.description()))
+        return
+
+    if name:
+        try:
+            name = int(name)
+        except ValueError:
+            name = stashmod.stashprefix + name
+
+    if pop or apply:
+        name, ctx = stashmod.get(repo, name)
+        stats = stashmod.apply(repo, ctx, **opts)
+        if stats and stats[3] > 0:
+            if pop:
+                raise util.Abort(_('unresolved conflicts, not popping'),
+                                 hint=_('use hg resolve and '
+                                        'hg stash --delete'))
+            else:
+                raise util.Abort(_('unresolved conflicts'),
+                                 hint=_('use hg resolve'))
+        if pop:
+            stashmod.delete(ui, repo, name)
+    elif delete:
+        name, ctx = stashmod.get(repo, name)
+        stashmod.delete(ui, repo, name)
+    elif show:
+        name, ctx = stashmod.get(repo, name)
+        diff(ui, repo, change=ctx.rev())
+    else:
+        ret = stashmod.create(ui, repo, name, **opts)
+        if ret:
+            ui.write(_("stashed working dir (hg stash --pop to unstash)\n"))
+
 @command('^status|st',
     [('A', 'all', None, _('show status of all files')),
     ('m', 'modified', None, _('show only modified files')),
diff --git a/mercurial/hg.py b/mercurial/hg.py
--- a/mercurial/hg.py
+++ b/mercurial/hg.py
@@ -457,12 +457,13 @@
     returns stats (see pydoc mercurial.merge.applyupdates)"""
     return mergemod.update(repo, node, False, overwrite, None)
 
-def update(repo, node):
+def update(repo, node, show_stats=True):
     """update the working directory to node, merging linear changes"""
     stats = updaterepo(repo, node, False)
-    _showstats(repo, stats)
-    if stats[3]:
-        repo.ui.status(_("use 'hg resolve' to retry unresolved file merges\n"))
+    if show_stats:
+        _showstats(repo, stats)
+        if stats[3]:
+            repo.ui.status(_("use 'hg resolve' to retry unresolved file merges\n"))
     return stats[3] > 0
 
 # naming conflict in clone()
diff --git a/mercurial/stash.py b/mercurial/stash.py
new file mode 100644
--- /dev/null
+++ b/mercurial/stash.py
@@ -0,0 +1,129 @@
+from node import nullid, short
+from i18n import _
+import hg, bookmarks, cmdutil, util, repair, phases, obsolete
+import merge as mergemod
+
+stashprefix = '.hg/stash/'
+
+def create(ui, repo, name=None, **opts):
+    curr = repo[None]
+    if len(curr.parents()) > 1:
+        raise util.Abort(_('cannot stash when merging'))
+    # stashes are marked using a bookmark with a special prefix
+    stashes = list_(repo)
+    if name in stashes:
+            raise util.Abort(_('stash %s already exists') % name)
+
+    e = cmdutil.commiteditor
+    if not opts['message'] and not opts['logfile']:
+        # we don't translate commit messages
+        opts['message'] = "Stashed working directory at %s" % str(curr.p1())
+
+    def commitfunc(ui, repo, message, match, opts):
+        return repo.commit(message, opts.get('user'), opts.get('date'),
+                           match, editor=e)
+    ph = repo.ui.config('phases', 'new-commit', phases.draft)
+    try:
+        repo.ui.setconfig('phases', 'new-commit', 'secret')
+        node = cmdutil.commit(ui, repo, commitfunc, [], opts)
+    finally:
+        repo.ui.setconfig('phases', 'new-commit', ph)
+    if not node:
+        ui.status(_("nothing to stash\n"))
+        return
+
+    ctx = repo[node]
+    ret = hg.update(repo, ctx.p1().node(), False)
+    assert not ret
+
+    if ctx in set(stashes.values()):
+        sortedstash = sorted(stashes.iteritems(), key=lambda kv: kv[1].rev())
+        for i, (name, sctx) in enumerate(sortedstash):
+            if ctx == sctx:
+                index = stashprefix + str(i)
+                break
+        ui.status(_("same changes already stashed at %s\n") % index)
+        return
+
+    marks = repo._bookmarks
+    # if no name was given, use '.<stash-node>'
+    if not name:
+        name = stashprefix + '.' + str(ctx)
+    marks[name] = ctx.node()
+    marks.write()
+
+    return name, ctx
+
+def list_(repo):
+    '''return a dict of stash name -> stash ctx'''
+    d = {}
+    for mark, n in repo._bookmarks.iteritems():
+        if mark.startswith(stashprefix):
+            d[mark] = repo[n]
+    return d
+
+def get(repo, id_=None):
+    """
+    if id_ = None, get the most recent stash (the one whose revision is
+    maximal. when it's an int, sort the stashes by revision and use it as an
+    index. otherwise, find a named stash with that name.
+    """
+    stashes = list_(repo)
+    if id_ is None:
+        if stashes:
+            return max(stashes.iteritems(), key=lambda kv: kv[1].rev())
+        else:
+            raise util.Abort(_('there are no stashes'))
+    else:
+        if isinstance(id_, int):
+            if id_ >= len(stashes):
+                raise util.Abort(_("stash index out of range"))
+            return sorted(stashes.iteritems(),
+                          key=lambda kv: kv[1].rev())[id_]
+        else:
+            try:
+                return id_, stashes[id_]
+            except KeyError:
+                raise util.Abort(_("stash %s doesn't exist") %
+                                 id_[len(stashprefix):])
+
+def apply(repo, ctx, force=False, **opts):
+    wctx = repo[None]
+    if not force and wctx.dirty(branch=False):
+        raise util.Abort(_('cannot apply with a dirty working directory'),
+                         hint=_('use -f to force'))
+
+    wlock = repo.wlock()
+    try:
+        # ui.forcemerge is an internal variable, do not document
+        repo.ui.setconfig('ui', 'forcemerge', opts.get('tool', ''))
+        try:
+            stats = mergemod.update(repo, ctx.node(), True, True,
+                                    False, ctx.p1().node(), True)
+        finally:
+            repo.ui.setconfig('ui', 'forcemerge', '')
+
+        # drop the second merge parent
+        repo.setparents(wctx.p1().node(), nullid)
+        repo.dirstate.write()
+        # fix up dirstate for copies and renames
+        cmdutil.duplicatecopies(repo, ctx.rev(), ctx.p1().rev())
+
+        return stats
+    finally:
+        wlock.release()
+
+def delete(ui, repo, name):
+    ctx = repo[name]
+    if ctx.children():
+        raise util.Abort(_('stash has children changesets, cannot delete'))
+    lock = repo.lock()
+    try:
+        del repo._bookmarks[name]
+        repo._bookmarks.write()
+        if obsolete._enabled:
+            obsolete.createmarkers(repo, [(ctx, ())])
+        else:
+            repair.strip(ui, repo, [ctx.node()], topic='stash-backup')
+    finally:
+        lock.release()
diff --git a/tests/test-stash.t b/tests/test-stash.t
new file mode 100644
--- /dev/null
+++ b/tests/test-stash.t
@@ -0,0 +1,235 @@
+  $ cat <<EOF >> $HGRCPATH
+  > [extensions]
+  > graphlog=
+  > [defaults]
+  > glog = --template "{rev}:{node|short} {bookmarks} {desc}\\n"
+  > [alias]
+  > slog = log --template "{rev}:{node|short} {bookmarks} {desc}\\n"
+  > EOF
+  $ hg init
+
+basic setup and testing:
+
+  $ touch a
+  $ hg ci -qAm.
+  $ hg stash
+  nothing to stash
+  $ hg stash --list
+  $ hg stash -d
+  abort: there are no stashes
+  [255]
+  $ hg stash -a
+  abort: there are no stashes
+  [255]
+  $ hg stash -s
+  abort: there are no stashes
+  [255]
+  $ hg stash -d foo
+  abort: stash foo doesn't exist
+  [255]
+  $ hg stash -a foo
+  abort: stash foo doesn't exist
+  [255]
+  $ hg stash -s foo
+  abort: stash foo doesn't exist
+  [255]
+
+stash a modification:
+
+  $ echo a >> a
+  $ hg slog
+  0:7f6b67e0497a  .
+  $ hg stash
+  stashed working dir (hg stash --pop to unstash)
+  $ hg slog
+  0:7f6b67e0497a  .
+  $ hg stash --list
+  0 <unnamed>@0:7f6b67e0497a - Stashed working directory at 7f6b67e0497a
+  $ hg stash -s
+  diff -r 7f6b67e0497a -r * a (glob)
+  --- a/a	Thu Jan 01 00:00:00 1970 +0000
+  +++ b/a	* (glob)
+  @@ -0,0 +1,1 @@
+  +a
+
+already stashed: (disabled for now since internal stash commit uses current date)
+
+$ echo a >> a
+$ hg stash
+same changes already stashed at .hg/stash/0
+
+apply it:
+
+  $ hg stash -a
+  $ hg parents
+  changeset:   0:7f6b67e0497a
+  tag:         tip
+  user:        test
+  date:        Thu Jan 01 00:00:00 1970 +0000
+  summary:     .
+  
+  $ hg diff --nodates
+  diff -r 7f6b67e0497a a
+  --- a/a
+  +++ b/a
+  @@ -0,0 +1,1 @@
+  +a
+  $ hg up -C .
+  1 files updated, 0 files merged, 0 files removed, 0 files unresolved
+  $ echo b >> b
+  $ hg ci -qAm.
+
+pop it (different parents):
+
+  $ hg stash --pop
+  saved backup bundle to $TESTTMP/.hg/strip-backup/*-stash-backup.hg (glob)
+  $ hg stash --list
+  $ hg diff --nodates
+  diff -r 901c2d3ce0fb a
+  --- a/a
+  +++ b/a
+  @@ -0,0 +1,1 @@
+  +a
+
+named stash:
+
+  $ echo c > c
+  $ echo a >> a
+  $ hg stash -A foo
+  adding c
+  stashed working dir (hg stash --pop to unstash)
+  $ hg stash --list
+  0 foo at 1:901c2d3ce0fb - Stashed working directory at 901c2d3ce0fb
+  $ hg stash -s foo
+  diff -r 901c2d3ce0fb -r * a (glob)
+  --- a/a	Thu Jan 01 00:00:00 1970 +0000
+  +++ b/a	* (glob)
+  @@ -0,0 +1,2 @@
+  +a
+  +a
+  diff -r 901c2d3ce0fb -r * c (glob)
+  --- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+  +++ b/c	* (glob)
+  @@ -0,0 +1,1 @@
+  +c
+  $ hg stash -a
+  $ hg stash -d foo
+  saved backup bundle to $TESTTMP/.hg/strip-backup/*-stash-backup.hg (glob)
+
+create several unnamed stashes:
+
+  $ echo a >> a
+  $ hg stash
+  stashed working dir (hg stash --pop to unstash)
+  $ echo aa >> a
+  $ hg stash -m wip
+  stashed working dir (hg stash --pop to unstash)
+  $ echo aaa >> a
+  $ hg stash
+  stashed working dir (hg stash --pop to unstash)
+  $ hg stash --list
+  0 <unnamed>@1:901c2d3ce0fb - Stashed working directory at 901c2d3ce0fb
+  1 <unnamed>@1:901c2d3ce0fb - wip
+  2 <unnamed>@1:901c2d3ce0fb - Stashed working directory at 901c2d3ce0fb
+
+apply them with a conflict:
+
+  $ hg stash --pop 1
+  saved backup bundle to $TESTTMP/.hg/strip-backup/*-stash-backup.hg (glob)
+  $ hg stash --list
+  0 <unnamed>@1:901c2d3ce0fb - Stashed working directory at 901c2d3ce0fb
+  1 <unnamed>@1:901c2d3ce0fb - Stashed working directory at 901c2d3ce0fb
+  $ hg stash -f -a
+  merging a
+  warning: conflicts during merge.
+  merging a incomplete! (edit conflicts, then use 'hg resolve --mark')
+  abort: unresolved conflicts
+  (use hg resolve)
+  [255]
+  $ hg update --clean . && rm a.orig
+  1 files updated, 0 files merged, 0 files removed, 0 files unresolved
+  $ hg res -l
+
+use mergetool when applying:
+
+  $ hg stash -f --pop --tool "internal:other"
+  saved backup bundle to $TESTTMP/.hg/strip-backup/*-stash-backup.hg (glob)
+  $ hg res -l
+  $ hg diff --nodates
+  diff -r 901c2d3ce0fb a
+  --- a/a
+  +++ b/a
+  @@ -0,0 +1,1 @@
+  +aaa
+  $ hg stash -d
+  saved backup bundle to $TESTTMP/.hg/strip-backup/*-stash-backup.hg (glob)
+
+test that renames are preserved:
+
+  $ hg up -qC .
+  $ hg mv b c
+  $ hg st --copies
+  A c
+    b
+  R b
+  $ hg stash
+  stashed working dir (hg stash --pop to unstash)
+  $ hg stash --pop
+  saved backup bundle to $TESTTMP/.hg/strip-backup/*-stash-backup.hg (glob)
+  $ hg st --copies
+  A c
+    b
+  R b
+  $ hg revert -q -a --no-backup
+
+stashed commits shouldn't be exchanged on push/pull:
+
+  $ echo a >> a
+  $ hg stash
+  stashed working dir (hg stash --pop to unstash)
+  $ hg clone . clone
+  requesting all changes
+  adding changesets
+  adding manifests
+  adding file changes
+  added 2 changesets with 2 changes to 2 files
+  updating to branch default
+  2 files updated, 0 files merged, 0 files removed, 0 files unresolved
+  $ cd clone
+  $ hg slog
+  1:901c2d3ce0fb  .
+  0:7f6b67e0497a  .
+  $ hg stash --list
+  $ cd .. && rm -r clone
+
+obsolete the parent of a stashed commit:
+
+  $ hg up 0 -q
+  $ cat > obs.py << EOF
+  > import mercurial.obsolete
+  > mercurial.obsolete._enabled = True
+  > EOF
+  $ echo '[extensions]' >> $HGRCPATH
+  $ echo "obs=${TESTTMP}/obs.py" >> $HGRCPATH
+
+  $ hg debugobsolete $(hg log --template '{node}' -r 1)
+  $ hg stash --list
+  0 <unnamed>@1:901c2d3ce0fb - Stashed working directory at 901c2d3ce0fb
+
+obsolete enabled, pop won't strip:
+
+  $ hg stash --pop
+
+no stashing with mq:
+
+  $ printf '[extensions]\nmq=\n' >> $HGRCPATH
+  $ hg qnew foo
+  $ echo a >> a
+  $ hg stash
+  abort: cannot stash with applied patches
+  [255]
+  $ hg revert -a --no-backup -q
+  $ hg qpop
+  popping foo
+  patch queue now empty
+  $ hg qrm foo


More information about the Mercurial-devel mailing list