git-squash-branch: handle empty squashes

Error out of the current tree is dirty (previously the dirty
content would be incorporated silently into the newly
squashed branch!).

Review URL: https://codereview.chromium.org/1064933004

git-svn-id: svn://svn.chromium.org/chrome/trunk/tools/depot_tools@294744 0039d316-1c4b-4281-b951-d872f2087c98
diff --git a/git_cl.py b/git_cl.py
index 4eee3f1..65c7fc7 100755
--- a/git_cl.py
+++ b/git_cl.py
@@ -201,20 +201,6 @@
   parser.parse_args = Parse
 
 
-def is_dirty_git_tree(cmd):
-  # Make sure index is up-to-date before running diff-index.
-  RunGit(['update-index', '--refresh', '-q'], error_ok=True)
-  dirty = RunGit(['diff-index', '--name-status', 'HEAD'])
-  if dirty:
-    print 'Cannot %s with a dirty tree. You must commit locally first.' % cmd
-    print 'Uncommitted files: (git diff-index --name-status HEAD)'
-    print dirty[:4096]
-    if len(dirty) > 4096:
-      print '... (run "git diff-index --name-status HEAD" to see full output).'
-    return True
-  return False
-
-
 def MatchSvnGlob(url, base_url, glob_spec, allow_wildcards):
   """Return the corresponding git ref if |base_url| together with |glob_spec|
   matches the full |url|.
@@ -1655,7 +1641,7 @@
                     help='Run checks even if tree is dirty')
   (options, args) = parser.parse_args(args)
 
-  if not options.force and is_dirty_git_tree('presubmit'):
+  if not options.force and git_common.is_dirty_git_tree('presubmit'):
     print 'use --force to check even if tree is dirty.'
     return 1
 
@@ -2032,7 +2018,7 @@
   add_git_similarity(parser)
   (options, args) = parser.parse_args(args)
 
-  if is_dirty_git_tree('upload'):
+  if git_common.is_dirty_git_tree('upload'):
     return 1
 
   options.reviewers = cleanup_list(options.reviewers)
@@ -2162,7 +2148,7 @@
   base_branch = args[0]
   base_has_submodules = IsSubmoduleMergeCommit(base_branch)
 
-  if is_dirty_git_tree(cmd):
+  if git_common.is_dirty_git_tree(cmd):
     return 1
 
   # This rev-list syntax means "show all commits not in my branch that
@@ -2538,7 +2524,7 @@
   issue_arg = args[0]
 
   # We don't want uncommitted changes mixed up with the patch.
-  if is_dirty_git_tree('patch'):
+  if git_common.is_dirty_git_tree('patch'):
     return 1
 
   # TODO(maruel): Use apply_issue.py
@@ -2558,7 +2544,7 @@
 def PatchIssue(issue_arg, reject, nocommit, directory):
   # There's a "reset --hard" when failing to apply the patch. In order
   # not to destroy users' data, make sure the tree is not dirty here.
-  assert(not is_dirty_git_tree('apply'))
+  assert(not git_common.is_dirty_git_tree('apply'))
 
   if type(issue_arg) is int or issue_arg.isdigit():
     # Input is an issue id.  Figure out the URL.
@@ -2921,7 +2907,7 @@
   # Staged changes would be committed along with the patch from last
   # upload, hence counted toward the "last upload" side in the final
   # diff output, and this is not what we want.
-  if is_dirty_git_tree('diff'):
+  if git_common.is_dirty_git_tree('diff'):
     return 1
 
   cl = Changelist()
diff --git a/git_common.py b/git_common.py
index a18f59f..6b21050 100644
--- a/git_common.py
+++ b/git_common.py
@@ -602,6 +602,24 @@
   run('config', '--' + scope, option, value)
 
 
+def get_dirty_files():
+  # Make sure index is up-to-date before running diff-index.
+  run_with_retcode('update-index', '--refresh', '-q')
+  return run('diff-index', '--name-status', 'HEAD')
+
+
+def is_dirty_git_tree(cmd):
+  dirty = get_dirty_files()
+  if dirty:
+    print 'Cannot %s with a dirty tree. You must commit locally first.' % cmd
+    print 'Uncommitted files: (git diff-index --name-status HEAD)'
+    print dirty[:4096]
+    if len(dirty) > 4096: # pragma: no cover
+      print '... (run "git diff-index --name-status HEAD" to see full output).'
+    return True
+  return False
+
+
 def squash_current_branch(header=None, merge_base=None):
   header = header or 'git squash commit.'
   merge_base = merge_base or get_or_create_merge_base(current_branch())
@@ -610,7 +628,14 @@
     log_msg += '\n'
   log_msg += run('log', '--reverse', '--format=%H%n%B', '%s..HEAD' % merge_base)
   run('reset', '--soft', merge_base)
+
+  if not get_dirty_files():
+    # Sometimes the squash can result in the same tree, meaning that there is
+    # nothing to commit at this point.
+    print 'Nothing to commit; squashed branch is empty'
+    return False
   run('commit', '-a', '-F', '-', indata=log_msg)
+  return True
 
 
 def tags(*args):
diff --git a/git_squash_branch.py b/git_squash_branch.py
index 83acfa7..33366d2 100755
--- a/git_squash_branch.py
+++ b/git_squash_branch.py
@@ -6,7 +6,7 @@
 import argparse
 import sys
 
-from git_common import squash_current_branch
+import git_common
 
 def main(args):
   parser = argparse.ArgumentParser()
@@ -14,7 +14,9 @@
       '-m', '--message', metavar='<msg>', default='git squash commit.',
       help='Use the given <msg> as the first line of the commit message.')
   opts = parser.parse_args(args)
-  squash_current_branch(opts.message)
+  if git_common.is_dirty_git_tree('squash-branch'):
+    return 1
+  git_common.squash_current_branch(opts.message)
   return 0
 
 
diff --git a/testing_support/git_test_utils.py b/testing_support/git_test_utils.py
index 10e54f5..fbe7c73 100644
--- a/testing_support/git_test_utils.py
+++ b/testing_support/git_test_utils.py
@@ -349,7 +349,6 @@
         env['GIT_%s' % singleton] = str(val)
     return env
 
-
   def git(self, *args, **kwargs):
     """Runs a git command specified by |args| in this repo."""
     assert self.repo_path is not None
diff --git a/tests/git_cl_test.py b/tests/git_cl_test.py
index 31eb972..71e8c60 100755
--- a/tests/git_cl_test.py
+++ b/tests/git_cl_test.py
@@ -75,7 +75,7 @@
     self.mock(subprocess2, 'check_call', self._mocked_call)
     self.mock(subprocess2, 'check_output', self._mocked_call)
     self.mock(subprocess2, 'communicate', self._mocked_call)
-    self.mock(subprocess2, 'Popen', self._mocked_call)
+    self.mock(git_common, 'is_dirty_git_tree', lambda x: False)
     self.mock(git_common, 'get_or_create_merge_base',
               lambda *a: (
                   self._mocked_call(['get_or_create_merge_base']+list(a))))
@@ -156,8 +156,6 @@
       similarity_call,
       ((['git', 'symbolic-ref', 'HEAD'],), 'master'),
       find_copies_call,
-      ((['git', 'update-index', '--refresh', '-q'],), ''),
-      ((['git', 'diff-index', '--name-status', 'HEAD'],), ''),
       ((['git', 'symbolic-ref', 'HEAD'],), 'master'),
       ((['git', 'config', 'branch.master.merge'],), 'master'),
       ((['git', 'config', 'branch.master.remote'],), 'origin'),
@@ -280,8 +278,6 @@
       ((['git', 'rev-list', '--merges',
          '--grep=^SVN changes up to revision [0-9]*$',
          'refs/remotes/origin/master^!'],), ''),
-      ((['git', 'update-index', '--refresh', '-q'],), ''),
-      ((['git', 'diff-index', '--name-status', 'HEAD'],), ''),
       ((['git', 'rev-list', '^refs/heads/working',
          'refs/remotes/origin/master'],),
          ''),
@@ -544,8 +540,6 @@
         ((['git', 'symbolic-ref', 'HEAD'],), 'master'),
         ((['git', 'config', '--int', '--get',
           'branch.master.git-find-copies'],), ''),
-        ((['git', 'update-index', '--refresh', '-q'],), ''),
-        ((['git', 'diff-index', '--name-status', 'HEAD'],), ''),
         ((['git', 'symbolic-ref', 'HEAD'],), 'master'),
         ((['git', 'config', 'branch.master.merge'],), 'master'),
         ((['git', 'config', 'branch.master.remote'],), 'origin'),
diff --git a/tests/git_common_test.py b/tests/git_common_test.py
index ee44eb3..029c823 100755
--- a/tests/git_common_test.py
+++ b/tests/git_common_test.py
@@ -561,10 +561,17 @@
       ('root_A', 'root_X'),
     ])
 
+  def testIsGitTreeDirty(self):
+    self.assertEquals(False, self.repo.run(self.gc.is_dirty_git_tree, 'foo'))
+    self.repo.open('test.file', 'w').write('test data')
+    self.repo.git('add', 'test.file')
+    self.assertEquals(True, self.repo.run(self.gc.is_dirty_git_tree, 'foo'))
+
   def testSquashBranch(self):
     self.repo.git('checkout', 'branch_K')
 
-    self.repo.run(self.gc.squash_current_branch, 'cool message')
+    self.assertEquals(True, self.repo.run(self.gc.squash_current_branch,
+                                          'cool message'))
 
     lines = ['cool message', '']
     for l in 'HIJK':
@@ -580,6 +587,14 @@
       'K'
     )
 
+  def testSquashBranchEmpty(self):
+    self.repo.git('checkout', 'branch_K')
+    self.repo.git('checkout', 'branch_G', '.')
+    self.repo.git('commit', '-m', 'revert all changes no branch')
+    # Should return False since the quash would result in an empty commit
+    stdout = self.repo.capture_stdio(self.gc.squash_current_branch)[0]
+    self.assertEquals(stdout, 'Nothing to commit; squashed branch is empty\n')
+
   def testRebase(self):
     self.assertSchema("""
     A B C D E F G