blob: 7e5493ed471d1896aa22a9c00525f252cdc1b78b [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
71
Neels Hofmeyr68d8f342018-11-12 22:44:08 +010072def git_ahead_behind(git_dir, branch, branch_upstream):
Oliver Smithb93f5042018-11-09 10:34:36 +010073 ''' Count revisions ahead/behind of the remote branch.
74 returns: (ahead, behind) (e.g. (0, 5)) '''
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +010075
Oliver Smithb93f5042018-11-09 10:34:36 +010076 # Missing remote branch
Neels Hofmeyr68d8f342018-11-12 22:44:08 +010077 if not branch_upstream:
Oliver Smithb93f5042018-11-09 10:34:36 +010078 return (0, 0)
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +010079
Neels Hofmeyr68d8f342018-11-12 22:44:08 +010080 behind = git_output(git_dir, 'rev-list', '--count', '%s..%s' % (branch, branch_upstream))
81 ahead = git_output(git_dir, 'rev-list', '--count', '%s..%s' % (branch_upstream, branch))
Oliver Smithb93f5042018-11-09 10:34:36 +010082 return (int(ahead.rstrip()), int(behind.rstrip()))
83
84
85def git_branches(git_dir, obj='refs/heads'):
86 ret = git_output(git_dir, 'for-each-ref', obj, '--format', '%(refname:short)')
87 return ret.splitlines()
88
89
90def git_branch_current(git_dir):
91 ret = git_output(git_dir, 'rev-parse', '--abbrev-ref', 'HEAD').rstrip()
92 if ret == 'HEAD':
93 return None
94 return ret
95
96
Neels Hofmeyr68d8f342018-11-12 22:44:08 +010097def git_branch_upstream(git_dir, branch_name='HEAD'):
98 '''Return an upstream branch name, or an None if there is none.'''
99 try:
100 return git_output(git_dir, 'rev-parse', '--abbrev-ref', '%s@{u}' % branch_name).rstrip()
101 except subprocess.CalledProcessError:
102 return None
103
104
Oliver Smithb93f5042018-11-09 10:34:36 +0100105def git_has_modifications(git_dir):
Oliver Smith2a30b522018-11-16 16:47:57 +0100106 return not git_bool(git_dir, 'diff', '--quiet', 'HEAD')
Oliver Smithb93f5042018-11-09 10:34:36 +0100107
108
Neels Hofmeyr68d8f342018-11-12 22:44:08 +0100109def git_can_fast_forward(git_dir, branch, branch_upstream):
110 return git_bool(git_dir, 'merge-base', '--is-ancestor', branch, branch_upstream)
Oliver Smithb93f5042018-11-09 10:34:36 +0100111
112
113def format_branch_ahead_behind(branch, ahead, behind):
114 ''' branch: string like "master"
115 ahead, behind: integers like 5, 3
116 returns: string like "master", "master[+5]", "master[-3]", "master[+5|-3]" '''
117 # Just the branch
118 if not ahead and not behind:
119 return branch
120
121 # Suffix with ahead/behind
122 ret = branch + '['
123 if ahead:
124 ret += '+' + str(ahead)
125 if behind:
126 ret += '|'
127 if behind:
128 ret += '-' + str(behind)
129 ret += ']'
130 return ret
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100131
132
133def git_branch_summary(git_dir):
134 '''return a list of strings: [git_dir, branch-info0, branch-info1,...]
135 infos are are arbitrary strings like "master[-1]"'''
136
137 interesting_branch_names = ('master',)
138
139 strs = [git_dir, ]
Oliver Smithb93f5042018-11-09 10:34:36 +0100140 if git_has_modifications(git_dir):
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100141 strs.append('MODS')
142
Oliver Smithb93f5042018-11-09 10:34:36 +0100143 branch_current = git_branch_current(git_dir)
144 for branch in git_branches(git_dir):
145 is_current = (branch == branch_current)
146 if not is_current and branch not in interesting_branch_names:
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100147 continue
Oliver Smithb93f5042018-11-09 10:34:36 +0100148
Neels Hofmeyr68d8f342018-11-12 22:44:08 +0100149 ahead, behind = git_ahead_behind(git_dir, branch,
150 git_branch_upstream(git_dir, branch))
151
Oliver Smithb93f5042018-11-09 10:34:36 +0100152 if not ahead and not behind and not is_current:
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100153 # skip branches that are "not interesting"
154 continue
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100155
Neels Hofmeyr68d8f342018-11-12 22:44:08 +0100156 # Branch with ahead/behind upstream info ("master[+1|-5]")
Oliver Smithb93f5042018-11-09 10:34:36 +0100157 strs.append(format_branch_ahead_behind(branch, ahead, behind))
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100158 return strs
159
160
161def format_summaries(summaries, sep0=' ', sep1=' '):
162 first_col = max([len(row[0]) for row in summaries])
163 first_col_fmt = '%' + str(first_col) + 's'
164
165 lines = []
166 for row in summaries:
167 lines.append('%s%s%s' % (first_col_fmt %
168 row[0], sep0, sep1.join(row[1:])))
169
170 return '\n'.join(lines)
171
172
173def git_dirs():
174 dirs = []
175 for sub in os.listdir():
176 git_path = os.path.join(sub, '.git')
177 if not os.path.isdir(git_path):
178 continue
179 dirs.append(sub)
180
181 if not dirs:
182 error('No subdirectories found that are git clones')
183
184 return list(sorted(dirs))
185
186
187def print_status():
188 infos = [git_branch_summary(git_dir) for git_dir in git_dirs()]
189 print(format_summaries(infos))
190
191
192def cmd_do(argv):
193 for git_dir in git_dirs():
194 git(git_dir, *argv, may_fail=True, section_marker=True)
195
196
197def cmd_sh(cmd):
198 if not cmd:
199 error('which command do you want to run?')
200 for git_dir in git_dirs():
201 print('\n===== %s =====' % git_dir)
202 print('+ %s' % cmd_to_str(cmd))
203 sys.stdout.flush()
204 subprocess.call(cmd, cwd=git_dir)
205 sys.stdout.flush()
206 sys.stderr.flush()
207
208
209class SkipThisRepo(Exception):
210 pass
211
212
213def ask(git_dir, *question, valid_answers=('*',)):
214 while True:
215 print('\n' + '\n '.join(question))
216 print(' ' + '\n '.join((
217 's skip this repo',
218 't show in tig',
219 'g show in gitk',
220 )))
221
222 answer = sys.stdin.readline().strip()
223 if answer == 's':
224 raise SkipThisRepo()
225 if answer == 't':
226 subprocess.call(('tig', '--all'), cwd=git_dir)
227 continue
228 if answer == 'g':
229 subprocess.call(('gitk', '--all'), cwd=git_dir)
230 continue
231
232 for v in valid_answers:
233 if v == answer:
234 return answer
235 if v == '*':
236 return answer
237 if v == '+' and len(answer):
238 return answer
239
240
Neels Hofmeyrae79f4b2018-11-23 04:09:10 +0100241def ask_reset_hard_or_push_f(git_dir, orig_branch, upstream_branch):
Neels Hofmeyrdff944b2018-11-12 23:27:49 +0100242 do_reset = ask(git_dir, 'Diverged.',
243 '%s: git reset --hard %s?' % (
244 orig_branch, upstream_branch),
245 '<empty> no',
Neels Hofmeyr20d95d02018-11-12 23:25:57 +0100246 'OK yes, reset to upstream (write OK in caps!)',
247 'P `push -f` to overwrite upstream (P in caps!)',
248 valid_answers=('', 'OK', 'P'))
Neels Hofmeyrdff944b2018-11-12 23:27:49 +0100249
250 if do_reset == 'OK':
251 git(git_dir, 'reset', '--hard', upstream_branch)
Neels Hofmeyr20d95d02018-11-12 23:25:57 +0100252 elif do_reset == 'P':
253 git(git_dir, 'push', '-f')
Neels Hofmeyrdff944b2018-11-12 23:27:49 +0100254
255
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100256def rebase(git_dir):
Oliver Smithb93f5042018-11-09 10:34:36 +0100257 orig_branch = git_branch_current(git_dir)
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100258 if orig_branch is None:
259 print('Not on a branch: %s' % git_dir)
260 raise SkipThisRepo()
261
Neels Hofmeyr68d8f342018-11-12 22:44:08 +0100262 upstream_branch = git_branch_upstream(git_dir, orig_branch)
263
264 print('Rebasing %r onto %r' % (orig_branch, upstream_branch))
265 ahead, behind = git_ahead_behind(git_dir, orig_branch, upstream_branch)
Oliver Smithb93f5042018-11-09 10:34:36 +0100266
267 if git_has_modifications(git_dir):
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100268 do_commit = ask(git_dir, 'Local mods.',
269 'c commit to this branch',
270 '<name> commit to new branch',
271 '<empty> skip')
272
273 if not do_commit:
274 raise SkipThisRepo()
275
276 if do_commit == 'c':
277 git(git_dir, 'commit', '-am', 'wip', may_fail=True)
278 else:
279 git(git_dir, 'checkout', '-b', do_commit)
280 git(git_dir, 'commit', '-am', 'wip', may_fail=True)
281 git(git_dir, 'checkout', orig_branch)
282
Oliver Smithb93f5042018-11-09 10:34:36 +0100283 if git_has_modifications(git_dir):
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100284 print('There still are local modifications')
285 raise SkipThisRepo()
286
Oliver Smithb93f5042018-11-09 10:34:36 +0100287 # Missing upstream branch
Neels Hofmeyr68d8f342018-11-12 22:44:08 +0100288 if not upstream_branch:
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100289 print('there is no upstream branch for %r' % orig_branch)
290
Oliver Smithb93f5042018-11-09 10:34:36 +0100291 # Diverged
292 elif ahead and behind:
Neels Hofmeyrae79f4b2018-11-23 04:09:10 +0100293 ask_reset_hard_or_push_f(git_dir, orig_branch, upstream_branch)
Oliver Smithb93f5042018-11-09 10:34:36 +0100294
295 # Behind
296 elif behind:
Neels Hofmeyr68d8f342018-11-12 22:44:08 +0100297 if git_can_fast_forward(git_dir, orig_branch, upstream_branch):
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100298 print('fast-forwarding...')
299 git(git_dir, 'merge')
300 else:
301 do_merge = ask(git_dir, 'Behind. git merge?',
302 "<empty> don't merge",
303 'ok git merge',
304 valid_answers=('', 'ok')
305 )
306
307 if do_merge == 'ok':
308 git(git_dir, 'merge')
309
Oliver Smithb93f5042018-11-09 10:34:36 +0100310 # Ahead
311 elif ahead:
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100312 do_commit = ask(git_dir, 'Ahead. commit to new branch?',
313 '<empty> no',
314 '<name> create new branch',
315 )
316 if do_commit:
317 git(git_dir, 'checkout', '-b', do_commit)
318 git(git_dir, 'commit', '-am', 'wip', may_fail=True)
319 git(git_dir, 'checkout', orig_branch)
320
Neels Hofmeyrae79f4b2018-11-23 04:09:10 +0100321 ask_reset_hard_or_push_f(git_dir, orig_branch, upstream_branch)
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100322
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100323 return orig_branch
324
325
326def cmd_rebase():
327 skipped = []
328 for git_dir in git_dirs():
329 try:
330 print('\n\n===== %s =====' % git_dir)
331 sys.stdout.flush()
332
333 branch = rebase(git_dir)
334 if branch != 'master':
335 git(git_dir, 'checkout', 'master')
336 rebase(git_dir)
337 git(git_dir, 'checkout', branch)
338
339 except SkipThisRepo:
340 print('\nSkipping %r' % git_dir)
341 skipped.append(git_dir)
342
343 print('\n\n==========\nrebase done.\n')
344 print_status()
345 if skipped:
346 print('\nskipped: %s' % ' '.join(skipped))
347
348
349def parse_args():
350 parser = argparse.ArgumentParser(description=doc)
351 sub = parser.add_subparsers(title='action', dest='action')
352 sub.required = True
353
354 # status
355 sub.add_parser('status', aliases=['st', 's'],
356 help='show a branch summary and indicate modifications')
357
358 # fetch
359 fetch = sub.add_parser('fetch', aliases=['f'],
360 help="run 'git fetch' in each clone (use before rebase)")
361 fetch.add_argument('remainder', nargs=argparse.REMAINDER,
362 help='additional arguments to be passed to git fetch')
363
364 # rebase
365 sub.add_parser('rebase', aliases=['r', 're'],
366 help='interactively ff-merge master, rebase current branches')
367
368 # sh
369 sh = sub.add_parser('sh',
370 help='run shell command in each clone (`gits sh echo hi`)')
371 sh.add_argument('remainder', nargs=argparse.REMAINDER,
372 help='command to run in each clone')
373
374 # do
375 do = sub.add_parser('do',
376 help='run git command in each clone (`gits do clean -dxf`)')
377 do.add_argument('remainder', nargs=argparse.REMAINDER,
378 help='git command to run in each clone')
379 return parser.parse_args()
380
381
382if __name__ == '__main__':
383 args = parse_args()
384 if args.action in ['status', 's', 'st']:
385 print_status()
386 elif args.action in ['fetch', 'f']:
387 cmd_do(['fetch'] + args.remainder)
388 elif args.action in ['rebase', 'r']:
389 cmd_rebase()
390 elif args.action == 'sh':
391 cmd_sh(args.remainder)
392 elif args.action == 'do':
393 cmd_do(args.remainder)
394
395# vim: shiftwidth=4 expandtab tabstop=4