├── tests ├── src │ ├── __init__.py │ └── clustergit.py ├── tests │ ├── __init__.py │ ├── tools.py │ └── clustergit_test.py ├── .gitignore ├── .coveragerc ├── run-tests.sh └── .pylintrc ├── doc ├── .gitignore ├── clustergit.png └── demo.sh ├── .gitignore ├── clustergit.cmd ├── README.md └── clustergit /tests/src/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /doc/.gitignore: -------------------------------------------------------------------------------- 1 | /demo/ 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | 3 | .idea 4 | -------------------------------------------------------------------------------- /tests/src/clustergit.py: -------------------------------------------------------------------------------- 1 | ../../clustergit -------------------------------------------------------------------------------- /clustergit.cmd: -------------------------------------------------------------------------------- 1 | @echo off 2 | python "%~dp0\clustergit" %* 3 | -------------------------------------------------------------------------------- /doc/clustergit.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mnagel/clustergit/HEAD/doc/clustergit.png -------------------------------------------------------------------------------- /tests/.gitignore: -------------------------------------------------------------------------------- 1 | apidoc 2 | cover 3 | .coverage 4 | pylint.html 5 | 6 | *.log 7 | *.pyc 8 | -------------------------------------------------------------------------------- /tests/.coveragerc: -------------------------------------------------------------------------------- 1 | # .coveragerc to control coverage.py 2 | [run] 3 | branch = True 4 | omit = /usr/* 5 | 6 | [report] 7 | exclude_lines = 8 | pragma: no cover 9 | if __name__ == .__main__.: 10 | -------------------------------------------------------------------------------- /tests/tests/tools.py: -------------------------------------------------------------------------------- 1 | from nose.tools import * 2 | 3 | def same(message, expected, actual): 4 | eq_(actual, expected, '%s. We expected [%s] but got [%s]' % (message, expected, actual)) 5 | 6 | def confirm(message, actual): 7 | eq_(message, True, actual) 8 | -------------------------------------------------------------------------------- /tests/run-tests.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | DIR=$(dirname "$0") 4 | 5 | rm -r cover .coverage 6 | nosetests --with-coverage --cover-html 7 | pylint --rcfile .pylintrc src > pylint.html 8 | 9 | rm -r apidoc 10 | sphinx-apidoc . --full -H clustergit -A mn -V 1 -R 1 -o apidoc 11 | cd apidoc 12 | vim -c ":%s/#sys.path.insert(0, os.path.abspath('.'))/sys.path.insert(0, os.path.abspath('..'))/" -c 'x' conf.py 13 | make html > output.log 2> errors.log 14 | echo "output saved to output.log and errors.log to stop spam" 15 | cd .. 16 | 17 | if [ $# == 0 ]; then 18 | echo $# parameters, going interactive 19 | echo "opening coverage report in browser" 20 | echo "opening lint report in browser" 21 | xdg-open "cover/index.html" 22 | xdg-open "pylint.html" 23 | xdg-open "apidoc/_build/html/index.html" 24 | else 25 | echo $# parameters, going non-interactive 26 | fi 27 | -------------------------------------------------------------------------------- /doc/demo.sh: -------------------------------------------------------------------------------- 1 | #! /bin/bash 2 | 3 | cd $(dirname "$0") 4 | touch demo 5 | rm -rf demo 6 | mkdir demo 7 | pushd demo 8 | 9 | mkdir repo1 10 | cd repo1 11 | git init 12 | cd .. 13 | 14 | mkdir repo2 15 | cd repo2 16 | git init 17 | git checkout --orphan otherbranch 18 | cd .. 19 | 20 | mkdir repo3 21 | cd repo3 22 | git init 23 | touch a b c 24 | git add . 25 | git commit -a -m "nevermind" 26 | cd .. 27 | 28 | mkdir arepo 29 | cd arepo 30 | git init 31 | touch a b c 32 | git add . 33 | git commit -a -m "nevermind" 34 | cd .. 35 | 36 | mkdir norepo 37 | 38 | git clone git@github.com:mnagel/clustergit 39 | cd clustergit 40 | touch a b c 41 | git add . 42 | git commit -a -m "nevermind" 43 | cd .. 44 | 45 | git clone git@github.com:mnagel/clustergit clustergit-clean 46 | 47 | popd 48 | 49 | ## 50 | # Demo multi-directory support 51 | ## 52 | cd $(dirname "$0") 53 | touch demo2 54 | rm -rf demo2 55 | mkdir demo2 56 | pushd demo2 57 | 58 | mkdir repo1_in_demo2 59 | cd repo1_in_demo2 60 | git init 61 | cd .. 62 | 63 | mkdir repo2_in_demo2 64 | cd repo2_in_demo2 65 | git init 66 | git checkout --orphan otherbranch 67 | cd .. 68 | 69 | mkdir repo3_in_demo2 70 | cd repo3_in_demo2 71 | git init 72 | touch a b c 73 | git add . 74 | git commit -a -m "nevermind" 75 | cd .. 76 | 77 | popd 78 | 79 | ## now run 80 | # clustergit -d demo -d demo2 --warn-unversioned 81 | # clustergit -d demo -d demo2 --pull 82 | -------------------------------------------------------------------------------- /tests/tests/clustergit_test.py: -------------------------------------------------------------------------------- 1 | from tools import same 2 | 3 | from cStringIO import StringIO 4 | import sys 5 | import tempfile 6 | 7 | import clustergit 8 | 9 | class TestClustergit: 10 | 11 | def test_check_no_repo(self): 12 | old_stdout = sys.stdout 13 | old_stderr = sys.stderr 14 | sys.stdout = mystdout = StringIO() 15 | sys.stderr = mystderr = StringIO() 16 | 17 | clustergit.main([]) 18 | 19 | actual_out = mystdout.getvalue() 20 | expected_out = """Starting git status... 21 | Scanning sub directories of . 22 | 23 | """ 24 | actual_err = mystderr.getvalue() 25 | expected_err = """Error: None of those sub directories had a .git file. 26 | """ 27 | same("stdout should be alright", expected_out, actual_out) 28 | same("stderr should be alright", expected_err, actual_err) 29 | 30 | sys.stdout = old_stdout 31 | sys.stderr = old_stderr 32 | 33 | 34 | 35 | def test_check_fresh_repo(self): 36 | dirpath = tempfile.mkdtemp() 37 | print "working in %s" % (dirpath) 38 | clustergit.run('cd "%s"; mkdir mygit; cd mygit; git init' % (dirpath), clustergit.read_arguments(['-v'])) 39 | 40 | old_stdout = sys.stdout 41 | old_stderr = sys.stderr 42 | sys.stdout = mystdout = StringIO() 43 | sys.stderr = mystderr = StringIO() 44 | 45 | clustergit.main(['-d', dirpath, '--no-color', '--align=0']) 46 | 47 | actual_out = mystdout.getvalue() 48 | expected_out = """Starting git status... 49 | Scanning sub directories of %s 50 | %s/mygit: Changes 51 | Done 52 | """ % (dirpath, dirpath) 53 | actual_err = mystderr.getvalue() 54 | expected_err = """""" 55 | same("stdout should be alright", expected_out, actual_out) 56 | same("stderr should be alright", expected_err, actual_err) 57 | 58 | sys.stdout = old_stdout 59 | sys.stderr = old_stderr 60 | 61 | 62 | 63 | def test_excluded(self): 64 | dirpath = tempfile.mkdtemp() 65 | print "working in %s" % (dirpath) 66 | out = clustergit.run( 67 | 'cd "%s";' % (dirpath) 68 | + 'mkdir notarepo repo1 repo2 target;' 69 | + 'cd repo1; git init; cd ..;' 70 | + 'cd repo2; git init; cd ..;' 71 | + 'tree -A', # show structure in error messages 72 | clustergit.read_arguments(['-v']) 73 | ) 74 | print out 75 | 76 | old_stdout = sys.stdout 77 | old_stderr = sys.stderr 78 | sys.stdout = mystdout = StringIO() 79 | sys.stderr = mystderr = StringIO() 80 | 81 | clustergit.main(['-d', dirpath, '--no-color', '--align=0', '--warn-unversioned', '--exclude=target']) 82 | 83 | actual_out = mystdout.getvalue() 84 | expected_out = """Starting git status... 85 | Scanning sub directories of {0} 86 | {0}/notarepo: Not a GIT repository 87 | {0}/repo1: Changes 88 | {0}/repo2: Changes 89 | Done 90 | """.format(dirpath) 91 | actual_err = mystderr.getvalue() 92 | expected_err = '' 93 | same("stdout should exclude target", expected_out, actual_out) 94 | same("stderr should be empty", expected_err, actual_err) 95 | 96 | sys.stdout = old_stdout 97 | sys.stderr = old_stderr 98 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # clustergit 2 | 3 | clustergit allows you to run git commands on multiple repositories at once. 4 | It is especially useful to run `git status` recursively on one folder. 5 | 6 | clustergit supports `git status`, `git pull`, `git push`, and more. 7 | 8 | ## Screenshot 9 | ![clustergit screenshot](doc/clustergit.png?raw=true "clustergit screenshot") 10 | 11 | To reproduce the above locally, run: 12 | 13 | ```bash 14 | cd doc 15 | bash demo.sh 16 | cd demo 17 | clustergit 18 | clustergit --warn-unversioned --pull 19 | ``` 20 | 21 | ## Installation 22 | 23 | Make the script executable and drop it somewhere in your $PATH. 24 | 25 | ## Dependencies 26 | 27 | * python3 28 | 29 | For latest version with python 2.7 support see: 30 | https://github.com/mnagel/clustergit/releases/tag/python27 31 | 32 | ## Usage 33 | 34 | Usage: clustergit [options] 35 | 36 | clustergit will scan through all subdirectories looking for a .git directory. 37 | When it finds one it'll look to see if there are any changes and let you know. 38 | If there are no changes it can also push and pull to/from a remote location. 39 | 40 | ## Options 41 | 42 | ``` 43 | usage: clustergit [-h] [-d DIRNAME] [-v] [-a ALIGN] [-r REMOTE] [--push] [-p] 44 | [-f] [--exec COMMAND] [-c] [-C] [-q] [-H] [-R] [-n] 45 | [-b BRANCH] [--recursive] [--skip-symlinks] [-e EXCLUDE] 46 | [-B CBRANCH] [--warn-unversioned] 47 | [--workers THREAD_POOL_WORKERS] [--print-asap] 48 | 49 | clustergit will scan through all subdirectories looking for a .git directory. 50 | When it finds one it'll look to see if there are any changes and let you know. 51 | If there are no changes it can also push and pull to/from a remote location. 52 | 53 | optional arguments: 54 | -h, --help show this help message and exit 55 | -d DIRNAME, --dir DIRNAME 56 | The directory to parse sub dirs from (default: .) 57 | -v, --verbose Show the full detail of git status (default: False) 58 | --all-branches Check unpushed commits in all branches (default: 59 | False) 60 | -a ALIGN, --align ALIGN 61 | Repo name align (space padding) (default: 40) 62 | -r REMOTE, --remote REMOTE 63 | Set the remote name (remotename:branchname) (default: 64 | ) 65 | --push Do a 'git push' if you've set a remote with -r it will 66 | push to there (default: False) 67 | -p, --pull Do a 'git pull' if you've set a remote with -r it will 68 | pull from there (default: False) 69 | -f, --fetch Do a 'git fetch' if you've set a remote with -r it 70 | will fetch from there (default: False) 71 | --exec COMMAND, --execute COMMAND 72 | Execute a shell command in each repository (default: ) 73 | -c, --clear Clear screen on startup (default: False) 74 | -C, --count-dirty Only display a count of not-clean repos (default: 75 | False) 76 | -q, --quiet Skip startup info (default: False) 77 | -H, --hide-clean Hide clean repos (default: False) 78 | -R, --relative Print relative paths (default: False) 79 | -n, --no-colors Disable ANSI color output. Disregard the alleged 80 | default -- color is on by default. (default: True) 81 | -b BRANCH, --branch BRANCH 82 | Warn if not on this branch. Set to empty string (-b 83 | '') to disable this feature. (default: master) 84 | --recursive Recursively search for git repos (default: False) 85 | --skip-symlinks Skip symbolic links when searching for git repos 86 | (default: False) 87 | -e EXCLUDE, --exclude EXCLUDE 88 | Regex to exclude directories (default: []) 89 | -B CBRANCH, --checkout-branch CBRANCH 90 | Checkout branch (default: None) 91 | --warn-unversioned Prints a warning if a directory is not under git 92 | version control (default: False) 93 | --workers THREAD_POOL_WORKERS 94 | Workers in thread pool for parallel execution 95 | (default: 4) 96 | --print-asap Print repository status as soon as possible not 97 | preserving order (default: False) 98 | 99 | ``` 100 | 101 | ## Contact 102 | 103 | via https://github.com/mnagel/clustergit 104 | 105 | ## Credits 106 | 107 | * show_status by Mike Pearce: https://github.com/MikePearce/Git-Status 108 | * patches to show_status by ilor: https://github.com/ilor/Git-Status 109 | 110 | ## License 111 | 112 | Files: 113 | 114 | * all files 115 | 116 | Copyright: 117 | 118 | * 2010 Mike Pearce mike@mikepearce.net 119 | * 2010 catchamonkey chris@sedlmayr.co.uk 120 | * 2015 sedrubal sebastian.endres@online.de 121 | * 2011-2020 Michael Nagel ubuntu@nailor.devzero.de 122 | 123 | License: 124 | 125 | * Mike Pearce: "Feel free to use it how you like, no licence required." 126 | * catchamonkey: "I guess whatever the original show_status's license is would apply to my patches. Other than that, I consider my additions to be public domain-ish or 2-clause BSD." 127 | * Michael Nagel: "Donated into the Public Domain." 128 | * sedrubal: "Donated into the Public Domain. Whenever this project gets a 'real' license, I'd prefer a GPL" 129 | -------------------------------------------------------------------------------- /tests/.pylintrc: -------------------------------------------------------------------------------- 1 | [MASTER] 2 | 3 | # Specify a configuration file. 4 | #rcfile= 5 | 6 | # Python code to execute, usually for sys.path manipulation such as 7 | # pygtk.require(). 8 | #init-hook= 9 | 10 | # Profiled execution. 11 | profile=no 12 | 13 | # Add files or directories to the blacklist. They should be base names, not 14 | # paths. 15 | ignore=CVS 16 | 17 | # Pickle collected data for later comparisons. 18 | persistent=yes 19 | 20 | # List of plugins (as comma separated values of python modules names) to load, 21 | # usually to register additional checkers. 22 | load-plugins= 23 | 24 | 25 | [MESSAGES CONTROL] 26 | 27 | # Enable the message, report, category or checker with the given id(s). You can 28 | # either give multiple identifier separated by comma (,) or put this option 29 | # multiple time. 30 | #enable= 31 | 32 | # Disable the message, report, category or checker with the given id(s). You 33 | # can either give multiple identifier separated by comma (,) or put this option 34 | # multiple time (only on the command line, not in the configuration file where 35 | # it should appear only once). 36 | #disable= 37 | 38 | 39 | [REPORTS] 40 | 41 | # Set the output format. Available formats are text, parseable, colorized, msvs 42 | # (visual studio) and html 43 | output-format=html 44 | 45 | # Include message's id in output 46 | include-ids=yes 47 | 48 | # Put messages in a separate file for each module / package specified on the 49 | # command line instead of printing them on stdout. Reports (if any) will be 50 | # written in a file name "pylint_global.[txt|html]". 51 | files-output=no 52 | 53 | # Tells whether to display a full report or only the messages 54 | reports=yes 55 | 56 | # Python expression which should return a note less than 10 (10 is the highest 57 | # note). You have access to the variables errors warning, statement which 58 | # respectively contain the number of errors / warnings messages and the total 59 | # number of statements analyzed. This is used by the global evaluation report 60 | # (RP0004). 61 | evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) 62 | 63 | # Add a comment according to your evaluation note. This is used by the global 64 | # evaluation report (RP0004). 65 | comment=yes 66 | 67 | 68 | [VARIABLES] 69 | 70 | # Tells whether we should check for unused import in __init__ files. 71 | init-import=no 72 | 73 | # A regular expression matching the beginning of the name of dummy variables 74 | # (i.e. not used). 75 | dummy-variables-rgx=_|dummy 76 | 77 | # List of additional names supposed to be defined in builtins. Remember that 78 | # you should avoid to define new builtins when possible. 79 | additional-builtins= 80 | 81 | 82 | [TYPECHECK] 83 | 84 | # Tells whether missing members accessed in mixin class should be ignored. A 85 | # mixin class is detected if its name ends with "mixin" (case insensitive). 86 | ignore-mixin-members=yes 87 | 88 | # List of classes names for which member attributes should not be checked 89 | # (useful for classes with attributes dynamically set). 90 | ignored-classes=SQLObject 91 | 92 | # When zope mode is activated, add a predefined set of Zope acquired attributes 93 | # to generated-members. 94 | zope=no 95 | 96 | # List of members which are set dynamically and missed by pylint inference 97 | # system, and so shouldn't trigger E0201 when accessed. Python regular 98 | # expressions are accepted. 99 | generated-members=REQUEST,acl_users,aq_parent 100 | 101 | 102 | [FORMAT] 103 | 104 | # Maximum number of characters on a single line. 105 | max-line-length=120 106 | 107 | # Maximum number of lines in a module 108 | max-module-lines=1000 109 | 110 | # String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 111 | # tab). 112 | indent-string=' ' 113 | 114 | 115 | [SIMILARITIES] 116 | 117 | # Minimum lines number of a similarity. 118 | min-similarity-lines=4 119 | 120 | # Ignore comments when computing similarities. 121 | ignore-comments=yes 122 | 123 | # Ignore docstrings when computing similarities. 124 | ignore-docstrings=yes 125 | 126 | 127 | [MISCELLANEOUS] 128 | 129 | # List of note tags to take in consideration, separated by a comma. 130 | notes=FIXME,XXX,TODO 131 | 132 | 133 | [BASIC] 134 | 135 | # Required attributes for module, separated by a comma 136 | required-attributes= 137 | 138 | # List of builtins function names that should not be used, separated by a comma 139 | bad-functions=map,filter,apply,input 140 | 141 | # Regular expression which should only match correct module names 142 | module-rgx=(([a-z_][a-z0-9_]*)|([A-Z][a-zA-Z0-9]+))$ 143 | 144 | # Regular expression which should only match correct module level names 145 | const-rgx=(([A-Z_][A-Z0-9_]*)|(__.*__))$ 146 | 147 | # Regular expression which should only match correct class names 148 | class-rgx=[A-Z_][a-zA-Z0-9]+$ 149 | 150 | # Regular expression which should only match correct function names 151 | function-rgx=[a-z_][a-z0-9_]{2,30}$ 152 | 153 | # Regular expression which should only match correct method names 154 | method-rgx=[a-z_][a-z0-9_]{2,30}$ 155 | 156 | # Regular expression which should only match correct instance attribute names 157 | attr-rgx=[a-z_][a-z0-9_]{2,30}$ 158 | 159 | # Regular expression which should only match correct argument names 160 | argument-rgx=[a-z_][a-z0-9_]{2,30}$ 161 | 162 | # Regular expression which should only match correct variable names 163 | variable-rgx=[a-z_][a-z0-9_]{2,30}$ 164 | 165 | # Regular expression which should only match correct list comprehension / 166 | # generator expression variable names 167 | inlinevar-rgx=[A-Za-z_][A-Za-z0-9_]*$ 168 | 169 | # Good variable names which should always be accepted, separated by a comma 170 | good-names=i,j,k,ex,Run,_ 171 | 172 | # Bad variable names which should always be refused, separated by a comma 173 | bad-names=foo,bar,baz,toto,tutu,tata 174 | 175 | # Regular expression which should only match functions or classes name which do 176 | # not require a docstring 177 | no-docstring-rgx=__.*__ 178 | 179 | 180 | [DESIGN] 181 | 182 | # Maximum number of arguments for function / method 183 | max-args=5 184 | 185 | # Argument names that match this expression will be ignored. Default to name 186 | # with leading underscore 187 | ignored-argument-names=_.* 188 | 189 | # Maximum number of locals for function / method body 190 | max-locals=15 191 | 192 | # Maximum number of return / yield for function / method body 193 | max-returns=6 194 | 195 | # Maximum number of branch for function / method body 196 | max-branchs=12 197 | 198 | # Maximum number of statements in function / method body 199 | max-statements=50 200 | 201 | # Maximum number of parents for a class (see R0901). 202 | max-parents=7 203 | 204 | # Maximum number of attributes for a class (see R0902). 205 | max-attributes=7 206 | 207 | # Minimum number of public methods for a class (see R0903). 208 | min-public-methods=2 209 | 210 | # Maximum number of public methods for a class (see R0904). 211 | max-public-methods=20 212 | 213 | 214 | [CLASSES] 215 | 216 | # List of interface methods to ignore, separated by a comma. This is used for 217 | # instance to not check methods defines in Zope's Interface base class. 218 | ignore-iface-methods=isImplementedBy,deferred,extends,names,namesAndDescriptions,queryDescriptionFor,getBases,getDescriptionFor,getDoc,getName,getTaggedValue,getTaggedValueTags,isEqualOrExtendedBy,setTaggedValue,isImplementedByInstancesOf,adaptWith,is_implemented_by 219 | 220 | # List of method names used to declare (i.e. assign) instance attributes. 221 | defining-attr-methods=__init__,__new__,setUp 222 | 223 | # List of valid names for the first argument in a class method. 224 | valid-classmethod-first-arg=cls 225 | 226 | 227 | [IMPORTS] 228 | 229 | # Deprecated modules which should not be used, separated by a comma 230 | deprecated-modules=regsub,string,TERMIOS,Bastion,rexec 231 | 232 | # Create a graph of every (i.e. internal and external) dependencies in the 233 | # given file (report RP0402 must not be disabled) 234 | import-graph= 235 | 236 | # Create a graph of external dependencies in the given file (report RP0402 must 237 | # not be disabled) 238 | ext-import-graph= 239 | 240 | # Create a graph of internal dependencies in the given file (report RP0402 must 241 | # not be disabled) 242 | int-import-graph= 243 | 244 | 245 | [EXCEPTIONS] 246 | 247 | # Exceptions that will emit a warning when being caught. Defaults to 248 | # "Exception" 249 | overgeneral-exceptions=Exception 250 | -------------------------------------------------------------------------------- /clustergit: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | 3 | """ run git commands on multiple git clones https://github.com/mnagel/clustergit """ 4 | 5 | import argparse 6 | import concurrent 7 | import io 8 | import itertools 9 | import os 10 | import re 11 | import subprocess 12 | import sys 13 | import time 14 | from argparse import ArgumentParser 15 | from concurrent import futures 16 | from typing import List, IO, Tuple 17 | 18 | # Special imports for Windows systems 19 | if os.name == 'nt': 20 | from ctypes import windll, c_ulong 21 | 22 | # Optional autocomplete import 23 | try: 24 | from argcomplete import autocomplete 25 | except ImportError: 26 | # Notice: install "argcomplete" to automatic complete the arguments 27 | def autocomplete(_args): 28 | pass 29 | 30 | flat_map = lambda f, xs: [y for ys in xs for y in f(ys)] 31 | 32 | def clearline(msg): 33 | # https://stackoverflow.com/a/53843296/2536029 34 | CURSOR_UP_ONE = '\033[K' 35 | ERASE_LINE = '\x1b[2K' 36 | sys.stdout.write(CURSOR_UP_ONE) 37 | sys.stdout.write(ERASE_LINE + '\r') 38 | print(msg, end='\r') 39 | 40 | 41 | def colorize(color: str, message: str) -> str: 42 | return "%s%s%s" % (color, message, Colors.ENDC) 43 | 44 | 45 | def decolorize(_color: str, message: str) -> str: 46 | for color in Colors.ALL: 47 | message = message.replace(color, '') 48 | return message 49 | 50 | 51 | # noinspection PyClassHasNoInit 52 | class Colors: 53 | BOLD = '\033[1m' # unused 54 | UNDERLINE = '\033[4m' # unused 55 | HEADER = '\033[95m' # unused 56 | OKBLUE = '\033[94m' # write operation succeeded 57 | OKGREEN = '\033[92m' # readonly operation succeeded 58 | OKPURPLE = '\033[95m' # readonly (fetch) operation succeeded 59 | WARNING = '\033[93m' # operation succeeded with non-default result 60 | FAIL = '\033[91m' # operation did not succeed 61 | ENDC = '\033[0m' # reset color 62 | 63 | # list of all colors 64 | ALL = [BOLD, UNDERLINE, HEADER, OKBLUE, OKGREEN, OKPURPLE, WARNING, FAIL, ENDC] 65 | 66 | # map from ASCII to Windows color text attribute 67 | WIN_DICT = { 68 | BOLD: 15, 69 | UNDERLINE: 15, 70 | HEADER: 15, 71 | OKBLUE: 11, 72 | OKGREEN: 10, 73 | WARNING: 14, 74 | FAIL: 12, 75 | ENDC: 15 76 | } 77 | 78 | 79 | def write_color(out: IO, color: str) -> None: 80 | # set text attribute for Windows and write ASCII color otherwise 81 | if os.name == 'nt' and out.isatty(): 82 | windll.Kernel32.SetConsoleTextAttribute( 83 | windll.Kernel32.GetStdHandle(c_ulong(0xfffffff5)), 84 | Colors.WIN_DICT[color] 85 | ) 86 | else: 87 | out.write(color) 88 | 89 | 90 | def write_with_color(out: IO, msg: str) -> None: 91 | # build regex for splitting by colors, split and iterate over elements 92 | for p in re.split(('(%s)' % '|'.join(Colors.ALL)).replace('[', '\\['), msg): 93 | # check if element is a color 94 | if p in Colors.ALL: 95 | write_color(out, p) 96 | else: 97 | # plain text 98 | out.write(p) 99 | # flush required to properly apply color 100 | out.flush() 101 | 102 | 103 | def read_arguments(args: List[str]) -> argparse.Namespace: 104 | parser = ArgumentParser( 105 | description=""" 106 | clustergit will scan through all subdirectories looking for a .git directory. 107 | When it finds one it'll look to see if there are any changes and let you know. 108 | If there are no changes it can also push and pull to/from a remote location. 109 | """.strip(), 110 | formatter_class=argparse.ArgumentDefaultsHelpFormatter 111 | ) 112 | parser.add_argument( 113 | "-d", "--dir", 114 | dest="dirname", 115 | # Action=append allows the caller to specify `-d` multiple times. 116 | # For example: `-d foo -d bar/batz/git_repos` would process both directories. 117 | action="append", 118 | help="The directory to parse sub dirs from", 119 | default=[] 120 | ) 121 | 122 | parser.add_argument( 123 | "-v", "--verbose", 124 | action="store_true", 125 | dest="verbose", 126 | default=False, 127 | help="Show the full detail of git status" 128 | ) 129 | 130 | parser.add_argument( 131 | "--all-branches", 132 | action="store_true", 133 | dest="check_all_branches", 134 | default=False, 135 | help="Check unpushed commits in all branches" 136 | ) 137 | 138 | parser.add_argument( 139 | "-a", "--align", 140 | action="store", 141 | dest="align", 142 | default=40, 143 | type=int, 144 | help="Repo name align (space padding)" 145 | ) 146 | 147 | parser.add_argument( 148 | "-r", "--remote", 149 | action="store", 150 | dest="remote", 151 | default="", 152 | help="Set the remote name (remotename:branchname)" 153 | ) 154 | 155 | parser.add_argument( 156 | "--push", 157 | action="store_true", 158 | dest="push", 159 | default=False, 160 | help="Do a 'git push' if you've set a remote with -r it will push to there" 161 | ) 162 | 163 | parser.add_argument( 164 | "-p", "--pull", 165 | action="store_true", 166 | dest="pull", 167 | default=False, 168 | help="Do a 'git pull' if you've set a remote with -r it will pull from there" 169 | ) 170 | 171 | parser.add_argument( 172 | "-f", "--fetch", 173 | action="store_true", 174 | dest="fetch", 175 | default=False, 176 | help="Do a 'git fetch' if you've set a remote with -r it will fetch from there" 177 | ) 178 | 179 | parser.add_argument( 180 | "--exec", "--execute", 181 | action="store", 182 | dest="command", 183 | type=str, 184 | default="", 185 | help="Execute a shell command in each repository" 186 | ) 187 | 188 | parser.add_argument( 189 | "-c", "--clear", 190 | action="store_true", 191 | dest="clear", 192 | default=False, 193 | help="Clear screen on startup" 194 | ) 195 | 196 | parser.add_argument( 197 | "-C", "--count-dirty", 198 | action="store_true", 199 | dest="count", 200 | default=False, 201 | help="Only display a count of not-clean repos" 202 | ) 203 | 204 | parser.add_argument( 205 | "-q", "--quiet", 206 | action="store_true", 207 | dest="quiet", 208 | default=False, 209 | help="Skip startup info" 210 | ) 211 | 212 | parser.add_argument( 213 | "-H", "--hide-clean", 214 | action="store_true", 215 | dest="hide_clean", 216 | default=False, 217 | help="Hide clean repos" 218 | ) 219 | 220 | parser.add_argument( 221 | "-R", "--relative", 222 | action="store_true", 223 | dest="relative", 224 | default=False, 225 | help="Print relative paths" 226 | ) 227 | 228 | parser.add_argument( 229 | "-n", "--no-colors", 230 | action="store_false", 231 | dest="colors", 232 | default=True, 233 | help="Disable ANSI color output. Disregard the alleged default -- color is on by default." 234 | ) 235 | 236 | parser.add_argument( 237 | "-b", "--branch", 238 | action="store", 239 | dest="branch", 240 | default="(master|main)", 241 | help="Warn if not on a branch matching this Regex. Set to empty string (-b '') to disable this feature." 242 | ) 243 | 244 | parser.add_argument( 245 | "--recursive", 246 | action="store_true", 247 | dest="recursive", 248 | default=False, 249 | help="Recursively search for git repos" 250 | ) 251 | 252 | parser.add_argument( 253 | "--skip-symlinks", 254 | action="store_true", 255 | dest="skipSymLinks", 256 | default=False, 257 | help="Skip symbolic links when searching for git repos" 258 | ) 259 | 260 | parser.add_argument( 261 | "-e", "--exclude", 262 | action="append", 263 | dest="exclude", 264 | default=[], 265 | help="Regex to exclude directories" 266 | ) 267 | 268 | parser.add_argument( 269 | "-B", "--checkout-branch", 270 | action="store", 271 | dest="cbranch", 272 | default=None, 273 | help="Checkout branch" 274 | ) 275 | 276 | parser.add_argument( 277 | "--warn-unversioned", 278 | action="store_true", 279 | dest="unversioned", 280 | default=False, 281 | help="Prints a warning if a directory is not under git version control" 282 | ) 283 | 284 | parser.add_argument( 285 | "--workers", 286 | type=int, 287 | dest="thread_pool_workers", 288 | default=4, 289 | help="Workers in thread pool for parallel execution" 290 | ) 291 | 292 | parser.add_argument( 293 | "--print-asap", 294 | action="store_true", 295 | dest="print_asap", 296 | default=False, 297 | help="Print repository status as soon as possible not preserving order" 298 | ) 299 | 300 | parser.add_argument( 301 | "--global-ignore-file", 302 | type=str, 303 | dest="global_ignore_file", 304 | default="$HOME/.config/clustergit/.clustergit-ignore", 305 | help="Global clustergit-ignore file" 306 | ) 307 | 308 | autocomplete(parser) 309 | options = parser.parse_args(args) 310 | return options 311 | 312 | 313 | def die_with_error(error: str = "Undefined Error!") -> None: 314 | """Writes an error to stderr""" 315 | write_with_color(sys.stderr, "Error: %s\n" % error) 316 | sys.exit(1) 317 | 318 | 319 | class GitDir: 320 | def __init__(self, path: str, options: argparse.Namespace) -> None: 321 | self.path = path 322 | 323 | if options.relative: 324 | self.path = os.path.relpath(self.path, options.dirname) 325 | 326 | self.dirty = None 327 | self.msg_buffer = io.StringIO() 328 | 329 | def analyze(self, options: argparse.Namespace) -> None: 330 | cmdprefix = '' 331 | if os.name != 'nt': 332 | cmdprefix = cmdprefix + ' LC_ALL=C' 333 | 334 | if options.verbose: 335 | self.write_to_msg_buffer("\n") 336 | self.write_to_msg_buffer("---------------- " + self.path + " -----------------\n") 337 | 338 | # OK, contains a .git file. Let's descend into it 339 | # and ask git for a status 340 | status, out = self.run('%s git status' % cmdprefix, options, self.path) 341 | if options.verbose: 342 | self.write_to_msg_buffer(out + "\n") 343 | 344 | if options.relative: 345 | self.path = os.path.relpath(self.path, options.dirname) 346 | messages = [] 347 | clean = True 348 | can_push = False 349 | can_pull = True 350 | if (len(options.branch) > 0 and not re.search(fr'On branch {options.branch}\n', out)): 351 | branch = out.splitlines()[0].replace("On branch ", "") 352 | messages.append(colorize(Colors.WARNING, "On branch %s" % branch)) 353 | can_pull = False 354 | clean = False 355 | # changed from "directory" to "tree" in git 2.9.1 356 | # https://github.com/mnagel/clustergit/issues/18 357 | if re.search(r'nothing to commit.?.?working (directory|tree) clean.?', out): 358 | messages.append(colorize(Colors.OKBLUE, "No Changes")) 359 | can_push = True 360 | elif 'nothing added to commit but untracked files present' in out: 361 | messages.append(colorize(Colors.WARNING, "Untracked files")) 362 | can_push = True 363 | clean = False 364 | elif 'No commits yet' in out: 365 | messages.append(colorize(Colors.OKBLUE, "No commits yet")) 366 | can_push = False 367 | clean = True 368 | else: 369 | messages.append(colorize(Colors.FAIL, "Changes")) 370 | can_pull = False 371 | clean = False 372 | if 'Your branch is ahead of' in out: 373 | messages.append(colorize(Colors.FAIL, "Unpushed commits")) 374 | can_pull = False 375 | clean = False 376 | else: 377 | can_push = False 378 | 379 | if options.check_all_branches: 380 | status, dirty_branches = self.run( 381 | '%s git --no-pager log --branches --not --remotes --no-walk --pretty=tformat:"%%D"' 382 | % cmdprefix, options, self.path 383 | ) 384 | other_dirty_branches = list( 385 | filter( 386 | lambda ref: not (ref.startswith('HEAD ->') or ref.startswith('tag:')), 387 | flat_map( 388 | lambda ref: ref.split(', '), 389 | dirty_branches.splitlines() 390 | ) 391 | ) 392 | ) 393 | if len(other_dirty_branches) > 0: 394 | messages.append(colorize( 395 | Colors.FAIL, 396 | "Unpushed commits on branches: [%s]" % ', '.join(other_dirty_branches) 397 | )) 398 | 399 | if clean: 400 | if not options.hide_clean: 401 | messages = [colorize(Colors.OKGREEN, "Clean")] 402 | else: 403 | messages = [] 404 | self.dirty = not clean 405 | 406 | if can_push and options.push: 407 | # Push to the remote 408 | status, push = self.run( 409 | '%s git push %s' 410 | % (cmdprefix, ' '.join(options.remote.split(":"))), options, self.path 411 | ) 412 | if options.verbose: 413 | self.write_to_msg_buffer(push + "\n") 414 | if re.search(r'\[(remote )?rejected\]', push): 415 | messages.append(colorize(Colors.FAIL, "Push rejected")) 416 | else: 417 | messages.append(colorize(Colors.OKBLUE, "Pushed OK")) 418 | 419 | if can_pull and options.pull: 420 | # Pull from the remote 421 | status, pull = self.run( 422 | '%s git pull %s' 423 | % (cmdprefix, ' '.join(options.remote.split(":"))), options, self.path 424 | ) 425 | if options.verbose: 426 | self.write_to_msg_buffer(pull + "\n") 427 | if re.search(r'Already up.to.date', pull): 428 | if not options.hide_clean: 429 | messages.append(colorize(Colors.OKGREEN, "Pulled nothing")) 430 | elif "CONFLICT" in pull: 431 | messages.append(colorize(Colors.FAIL, "Pull conflict")) 432 | elif "fatal: No remote repository specified." in pull \ 433 | or "There is no tracking information for the current branch." in pull: 434 | messages.append(colorize(Colors.WARNING, "Pull remote not configured")) 435 | elif "fatal: " in pull: 436 | messages.append(colorize(Colors.FAIL, "Pull fatal")) 437 | messages.append("\n" + pull) 438 | else: 439 | messages.append(colorize(Colors.OKBLUE, "Pulled")) 440 | 441 | if options.fetch: 442 | # fetch from the remote 443 | # deal with [deleted] [new branch] and sha 444 | status, fetch = self.run( 445 | '%s git fetch --all --prune %s' 446 | % (cmdprefix, ' '.join(options.remote.split(":"))), options, self.path 447 | ) 448 | if options.verbose: 449 | self.write_to_msg_buffer(fetch + "\n") 450 | if "error: " in fetch: 451 | messages.append(colorize(Colors.FAIL, "Fetch fatal")) 452 | else: 453 | messages.append(colorize(Colors.OKPURPLE, "Fetched")) 454 | if status != 0: 455 | messages.append(colorize(Colors.FAIL, "Fetch unsuccessful")) 456 | 457 | if options.command: 458 | exit_status, output = self.run( 459 | '%s %s' % (cmdprefix, options.command), 460 | options, self.path 461 | ) 462 | if not options.colors: 463 | output = decolorize('', output) 464 | if not options.quiet: 465 | messages.append('\n' + output) 466 | if exit_status != 0: 467 | msg = "The command exited with status {s} in {r}\nThe output was:{o}" 468 | msg = msg.format(s=exit_status, r=self.path, o=output) 469 | self.write_to_msg_buffer(colorize(Colors.FAIL, msg)) 470 | 471 | if options.cbranch: 472 | status, checkoutbranch = self.run( 473 | '%s git checkout %s' 474 | % (cmdprefix, options.cbranch), options, self.path 475 | ) 476 | if "Already on" in checkoutbranch: 477 | if not options.hide_clean: 478 | messages.append(colorize(Colors.OKGREEN, "No action")) 479 | elif "error: " in checkoutbranch: 480 | messages.append(colorize(Colors.FAIL, "Checkout failed")) 481 | else: 482 | messages.append(colorize(Colors.OKBLUE, "Checkout successful")) 483 | 484 | if not options.count and messages: 485 | self.write_to_msg_buffer(self.path.ljust(options.align) + ": ") 486 | write_with_color(self.msg_buffer, ", ".join(messages) + "\n") 487 | 488 | if options.verbose: 489 | self.write_to_msg_buffer("---------------- " + self.path + " -----------------\n") 490 | 491 | def run(self, command: str, options: argparse.Namespace, work_dir: str) -> Tuple[int, str]: 492 | if options.verbose: 493 | self.write_to_msg_buffer("running %s\n" % command) 494 | try: 495 | output = subprocess.check_output(command, stderr=subprocess.STDOUT, shell=True, cwd=work_dir) 496 | if isinstance(output, bytes): 497 | output = output.decode("utf-8") 498 | return 0, output 499 | except subprocess.CalledProcessError as e: 500 | if isinstance(e.output, bytes): 501 | e.output = e.output.decode("utf-8") 502 | return e.returncode, e.output 503 | 504 | def write_to_msg_buffer(self, msg: str) -> None: 505 | self.msg_buffer.write(msg) 506 | 507 | def get_msg_buffer_as_str(self) -> str: 508 | return self.msg_buffer.getvalue() 509 | 510 | 511 | def scan(dirpath: str, dirnames: List[str], options: argparse.Namespace) -> List[GitDir]: 512 | """ 513 | Check the subdirectories of a single directory. 514 | See if they are versioned in git and display the requested information. 515 | """ 516 | 517 | def dir_filter(path: str) -> bool: 518 | # Remove excluded directories 519 | for ex in options.exclude: 520 | if re.search(ex, path): 521 | if options.verbose: 522 | print(f'skipping {path}') 523 | return False 524 | 525 | # Remove if is there a .clustergit-ignore file 526 | if os.path.exists(os.path.join(path, ".clustergit-ignore")): 527 | if options.verbose: 528 | print(f'skipping {path} directory') 529 | return False 530 | 531 | # Remove if there is no .git dir 532 | if os.path.exists(os.path.join(path, ".git")): 533 | if options.skipSymLinks and os.path.islink(path): 534 | if options.verbose: 535 | print(f'skipping {path} symbolic link') 536 | return False 537 | else: 538 | # Not a git directory 539 | if options.unversioned: 540 | sys.stdout.write(path.ljust(options.align) + ": ") 541 | write_with_color(sys.stdout, colorize(Colors.WARNING, "Not a GIT repository") + "\n") 542 | sys.stdout.flush() 543 | return False 544 | 545 | return True 546 | 547 | # Sort directories by name 548 | dirnames.sort() 549 | 550 | # Filter directories and convert to paths 551 | paths = [os.path.join(dirpath, dirname) for dirname in dirnames] 552 | 553 | git_dirs = [GitDir(path, options) for path in paths if dir_filter(path)] 554 | 555 | return git_dirs 556 | 557 | 558 | def analyze(git_dirs: List[GitDir], options: argparse.Namespace) -> int: 559 | def analyze_single(git_dir: GitDir) -> GitDir: 560 | git_dir.analyze(options) 561 | return git_dir 562 | 563 | spinner = itertools.cycle(['. ', ' . ', ' . ', ' .', ' . ', ' . ']) 564 | dirties = 0 565 | with concurrent.futures.ThreadPoolExecutor(max_workers=options.thread_pool_workers) as executor: 566 | # Submit for parallel execution 567 | fs = {executor.submit(analyze_single, git_dir): git_dir.path for git_dir in git_dirs} 568 | 569 | try: 570 | if options.print_asap: 571 | # Print results soon as one is finished (prints faster but does not preserve order) 572 | for future in concurrent.futures.as_completed(fs): 573 | write_with_color(sys.stdout, future.result().get_msg_buffer_as_str()) 574 | else: 575 | # Wait for parallel execution to finish 576 | for i, (future, label) in enumerate(fs.items()): 577 | while not future.done(): 578 | clearline(f'Waiting for {label} {next(spinner)} ({i + 1}/{len(fs)})') 579 | time.sleep(0.1) 580 | write_with_color(sys.stdout, future.result().get_msg_buffer_as_str()) 581 | finally: 582 | for future in fs: 583 | future.cancel() 584 | 585 | for git_dir in git_dirs: 586 | if git_dir.dirty: 587 | dirties += 1 588 | 589 | return dirties 590 | 591 | 592 | # ------------------- 593 | # Now, onto the main event! 594 | # ------------------- 595 | def main(args: List[str]) -> None: 596 | try: 597 | options = read_arguments(args) 598 | if options.clear: 599 | os.system('clear') 600 | 601 | # If there are no dirnames set, the list will be empty; 602 | # set to default value of current directory. 603 | if not options.dirname: 604 | options.dirname = ['.'] 605 | 606 | if not options.quiet: 607 | print(f'Scanning sub directories of {options.dirname}') 608 | 609 | if not options.colors: 610 | # noinspection PyGlobalUndefined 611 | global colorize 612 | colorize = decolorize 613 | 614 | options.global_ignore_file = os.path.expandvars(options.global_ignore_file) 615 | 616 | if options.global_ignore_file and os.path.exists(options.global_ignore_file): 617 | print(f'Reading global .clustergit-ignore file from {options.global_ignore_file}') 618 | with open(options.global_ignore_file, 'r') as ignore_file: 619 | for line in ignore_file: 620 | options.exclude.append(re.compile(line.strip())) 621 | 622 | git_dirs = [] 623 | 624 | for directory in options.dirname: 625 | for (path, dirs, files) in os.walk(directory, topdown=True): 626 | 627 | # Filter dirs to prevent unnecessary recursion in subdirectories 628 | if options.exclude: 629 | dirs[:] = [d for d in dirs if not any([re.search(regex, os.path.join(path, d)) for regex in options.exclude])] 630 | 631 | git_dirs.extend(scan(dirpath=path, dirnames=dirs, options=options)) 632 | if not options.recursive: 633 | break 634 | 635 | if len(git_dirs) == 0: 636 | die_with_error("None of those sub directories had a .git file") 637 | 638 | dirties = analyze(git_dirs, options) 639 | 640 | if options.count: 641 | print(str(dirties)) 642 | if dirties == 0 and options.hide_clean: 643 | print("All repos clean") 644 | 645 | if not options.quiet: 646 | print("Done") 647 | except (KeyboardInterrupt, SystemExit): 648 | print("") 649 | 650 | 651 | if __name__ == '__main__': 652 | main(sys.argv[1:]) 653 | --------------------------------------------------------------------------------