Convert "stg refresh" to the new infrastructure
authorKarl Hasselström <kha@treskal.com>
Sun, 21 Sep 2008 12:17:42 +0000 (14:17 +0200)
committerKarl Hasselström <kha@treskal.com>
Sun, 21 Sep 2008 12:19:07 +0000 (14:19 +0200)
And in the process, make it more powerful: it will now first create a
temp patch containing the updates, and then try to merge it into the
patch to be updated. If that patch is applied, this is done by
popping, pushing, and coalescing; if it is unapplied, it is done with
an in-index merge. This allows us to correctly handle a few corner
cases that didn't use to work, such as adding a new file to a
non-topmost patch (fixes the t2701 test failure).

The temp patch creation and merging is logged in two separate stages,
so that the user can undo them separately.

Also, whenever path limiting is used, we will now use a temporary
index in order to avoid including all staged updates (since they may
touch stuff outside the path limiters).

Support for the --force, --undo, and --annotate flags were dropped.

Signed-off-by: Karl Hasselström <kha@treskal.com>
stgit/commands/refresh.py
stgit/lib/git.py
stgit/lib/stack.py
stgit/lib/transaction.py
t/t2701-refresh-p.sh
t/t3100-reset.sh

index 7be94e0..384cfb9 100644 (file)
@@ -1,6 +1,8 @@
+# -*- coding: utf-8 -*-
 
 __copyright__ = """
 Copyright (C) 2005, Catalin Marinas <catalin.marinas@gmail.com>
+Copyright (C) 2008, Karl Hasselström <kha@treskal.com>
 
 This program is free software; you can redistribute it and/or modify
 it under the terms of the GNU General Public License version 2 as
@@ -16,122 +18,214 @@ along with this program; if not, write to the Free Software
 Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
 """
 
-import sys, os
 from stgit.argparse import opt
-from stgit.commands.common import *
-from stgit.utils import *
-from stgit.out import *
-from stgit import stack, git
-from stgit.config import config
+from stgit.commands import common
+from stgit.lib import git, transaction
+from stgit.out import out
+from stgit import utils
 
 help = 'Generate a new commit for the current patch'
 kind = 'patch'
 usage = ['[options] [<files or dirs>]']
 description = """
-Include the latest tree changes in the current patch. This command
-generates a new GIT commit object with the patch details, the previous
-one no longer being visible. The '--force' option is useful
-when a commit object was created with a different tool
-but the changes need to be included in the current patch."""
+Include the latest work tree and index changes in the current patch.
+This command generates a new git commit object for the patch; the old
+commit is no longer visible.
+
+You may optionally list one or more files or directories relative to
+the current working directory; if you do, only matching files will be
+updated.
+
+Behind the scenes, stg refresh first creates a new temporary patch
+with your updates, and then merges that patch into the patch you asked
+to have refreshed. If you asked to refresh a patch other than the
+topmost patch, there can be conflicts; in that case, the temporary
+patch will be left for you to take care of, for example with stg
+coalesce.
+
+The creation of the temporary patch is recorded in a separate entry in
+the patch stack log; this means that one undo step will undo the merge
+between the other patch and the temp patch, and two undo steps will
+additionally get rid of the temp patch."""
 
 options = [
-    opt('-f', '--force', action = 'store_true',
-        short = 'Force the refresh even if HEAD and top differ'),
-    opt('--update', action = 'store_true',
+    opt('-u', '--update', action = 'store_true',
         short = 'Only update the current patch files'),
-    opt('--index', action = 'store_true',
+    opt('-i', '--index', action = 'store_true',
         short = 'Refresh from index instead of worktree', long = """
         Instead of setting the patch top to the current contents of
         the worktree, set it to the current contents of the index."""),
-    opt('--undo', action = 'store_true',
-        short = 'Revert the commit generated by the last refresh'),
-    opt('-a', '--annotate', metavar = 'NOTE',
-        short = 'Annotate the patch log entry'),
     opt('-p', '--patch',
         short = 'Refresh (applied) PATCH instead of the top patch')]
 
-directory = DirectoryHasRepository(log = True)
+directory = common.DirectoryHasRepositoryLib()
 
-def func(parser, options, args):
-    """Generate a new commit for the current or given patch.
-    """
-    args = git.ls_files(args)
-    directory.cd_to_topdir()
-
-    autoresolved = config.get('stgit.autoresolved')
-    if autoresolved != 'yes':
-        check_conflicts()
-
-    if options.patch:
-        if args or options.update:
-            raise CmdException, \
-                  'Only full refresh is available with the --patch option'
-        patch = options.patch
-        if not crt_series.patch_applied(patch):
-            raise CmdException, 'Patches "%s" not applied' % patch
+def get_patch(stack, given_patch):
+    """Get the name of the patch we are to refresh."""
+    if given_patch:
+        patch_name = given_patch
+        if not stack.patches.exists(patch_name):
+            raise common.CmdException('%s: no such patch' % patch_name)
+        return patch_name
+    else:
+        if not stack.patchorder.applied:
+            raise common.CmdException(
+                'Cannot refresh top patch, because no patches are applied')
+        return stack.patchorder.applied[-1]
+
+def list_files(stack, patch_name, args, index, update):
+    """Figure out which files to update."""
+    if index:
+        # --index: Don't update the index.
+        return set()
+    paths = stack.repository.default_iw.changed_files(
+        stack.head.data.tree, args or [])
+    if update:
+        # --update: Restrict update to the paths that were already
+        # part of the patch.
+        paths &= stack.patches.get(patch_name).files()
+    return paths
+
+def write_tree(stack, paths, temp_index):
+    """Possibly update the index, and then write its tree.
+    @return: The written tree.
+    @rtype: L{Tree<stgit.git.Tree>}"""
+    def go(index):
+        if paths:
+            iw = git.IndexAndWorktree(index, stack.repository.default_worktree)
+            iw.update_index(paths)
+        return index.write_tree()
+    if temp_index:
+        index = stack.repository.temp_index()
+        try:
+            index.read_tree(stack.head)
+            return go(index)
+        finally:
+            index.delete()
+            stack.repository.default_iw.update_index(paths)
+    else:
+        return go(stack.repository.default_index)
+
+def make_temp_patch(stack, patch_name, paths, temp_index):
+    """Commit index to temp patch, in a complete transaction. If any path
+    limiting is in effect, use a temp index."""
+    tree = write_tree(stack, paths, temp_index)
+    commit = stack.repository.commit(git.CommitData(
+            tree = tree, parents = [stack.head],
+            message = 'Refresh of %s' % patch_name))
+    temp_name = utils.make_patch_name('refresh-temp', stack.patches.exists)
+    trans = transaction.StackTransaction(stack,
+                                         'refresh (create temporary patch)')
+    trans.patches[temp_name] = commit
+    trans.applied.append(temp_name)
+    return trans.run(stack.repository.default_iw,
+                     print_current_patch = False), temp_name
+
+def absorb_applied(trans, iw, patch_name, temp_name):
+    """Absorb the temp patch (C{temp_name}) into the given patch
+    (C{patch_name}), which must be applied.
+
+    @return: C{True} if we managed to absorb the temp patch, C{False}
+             if we had to leave it for the user to deal with."""
+    temp_absorbed = False
+    try:
+        # Pop any patch on top of the patch we're refreshing.
+        to_pop = trans.applied[trans.applied.index(patch_name)+1:]
+        if len(to_pop) > 1:
+            popped = trans.pop_patches(lambda pn: pn in to_pop)
+            assert not popped # no other patches were popped
+            trans.push_patch(temp_name, iw)
+        assert to_pop.pop() == temp_name
+
+        # Absorb the temp patch.
+        temp_cd = trans.patches[temp_name].data
+        assert trans.patches[patch_name] == temp_cd.parent
+        trans.patches[patch_name] = trans.stack.repository.commit(
+            trans.patches[patch_name].data.set_tree(temp_cd.tree))
+        popped = trans.delete_patches(lambda pn: pn == temp_name, quiet = True)
+        assert not popped # the temp patch was topmost
+        temp_absorbed = True
+
+        # Push back any patch we were forced to pop earlier.
+        for pn in to_pop:
+            trans.push_patch(pn, iw)
+    except transaction.TransactionHalted:
+        pass
+    return temp_absorbed
+
+def absorb_unapplied(trans, iw, patch_name, temp_name):
+    """Absorb the temp patch (C{temp_name}) into the given patch
+    (C{patch_name}), which must be unapplied.
+
+    @param iw: Not used.
+    @return: C{True} if we managed to absorb the temp patch, C{False}
+             if we had to leave it for the user to deal with."""
+
+    # Pop the temp patch.
+    popped = trans.pop_patches(lambda pn: pn == temp_name)
+    assert not popped # the temp patch was topmost
+
+    # Try to create the new tree of the refreshed patch. (This is the
+    # same operation as pushing the temp patch onto the patch we're
+    # trying to refresh -- but we don't have a worktree to spill
+    # conflicts to, so if the simple merge doesn't succeed, we have to
+    # give up.)
+    patch_cd = trans.patches[patch_name].data
+    temp_cd = trans.patches[temp_name].data
+    new_tree = trans.stack.repository.simple_merge(
+        base = temp_cd.parent.data.tree,
+        ours = patch_cd.tree, theirs = temp_cd.tree)
+    if new_tree:
+        # It worked. Refresh the patch with the new tree, and delete
+        # the temp patch.
+        trans.patches[patch_name] = trans.stack.repository.commit(
+            patch_cd.set_tree(new_tree))
+        popped = trans.delete_patches(lambda pn: pn == temp_name, quiet = True)
+        assert not popped # the temp patch was not applied
+        return True
     else:
-        patch = crt_series.get_current()
-        if not patch:
-            raise CmdException, 'No patches applied'
-
-    if options.index:
-        if args or options.update:
-            raise CmdException, \
-                  'Only full refresh is available with the --index option'
-        if options.patch:
-            raise CmdException, \
-                  '--patch is not compatible with the --index option'
-
-    if not options.force:
-        check_head_top_equal(crt_series)
-
-    if options.undo:
-        out.start('Undoing the refresh of "%s"' % patch)
-        crt_series.undo_refresh()
-        out.done()
-        return
-
-    if not options.index:
-        files = [path for (stat, path) in git.tree_status(files = args, verbose = True)]
-
-    if options.index or files or not crt_series.head_top_equal():
-        if options.patch:
-            applied = crt_series.get_applied()
-            between = applied[:applied.index(patch):-1]
-            pop_patches(crt_series, between, keep = True)
-        elif options.update:
-            rev1 = git_id(crt_series, 'HEAD^')
-            rev2 = git_id(crt_series, 'HEAD')
-            patch_files = git.barefiles(rev1, rev2).split('\n')
-            files = [f for f in files if f in patch_files]
-            if not files:
-                out.info('No modified files for updating patch "%s"' % patch)
-                return
-
-        out.start('Refreshing patch "%s"' % patch)
-
-        if autoresolved == 'yes':
-            resolved_all()
-
-        if options.index:
-            crt_series.refresh_patch(cache_update = False,
-                                     backup = True, notes = options.annotate)
-        else:
-            crt_series.refresh_patch(files = files,
-                                     backup = True, notes = options.annotate)
-
-        if crt_series.empty_patch(patch):
-            out.done('empty patch')
-        else:
-            out.done()
-
-        if options.patch:
-            between.reverse()
-            push_patches(crt_series, between)
-    elif options.annotate:
-        # only annotate the top log entry as there is no need to
-        # refresh the patch and generate a full commit
-        crt_series.log_patch(crt_series.get_patch(patch), None,
-                             notes = options.annotate)
+        # Nope, we couldn't create the new tree, so we'll just have to
+        # leave the temp patch for the user.
+        return False
+
+def absorb(stack, patch_name, temp_name):
+    """Absorb the temp patch into the target patch."""
+    trans = transaction.StackTransaction(stack, 'refresh')
+    iw = stack.repository.default_iw
+    f = { True: absorb_applied, False: absorb_unapplied
+          }[patch_name in trans.applied]
+    if f(trans, iw, patch_name, temp_name):
+        def info_msg(): pass
     else:
-        out.info('Patch "%s" is already up to date' % patch)
+        def info_msg():
+            out.warn('The new changes did not apply cleanly to %s.'
+                     % patch_name, 'They were saved in %s.' % temp_name)
+    r = trans.run(iw)
+    info_msg()
+    return r
+
+def func(parser, options, args):
+    """Generate a new commit for the current or given patch."""
+
+    # Catch illegal argument combinations.
+    path_limiting = bool(args or options.update)
+    if options.index and path_limiting:
+        raise common.CmdException(
+            'Only full refresh is available with the --index option')
+
+    stack = directory.repository.current_stack
+    patch_name = get_patch(stack, options.patch)
+    paths = list_files(stack, patch_name, args, options.index, options.update)
+
+    # Make sure there are no conflicts in the files we want to
+    # refresh.
+    if stack.repository.default_index.conflicts() & paths:
+        raise common.CmdException(
+            'Cannot refresh -- resolve conflicts first')
+
+    # Commit index to temp patch, and absorb it into the target patch.
+    retval, temp_name = make_temp_patch(
+        stack, patch_name, paths, temp_index = path_limiting)
+    if retval != utils.STGIT_SUCCESS:
+        return retval
+    return absorb(stack, patch_name, temp_name)
index dd49605..22fdce4 100644 (file)
@@ -511,6 +511,14 @@ class RunWithEnvCwd(RunWithEnv):
         @type env: dict
         @param env: Extra environment"""
         return RunWithEnv.run(self, args, env).cwd(self.cwd)
+    def run_in_cwd(self, args):
+        """Run the given command with an environment given by self.env and
+        self.env_in_cwd, without changing the current working
+        directory.
+
+        @type args: list of strings
+        @param args: Command and argument vector"""
+        return RunWithEnv.run(self, args, self.env_in_cwd)
 
 class Repository(RunWithEnv):
     """Represents a git repository."""
@@ -637,6 +645,32 @@ class Repository(RunWithEnv):
         assert isinstance(t2, Tree)
         return self.run(['git', 'diff-tree', '-p'] + list(diff_opts)
                         + [t1.sha1, t2.sha1]).raw_output()
+    def diff_tree_files(self, t1, t2):
+        """Given two L{Tree}s C{t1} and C{t2}, iterate over all files for
+        which they differ. For each file, yield a tuple with the old
+        file mode, the new file mode, the old blob, the new blob, the
+        status, the old filename, and the new filename. Except in case
+        of a copy or a rename, the old and new filenames are
+        identical."""
+        assert isinstance(t1, Tree)
+        assert isinstance(t2, Tree)
+        i = iter(self.run(['git', 'diff-tree', '-r', '-z'] + [t1.sha1, t2.sha1]
+                          ).raw_output().split('\0'))
+        try:
+            while True:
+                x = i.next()
+                if not x:
+                    continue
+                omode, nmode, osha1, nsha1, status = x[1:].split(' ')
+                fn1 = i.next()
+                if status[0] in ['C', 'R']:
+                    fn2 = i.next()
+                else:
+                    fn2 = fn1
+                yield (omode, nmode, self.get_blob(osha1),
+                       self.get_blob(nsha1), status, fn1, fn2)
+        except StopIteration:
+            pass
 
 class MergeException(exception.StgException):
     """Exception raised when a merge fails for some reason."""
@@ -660,6 +694,9 @@ class Index(RunWithEnv):
     def read_tree(self, tree):
         self.run(['git', 'read-tree', tree.sha1]).no_output()
     def write_tree(self):
+        """Write the index contents to the repository.
+        @return: The resulting L{Tree}
+        @rtype: L{Tree}"""
         try:
             return self.__repository.get_tree(
                 self.run(['git', 'write-tree']).discard_stderr(
@@ -747,6 +784,7 @@ class Worktree(object):
     def __init__(self, directory):
         self.__directory = directory
     env = property(lambda self: { 'GIT_WORK_TREE': '.' })
+    env_in_cwd = property(lambda self: { 'GIT_WORK_TREE': self.directory })
     directory = property(lambda self: self.__directory)
 
 class CheckoutException(exception.StgException):
@@ -763,6 +801,7 @@ class IndexAndWorktree(RunWithEnvCwd):
     index = property(lambda self: self.__index)
     env = property(lambda self: utils.add_dict(self.__index.env,
                                                self.__worktree.env))
+    env_in_cwd = property(lambda self: self.__worktree.env_in_cwd)
     cwd = property(lambda self: self.__worktree.directory)
     def checkout_hard(self, tree):
         assert isinstance(tree, Tree)
@@ -796,11 +835,24 @@ class IndexAndWorktree(RunWithEnvCwd):
                 raise MergeConflictException()
             else:
                 raise MergeException('Index/worktree dirty')
-    def changed_files(self):
-        return self.run(['git', 'diff-files', '--name-only']).output_lines()
-    def update_index(self, files):
-        self.run(['git', 'update-index', '--remove', '-z', '--stdin']
-                 ).input_nulterm(files).discard_output()
+    def changed_files(self, tree, pathlimits = []):
+        """Return the set of files in the worktree that have changed with
+        respect to C{tree}. The listing is optionally restricted to
+        those files that match any of the path limiters given.
+
+        The path limiters are relative to the current working
+        directory; the returned file names are relative to the
+        repository root."""
+        assert isinstance(tree, Tree)
+        return set(self.run_in_cwd(
+                ['git', 'diff-index', tree.sha1, '--name-only', '-z', '--']
+                + list(pathlimits)).raw_output().split('\0')[:-1])
+    def update_index(self, paths):
+        """Update the index with files from the worktree. C{paths} is an
+        iterable of paths relative to the root of the repository."""
+        cmd = ['git', 'update-index', '--remove']
+        self.run(cmd + ['-z', '--stdin']
+                 ).input_nulterm(paths).discard_output()
 
 class Branch(object):
     """Represents a Git branch."""
index d5dbd48..47679b6 100644 (file)
@@ -90,6 +90,15 @@ class Patch(object):
         return self.name in self.__stack.patchorder.applied
     def is_empty(self):
         return self.commit.data.is_nochange()
+    def files(self):
+        """Return the set of files this patch touches."""
+        fs = set()
+        for (_, _, _, _, _, oldname, newname
+             ) in self.__stack.repository.diff_tree_files(
+            self.commit.data.tree, self.commit.data.parent.data.tree):
+            fs.add(oldname)
+            fs.add(newname)
+        return fs
 
 class PatchOrder(object):
     """Keeps track of patch order, and which patches are applied.
index c6e2db3..de62a8c 100644 (file)
@@ -181,7 +181,8 @@ class StackTransaction(object):
         if iw:
             self.__checkout(self.__stack.head.data.tree, iw,
                             allow_bad_head = True)
-    def run(self, iw = None, set_head = True, allow_bad_head = False):
+    def run(self, iw = None, set_head = True, allow_bad_head = False,
+            print_current_patch = True):
         """Execute the transaction. Will either succeed, or fail (with an
         exception) and do nothing."""
         self.__check_consistency()
@@ -223,7 +224,8 @@ class StackTransaction(object):
             self.__patches = _TransPatchMap(self.__stack)
             self.__conflicting_push()
             write(self.__msg + ' (CONFLICT)')
-        _print_current_patch(old_applied, self.__applied)
+        if print_current_patch:
+            _print_current_patch(old_applied, self.__applied)
 
         if self.__error:
             return utils.STGIT_CONFLICT
@@ -259,7 +261,7 @@ class StackTransaction(object):
         self.__print_popped(popped)
         return popped1
 
-    def delete_patches(self, p):
+    def delete_patches(self, p, quiet = False):
         """Delete all patches pn for which p(pn) is true. Return the list of
         other patches that had to be popped to accomplish this. Always
         succeeds."""
@@ -278,7 +280,8 @@ class StackTransaction(object):
             if p(pn):
                 s = ['', ' (empty)'][self.patches[pn].data.is_nochange()]
                 self.patches[pn] = None
-                out.info('Deleted %s%s' % (pn, s))
+                if not quiet:
+                    out.info('Deleted %s%s' % (pn, s))
         return popped
 
     def push_patch(self, pn, iw = None):
index 6fb7352..ed4bac4 100755 (executable)
@@ -29,7 +29,7 @@ EOF
 cat > expected2.txt <<EOF
 A 2.txt
 EOF
-test_expect_failure 'Add new file to non-top patch' '
+test_expect_success 'Add new file to non-top patch' '
     stg status > status1.txt &&
     test_cmp expected0.txt status1.txt &&
     echo y > new.txt &&
index 0b5cd2c..3024975 100755 (executable)
@@ -152,7 +152,7 @@ cat > expected.txt <<EOF
 222
 EOF
 test_expect_success '... and undo the refresh' '
-    stg reset master.stgit^~1 &&
+    stg reset master.stgit^~2 &&
     test "$(echo $(stg series --all))" = "+ p1 > p2" &&
     test_cmp expected.txt a
 '