Neels Hofmeyr | b459b6c | 2018-10-31 21:35:36 +0100 | [diff] [blame] | 1 | #!/usr/bin/env python3 |
| 2 | # |
| 3 | # (C) 2018 by Neels Hofmeyr <neels@hofmeyr.de> |
| 4 | # All rights reserved. |
| 5 | # |
| 6 | # This program is free software: you can redistribute it and/or modify |
| 7 | # it under the terms of the GNU General Public License as published by |
| 8 | # the Free Software Foundation, either version 3 of the License, or |
| 9 | # (at your option) any later version. |
| 10 | # |
| 11 | # This program is distributed in the hope that it will be useful, |
| 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of |
| 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the |
| 14 | # GNU General Public License for more details. |
| 15 | # |
| 16 | # You should have received a copy of the GNU General Public License |
| 17 | # along with this program. If not, see <http://www.gnu.org/licenses/>. |
| 18 | |
| 19 | import sys |
| 20 | import subprocess |
Neels Hofmeyr | b459b6c | 2018-10-31 21:35:36 +0100 | [diff] [blame] | 21 | import argparse |
| 22 | import os |
| 23 | import shlex |
| 24 | |
| 25 | doc = '''gits: conveniently manage several git subdirectories. |
| 26 | Instead of doing the 'cd foo; git status; cd ../bar; git status' dance, this |
| 27 | helps to save your time with: status, fetch, rebase, ... |
| 28 | ''' |
| 29 | |
Neels Hofmeyr | b459b6c | 2018-10-31 21:35:36 +0100 | [diff] [blame] | 30 | |
| 31 | def error(*msgs): |
| 32 | sys.stderr.write(''.join(msgs)) |
| 33 | sys.stderr.write('\n') |
| 34 | exit(1) |
| 35 | |
| 36 | |
| 37 | def cmd_to_str(cmd): |
| 38 | return ' '.join(shlex.quote(c) for c in cmd) |
| 39 | |
| 40 | |
| 41 | def git(git_dir, *args, may_fail=False, section_marker=False, show_cmd=True): |
| 42 | sys.stdout.flush() |
| 43 | sys.stderr.flush() |
| 44 | |
| 45 | if section_marker: |
| 46 | print('\n===== %s =====' % git_dir) |
| 47 | sys.stdout.flush() |
| 48 | |
| 49 | cmd = ['git', '-C', git_dir] + list(args) |
| 50 | if show_cmd: |
| 51 | print('+ %s' % cmd_to_str(cmd)) |
| 52 | sys.stdout.flush() |
| 53 | |
| 54 | rc = subprocess.call(cmd) |
| 55 | if rc and not may_fail: |
| 56 | error('git returned error! command: git -C %r %s' % |
| 57 | (git_dir, ' '.join(repr(arg) for arg in args))) |
| 58 | |
| 59 | |
| 60 | def git_output(git_dir, *args): |
| 61 | return subprocess.check_output(['git', '-C', git_dir, ] + list(args)).decode('utf-8') |
| 62 | |
| 63 | |
Oliver Smith | b93f504 | 2018-11-09 10:34:36 +0100 | [diff] [blame^] | 64 | def git_bool(git_dir, *args): |
| 65 | try: |
| 66 | subprocess.check_output(['git', '-C', git_dir, ] + list(args)) |
| 67 | return True |
| 68 | except subprocess.CalledProcessError as e: |
| 69 | return False |
Neels Hofmeyr | b459b6c | 2018-10-31 21:35:36 +0100 | [diff] [blame] | 70 | |
| 71 | |
Oliver Smith | b93f504 | 2018-11-09 10:34:36 +0100 | [diff] [blame^] | 72 | def git_branch_exists(git_dir, branch='origin/master'): |
| 73 | return git_bool(git_dir, 'rev-parse', '--quiet', '--verify', branch) |
Neels Hofmeyr | b459b6c | 2018-10-31 21:35:36 +0100 | [diff] [blame] | 74 | |
Neels Hofmeyr | b459b6c | 2018-10-31 21:35:36 +0100 | [diff] [blame] | 75 | |
Oliver Smith | b93f504 | 2018-11-09 10:34:36 +0100 | [diff] [blame^] | 76 | def git_ahead_behind(git_dir, branch='master', remote='origin'): |
| 77 | ''' Count revisions ahead/behind of the remote branch. |
| 78 | returns: (ahead, behind) (e.g. (0, 5)) ''' |
Neels Hofmeyr | b459b6c | 2018-10-31 21:35:36 +0100 | [diff] [blame] | 79 | |
Oliver Smith | b93f504 | 2018-11-09 10:34:36 +0100 | [diff] [blame^] | 80 | # Missing remote branch |
| 81 | if not git_branch_exists(git_dir, remote + '/' + branch): |
| 82 | return (0, 0) |
Neels Hofmeyr | b459b6c | 2018-10-31 21:35:36 +0100 | [diff] [blame] | 83 | |
Oliver Smith | b93f504 | 2018-11-09 10:34:36 +0100 | [diff] [blame^] | 84 | behind = git_output(git_dir, 'rev-list', '--count', '%s..%s/%s' % (branch, remote, branch)) |
| 85 | ahead = git_output(git_dir, 'rev-list', '--count', '%s/%s..%s' % (remote, branch, branch)) |
| 86 | return (int(ahead.rstrip()), int(behind.rstrip())) |
| 87 | |
| 88 | |
| 89 | def git_branches(git_dir, obj='refs/heads'): |
| 90 | ret = git_output(git_dir, 'for-each-ref', obj, '--format', '%(refname:short)') |
| 91 | return ret.splitlines() |
| 92 | |
| 93 | |
| 94 | def git_branch_current(git_dir): |
| 95 | ret = git_output(git_dir, 'rev-parse', '--abbrev-ref', 'HEAD').rstrip() |
| 96 | if ret == 'HEAD': |
| 97 | return None |
| 98 | return ret |
| 99 | |
| 100 | |
| 101 | def git_has_modifications(git_dir): |
| 102 | return not git_bool(git_dir, 'diff-index', '--quiet', 'HEAD') |
| 103 | |
| 104 | |
| 105 | def git_can_fast_forward(git_dir, branch='master', remote='origin'): |
| 106 | return git_bool(git_dir, 'merge-base', '--is-ancestor', 'HEAD', remote + '/' + branch) |
| 107 | |
| 108 | |
| 109 | def format_branch_ahead_behind(branch, ahead, behind): |
| 110 | ''' branch: string like "master" |
| 111 | ahead, behind: integers like 5, 3 |
| 112 | returns: string like "master", "master[+5]", "master[-3]", "master[+5|-3]" ''' |
| 113 | # Just the branch |
| 114 | if not ahead and not behind: |
| 115 | return branch |
| 116 | |
| 117 | # Suffix with ahead/behind |
| 118 | ret = branch + '[' |
| 119 | if ahead: |
| 120 | ret += '+' + str(ahead) |
| 121 | if behind: |
| 122 | ret += '|' |
| 123 | if behind: |
| 124 | ret += '-' + str(behind) |
| 125 | ret += ']' |
| 126 | return ret |
Neels Hofmeyr | b459b6c | 2018-10-31 21:35:36 +0100 | [diff] [blame] | 127 | |
| 128 | |
| 129 | def git_branch_summary(git_dir): |
| 130 | '''return a list of strings: [git_dir, branch-info0, branch-info1,...] |
| 131 | infos are are arbitrary strings like "master[-1]"''' |
| 132 | |
| 133 | interesting_branch_names = ('master',) |
| 134 | |
| 135 | strs = [git_dir, ] |
Oliver Smith | b93f504 | 2018-11-09 10:34:36 +0100 | [diff] [blame^] | 136 | if git_has_modifications(git_dir): |
Neels Hofmeyr | b459b6c | 2018-10-31 21:35:36 +0100 | [diff] [blame] | 137 | strs.append('MODS') |
| 138 | |
Oliver Smith | b93f504 | 2018-11-09 10:34:36 +0100 | [diff] [blame^] | 139 | branch_current = git_branch_current(git_dir) |
| 140 | for branch in git_branches(git_dir): |
| 141 | is_current = (branch == branch_current) |
| 142 | if not is_current and branch not in interesting_branch_names: |
Neels Hofmeyr | b459b6c | 2018-10-31 21:35:36 +0100 | [diff] [blame] | 143 | continue |
Oliver Smith | b93f504 | 2018-11-09 10:34:36 +0100 | [diff] [blame^] | 144 | |
| 145 | ahead, behind = git_ahead_behind(git_dir, branch) |
| 146 | if not ahead and not behind and not is_current: |
Neels Hofmeyr | b459b6c | 2018-10-31 21:35:36 +0100 | [diff] [blame] | 147 | # skip branches that are "not interesting" |
| 148 | continue |
Neels Hofmeyr | b459b6c | 2018-10-31 21:35:36 +0100 | [diff] [blame] | 149 | |
Oliver Smith | b93f504 | 2018-11-09 10:34:36 +0100 | [diff] [blame^] | 150 | # Branch with ahead/behind origin info ("master[+1|-5]") |
| 151 | strs.append(format_branch_ahead_behind(branch, ahead, behind)) |
Neels Hofmeyr | b459b6c | 2018-10-31 21:35:36 +0100 | [diff] [blame] | 152 | return strs |
| 153 | |
| 154 | |
| 155 | def format_summaries(summaries, sep0=' ', sep1=' '): |
| 156 | first_col = max([len(row[0]) for row in summaries]) |
| 157 | first_col_fmt = '%' + str(first_col) + 's' |
| 158 | |
| 159 | lines = [] |
| 160 | for row in summaries: |
| 161 | lines.append('%s%s%s' % (first_col_fmt % |
| 162 | row[0], sep0, sep1.join(row[1:]))) |
| 163 | |
| 164 | return '\n'.join(lines) |
| 165 | |
| 166 | |
| 167 | def git_dirs(): |
| 168 | dirs = [] |
| 169 | for sub in os.listdir(): |
| 170 | git_path = os.path.join(sub, '.git') |
| 171 | if not os.path.isdir(git_path): |
| 172 | continue |
| 173 | dirs.append(sub) |
| 174 | |
| 175 | if not dirs: |
| 176 | error('No subdirectories found that are git clones') |
| 177 | |
| 178 | return list(sorted(dirs)) |
| 179 | |
| 180 | |
| 181 | def print_status(): |
| 182 | infos = [git_branch_summary(git_dir) for git_dir in git_dirs()] |
| 183 | print(format_summaries(infos)) |
| 184 | |
| 185 | |
| 186 | def cmd_do(argv): |
| 187 | for git_dir in git_dirs(): |
| 188 | git(git_dir, *argv, may_fail=True, section_marker=True) |
| 189 | |
| 190 | |
| 191 | def cmd_sh(cmd): |
| 192 | if not cmd: |
| 193 | error('which command do you want to run?') |
| 194 | for git_dir in git_dirs(): |
| 195 | print('\n===== %s =====' % git_dir) |
| 196 | print('+ %s' % cmd_to_str(cmd)) |
| 197 | sys.stdout.flush() |
| 198 | subprocess.call(cmd, cwd=git_dir) |
| 199 | sys.stdout.flush() |
| 200 | sys.stderr.flush() |
| 201 | |
| 202 | |
| 203 | class SkipThisRepo(Exception): |
| 204 | pass |
| 205 | |
| 206 | |
| 207 | def ask(git_dir, *question, valid_answers=('*',)): |
| 208 | while True: |
| 209 | print('\n' + '\n '.join(question)) |
| 210 | print(' ' + '\n '.join(( |
| 211 | 's skip this repo', |
| 212 | 't show in tig', |
| 213 | 'g show in gitk', |
| 214 | ))) |
| 215 | |
| 216 | answer = sys.stdin.readline().strip() |
| 217 | if answer == 's': |
| 218 | raise SkipThisRepo() |
| 219 | if answer == 't': |
| 220 | subprocess.call(('tig', '--all'), cwd=git_dir) |
| 221 | continue |
| 222 | if answer == 'g': |
| 223 | subprocess.call(('gitk', '--all'), cwd=git_dir) |
| 224 | continue |
| 225 | |
| 226 | for v in valid_answers: |
| 227 | if v == answer: |
| 228 | return answer |
| 229 | if v == '*': |
| 230 | return answer |
| 231 | if v == '+' and len(answer): |
| 232 | return answer |
| 233 | |
| 234 | |
| 235 | def rebase(git_dir): |
Oliver Smith | b93f504 | 2018-11-09 10:34:36 +0100 | [diff] [blame^] | 236 | orig_branch = git_branch_current(git_dir) |
Neels Hofmeyr | b459b6c | 2018-10-31 21:35:36 +0100 | [diff] [blame] | 237 | if orig_branch is None: |
| 238 | print('Not on a branch: %s' % git_dir) |
| 239 | raise SkipThisRepo() |
| 240 | |
Oliver Smith | b93f504 | 2018-11-09 10:34:36 +0100 | [diff] [blame^] | 241 | print('Rebasing branch: ' + orig_branch) |
| 242 | ahead, behind = git_ahead_behind(git_dir, orig_branch) |
| 243 | |
| 244 | if git_has_modifications(git_dir): |
Neels Hofmeyr | b459b6c | 2018-10-31 21:35:36 +0100 | [diff] [blame] | 245 | do_commit = ask(git_dir, 'Local mods.', |
| 246 | 'c commit to this branch', |
| 247 | '<name> commit to new branch', |
| 248 | '<empty> skip') |
| 249 | |
| 250 | if not do_commit: |
| 251 | raise SkipThisRepo() |
| 252 | |
| 253 | if do_commit == 'c': |
| 254 | git(git_dir, 'commit', '-am', 'wip', may_fail=True) |
| 255 | else: |
| 256 | git(git_dir, 'checkout', '-b', do_commit) |
| 257 | git(git_dir, 'commit', '-am', 'wip', may_fail=True) |
| 258 | git(git_dir, 'checkout', orig_branch) |
| 259 | |
Oliver Smith | b93f504 | 2018-11-09 10:34:36 +0100 | [diff] [blame^] | 260 | if git_has_modifications(git_dir): |
Neels Hofmeyr | b459b6c | 2018-10-31 21:35:36 +0100 | [diff] [blame] | 261 | print('There still are local modifications') |
| 262 | raise SkipThisRepo() |
| 263 | |
Oliver Smith | b93f504 | 2018-11-09 10:34:36 +0100 | [diff] [blame^] | 264 | # Missing upstream branch |
| 265 | if not git_branch_exists(git_dir, 'origin/' + orig_branch): |
Neels Hofmeyr | b459b6c | 2018-10-31 21:35:36 +0100 | [diff] [blame] | 266 | print('there is no upstream branch for %r' % orig_branch) |
| 267 | |
Oliver Smith | b93f504 | 2018-11-09 10:34:36 +0100 | [diff] [blame^] | 268 | # Diverged |
| 269 | elif ahead and behind: |
| 270 | do_reset = ask(git_dir, 'Diverged.', |
| 271 | '%s: git reset --hard origin/%s?' % ( |
| 272 | orig_branch, orig_branch), |
| 273 | '<empty> no', |
| 274 | 'OK yes (write OK in caps!)', |
| 275 | valid_answers=('', 'OK')) |
| 276 | |
| 277 | if do_reset == 'OK': |
| 278 | git(git_dir, 'reset', '--hard', 'origin/%s' % orig_branch) |
| 279 | |
| 280 | # Behind |
| 281 | elif behind: |
| 282 | if git_can_fast_forward(git_dir, orig_branch): |
Neels Hofmeyr | b459b6c | 2018-10-31 21:35:36 +0100 | [diff] [blame] | 283 | print('fast-forwarding...') |
| 284 | git(git_dir, 'merge') |
| 285 | else: |
| 286 | do_merge = ask(git_dir, 'Behind. git merge?', |
| 287 | "<empty> don't merge", |
| 288 | 'ok git merge', |
| 289 | valid_answers=('', 'ok') |
| 290 | ) |
| 291 | |
| 292 | if do_merge == 'ok': |
| 293 | git(git_dir, 'merge') |
| 294 | |
Oliver Smith | b93f504 | 2018-11-09 10:34:36 +0100 | [diff] [blame^] | 295 | # Ahead |
| 296 | elif ahead: |
Neels Hofmeyr | b459b6c | 2018-10-31 21:35:36 +0100 | [diff] [blame] | 297 | do_commit = ask(git_dir, 'Ahead. commit to new branch?', |
| 298 | '<empty> no', |
| 299 | '<name> create new branch', |
| 300 | ) |
| 301 | if do_commit: |
| 302 | git(git_dir, 'checkout', '-b', do_commit) |
| 303 | git(git_dir, 'commit', '-am', 'wip', may_fail=True) |
| 304 | git(git_dir, 'checkout', orig_branch) |
| 305 | |
| 306 | do_reset = ask(git_dir, '%s: git reset --hard origin/%s?' % (orig_branch, orig_branch), |
| 307 | '<empty> no', |
| 308 | 'OK yes (write OK in caps!)', |
| 309 | valid_answers=('', 'OK')) |
| 310 | |
| 311 | if do_reset == 'OK': |
| 312 | git(git_dir, 'reset', '--hard', 'origin/%s' % orig_branch) |
| 313 | |
Neels Hofmeyr | b459b6c | 2018-10-31 21:35:36 +0100 | [diff] [blame] | 314 | return orig_branch |
| 315 | |
| 316 | |
| 317 | def cmd_rebase(): |
| 318 | skipped = [] |
| 319 | for git_dir in git_dirs(): |
| 320 | try: |
| 321 | print('\n\n===== %s =====' % git_dir) |
| 322 | sys.stdout.flush() |
| 323 | |
| 324 | branch = rebase(git_dir) |
| 325 | if branch != 'master': |
| 326 | git(git_dir, 'checkout', 'master') |
| 327 | rebase(git_dir) |
| 328 | git(git_dir, 'checkout', branch) |
| 329 | |
| 330 | except SkipThisRepo: |
| 331 | print('\nSkipping %r' % git_dir) |
| 332 | skipped.append(git_dir) |
| 333 | |
| 334 | print('\n\n==========\nrebase done.\n') |
| 335 | print_status() |
| 336 | if skipped: |
| 337 | print('\nskipped: %s' % ' '.join(skipped)) |
| 338 | |
| 339 | |
| 340 | def parse_args(): |
| 341 | parser = argparse.ArgumentParser(description=doc) |
| 342 | sub = parser.add_subparsers(title='action', dest='action') |
| 343 | sub.required = True |
| 344 | |
| 345 | # status |
| 346 | sub.add_parser('status', aliases=['st', 's'], |
| 347 | help='show a branch summary and indicate modifications') |
| 348 | |
| 349 | # fetch |
| 350 | fetch = sub.add_parser('fetch', aliases=['f'], |
| 351 | help="run 'git fetch' in each clone (use before rebase)") |
| 352 | fetch.add_argument('remainder', nargs=argparse.REMAINDER, |
| 353 | help='additional arguments to be passed to git fetch') |
| 354 | |
| 355 | # rebase |
| 356 | sub.add_parser('rebase', aliases=['r', 're'], |
| 357 | help='interactively ff-merge master, rebase current branches') |
| 358 | |
| 359 | # sh |
| 360 | sh = sub.add_parser('sh', |
| 361 | help='run shell command in each clone (`gits sh echo hi`)') |
| 362 | sh.add_argument('remainder', nargs=argparse.REMAINDER, |
| 363 | help='command to run in each clone') |
| 364 | |
| 365 | # do |
| 366 | do = sub.add_parser('do', |
| 367 | help='run git command in each clone (`gits do clean -dxf`)') |
| 368 | do.add_argument('remainder', nargs=argparse.REMAINDER, |
| 369 | help='git command to run in each clone') |
| 370 | return parser.parse_args() |
| 371 | |
| 372 | |
| 373 | if __name__ == '__main__': |
| 374 | args = parse_args() |
| 375 | if args.action in ['status', 's', 'st']: |
| 376 | print_status() |
| 377 | elif args.action in ['fetch', 'f']: |
| 378 | cmd_do(['fetch'] + args.remainder) |
| 379 | elif args.action in ['rebase', 'r']: |
| 380 | cmd_rebase() |
| 381 | elif args.action == 'sh': |
| 382 | cmd_sh(args.remainder) |
| 383 | elif args.action == 'do': |
| 384 | cmd_do(args.remainder) |
| 385 | |
| 386 | # vim: shiftwidth=4 expandtab tabstop=4 |