blob: b189b6c0c64fe22ff64cd67d6cc28db52d446c9d [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
Neels Hofmeyr68d8f342018-11-12 22:44:08 +010076def git_ahead_behind(git_dir, branch, branch_upstream):
Oliver Smithb93f5042018-11-09 10:34:36 +010077 ''' Count revisions ahead/behind of the remote branch.
78 returns: (ahead, behind) (e.g. (0, 5)) '''
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +010079
Oliver Smithb93f5042018-11-09 10:34:36 +010080 # Missing remote branch
Neels Hofmeyr68d8f342018-11-12 22:44:08 +010081 if not branch_upstream:
Oliver Smithb93f5042018-11-09 10:34:36 +010082 return (0, 0)
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +010083
Neels Hofmeyrbc2caa32020-08-26 15:24:13 +020084 behind = git_output(git_dir, 'rev-list', '--count', '%s..%s' % (safe_branch_name(branch), branch_upstream))
85 ahead = git_output(git_dir, 'rev-list', '--count', '%s..%s' % (branch_upstream, safe_branch_name(branch)))
Oliver Smithb93f5042018-11-09 10:34:36 +010086 return (int(ahead.rstrip()), int(behind.rstrip()))
87
88
89def 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
94def 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
Neels Hofmeyr68d8f342018-11-12 22:44:08 +0100101def git_branch_upstream(git_dir, branch_name='HEAD'):
102 '''Return an upstream branch name, or an None if there is none.'''
103 try:
104 return git_output(git_dir, 'rev-parse', '--abbrev-ref', '%s@{u}' % branch_name).rstrip()
105 except subprocess.CalledProcessError:
106 return None
107
108
Oliver Smithb93f5042018-11-09 10:34:36 +0100109def git_has_modifications(git_dir):
Oliver Smith2a30b522018-11-16 16:47:57 +0100110 return not git_bool(git_dir, 'diff', '--quiet', 'HEAD')
Oliver Smithb93f5042018-11-09 10:34:36 +0100111
112
Neels Hofmeyr68d8f342018-11-12 22:44:08 +0100113def git_can_fast_forward(git_dir, branch, branch_upstream):
114 return git_bool(git_dir, 'merge-base', '--is-ancestor', branch, branch_upstream)
Oliver Smithb93f5042018-11-09 10:34:36 +0100115
116
117def format_branch_ahead_behind(branch, ahead, behind):
118 ''' branch: string like "master"
119 ahead, behind: integers like 5, 3
120 returns: string like "master", "master[+5]", "master[-3]", "master[+5|-3]" '''
121 # Just the branch
122 if not ahead and not behind:
123 return branch
124
125 # Suffix with ahead/behind
126 ret = branch + '['
127 if ahead:
128 ret += '+' + str(ahead)
129 if behind:
130 ret += '|'
131 if behind:
132 ret += '-' + str(behind)
133 ret += ']'
134 return ret
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100135
136
137def git_branch_summary(git_dir):
138 '''return a list of strings: [git_dir, branch-info0, branch-info1,...]
139 infos are are arbitrary strings like "master[-1]"'''
140
141 interesting_branch_names = ('master',)
142
143 strs = [git_dir, ]
Oliver Smithb93f5042018-11-09 10:34:36 +0100144 if git_has_modifications(git_dir):
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100145 strs.append('MODS')
146
Oliver Smithb93f5042018-11-09 10:34:36 +0100147 branch_current = git_branch_current(git_dir)
148 for branch in git_branches(git_dir):
149 is_current = (branch == branch_current)
150 if not is_current and branch not in interesting_branch_names:
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100151 continue
Oliver Smithb93f5042018-11-09 10:34:36 +0100152
Neels Hofmeyr68d8f342018-11-12 22:44:08 +0100153 ahead, behind = git_ahead_behind(git_dir, branch,
154 git_branch_upstream(git_dir, branch))
155
Oliver Smithb93f5042018-11-09 10:34:36 +0100156 if not ahead and not behind and not is_current:
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100157 # skip branches that are "not interesting"
158 continue
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100159
Neels Hofmeyr68d8f342018-11-12 22:44:08 +0100160 # Branch with ahead/behind upstream info ("master[+1|-5]")
Oliver Smithb93f5042018-11-09 10:34:36 +0100161 strs.append(format_branch_ahead_behind(branch, ahead, behind))
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100162 return strs
163
164
165def format_summaries(summaries, sep0=' ', sep1=' '):
166 first_col = max([len(row[0]) for row in summaries])
167 first_col_fmt = '%' + str(first_col) + 's'
168
169 lines = []
170 for row in summaries:
171 lines.append('%s%s%s' % (first_col_fmt %
172 row[0], sep0, sep1.join(row[1:])))
173
174 return '\n'.join(lines)
175
176
177def git_dirs():
178 dirs = []
179 for sub in os.listdir():
180 git_path = os.path.join(sub, '.git')
181 if not os.path.isdir(git_path):
182 continue
183 dirs.append(sub)
184
185 if not dirs:
186 error('No subdirectories found that are git clones')
187
188 return list(sorted(dirs))
189
190
191def print_status():
192 infos = [git_branch_summary(git_dir) for git_dir in git_dirs()]
193 print(format_summaries(infos))
194
195
196def cmd_do(argv):
197 for git_dir in git_dirs():
198 git(git_dir, *argv, may_fail=True, section_marker=True)
199
200
201def cmd_sh(cmd):
202 if not cmd:
203 error('which command do you want to run?')
204 for git_dir in git_dirs():
205 print('\n===== %s =====' % git_dir)
206 print('+ %s' % cmd_to_str(cmd))
207 sys.stdout.flush()
208 subprocess.call(cmd, cwd=git_dir)
209 sys.stdout.flush()
210 sys.stderr.flush()
211
212
213class SkipThisRepo(Exception):
214 pass
215
216
217def ask(git_dir, *question, valid_answers=('*',)):
218 while True:
219 print('\n' + '\n '.join(question))
220 print(' ' + '\n '.join((
221 's skip this repo',
222 't show in tig',
223 'g show in gitk',
224 )))
225
226 answer = sys.stdin.readline().strip()
227 if answer == 's':
228 raise SkipThisRepo()
229 if answer == 't':
230 subprocess.call(('tig', '--all'), cwd=git_dir)
231 continue
232 if answer == 'g':
233 subprocess.call(('gitk', '--all'), cwd=git_dir)
234 continue
235
236 for v in valid_answers:
237 if v == answer:
238 return answer
239 if v == '*':
240 return answer
241 if v == '+' and len(answer):
242 return answer
243
244
Neels Hofmeyrae79f4b2018-11-23 04:09:10 +0100245def ask_reset_hard_or_push_f(git_dir, orig_branch, upstream_branch):
Neels Hofmeyrdff944b2018-11-12 23:27:49 +0100246 do_reset = ask(git_dir, 'Diverged.',
Neels Hofmeyrbc2caa32020-08-26 15:24:13 +0200247 '<empty> skip',
248 'RH git reset --hard %s' % upstream_branch,
249 'PF `push -f` to overwrite upstream',
250 valid_answers=('', 'RH', 'PF'))
Neels Hofmeyrdff944b2018-11-12 23:27:49 +0100251
Neels Hofmeyrbc2caa32020-08-26 15:24:13 +0200252 if do_reset == 'RH':
Neels Hofmeyrdff944b2018-11-12 23:27:49 +0100253 git(git_dir, 'reset', '--hard', upstream_branch)
Neels Hofmeyrbc2caa32020-08-26 15:24:13 +0200254 elif do_reset == 'PF':
Neels Hofmeyr20d95d02018-11-12 23:25:57 +0100255 git(git_dir, 'push', '-f')
Neels Hofmeyrdff944b2018-11-12 23:27:49 +0100256
257
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100258def rebase(git_dir):
Oliver Smithb93f5042018-11-09 10:34:36 +0100259 orig_branch = git_branch_current(git_dir)
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100260 if orig_branch is None:
261 print('Not on a branch: %s' % git_dir)
262 raise SkipThisRepo()
263
Neels Hofmeyr68d8f342018-11-12 22:44:08 +0100264 upstream_branch = git_branch_upstream(git_dir, orig_branch)
265
Neels Hofmeyr94e0aec2019-03-15 15:34:30 +0100266 print('Checking for rebase of %r onto %r' % (orig_branch, upstream_branch))
Oliver Smithb93f5042018-11-09 10:34:36 +0100267
268 if git_has_modifications(git_dir):
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100269 do_commit = ask(git_dir, 'Local mods.',
270 'c commit to this branch',
271 '<name> commit to new branch',
Neels Hofmeyrbc2caa32020-08-26 15:24:13 +0200272 '<empty> skip this repo')
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100273
274 if not do_commit:
275 raise SkipThisRepo()
276
277 if do_commit == 'c':
278 git(git_dir, 'commit', '-am', 'wip', may_fail=True)
279 else:
280 git(git_dir, 'checkout', '-b', do_commit)
281 git(git_dir, 'commit', '-am', 'wip', may_fail=True)
282 git(git_dir, 'checkout', orig_branch)
283
Oliver Smithb93f5042018-11-09 10:34:36 +0100284 if git_has_modifications(git_dir):
Neels Hofmeyrbc2caa32020-08-26 15:24:13 +0200285 error('There still are local modifications')
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100286
Oliver Smithb93f5042018-11-09 10:34:36 +0100287 # Missing upstream branch
Neels Hofmeyr68d8f342018-11-12 22:44:08 +0100288 if not upstream_branch:
Neels Hofmeyrbc2caa32020-08-26 15:24:13 +0200289 do_set_upstream = ask(git_dir, 'there is no upstream branch for %r' % orig_branch,
290 '<empty> skip',
291 'p create upstream branch (git push --set-upstream orgin %s)' % orig_branch,
292 'm checkout master',
293 valid_answers=('', 'p', 'm'))
294
295 if do_set_upstream == 'p':
296 git(git_dir, 'push', '--set-upstream', 'origin', orig_branch);
297 upstream_branch = git_branch_upstream(git_dir, orig_branch)
298 if not upstream_branch:
299 error('There still is no upstream branch')
300 elif do_set_upstream == 'm':
301 git(git_dir, 'checkout', 'master')
302 return orig_branch
303 else:
304 print('skipping branch, because there is no upstream: %r' % orig_branch)
305 return orig_branch
306
307 ahead, behind = git_ahead_behind(git_dir, orig_branch, upstream_branch)
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100308
Oliver Smithb93f5042018-11-09 10:34:36 +0100309 # Diverged
Neels Hofmeyrbc2caa32020-08-26 15:24:13 +0200310 if ahead and behind:
Neels Hofmeyrae79f4b2018-11-23 04:09:10 +0100311 ask_reset_hard_or_push_f(git_dir, orig_branch, upstream_branch)
Oliver Smithb93f5042018-11-09 10:34:36 +0100312
313 # Behind
314 elif behind:
Neels Hofmeyr68d8f342018-11-12 22:44:08 +0100315 if git_can_fast_forward(git_dir, orig_branch, upstream_branch):
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100316 print('fast-forwarding...')
317 git(git_dir, 'merge')
318 else:
319 do_merge = ask(git_dir, 'Behind. git merge?',
320 "<empty> don't merge",
Neels Hofmeyrbc2caa32020-08-26 15:24:13 +0200321 'm git merge',
322 valid_answers=('', 'm')
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100323 )
324
Neels Hofmeyrbc2caa32020-08-26 15:24:13 +0200325 if do_merge == 'm':
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100326 git(git_dir, 'merge')
327
Oliver Smithb93f5042018-11-09 10:34:36 +0100328 # Ahead
329 elif ahead:
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100330 do_commit = ask(git_dir, 'Ahead. commit to new branch?',
331 '<empty> no',
332 '<name> create new branch',
333 )
334 if do_commit:
335 git(git_dir, 'checkout', '-b', do_commit)
336 git(git_dir, 'commit', '-am', 'wip', may_fail=True)
337 git(git_dir, 'checkout', orig_branch)
338
Neels Hofmeyrae79f4b2018-11-23 04:09:10 +0100339 ask_reset_hard_or_push_f(git_dir, orig_branch, upstream_branch)
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100340
Neels Hofmeyrefa34ac2019-03-15 15:34:45 +0100341 if git_has_modifications(git_dir):
Neels Hofmeyrbc2caa32020-08-26 15:24:13 +0200342 error('There are local modifications')
Neels Hofmeyrefa34ac2019-03-15 15:34:45 +0100343
344 # Rebase onto origin/master? Only when this isn't already the master branch
345 if upstream_branch != 'origin/master':
346 ahead, behind = git_ahead_behind(git_dir, orig_branch, 'origin/master')
347
348 if ahead and behind:
349 do_rebase = ask(git_dir, '%r diverged from master. git rebase -i origin/master?' % orig_branch,
350 "<empty> don't rebase",
Neels Hofmeyrbc2caa32020-08-26 15:24:13 +0200351 'r rebase onto origin/master',
352 valid_answers=('', 'r'))
Neels Hofmeyrefa34ac2019-03-15 15:34:45 +0100353
Neels Hofmeyrbc2caa32020-08-26 15:24:13 +0200354 if do_rebase == 'r':
Neels Hofmeyrefa34ac2019-03-15 15:34:45 +0100355 git(git_dir, 'rebase', '-i', 'origin/master')
356 # On conflicts, we'll exit with error implicitly
357
Neels Hofmeyr7f46be32019-03-29 15:42:41 +0100358 if upstream_branch is not None:
359 do_push = ask(git_dir, 'git push -f to overwrite %r?' % upstream_branch,
360 "<empty> don't overwrite upstream",
361 'P `push -f` to overwrite upstream (P in caps!)',
362 valid_answers=('', 'P'))
363 if do_push == 'P':
364 git(git_dir, 'push', '-f')
Neels Hofmeyrefa34ac2019-03-15 15:34:45 +0100365
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100366 return orig_branch
367
368
369def cmd_rebase():
370 skipped = []
371 for git_dir in git_dirs():
372 try:
373 print('\n\n===== %s =====' % git_dir)
374 sys.stdout.flush()
375
376 branch = rebase(git_dir)
377 if branch != 'master':
378 git(git_dir, 'checkout', 'master')
379 rebase(git_dir)
380 git(git_dir, 'checkout', branch)
381
382 except SkipThisRepo:
383 print('\nSkipping %r' % git_dir)
384 skipped.append(git_dir)
385
386 print('\n\n==========\nrebase done.\n')
387 print_status()
388 if skipped:
389 print('\nskipped: %s' % ' '.join(skipped))
390
391
392def parse_args():
393 parser = argparse.ArgumentParser(description=doc)
394 sub = parser.add_subparsers(title='action', dest='action')
395 sub.required = True
396
397 # status
398 sub.add_parser('status', aliases=['st', 's'],
399 help='show a branch summary and indicate modifications')
400
401 # fetch
402 fetch = sub.add_parser('fetch', aliases=['f'],
403 help="run 'git fetch' in each clone (use before rebase)")
404 fetch.add_argument('remainder', nargs=argparse.REMAINDER,
405 help='additional arguments to be passed to git fetch')
406
407 # rebase
408 sub.add_parser('rebase', aliases=['r', 're'],
409 help='interactively ff-merge master, rebase current branches')
410
411 # sh
412 sh = sub.add_parser('sh',
413 help='run shell command in each clone (`gits sh echo hi`)')
414 sh.add_argument('remainder', nargs=argparse.REMAINDER,
415 help='command to run in each clone')
416
417 # do
418 do = sub.add_parser('do',
419 help='run git command in each clone (`gits do clean -dxf`)')
420 do.add_argument('remainder', nargs=argparse.REMAINDER,
421 help='git command to run in each clone')
422 return parser.parse_args()
423
424
425if __name__ == '__main__':
426 args = parse_args()
427 if args.action in ['status', 's', 'st']:
428 print_status()
429 elif args.action in ['fetch', 'f']:
430 cmd_do(['fetch'] + args.remainder)
431 elif args.action in ['rebase', 'r']:
432 cmd_rebase()
433 elif args.action == 'sh':
434 cmd_sh(args.remainder)
435 elif args.action == 'do':
436 cmd_do(args.remainder)
437
438# vim: shiftwidth=4 expandtab tabstop=4