blob: ea2e0584365ce18114ccbbd324b805770d780612 [file] [log] [blame]
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +01001#!/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
19import sys
20import subprocess
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +010021import argparse
22import os
23import shlex
24
25doc = '''gits: conveniently manage several git subdirectories.
26Instead of doing the 'cd foo; git status; cd ../bar; git status' dance, this
27helps to save your time with: status, fetch, rebase, ...
28'''
29
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +010030
31def error(*msgs):
32 sys.stderr.write(''.join(msgs))
33 sys.stderr.write('\n')
34 exit(1)
35
36
37def cmd_to_str(cmd):
38 return ' '.join(shlex.quote(c) for c in cmd)
39
40
41def 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
60def git_output(git_dir, *args):
Neels Hofmeyr68d8f342018-11-12 22:44:08 +010061 return subprocess.check_output(['git', '-C', git_dir, ] + list(args), stderr=subprocess.STDOUT).decode('utf-8')
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +010062
63
Oliver Smithb93f5042018-11-09 10:34:36 +010064def 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 Hofmeyrb459b6c2018-10-31 21:35:36 +010070
Neels Hofmeyrbc2caa32020-08-26 15:24:13 +020071def safe_branch_name(branch):
72 if '/' in branch:
73 return branch
74 return 'refs/heads/' + branch
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +010075
Oliver Smithb93f5042018-11-09 10:34:36 +010076def git_branches(git_dir, obj='refs/heads'):
77 ret = git_output(git_dir, 'for-each-ref', obj, '--format', '%(refname:short)')
78 return ret.splitlines()
79
80
81def git_branch_current(git_dir):
82 ret = git_output(git_dir, 'rev-parse', '--abbrev-ref', 'HEAD').rstrip()
83 if ret == 'HEAD':
84 return None
85 return ret
86
87
Neels Hofmeyr68d8f342018-11-12 22:44:08 +010088def git_branch_upstream(git_dir, branch_name='HEAD'):
89 '''Return an upstream branch name, or an None if there is none.'''
90 try:
91 return git_output(git_dir, 'rev-parse', '--abbrev-ref', '%s@{u}' % branch_name).rstrip()
92 except subprocess.CalledProcessError:
93 return None
94
95
Oliver Smithb93f5042018-11-09 10:34:36 +010096def git_has_modifications(git_dir):
Oliver Smith2a30b522018-11-16 16:47:57 +010097 return not git_bool(git_dir, 'diff', '--quiet', 'HEAD')
Oliver Smithb93f5042018-11-09 10:34:36 +010098
99
Neels Hofmeyr68d8f342018-11-12 22:44:08 +0100100def git_can_fast_forward(git_dir, branch, branch_upstream):
101 return git_bool(git_dir, 'merge-base', '--is-ancestor', branch, branch_upstream)
Oliver Smithb93f5042018-11-09 10:34:36 +0100102
103
Neels Hofmeyr67e53d92020-09-08 01:37:05 +0200104class AheadBehind:
105 ''' Count revisions ahead/behind of the remote branch.
106 returns: (ahead, behind) (e.g. (0, 5)) '''
107 def __init__(s, git_dir, local, remote):
108 s.git_dir = git_dir
109 s.local = local
110 s.remote = remote
111 s.can_ff = False
Oliver Smithb93f5042018-11-09 10:34:36 +0100112
Neels Hofmeyr67e53d92020-09-08 01:37:05 +0200113 if not remote:
114 s.ahead = 0
115 s.behind = 0
116 else:
117 behind_str = git_output(git_dir, 'rev-list', '--count', '%s..%s' % (safe_branch_name(local), remote))
118 ahead_str = git_output(git_dir, 'rev-list', '--count', '%s..%s' % (remote, safe_branch_name(local)))
119 s.ahead = int(ahead_str.rstrip())
120 s.behind = int(behind_str.rstrip())
121 s.can_ff = s.behind and git_can_fast_forward(git_dir, local, remote)
122
123
124
125 def is_diverged(s):
126 return s.ahead and s.behind
127
128 def is_behind(s):
129 return (not s.ahead) and s.behind
130
131 def is_ahead(s):
132 return s.ahead and not s.behind
133
134 def is_sync(s):
135 return s.ahead == 0 and s.behind == 0
136
137 def ff(s):
138 print('fast-forwarding %s to %s...' % (s.local, s.remote))
139 if git_branch_current(s.git_dir) != s.local:
140 git(s.git_dir, 'checkout', s.local)
141 git(s.git_dir, 'merge', s.remote)
142
143 def __str__(s):
144 # Just the branch
145 if not s.ahead and not s.behind:
146 return s.local
147
148 # Suffix with ahead/behind
149 ret = s.local + '['
150 if s.ahead:
151 ret += '+' + str(s.ahead)
152 if s.behind:
153 ret += '|'
154 if s.behind:
155 ret += '-' + str(s.behind)
156 ret += ']'
157 return ret
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100158
159
160def git_branch_summary(git_dir):
161 '''return a list of strings: [git_dir, branch-info0, branch-info1,...]
162 infos are are arbitrary strings like "master[-1]"'''
163
164 interesting_branch_names = ('master',)
165
166 strs = [git_dir, ]
Oliver Smithb93f5042018-11-09 10:34:36 +0100167 if git_has_modifications(git_dir):
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100168 strs.append('MODS')
169
Oliver Smithb93f5042018-11-09 10:34:36 +0100170 branch_current = git_branch_current(git_dir)
171 for branch in git_branches(git_dir):
172 is_current = (branch == branch_current)
173 if not is_current and branch not in interesting_branch_names:
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100174 continue
Oliver Smithb93f5042018-11-09 10:34:36 +0100175
Neels Hofmeyr67e53d92020-09-08 01:37:05 +0200176 ab = AheadBehind(git_dir, branch, git_branch_upstream(git_dir, branch))
Neels Hofmeyr68d8f342018-11-12 22:44:08 +0100177
Neels Hofmeyr67e53d92020-09-08 01:37:05 +0200178 if not ab.ahead and not ab.behind and not is_current:
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100179 # skip branches that are "not interesting"
180 continue
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100181
Neels Hofmeyr68d8f342018-11-12 22:44:08 +0100182 # Branch with ahead/behind upstream info ("master[+1|-5]")
Neels Hofmeyr67e53d92020-09-08 01:37:05 +0200183 strs.append(str(ab))
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100184 return strs
185
186
187def format_summaries(summaries, sep0=' ', sep1=' '):
188 first_col = max([len(row[0]) for row in summaries])
189 first_col_fmt = '%' + str(first_col) + 's'
190
191 lines = []
192 for row in summaries:
193 lines.append('%s%s%s' % (first_col_fmt %
194 row[0], sep0, sep1.join(row[1:])))
195
196 return '\n'.join(lines)
197
198
199def git_dirs():
200 dirs = []
201 for sub in os.listdir():
202 git_path = os.path.join(sub, '.git')
203 if not os.path.isdir(git_path):
204 continue
205 dirs.append(sub)
206
207 if not dirs:
208 error('No subdirectories found that are git clones')
209
210 return list(sorted(dirs))
211
212
213def print_status():
214 infos = [git_branch_summary(git_dir) for git_dir in git_dirs()]
215 print(format_summaries(infos))
216
217
218def cmd_do(argv):
219 for git_dir in git_dirs():
220 git(git_dir, *argv, may_fail=True, section_marker=True)
221
222
223def cmd_sh(cmd):
224 if not cmd:
225 error('which command do you want to run?')
226 for git_dir in git_dirs():
227 print('\n===== %s =====' % git_dir)
228 print('+ %s' % cmd_to_str(cmd))
229 sys.stdout.flush()
230 subprocess.call(cmd, cwd=git_dir)
231 sys.stdout.flush()
232 sys.stderr.flush()
233
234
235class SkipThisRepo(Exception):
236 pass
237
238
239def ask(git_dir, *question, valid_answers=('*',)):
240 while True:
241 print('\n' + '\n '.join(question))
242 print(' ' + '\n '.join((
243 's skip this repo',
244 't show in tig',
245 'g show in gitk',
246 )))
247
248 answer = sys.stdin.readline().strip()
249 if answer == 's':
250 raise SkipThisRepo()
251 if answer == 't':
252 subprocess.call(('tig', '--all'), cwd=git_dir)
253 continue
254 if answer == 'g':
255 subprocess.call(('gitk', '--all'), cwd=git_dir)
256 continue
257
258 for v in valid_answers:
259 if v == answer:
260 return answer
261 if v == '*':
262 return answer
263 if v == '+' and len(answer):
264 return answer
265
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100266def rebase(git_dir):
Oliver Smithb93f5042018-11-09 10:34:36 +0100267 orig_branch = git_branch_current(git_dir)
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100268 if orig_branch is None:
269 print('Not on a branch: %s' % git_dir)
270 raise SkipThisRepo()
271
Neels Hofmeyr68d8f342018-11-12 22:44:08 +0100272 upstream_branch = git_branch_upstream(git_dir, orig_branch)
273
Neels Hofmeyr94e0aec2019-03-15 15:34:30 +0100274 print('Checking for rebase of %r onto %r' % (orig_branch, upstream_branch))
Oliver Smithb93f5042018-11-09 10:34:36 +0100275
276 if git_has_modifications(git_dir):
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100277 do_commit = ask(git_dir, 'Local mods.',
278 'c commit to this branch',
279 '<name> commit to new branch',
Neels Hofmeyrbc2caa32020-08-26 15:24:13 +0200280 '<empty> skip this repo')
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100281
282 if not do_commit:
283 raise SkipThisRepo()
284
285 if do_commit == 'c':
286 git(git_dir, 'commit', '-am', 'wip', may_fail=True)
287 else:
288 git(git_dir, 'checkout', '-b', do_commit)
289 git(git_dir, 'commit', '-am', 'wip', may_fail=True)
290 git(git_dir, 'checkout', orig_branch)
291
Oliver Smithb93f5042018-11-09 10:34:36 +0100292 if git_has_modifications(git_dir):
Neels Hofmeyrbc2caa32020-08-26 15:24:13 +0200293 error('There still are local modifications')
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100294
Oliver Smithb93f5042018-11-09 10:34:36 +0100295 # Missing upstream branch
Neels Hofmeyr68d8f342018-11-12 22:44:08 +0100296 if not upstream_branch:
Neels Hofmeyrbc2caa32020-08-26 15:24:13 +0200297 do_set_upstream = ask(git_dir, 'there is no upstream branch for %r' % orig_branch,
298 '<empty> skip',
Neels Hofmeyr67e53d92020-09-08 01:37:05 +0200299 'P create upstream branch (git push --set-upstream orgin %s)' % orig_branch,
Neels Hofmeyrbc2caa32020-08-26 15:24:13 +0200300 'm checkout master',
301 valid_answers=('', 'p', 'm'))
302
303 if do_set_upstream == 'p':
304 git(git_dir, 'push', '--set-upstream', 'origin', orig_branch);
305 upstream_branch = git_branch_upstream(git_dir, orig_branch)
306 if not upstream_branch:
307 error('There still is no upstream branch')
308 elif do_set_upstream == 'm':
309 git(git_dir, 'checkout', 'master')
310 return orig_branch
311 else:
312 print('skipping branch, because there is no upstream: %r' % orig_branch)
313 return orig_branch
314
Neels Hofmeyr67e53d92020-09-08 01:37:05 +0200315 while True:
316 # bu: branch-to-upstream
317 # bm: branch-to-master
318 # um: upstream-to-master
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100319
Neels Hofmeyr67e53d92020-09-08 01:37:05 +0200320 upstream_branch = git_branch_upstream(git_dir, orig_branch)
321 um = AheadBehind(git_dir, upstream_branch, 'origin/master')
Oliver Smithb93f5042018-11-09 10:34:36 +0100322
Neels Hofmeyr67e53d92020-09-08 01:37:05 +0200323 bm = AheadBehind(git_dir, orig_branch, 'origin/master')
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100324
Neels Hofmeyr67e53d92020-09-08 01:37:05 +0200325 if bm.can_ff:
326 bm.ff()
327 continue
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100328
Neels Hofmeyr67e53d92020-09-08 01:37:05 +0200329 bu = AheadBehind(git_dir, orig_branch, upstream_branch)
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100330
Neels Hofmeyr67e53d92020-09-08 01:37:05 +0200331 if bu.can_ff:
332 bu.ff()
333 continue
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100334
Neels Hofmeyr67e53d92020-09-08 01:37:05 +0200335 if not bu.is_sync():
336 print(str(bu))
337 if not bm.is_sync():
338 print('to master: ' + str(bm))
339 if not um.is_sync():
340 print('upstream to master: ' + str(um))
Neels Hofmeyrefa34ac2019-03-15 15:34:45 +0100341
Neels Hofmeyr67e53d92020-09-08 01:37:05 +0200342 options = ['----- %s' % git_dir,
343 '<empty> skip']
344 valid_answers = ['']
345 all_good = True
Neels Hofmeyrefa34ac2019-03-15 15:34:45 +0100346
Neels Hofmeyr67e53d92020-09-08 01:37:05 +0200347 if um.is_diverged():
348 all_good = False
349 if bu.is_diverged():
350 options.append('rum rebase onto upstream, then onto master')
351 valid_answers.append('rum')
Neels Hofmeyrefa34ac2019-03-15 15:34:45 +0100352
Neels Hofmeyr67e53d92020-09-08 01:37:05 +0200353 #if bm.is_diverged():
354 options.append('rm rebase onto master: git rebase -i origin/master')
355 valid_answers.append('rm')
Neels Hofmeyrefa34ac2019-03-15 15:34:45 +0100356
Neels Hofmeyr67e53d92020-09-08 01:37:05 +0200357 if bu.is_diverged():
358 all_good = False
359 options.append('ru rebase onto upstream: git rebase -i %s' % upstream_branch)
360 valid_answers.append('ru')
361
362 options.append('RU reset to upstream: git reset --hard %s' % upstream_branch)
363 valid_answers.append('RU')
364
365 if bu.is_diverged() or bu.is_ahead():
366 all_good = False
367 options.append('P push to overwrite upstream: git push -f')
368 valid_answers.append('P')
369
370 if orig_branch == 'master' and (bm.is_ahead() or bm.is_diverged()):
371 all_good = False
372 options.append('<name> create new branch')
373 valid_answers.append('+')
374
375 if all_good:
376 break
377
378 do = ask(git_dir, *options, valid_answers=valid_answers)
379
380 if not do:
381 break
382
383 if do == 'rum' or do == 'ru':
384 git(git_dir, 'rebase', '-i', upstream_branch)
385
386 if do == 'rum' or do == 'rm':
387 git(git_dir, 'rebase', '-i', 'origin/master')
388
389 if do == 'RU':
390 git(git_dir, 'reset', '--hard', upstream_branch)
391
392 if do == 'P':
393 git(git_dir, 'push', '-f')
394
395 if do not in valid_answers:
396 new_branch = do
397 # create new branch
398 print('''git(git_dir, 'checkout', '-b', new_branch)''')
399 #orig_branch = new_branch
Neels Hofmeyrefa34ac2019-03-15 15:34:45 +0100400
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100401 return orig_branch
402
403
404def cmd_rebase():
405 skipped = []
406 for git_dir in git_dirs():
407 try:
408 print('\n\n===== %s =====' % git_dir)
409 sys.stdout.flush()
410
411 branch = rebase(git_dir)
412 if branch != 'master':
Neels Hofmeyr67e53d92020-09-08 01:37:05 +0200413 mm = AheadBehind(git_dir, 'master', 'origin/master')
414 if not mm.is_sync():
415 git(git_dir, 'checkout', 'master')
416 rebase(git_dir)
417 git(git_dir, 'checkout', branch)
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100418
419 except SkipThisRepo:
420 print('\nSkipping %r' % git_dir)
421 skipped.append(git_dir)
422
423 print('\n\n==========\nrebase done.\n')
424 print_status()
425 if skipped:
426 print('\nskipped: %s' % ' '.join(skipped))
427
428
429def parse_args():
430 parser = argparse.ArgumentParser(description=doc)
431 sub = parser.add_subparsers(title='action', dest='action')
432 sub.required = True
433
434 # status
435 sub.add_parser('status', aliases=['st', 's'],
436 help='show a branch summary and indicate modifications')
437
438 # fetch
439 fetch = sub.add_parser('fetch', aliases=['f'],
440 help="run 'git fetch' in each clone (use before rebase)")
441 fetch.add_argument('remainder', nargs=argparse.REMAINDER,
442 help='additional arguments to be passed to git fetch')
443
444 # rebase
445 sub.add_parser('rebase', aliases=['r', 're'],
446 help='interactively ff-merge master, rebase current branches')
447
448 # sh
449 sh = sub.add_parser('sh',
450 help='run shell command in each clone (`gits sh echo hi`)')
451 sh.add_argument('remainder', nargs=argparse.REMAINDER,
452 help='command to run in each clone')
453
454 # do
455 do = sub.add_parser('do',
456 help='run git command in each clone (`gits do clean -dxf`)')
457 do.add_argument('remainder', nargs=argparse.REMAINDER,
458 help='git command to run in each clone')
459 return parser.parse_args()
460
461
462if __name__ == '__main__':
463 args = parse_args()
464 if args.action in ['status', 's', 'st']:
465 print_status()
466 elif args.action in ['fetch', 'f']:
467 cmd_do(['fetch'] + args.remainder)
468 elif args.action in ['rebase', 'r']:
469 cmd_rebase()
470 elif args.action == 'sh':
471 cmd_sh(args.remainder)
472 elif args.action == 'do':
473 cmd_do(args.remainder)
474
475# vim: shiftwidth=4 expandtab tabstop=4