Subject: [PATCH 1/2] New stg command: assimilate

From: Karl Hasselström <[email-masked]>
Date: Sun, 22 Oct 2006 15:08:02 +0200

From: Karl Hasselström <[email-masked]>

Introduce an "assimilate" command, with no options. It takes any GIT
commits committed on top of your StGIT patch stack and converts them
into StGIT patches.

Also change the error message when an StGIT command can't do its job
because there are GIT commits on top of the stack. Instead of
recommending "refresh -f", which is a destructive operation, recommend
"assimilate", which is not.

NOTE: "assimilate" currently refuses to work its magic if it
encounters a merge commit. This is reasonable, since merge commits
can't (yet) be represented as StGIT patches. However, it would be
possible (and well-defined) to replace the merge commit with a regular
commit on the branch with the same end result (tree object),
discarding all the parents that aren't on our branch. But this would
take a substantial amount of code, and is of dubious value, so for now
"assimilate" just cries bloody murder if it finds a merge.

Signed-off-by: Karl Hasselström <[email-masked]>
---

 stgit/commands/assimilate.py |   96 ++++++++++++++++++++++++++++++++++++++++++
 stgit/commands/common.py     |    8 ++--
 stgit/git.py                 |    3 +
 stgit/main.py                |    3 +
 stgit/stack.py               |   28 +++++++++---
 5 files changed, 126 insertions(+), 12 deletions(-)

diff --git a/stgit/commands/assimilate.py b/stgit/commands/assimilate.py
new file mode 100644
index 0000000..2c0ec56
--- /dev/null
+++ b/stgit/commands/assimilate.py
@@ -0,0 +1,96 @@
+# -*- coding: utf-8 -*-
+
+__copyright__ = """
+Copyright (C) 2006, Karl Hasselström <[email-masked]>
+
+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
+published by the Free Software Foundation.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+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 optparse import OptionParser, make_option
+
+from stgit.commands.common import *
+from stgit.utils import *
+from stgit import stack, git
+
+help = 'StGIT-ify any GIT commits made on top of your StGIT stack'
+usage = """%prog
+
+If you have made GIT commits on top of your stack of StGIT patches,
+many StGIT commands will refuse to work. This command converts any
+such commits to StGIT patches, preserving their contents.
+
+Only GIT commits with exactly one parent can be assimilated; in other
+words, if you have committed a merge on top of your stack, this
+command cannot help you."""
+
+options = []
+
+def func(parser, options, args):
+    """Assimilate a number of patches.
+    """
+
+    def nothing_to_do():
+        print 'No commits to assimilate'
+
+    top_patch = crt_series.get_current_patch()
+    if not top_patch:
+        return nothing_to_do()
+
+    victims = []
+    victim = git.get_commit(git.get_head())
+    while victim.get_id_hash() != top_patch.get_top():
+        victims.append(victim)
+        parents = victim.get_parents()
+        if not parents:
+            raise CmdException, 'Commit %s has no parents, aborting' % victim
+        elif len(parents) > 1:
+            raise CmdException, 'Commit %s is a merge, aborting' % victim
+        victim = git.get_commit(parents[0])
+
+    if not victims:
+        return nothing_to_do()
+
+    if crt_series.get_protected():
+        raise CmdException(
+            'This branch is protected. Modification is not permitted')
+
+    patch2name = {}
+    name2patch = {}
+    def name_taken(name):
+        return patchname in name2patch or crt_series.patch_exists(patchname)
+    for victim in victims:
+        patchname = make_patch_name(victim.get_log())
+        if not patchname:
+            patchname = 'patch'
+        if name_taken(patchname):
+            suffix = 0
+            while name_taken('%s-%d' % (patchname, suffix)):
+                suffix += 1
+            patchname = '%s-%d' % (patchname, suffix)
+        patch2name[victim] = patchname
+        name2patch[patchname] = victim
+
+    for victim in reversed(victims):
+        print ('Creating patch "%s" from commit %s'
+               % (patch2name[victim], victim))
+        aname, amail, adate = name_email_date(victim.get_author())
+        cname, cmail, cdate = name_email_date(victim.get_committer())
+        crt_series.new_patch(
+            patch2name[victim],
+            can_edit = False, before_existing = False, refresh = False,
+            top = victim.get_id_hash(), bottom = victim.get_parent(),
+            message = victim.get_log(),
+            author_name = aname, author_email = amail, author_date = adate,
+            committer_name = cname, committer_email = cmail)
diff --git a/stgit/commands/common.py b/stgit/commands/common.py
index bf8481e..1ea6025 100644
--- a/stgit/commands/common.py
+++ b/stgit/commands/common.py
@@ -113,10 +113,10 @@ def check_local_changes():
 
 def check_head_top_equal():
     if not crt_series.head_top_equal():
-        raise CmdException, \
-              'HEAD and top are not the same. You probably committed\n' \
-              '  changes to the tree outside of StGIT. If you know what you\n' \
-              '  are doing, use the "refresh -f" command'
+        raise CmdException(
+            'HEAD and top are not the same. You probably committed\n'
+            '  changes to the tree outside of StGIT. To bring them\n'
+            '  into StGIT, use the "assimilate" command')
 
 def check_conflicts():
     if os.path.exists(os.path.join(basedir.get(), 'conflicts')):
diff --git a/stgit/git.py b/stgit/git.py
index 43bdc7e..42b0d12 100644
--- a/stgit/git.py
+++ b/stgit/git.py
@@ -79,6 +79,9 @@ class Commit:
     def get_log(self):
         return self.__log
 
+    def __str__(self):
+        return self.get_id_hash()
+
 # dictionary of Commit objects, used to avoid multiple calls to git
 __commits = dict()
 
diff --git a/stgit/main.py b/stgit/main.py
index e9cc6cd..de35ca8 100644
--- a/stgit/main.py
+++ b/stgit/main.py
@@ -30,6 +30,7 @@ from stgit.commands.common import *
 # The commands
 import stgit.commands.add
 import stgit.commands.applied
+import stgit.commands.assimilate
 import stgit.commands.branch
 import stgit.commands.delete
 import stgit.commands.diff
@@ -70,6 +71,7 @@ #
 commands = {
     'add':      stgit.commands.add,
     'applied':  stgit.commands.applied,
+    'assimilate': stgit.commands.assimilate,
     'branch':   stgit.commands.branch,
     'delete':   stgit.commands.delete,
     'diff':     stgit.commands.diff,
@@ -113,6 +115,7 @@ repocommands = (
     )
 stackcommands = (
     'applied',
+    'assimilate',
     'clean',
     'commit',
     'float',
diff --git a/stgit/stack.py b/stgit/stack.py
index 93a3d4e..e50f189 100644
--- a/stgit/stack.py
+++ b/stgit/stack.py
@@ -350,9 +350,17 @@ class Series:
         """
         return Patch(name, self.__patch_dir, self.__refs_dir)
 
+    def get_current_patch(self):
+        """Return a Patch object representing the topmost patch, or
+        None if there is no such patch."""
+        crt = self.get_current()
+        if not crt:
+            return None
+        return Patch(crt, self.__patch_dir, self.__refs_dir)
+
     def get_current(self):
-        """Return a Patch object representing the topmost patch
-        """
+        """Return the name of the topmost patch, or None if there is
+        no such patch."""
         if os.path.isfile(self.__current_file):
             name = read_string(self.__current_file)
         else:
@@ -414,6 +422,11 @@ class Series:
         """
         return name in self.get_unapplied()
 
+    def patch_exists(self, name):
+        """Return true if there is a patch with the given name, false
+        otherwise."""
+        return self.__patch_applied(name) or self.__patch_applied(name)
+
     def __begin_stack_check(self):
         """Save the current HEAD into .git/refs/heads/base if the stack
         is empty
@@ -433,12 +446,11 @@ class Series:
     def head_top_equal(self):
         """Return true if the head and the top are the same
         """
-        crt = self.get_current()
+        crt = self.get_current_patch()
         if not crt:
             # we don't care, no patches applied
             return True
-        return git.get_head() == Patch(crt, self.__patch_dir,
-                                       self.__refs_dir).get_top()
+        return git.get_head() == crt.get_top()
 
     def is_initialised(self):
         """Checks if series is already initialised
@@ -688,7 +700,7 @@ class Series:
                   top = None, bottom = None,
                   author_name = None, author_email = None, author_date = None,
                   committer_name = None, committer_email = None,
-                  before_existing = False):
+                  before_existing = False, refresh = True):
         """Creates a new patch
         """
         if self.__patch_applied(name) or self.__patch_unapplied(name):
@@ -741,8 +753,8 @@ class Series:
         else:
             append_string(self.__applied_file, patch.get_name())
             self.__set_current(name)
-
-            self.refresh_patch(cache_update = False, log = 'new')
+            if refresh:
+                self.refresh_patch(cache_update = False, log = 'new')
 
     def delete_patch(self, name):
         """Deletes a patch