├── src └── gitsweep │ ├── __init__.py │ ├── scripts │ ├── __init__.py │ └── test.py │ ├── tests │ ├── __init__.py │ ├── test_deleter.py │ ├── test_inspector.py │ ├── testcases.py │ └── test_cli.py │ ├── entrypoints.py │ ├── deleter.py │ ├── inspector.py │ ├── base.py │ └── cli.py ├── MANIFEST.in ├── .gitignore ├── tox.ini ├── NEWS.txt ├── LICENSE.txt ├── setup.py ├── bootstrap.py └── README.rst /src/gitsweep/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/gitsweep/scripts/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /src/gitsweep/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README.rst 2 | include NEWS.txt 3 | -------------------------------------------------------------------------------- /src/gitsweep/scripts/test.py: -------------------------------------------------------------------------------- 1 | from gitsweep.entrypoints import test 2 | 3 | __test__ = False 4 | 5 | if __name__ == '__main__': 6 | test() 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | 3 | .installed.cfg 4 | bin 5 | develop-eggs 6 | 7 | *.egg-info 8 | 9 | tmp 10 | build 11 | dist 12 | .jig 13 | eggs 14 | parts 15 | .tox 16 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [testenv] 2 | deps = 3 | nose 4 | mock 5 | commands = python -m gitsweep.scripts.test 6 | 7 | [testenv:2.6] 8 | basepython = python2.6 9 | 10 | [testenv:2.7] 11 | basepython = python2.7 12 | -------------------------------------------------------------------------------- /src/gitsweep/entrypoints.py: -------------------------------------------------------------------------------- 1 | def main(): 2 | """ 3 | Command-line interface. 4 | """ 5 | import sys 6 | 7 | from gitsweep.cli import CommandLine 8 | 9 | CommandLine(sys.argv).run() 10 | 11 | 12 | def test(): 13 | """ 14 | Run git-sweep's test suite. 15 | """ 16 | import nose 17 | 18 | import sys 19 | 20 | nose.main(argv=['nose'] + sys.argv[1:]) 21 | -------------------------------------------------------------------------------- /NEWS.txt: -------------------------------------------------------------------------------- 1 | News 2 | ==== 3 | 4 | 0.1.1 5 | 6 | *Release date: March 28, 2012* 7 | 8 | * Fix issue #1 which makes the git-sweep help menus more useful 9 | * Fix a minor grammar issue in the help 10 | * Fix issue #2 which dropped extra options when telling you to use 11 | cleanup 12 | * Added a --force option to skip confirmation prompt 13 | 14 | 0.1.0 15 | ----- 16 | 17 | *Release date: n/a* 18 | 19 | * Initial release 20 | -------------------------------------------------------------------------------- /src/gitsweep/deleter.py: -------------------------------------------------------------------------------- 1 | from .base import BaseOperation 2 | 3 | 4 | class Deleter(BaseOperation): 5 | 6 | """ 7 | Removes remote branches from the remote. 8 | 9 | """ 10 | def remove_remote_refs(self, refs): 11 | """ 12 | Removes the remote refs from the remote. 13 | 14 | ``refs`` should be a lit of ``git.RemoteRefs`` objects. 15 | """ 16 | origin = self._origin 17 | 18 | pushes = [] 19 | for ref in refs: 20 | pushes.append(origin.push(':{0}'.format(ref.remote_head))) 21 | 22 | return pushes 23 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2012 Arc90, Inc. 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a 4 | copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included 12 | in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS 15 | OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 17 | IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY 18 | CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, 19 | TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE 20 | SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /src/gitsweep/inspector.py: -------------------------------------------------------------------------------- 1 | from git import Git 2 | 3 | from .base import BaseOperation 4 | 5 | 6 | class Inspector(BaseOperation): 7 | 8 | """ 9 | Used to introspect a Git repository. 10 | 11 | """ 12 | def merged_refs(self, skip=[]): 13 | """ 14 | Returns a list of remote refs that have been merged into the master 15 | branch. 16 | 17 | The "master" branch may have a different name than master. The value of 18 | ``self.master_name`` is used to determine what this name is. 19 | """ 20 | origin = self._origin 21 | 22 | master = self._master_ref(origin) 23 | refs = self._filtered_remotes( 24 | origin, skip=['HEAD', self.master_branch] + skip) 25 | merged = [] 26 | 27 | for ref in refs: 28 | upstream = '{origin}/{master}'.format( 29 | origin=origin.name, master=master.remote_head) 30 | head = '{origin}/{branch}'.format( 31 | origin=origin.name, branch=ref.remote_head) 32 | cmd = Git(self.repo.working_dir) 33 | # Drop to the git binary to do this, it's just easier to work with 34 | # at this level. 35 | (retcode, stdout, stderr) = cmd.execute( 36 | ['git', 'cherry', upstream, head], 37 | with_extended_output=True, with_exceptions=False) 38 | if retcode == 0 and not stdout: 39 | # This means there are no commits in the branch that are not 40 | # also in the master branch. This is ready to be deleted. 41 | merged.append(ref) 42 | 43 | return merged 44 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import os 3 | from setuptools import setup, find_packages 4 | 5 | here = os.path.abspath(os.path.dirname(__file__)) 6 | README = open(os.path.join(here, 'README.rst')).read() 7 | NEWS = open(os.path.join(here, 'NEWS.txt')).read() 8 | 9 | version = '0.1.1' 10 | 11 | install_requires = [ 12 | 'GitPython>=0.3.2RC1'] 13 | 14 | # Add argparse if less than Python 2.7 15 | if sys.version_info[0] <= 2 and sys.version_info[1] < 7: 16 | install_requires.append('argparse>=1.2.1') 17 | 18 | setup(name='git-sweep', 19 | version=version, 20 | description="Clean up branches from your Git remotes", 21 | long_description=README + '\n\n' + NEWS, 22 | classifiers=[ 23 | 'Development Status :: 4 - Beta', 24 | 'Environment :: Console', 25 | 'License :: OSI Approved :: MIT License', 26 | 'Intended Audience :: Developers', 27 | 'Natural Language :: English', 28 | 'Operating System :: POSIX', 29 | 'Programming Language :: Python :: 2.6', 30 | 'Programming Language :: Python :: 2.7', 31 | 'Topic :: Software Development :: Quality Assurance', 32 | 'Topic :: Software Development :: Version Control', 33 | 'Topic :: Text Processing' 34 | ], 35 | keywords='git maintenance branches', 36 | author='Arc90, Inc.', 37 | author_email='', 38 | url='http://arc90.com', 39 | license='MIT', 40 | packages=find_packages('src'), 41 | package_dir = {'': 'src'}, 42 | include_package_data=True, 43 | zip_safe=False, 44 | install_requires=install_requires, 45 | entry_points={ 46 | 'console_scripts': 47 | ['git-sweep=gitsweep.entrypoints:main'] 48 | } 49 | ) 50 | -------------------------------------------------------------------------------- /src/gitsweep/base.py: -------------------------------------------------------------------------------- 1 | class MissingRemote(Exception): 2 | 3 | """ 4 | Raise when a remote by name is not found. 5 | 6 | """ 7 | pass 8 | 9 | 10 | class MissingMasterBranch(Exception): 11 | 12 | """ 13 | Raise when the "master" branch cannot be located. 14 | 15 | """ 16 | pass 17 | 18 | 19 | class BaseOperation(object): 20 | 21 | """ 22 | Base class for all Git-related operations. 23 | 24 | """ 25 | def __init__(self, repo, remote_name='origin', master_branch='master'): 26 | self.repo = repo 27 | self.remote_name = remote_name 28 | self.master_branch = master_branch 29 | 30 | def _filtered_remotes(self, origin, skip=[]): 31 | """ 32 | Returns a list of remote refs, skipping ones you don't need. 33 | 34 | If ``skip`` is empty, it will default to ``['HEAD', 35 | self.master_branch]``. 36 | """ 37 | if not skip: 38 | skip = ['HEAD', self.master_branch] 39 | 40 | refs = [i for i in origin.refs if not i.remote_head in skip] 41 | 42 | return refs 43 | 44 | def _master_ref(self, origin): 45 | """ 46 | Finds the master ref object that matches master branch. 47 | """ 48 | for ref in origin.refs: 49 | if ref.remote_head == self.master_branch: 50 | return ref 51 | 52 | raise MissingMasterBranch( 53 | 'Could not find ref for {0}'.format(self.master_branch)) 54 | 55 | @property 56 | def _origin(self): 57 | """ 58 | Gets the remote that references origin by name self.origin_name. 59 | """ 60 | origin = None 61 | 62 | for remote in self.repo.remotes: 63 | if remote.name == self.remote_name: 64 | origin = remote 65 | 66 | if not origin: 67 | raise MissingRemote('Could not find the remote named {0}'.format( 68 | self.remote_name)) 69 | 70 | return origin 71 | -------------------------------------------------------------------------------- /src/gitsweep/tests/test_deleter.py: -------------------------------------------------------------------------------- 1 | from gitsweep.tests.testcases import (GitSweepTestCase, InspectorTestCase, 2 | DeleterTestCase) 3 | 4 | 5 | class TestDeleter(GitSweepTestCase, InspectorTestCase, DeleterTestCase): 6 | 7 | """ 8 | Can delete remote refs from a remote. 9 | 10 | """ 11 | def setUp(self): 12 | super(TestDeleter, self).setUp() 13 | 14 | for i in range(1, 6): 15 | self.command('git checkout -b branch{0}'.format(i)) 16 | self.make_commit() 17 | self.command('git checkout master') 18 | self.make_commit() 19 | self.command('git merge branch{0}'.format(i)) 20 | 21 | def test_will_delete_merged_from_clone(self): 22 | """ 23 | Given a list of refs, will delete them from cloned repo. 24 | 25 | This test looks at our cloned repository, the one which is setup to 26 | track the remote and makes sure that the changes occur on it as 27 | expected. 28 | """ 29 | clone = self.remote.remotes[0] 30 | 31 | # Grab all the remote branches 32 | before = [i.remote_head for i in clone.refs] 33 | # We should have 5 branches plus HEAD and master 34 | self.assertEqual(7, len(before)) 35 | 36 | # Delete from the remote through the clone 37 | pushes = self.deleter.remove_remote_refs( 38 | self.merged_refs(refobjs=True)) 39 | 40 | # Make sure it removed the expected number 41 | self.assertEqual(5, len(pushes)) 42 | 43 | # Grab all the remote branches again 44 | after = [i.remote_head for i in clone.refs] 45 | after.sort() 46 | 47 | # We should be down to 2, HEAD and master 48 | self.assertEqual(['HEAD', 'master'], after) 49 | 50 | def test_will_delete_merged_on_remote(self): 51 | """ 52 | With the list of refs, will delete these from the remote. 53 | 54 | This test makes assertion against the remote, not the clone repository. 55 | We are testing to see if the interactions in the cloned repo are pushed 56 | through to the remote. 57 | 58 | Note that accessing the repository directly does not include the 59 | symbolic reference of HEAD. 60 | """ 61 | remote = self.repo 62 | 63 | # Get a list of branches on this remote 64 | before = [i.name for i in remote.refs] 65 | # Should be 5 branches + master 66 | self.assertEqual(6, len(before)) 67 | 68 | # Delete through the clone which pushes to this remote 69 | pushes = self.deleter.remove_remote_refs( 70 | self.merged_refs(refobjs=True)) 71 | 72 | # Make sure it removed the expected number 73 | self.assertEqual(5, len(pushes)) 74 | 75 | # Grab again 76 | after = [i.name for i in remote.refs] 77 | # Should be down to just master 78 | self.assertEqual(['master'], after) 79 | -------------------------------------------------------------------------------- /bootstrap.py: -------------------------------------------------------------------------------- 1 | ############################################################################## 2 | # 3 | # Copyright (c) 2006 Zope Corporation and Contributors. 4 | # All Rights Reserved. 5 | # 6 | # This software is subject to the provisions of the Zope Public License, 7 | # Version 2.1 (ZPL). A copy of the ZPL should accompany this distribution. 8 | # THIS SOFTWARE IS PROVIDED "AS IS" AND ANY AND ALL EXPRESS OR IMPLIED 9 | # WARRANTIES ARE DISCLAIMED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 10 | # WARRANTIES OF TITLE, MERCHANTABILITY, AGAINST INFRINGEMENT, AND FITNESS 11 | # FOR A PARTICULAR PURPOSE. 12 | # 13 | ############################################################################## 14 | """Bootstrap a buildout-based project 15 | 16 | Simply run this script in a directory containing a buildout.cfg. 17 | The script accepts buildout command-line options, so you can 18 | use the -c option to specify an alternate configuration file. 19 | 20 | $Id: bootstrap.py 102545 2009-08-06 14:49:47Z chrisw $ 21 | """ 22 | 23 | import os, shutil, sys, tempfile, urllib2 24 | from optparse import OptionParser 25 | 26 | tmpeggs = tempfile.mkdtemp() 27 | 28 | is_jython = sys.platform.startswith('java') 29 | 30 | # parsing arguments 31 | parser = OptionParser() 32 | parser.add_option("-v", "--version", dest="version", 33 | help="use a specific zc.buildout version") 34 | parser.add_option("-d", "--distribute", 35 | action="store_true", dest="distribute", default=True, 36 | help="Use Disribute rather than Setuptools.") 37 | 38 | options, args = parser.parse_args() 39 | 40 | if options.version is not None: 41 | VERSION = '==%s' % options.version 42 | else: 43 | VERSION = '' 44 | 45 | USE_DISTRIBUTE = options.distribute 46 | args = args + ['bootstrap'] 47 | 48 | to_reload = False 49 | try: 50 | import pkg_resources 51 | if not hasattr(pkg_resources, '_distribute'): 52 | to_reload = True 53 | raise ImportError 54 | except ImportError: 55 | ez = {} 56 | if USE_DISTRIBUTE: 57 | exec urllib2.urlopen('http://python-distribute.org/distribute_setup.py' 58 | ).read() in ez 59 | ez['use_setuptools'](to_dir=tmpeggs, download_delay=0, no_fake=True) 60 | else: 61 | exec urllib2.urlopen('http://peak.telecommunity.com/dist/ez_setup.py' 62 | ).read() in ez 63 | ez['use_setuptools'](to_dir=tmpeggs, download_delay=0) 64 | 65 | if to_reload: 66 | reload(pkg_resources) 67 | else: 68 | import pkg_resources 69 | 70 | if sys.platform == 'win32': 71 | def quote(c): 72 | if ' ' in c: 73 | return '"%s"' % c # work around spawn lamosity on windows 74 | else: 75 | return c 76 | else: 77 | def quote (c): 78 | return c 79 | 80 | cmd = 'from setuptools.command.easy_install import main; main()' 81 | ws = pkg_resources.working_set 82 | 83 | if USE_DISTRIBUTE: 84 | requirement = 'distribute' 85 | else: 86 | requirement = 'setuptools' 87 | 88 | if is_jython: 89 | import subprocess 90 | 91 | assert subprocess.Popen([sys.executable] + ['-c', quote(cmd), '-mqNxd', 92 | quote(tmpeggs), 'zc.buildout' + VERSION], 93 | env=dict(os.environ, 94 | PYTHONPATH= 95 | ws.find(pkg_resources.Requirement.parse(requirement)).location 96 | ), 97 | ).wait() == 0 98 | 99 | else: 100 | assert os.spawnle( 101 | os.P_WAIT, sys.executable, quote (sys.executable), 102 | '-c', quote (cmd), '-mqNxd', quote (tmpeggs), 'zc.buildout' + VERSION, 103 | dict(os.environ, 104 | PYTHONPATH= 105 | ws.find(pkg_resources.Requirement.parse(requirement)).location 106 | ), 107 | ) == 0 108 | 109 | ws.add_entry(tmpeggs) 110 | ws.require('zc.buildout' + VERSION) 111 | import zc.buildout.buildout 112 | zc.buildout.buildout.main(args) 113 | shutil.rmtree(tmpeggs) 114 | -------------------------------------------------------------------------------- /src/gitsweep/tests/test_inspector.py: -------------------------------------------------------------------------------- 1 | from gitsweep.tests.testcases import GitSweepTestCase, InspectorTestCase 2 | 3 | 4 | class TestInspector(GitSweepTestCase, InspectorTestCase): 5 | 6 | """ 7 | Inspector can find merged branches and present them for cleaning. 8 | 9 | """ 10 | def test_no_branches(self): 11 | """ 12 | If only the master branch is present, nothing to clean. 13 | """ 14 | self.assertEqual([], self.inspector.merged_refs()) 15 | 16 | def test_filtered_refs(self): 17 | """ 18 | Will filter references and not return HEAD and master. 19 | """ 20 | for i in range(1, 4): 21 | self.command('git checkout -b branch{0}'.format(i)) 22 | self.command('git checkout master') 23 | 24 | refs = self.inspector._filtered_remotes( 25 | self.inspector.repo.remotes[0]) 26 | 27 | self.assertEqual(['branch1', 'branch2', 'branch3'], 28 | [i.remote_head for i in refs]) 29 | 30 | def test_one_branch_no_commits(self): 31 | """ 32 | There is one branch on the remote that is the same as master. 33 | """ 34 | self.command('git checkout -b branch1') 35 | self.command('git checkout master') 36 | 37 | # Since this is the same as master, it should show up as merged 38 | self.assertEqual(['branch1'], self.merged_refs()) 39 | 40 | def test_one_branch_one_commit(self): 41 | """ 42 | A commit has been made in the branch so it's not safe to remove. 43 | """ 44 | self.command('git checkout -b branch1') 45 | 46 | self.make_commit() 47 | 48 | self.command('git checkout master') 49 | 50 | # Since there is a commit in branch1, it's not safe to remove it 51 | self.assertEqual([], self.merged_refs()) 52 | 53 | def test_one_merged_branch(self): 54 | """ 55 | If a branch has been merged, it's safe to delete it. 56 | """ 57 | self.command('git checkout -b branch1') 58 | 59 | self.make_commit() 60 | 61 | self.command('git checkout master') 62 | 63 | self.command('git merge branch1') 64 | 65 | self.assertEqual(['branch1'], self.merged_refs()) 66 | 67 | def test_commit_in_master(self): 68 | """ 69 | Commits in master not in the branch do not block it for deletion. 70 | """ 71 | self.command('git checkout -b branch1') 72 | 73 | self.make_commit() 74 | 75 | self.command('git checkout master') 76 | 77 | self.make_commit() 78 | 79 | self.command('git merge branch1') 80 | 81 | self.assertEqual(['branch1'], self.merged_refs()) 82 | 83 | def test_large_set_of_changes(self): 84 | r""" 85 | A long list of changes is properly marked for deletion. 86 | 87 | The branch history for this will look like this: 88 | 89 | :: 90 | 91 | |\ 92 | | * 08d07e1 Adding 4e510716 93 | * | 056abb2 Adding a0dfc9fb 94 | |/ 95 | * 9d77626 Merge branch 'branch4' 96 | |\ 97 | | * 956b3f9 Adding e16ec279 98 | * | d11315e Adding 9571d55d 99 | |/ 100 | * f100932 Merge branch 'branch3' 101 | |\ 102 | | * c641899 Adding 9b33164f 103 | * | 17c1e35 Adding b56c43be 104 | |/ 105 | * c83c8d3 Merge branch 'branch2' 106 | |\ 107 | | * bead4e5 Adding 31a13fa4 108 | * | 5a88ec3 Adding b6a45f21 109 | |/ 110 | * f34643d Merge branch 'branch1' 111 | |\ 112 | | * 8e110c4 Adding 11948eb5 113 | * | 4c94394 Adding db29f4aa 114 | |/ 115 | """ 116 | for i in range(1, 6): 117 | self.command('git checkout -b branch{0}'.format(i)) 118 | self.make_commit() 119 | self.command('git checkout master') 120 | self.make_commit() 121 | self.command('git merge branch{0}'.format(i)) 122 | 123 | self.assertEqual( 124 | ['branch1', 'branch2', 'branch3', 'branch4', 'branch5'], 125 | self.merged_refs()) 126 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | git-sweep 2 | ========= 3 | 4 | A command-line tool that helps you clean up Git branches that have been merged 5 | into master. 6 | 7 | One of the best features of Git is cheap branches. There are existing branching 8 | models like `GitHub Flow`_ and Vincent Driessen's `git-flow`_ that describe 9 | methods for using this feature. 10 | 11 | The problem 12 | ----------- 13 | 14 | Your ``master`` branch is typically where all your code lands. All features 15 | branches are meant to be short-lived and merged into ``master`` once they are 16 | completed. 17 | 18 | As time marches on, you can build up **a long list of branches that are no 19 | longer needed**. They've been merged into ``master``, what do we do with them 20 | now? 21 | 22 | The answer 23 | ---------- 24 | 25 | Using ``git-sweep`` you can **safely remove remote branches that have been 26 | merged into master**. 27 | 28 | To install it run: 29 | 30 | :: 31 | 32 | pip install git-sweep || easy_install git-sweep 33 | 34 | Try it for yourself (safely) 35 | ---------------------------- 36 | 37 | To see a list of branches that git-sweep detects are merged into your master branch: 38 | 39 | You need to have your Git repository as your current working directory. 40 | 41 | :: 42 | 43 | $ cd myrepo 44 | 45 | The ``preview`` command doesn't make any changes to your repo. 46 | 47 | :: 48 | 49 | $ git-sweep preview 50 | Fetching from the remote 51 | These branches have been merged into master: 52 | 53 | branch1 54 | branch2 55 | branch3 56 | branch4 57 | branch5 58 | 59 | To delete them, run again with `git-sweep cleanup` 60 | 61 | If you are happy with the list, you can run the command that deletes these 62 | branches from the remote, ``cleanup``: 63 | 64 | :: 65 | 66 | $ git-sweep cleanup 67 | Fetching from the remote 68 | These branches have been merged into master: 69 | 70 | branch1 71 | branch2 72 | branch3 73 | branch4 74 | branch5 75 | 76 | Delete these branches? (y/n) y 77 | deleting branch1 (done) 78 | deleting branch2 (done) 79 | deleting branch3 (done) 80 | deleting branch4 (done) 81 | deleting branch5 (done) 82 | 83 | All done! 84 | 85 | Tell everyone to run `git fetch --prune` to sync with this remote. 86 | (you don't have to, yours is synced) 87 | 88 | *Note: this can take a little time, it's talking over the tubes to the remote.* 89 | 90 | You can also give it a different name for your remote and master branches. 91 | 92 | :: 93 | 94 | $ git-sweep preview --master=develop --origin=github 95 | ... 96 | 97 | Tell it to skip the ``git fetch`` that it does by default. 98 | 99 | :: 100 | 101 | $ git-sweep preview --nofetch 102 | These branches have been merged into master: 103 | 104 | branch1 105 | 106 | To delete them, run again with `git-sweep cleanup --nofetch` 107 | 108 | Make it skip certain branches. 109 | 110 | :: 111 | 112 | $ git-sweep preview --skip=develop 113 | Fetching from the remote 114 | These branches have been merged into master: 115 | 116 | important-upgrade 117 | upgrade-libs 118 | derp-removal 119 | 120 | To delete them, run again with `git-sweep cleanup --skip=develop` 121 | 122 | Once git-sweep finds the branches, you'll be asked to confirm that you wish to 123 | delete them. 124 | 125 | :: 126 | 127 | Delete these branches? (y/n) 128 | 129 | You can use the ``--force`` option to bypass this and start deleting 130 | immediately. 131 | 132 | :: 133 | 134 | $ git-sweep cleanup --skip=develop --force 135 | Fetching from the remote 136 | These branches have been merged into master: 137 | 138 | important-upgrade 139 | upgrade-libs 140 | derp-removal 141 | 142 | deleting important-upgrade (done) 143 | deleting upgrade-libs (done) 144 | deleting derp-removal (done) 145 | 146 | All done! 147 | 148 | Tell everyone to run `git fetch --prune` to sync with this remote. 149 | (you don't have to, yours is synced) 150 | 151 | 152 | Deleting local branches 153 | ----------- 154 | 155 | You can also clean up local branches by using simple hack: 156 | 157 | :: 158 | 159 | $ cd myrepo 160 | $ git remote add local $(pwd) 161 | $ git-sweep cleanup --origin=local 162 | 163 | 164 | Development 165 | ----------- 166 | 167 | git-sweep uses `git-flow`_ for development and release cylces. If you want to 168 | hack on this with us, fork the project and put a pull request into the 169 | ``develop`` branch when you get done. 170 | 171 | To run the tests, bootstrap Buildout and run this command: 172 | 173 | :: 174 | 175 | $ git clone http://github.com/arc90/git-sweep.git 176 | $ cd git-sweep 177 | $ python2.7 bootstrap.py 178 | ... 179 | $ ./bin/buildout 180 | ... 181 | $ ./bin/test 182 | 183 | We also use Tox_. It will run the tests for Python 2.6 and 2.7. 184 | 185 | :: 186 | 187 | $ ./bin/tox 188 | 189 | Requirements 190 | ------------ 191 | 192 | * Git >= 1.7 193 | * Python >= 2.6 194 | 195 | License 196 | ------- 197 | 198 | Friendly neighborhood MIT license. 199 | 200 | .. _GitHub Flow: http://scottchacon.com/2011/08/31/github-flow.html 201 | .. _git-flow: http://nvie.com/posts/a-successful-git-branching-model/ 202 | .. _Tox: http://pypi.python.org/pypi/tox 203 | -------------------------------------------------------------------------------- /src/gitsweep/cli.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from os import getcwd 3 | from argparse import ArgumentParser 4 | from textwrap import dedent 5 | 6 | from git import Repo, InvalidGitRepositoryError 7 | 8 | from gitsweep.inspector import Inspector 9 | from gitsweep.deleter import Deleter 10 | 11 | 12 | class CommandLine(object): 13 | 14 | """ 15 | Main interface to the command-line for running git-sweep. 16 | 17 | """ 18 | parser = ArgumentParser( 19 | description='Clean up your Git remote branches.', 20 | usage='git-sweep [-h]', 21 | ) 22 | 23 | _sub_parsers = parser.add_subparsers(title='action', 24 | description='Preview changes or perform clean up') 25 | 26 | _origin_kwargs = { 27 | 'help': 'The name of the remote you wish to clean up', 28 | 'dest': 'origin', 29 | 'default': 'origin'} 30 | 31 | _master_kwargs = { 32 | 'help': 'The name of what you consider the master branch', 33 | 'dest': 'master', 34 | 'default': 'master'} 35 | 36 | _skip_kwargs = { 37 | 'help': 'Comma-separated list of branches to skip', 38 | 'dest': 'skips', 39 | 'default': ''} 40 | 41 | _no_fetch_kwargs = { 42 | 'help': 'Do not fetch from the remote', 43 | 'dest': 'fetch', 44 | 'action': 'store_false', 45 | 'default': True} 46 | 47 | _preview_usage = dedent(''' 48 | git-sweep preview [-h] [--nofetch] [--skip SKIPS] 49 | [--master MASTER] [--origin ORIGIN] 50 | '''.strip()) 51 | 52 | _preview = _sub_parsers.add_parser('preview', 53 | help='Preview the branches that will be deleted', 54 | usage=_preview_usage) 55 | _preview.add_argument('--origin', **_origin_kwargs) 56 | _preview.add_argument('--master', **_master_kwargs) 57 | _preview.add_argument('--nofetch', **_no_fetch_kwargs) 58 | _preview.add_argument('--skip', **_skip_kwargs) 59 | _preview.set_defaults(action='preview') 60 | 61 | _cleanup_usage = dedent(''' 62 | git-sweep cleanup [-h] [--nofetch] [--skip SKIPS] [--force] 63 | [--master MASTER] [--origin ORIGIN] 64 | '''.strip()) 65 | 66 | _cleanup = _sub_parsers.add_parser('cleanup', 67 | help='Delete merged branches from the remote', 68 | usage=_cleanup_usage) 69 | _cleanup.add_argument('--force', action='store_true', default=False, 70 | dest='force', help='Do not ask, cleanup immediately') 71 | _cleanup.add_argument('--origin', **_origin_kwargs) 72 | _cleanup.add_argument('--master', **_master_kwargs) 73 | _cleanup.add_argument('--nofetch', **_no_fetch_kwargs) 74 | _cleanup.add_argument('--skip', **_skip_kwargs) 75 | _cleanup.set_defaults(action='cleanup') 76 | 77 | def __init__(self, args): 78 | self.args = args[1:] 79 | 80 | def run(self): 81 | """ 82 | Runs git-sweep. 83 | """ 84 | try: 85 | if not self.args: 86 | self.parser.print_help() 87 | sys.exit(1) 88 | 89 | self._sweep() 90 | 91 | sys.exit(0) 92 | except InvalidGitRepositoryError: 93 | sys.stdout.write('This is not a Git repository\n') 94 | except Exception as e: 95 | sys.stdout.write(str(e) + '\n') 96 | 97 | sys.exit(1) 98 | 99 | def _sweep(self): 100 | """ 101 | Runs git-sweep. 102 | """ 103 | args = self.parser.parse_args(self.args) 104 | 105 | dry_run = True if args.action == 'preview' else False 106 | fetch = args.fetch 107 | skips = [i.strip() for i in args.skips.split(',')] 108 | 109 | # Is this a Git repository? 110 | repo = Repo(getcwd()) 111 | 112 | remote_name = args.origin 113 | 114 | # Fetch from the remote so that we have the latest commits 115 | if fetch: 116 | for remote in repo.remotes: 117 | if remote.name == remote_name: 118 | sys.stdout.write('Fetching from the remote\n') 119 | remote.fetch() 120 | 121 | master_branch = args.master 122 | 123 | # Find branches that could be merged 124 | inspector = Inspector(repo, remote_name=remote_name, 125 | master_branch=master_branch) 126 | ok_to_delete = inspector.merged_refs(skip=skips) 127 | 128 | if ok_to_delete: 129 | sys.stdout.write( 130 | 'These branches have been merged into {0}:\n\n'.format( 131 | master_branch)) 132 | else: 133 | sys.stdout.write('No remote branches are available for ' 134 | 'cleaning up\n') 135 | 136 | for ref in ok_to_delete: 137 | sys.stdout.write(' {0}\n'.format(ref.remote_head)) 138 | 139 | if not dry_run: 140 | deleter = Deleter(repo, remote_name=remote_name, 141 | master_branch=master_branch) 142 | 143 | if not args.force: 144 | sys.stdout.write('\nDelete these branches? (y/n) ') 145 | answer = raw_input() 146 | if args.force or answer.lower().startswith('y'): 147 | sys.stdout.write('\n') 148 | for ref in ok_to_delete: 149 | sys.stdout.write(' deleting {0}'.format(ref.remote_head)) 150 | deleter.remove_remote_refs([ref]) 151 | sys.stdout.write(' (done)\n') 152 | 153 | sys.stdout.write('\nAll done!\n') 154 | sys.stdout.write('\nTell everyone to run `git fetch --prune` ' 155 | 'to sync with this remote.\n') 156 | sys.stdout.write('(you don\'t have to, yours is synced)\n') 157 | else: 158 | sys.stdout.write('\nOK, aborting.\n') 159 | elif ok_to_delete: 160 | # Replace the first argument with cleanup 161 | sysv_copy = self.args[:] 162 | sysv_copy[0] = 'cleanup' 163 | command = 'git-sweep {0}'.format(' '.join(sysv_copy)) 164 | 165 | sys.stdout.write( 166 | '\nTo delete them, run again with `{0}`\n'.format(command)) 167 | -------------------------------------------------------------------------------- /src/gitsweep/tests/testcases.py: -------------------------------------------------------------------------------- 1 | import sys 2 | from os import chdir, getcwd 3 | from os.path import join, basename 4 | from tempfile import mkdtemp 5 | from unittest import TestCase 6 | from uuid import uuid4 as uuid 7 | from shutil import rmtree 8 | from shlex import split 9 | from contextlib import contextmanager, nested 10 | from textwrap import dedent 11 | 12 | from mock import patch 13 | from git import Repo 14 | from git.cmd import Git 15 | 16 | from gitsweep.inspector import Inspector 17 | from gitsweep.deleter import Deleter 18 | from gitsweep.cli import CommandLine 19 | 20 | 21 | @contextmanager 22 | def cwd_bounce(dir): 23 | """ 24 | Temporarily changes to a directory and changes back in the end. 25 | 26 | Where ``dir`` is the directory you wish to change to. When the context 27 | manager exits it will change back to the original working directory. 28 | 29 | Context manager will yield the original working directory and make that 30 | available to the context manager's assignment target. 31 | """ 32 | original_dir = getcwd() 33 | 34 | try: 35 | chdir(dir) 36 | 37 | yield original_dir 38 | finally: 39 | chdir(original_dir) 40 | 41 | 42 | class GitSweepTestCase(TestCase): 43 | 44 | """ 45 | Sets up a Git repository and provides some command to manipulate it. 46 | 47 | """ 48 | def setUp(self): 49 | """ 50 | Sets up the Git repository for testing. 51 | 52 | The following will be available after :py:method`setUp()` runs. 53 | 54 | self.repodir 55 | The absolute filename of the Git repository 56 | 57 | self.repo 58 | A ``git.Repo`` object for self.repodir 59 | 60 | This will create the root commit in the test repository automaticall. 61 | """ 62 | super(GitSweepTestCase, self).setUp() 63 | 64 | repodir = mkdtemp() 65 | 66 | self.repodir = repodir 67 | self.repo = Repo.init(repodir) 68 | 69 | rootcommit_filename = join(repodir, 'rootcommit') 70 | 71 | with open(rootcommit_filename, 'w') as fh: 72 | fh.write('') 73 | 74 | self.repo.index.add([basename(rootcommit_filename)]) 75 | self.repo.index.commit('Root commit') 76 | 77 | # Cache the remote per test 78 | self._remote = None 79 | 80 | # Keep track of cloned repositories that track self.repo 81 | self._clone_dirs = [] 82 | 83 | def tearDown(self): 84 | """ 85 | Remove any created repositories. 86 | """ 87 | rmtree(self.repodir) 88 | 89 | for clone in self._clone_dirs: 90 | rmtree(clone) 91 | 92 | def assertResults(self, expected, actual): 93 | """ 94 | Assert that output matches expected argument. 95 | """ 96 | expected = dedent(expected).strip() 97 | 98 | actual = actual.strip() 99 | 100 | self.assertEqual(expected, actual) 101 | 102 | def command(self, command): 103 | """ 104 | Runs the Git command in self.repo 105 | """ 106 | args = split(command) 107 | 108 | cmd = Git(self.repodir) 109 | 110 | cmd.execute(args) 111 | 112 | @property 113 | def remote(self): 114 | """ 115 | Clones the test case's repository and tracks it as a remote. 116 | 117 | Returns a ``git.Repo`` object. 118 | """ 119 | if not self._remote: 120 | clonedir = mkdtemp() 121 | self._clone_dirs.append(clonedir) 122 | 123 | self._remote = Repo.clone(self.repo, clonedir) 124 | 125 | # Update in case the remote has changed 126 | self._remote.remotes[0].pull() 127 | return self._remote 128 | 129 | def graph(self): 130 | """ 131 | Prints a graph of the git log. 132 | 133 | This is used for testing and debugging only. 134 | """ 135 | sys.stdout.write(Git(self.repodir).execute( 136 | ['git', 'log', '--graph', '--oneline'])) 137 | 138 | def make_commit(self): 139 | """ 140 | Makes a random commit in the current branch. 141 | """ 142 | fragment = uuid().hex[:8] 143 | filename = join(self.repodir, fragment) 144 | with open(filename, 'w') as fh: 145 | fh.write(uuid().hex) 146 | 147 | self.repo.index.add([basename(filename)]) 148 | self.repo.index.commit('Adding {0}'.format(basename(filename))) 149 | 150 | 151 | class InspectorTestCase(TestCase): 152 | 153 | """ 154 | Creates an Inspector object for testing. 155 | 156 | """ 157 | def setUp(self): 158 | super(InspectorTestCase, self).setUp() 159 | 160 | self._inspector = None 161 | 162 | @property 163 | def inspector(self): 164 | """ 165 | Return and optionally create an Inspector from self.remote. 166 | """ 167 | if not self._inspector: 168 | self._inspector = Inspector(self.remote) 169 | 170 | return self._inspector 171 | 172 | def merged_refs(self, refobjs=False): 173 | """ 174 | Get a list of branch names from merged refs from self.inspector. 175 | 176 | By default, it returns a list of branch names. You can return the 177 | actual ``git.RemoteRef`` objects by passing ``refobjs=True``. 178 | """ 179 | refs = self.inspector.merged_refs() 180 | 181 | if refobjs: 182 | return refs 183 | 184 | return [i.remote_head for i in refs] 185 | 186 | 187 | class DeleterTestCase(TestCase): 188 | 189 | """ 190 | Creates a Deleter object for testing. 191 | 192 | """ 193 | def setUp(self): 194 | super(DeleterTestCase, self).setUp() 195 | 196 | self._deleter = None 197 | 198 | @property 199 | def deleter(self): 200 | """ 201 | Return and optionally create a Deleter from self.remote. 202 | """ 203 | if not self._deleter: 204 | self._deleter = Deleter(self.remote) 205 | 206 | return self._deleter 207 | 208 | 209 | class CommandTestCase(GitSweepTestCase, InspectorTestCase, DeleterTestCase): 210 | 211 | """ 212 | Used to test the command-line interface. 213 | 214 | """ 215 | def setUp(self): 216 | super(CommandTestCase, self).setUp() 217 | 218 | self._commandline = None 219 | self._original_dir = getcwd() 220 | 221 | # Change the working directory to our clone 222 | chdir(self.remote.working_dir) 223 | 224 | def tearDown(self): 225 | """ 226 | Change back to the original directory. 227 | """ 228 | chdir(self._original_dir) 229 | 230 | @property 231 | def cli(self): 232 | """ 233 | Return and optionally create a CommandLine object. 234 | """ 235 | if not self._commandline: 236 | self._commandline = CommandLine([]) 237 | 238 | return self._commandline 239 | 240 | def gscommand(self, command): 241 | """ 242 | Runs the command with the given args. 243 | """ 244 | args = split(command) 245 | 246 | self.cli.args = args[1:] 247 | 248 | patches = ( 249 | patch.object(sys, 'stdout'), 250 | patch.object(sys, 'stderr')) 251 | 252 | with nested(*patches): 253 | stdout = sys.stdout 254 | stderr = sys.stderr 255 | try: 256 | self.cli.run() 257 | except SystemExit as se: 258 | pass 259 | 260 | stdout = ''.join([i[0][0] for i in stdout.write.call_args_list]) 261 | stderr = ''.join([i[0][0] for i in stderr.write.call_args_list]) 262 | 263 | return (se.code, stdout, stderr) 264 | -------------------------------------------------------------------------------- /src/gitsweep/tests/test_cli.py: -------------------------------------------------------------------------------- 1 | from mock import patch 2 | 3 | from gitsweep.tests.testcases import CommandTestCase 4 | 5 | 6 | class TestHelpMenu(CommandTestCase): 7 | 8 | """ 9 | Command-line tool can show the help menu. 10 | 11 | """ 12 | def test_help(self): 13 | """ 14 | If no arguments are given the help menu is displayed. 15 | """ 16 | (retcode, stdout, stderr) = self.gscommand('git-sweep -h') 17 | 18 | self.assertResults(''' 19 | usage: git-sweep [-h] 20 | 21 | Clean up your Git remote branches. 22 | 23 | optional arguments: 24 | -h, --help show this help message and exit 25 | 26 | action: 27 | Preview changes or perform clean up 28 | 29 | {preview,cleanup} 30 | preview Preview the branches that will be deleted 31 | cleanup Delete merged branches from the remote 32 | ''', stdout) 33 | 34 | def test_fetch(self): 35 | """ 36 | Will fetch if told not to. 37 | """ 38 | (retcode, stdout, stderr) = self.gscommand('git-sweep preview') 39 | 40 | self.assertResults(''' 41 | Fetching from the remote 42 | No remote branches are available for cleaning up 43 | ''', stdout) 44 | 45 | def test_no_fetch(self): 46 | """ 47 | Will not fetch if told not to. 48 | """ 49 | (retcode, stdout, stderr) = self.gscommand( 50 | 'git-sweep preview --nofetch') 51 | 52 | self.assertResults(''' 53 | No remote branches are available for cleaning up 54 | ''', stdout) 55 | 56 | def test_will_preview(self): 57 | """ 58 | Will preview the proposed deletes. 59 | """ 60 | for i in range(1, 6): 61 | self.command('git checkout -b branch{0}'.format(i)) 62 | self.make_commit() 63 | self.command('git checkout master') 64 | self.make_commit() 65 | self.command('git merge branch{0}'.format(i)) 66 | 67 | (retcode, stdout, stderr) = self.gscommand('git-sweep preview') 68 | 69 | self.assertResults(''' 70 | Fetching from the remote 71 | These branches have been merged into master: 72 | 73 | branch1 74 | branch2 75 | branch3 76 | branch4 77 | branch5 78 | 79 | To delete them, run again with `git-sweep cleanup` 80 | ''', stdout) 81 | 82 | def test_will_preserve_arguments(self): 83 | """ 84 | The recommended cleanup command contains the same arguments given. 85 | """ 86 | for i in range(1, 6): 87 | self.command('git checkout -b branch{0}'.format(i)) 88 | self.make_commit() 89 | self.command('git checkout master') 90 | self.make_commit() 91 | self.command('git merge branch{0}'.format(i)) 92 | 93 | preview = 'git-sweep preview --master=master --origin=origin' 94 | cleanup = 'git-sweep cleanup --master=master --origin=origin' 95 | 96 | (retcode, stdout, stderr) = self.gscommand(preview) 97 | 98 | self.assertResults(''' 99 | Fetching from the remote 100 | These branches have been merged into master: 101 | 102 | branch1 103 | branch2 104 | branch3 105 | branch4 106 | branch5 107 | 108 | To delete them, run again with `{0}` 109 | '''.format(cleanup), stdout) 110 | 111 | def test_will_preview_none_found(self): 112 | """ 113 | Will preview the proposed deletes. 114 | """ 115 | for i in range(1, 6): 116 | self.command('git checkout -b branch{0}'.format(i)) 117 | self.make_commit() 118 | self.command('git checkout master') 119 | 120 | (retcode, stdout, stderr) = self.gscommand('git-sweep preview') 121 | 122 | self.assertResults(''' 123 | Fetching from the remote 124 | No remote branches are available for cleaning up 125 | ''', stdout) 126 | 127 | def test_will_cleanup(self): 128 | """ 129 | Will preview the proposed deletes. 130 | """ 131 | for i in range(1, 6): 132 | self.command('git checkout -b branch{0}'.format(i)) 133 | self.make_commit() 134 | self.command('git checkout master') 135 | self.make_commit() 136 | self.command('git merge branch{0}'.format(i)) 137 | 138 | with patch('gitsweep.cli.raw_input', create=True) as ri: 139 | ri.return_value = 'y' 140 | (retcode, stdout, stderr) = self.gscommand('git-sweep cleanup') 141 | 142 | self.assertResults(''' 143 | Fetching from the remote 144 | These branches have been merged into master: 145 | 146 | branch1 147 | branch2 148 | branch3 149 | branch4 150 | branch5 151 | 152 | Delete these branches? (y/n) 153 | deleting branch1 (done) 154 | deleting branch2 (done) 155 | deleting branch3 (done) 156 | deleting branch4 (done) 157 | deleting branch5 (done) 158 | 159 | All done! 160 | 161 | Tell everyone to run `git fetch --prune` to sync with this remote. 162 | (you don't have to, yours is synced) 163 | ''', stdout) 164 | 165 | def test_will_abort_cleanup(self): 166 | """ 167 | Will preview the proposed deletes. 168 | """ 169 | for i in range(1, 6): 170 | self.command('git checkout -b branch{0}'.format(i)) 171 | self.make_commit() 172 | self.command('git checkout master') 173 | self.make_commit() 174 | self.command('git merge branch{0}'.format(i)) 175 | 176 | with patch('gitsweep.cli.raw_input', create=True) as ri: 177 | ri.return_value = 'n' 178 | (retcode, stdout, stderr) = self.gscommand('git-sweep cleanup') 179 | 180 | self.assertResults(''' 181 | Fetching from the remote 182 | These branches have been merged into master: 183 | 184 | branch1 185 | branch2 186 | branch3 187 | branch4 188 | branch5 189 | 190 | Delete these branches? (y/n) 191 | OK, aborting. 192 | ''', stdout) 193 | 194 | def test_will_skip_certain_branches(self): 195 | """ 196 | Can be forced to skip certain branches. 197 | """ 198 | for i in range(1, 6): 199 | self.command('git checkout -b branch{0}'.format(i)) 200 | self.make_commit() 201 | self.command('git checkout master') 202 | self.make_commit() 203 | self.command('git merge branch{0}'.format(i)) 204 | 205 | (retcode, stdout, stderr) = self.gscommand( 206 | 'git-sweep preview --skip=branch1,branch2') 207 | 208 | cleanup = 'git-sweep cleanup --skip=branch1,branch2' 209 | 210 | self.assertResults(''' 211 | Fetching from the remote 212 | These branches have been merged into master: 213 | 214 | branch3 215 | branch4 216 | branch5 217 | 218 | To delete them, run again with `{0}` 219 | '''.format(cleanup), stdout) 220 | 221 | def test_will_force_clean(self): 222 | """ 223 | Will cleanup immediately if forced. 224 | """ 225 | for i in range(1, 6): 226 | self.command('git checkout -b branch{0}'.format(i)) 227 | self.make_commit() 228 | self.command('git checkout master') 229 | self.make_commit() 230 | self.command('git merge branch{0}'.format(i)) 231 | 232 | (retcode, stdout, stderr) = self.gscommand('git-sweep cleanup --force') 233 | 234 | self.assertResults(''' 235 | Fetching from the remote 236 | These branches have been merged into master: 237 | 238 | branch1 239 | branch2 240 | branch3 241 | branch4 242 | branch5 243 | 244 | deleting branch1 (done) 245 | deleting branch2 (done) 246 | deleting branch3 (done) 247 | deleting branch4 (done) 248 | deleting branch5 (done) 249 | 250 | All done! 251 | 252 | Tell everyone to run `git fetch --prune` to sync with this remote. 253 | (you don't have to, yours is synced) 254 | ''', stdout) 255 | --------------------------------------------------------------------------------