blob: 8b752785cfd6a0f9f5105a73ede97fe92297b124 [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):
61 return subprocess.check_output(['git', '-C', git_dir, ] + list(args)).decode('utf-8')
62
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
Oliver Smithb93f5042018-11-09 10:34:36 +010072def git_branch_exists(git_dir, branch='origin/master'):
73 return git_bool(git_dir, 'rev-parse', '--quiet', '--verify', branch)
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +010074
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +010075
Oliver Smithb93f5042018-11-09 10:34:36 +010076def 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 Hofmeyrb459b6c2018-10-31 21:35:36 +010079
Oliver Smithb93f5042018-11-09 10:34:36 +010080 # Missing remote branch
81 if not git_branch_exists(git_dir, remote + '/' + branch):
82 return (0, 0)
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +010083
Oliver Smithb93f5042018-11-09 10:34:36 +010084 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
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
101def git_has_modifications(git_dir):
102 return not git_bool(git_dir, 'diff-index', '--quiet', 'HEAD')
103
104
105def 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
109def 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 Hofmeyrb459b6c2018-10-31 21:35:36 +0100127
128
129def 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 Smithb93f5042018-11-09 10:34:36 +0100136 if git_has_modifications(git_dir):
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100137 strs.append('MODS')
138
Oliver Smithb93f5042018-11-09 10:34:36 +0100139 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 Hofmeyrb459b6c2018-10-31 21:35:36 +0100143 continue
Oliver Smithb93f5042018-11-09 10:34:36 +0100144
145 ahead, behind = git_ahead_behind(git_dir, branch)
146 if not ahead and not behind and not is_current:
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100147 # skip branches that are "not interesting"
148 continue
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100149
Oliver Smithb93f5042018-11-09 10:34:36 +0100150 # Branch with ahead/behind origin info ("master[+1|-5]")
151 strs.append(format_branch_ahead_behind(branch, ahead, behind))
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100152 return strs
153
154
155def 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
167def 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
181def print_status():
182 infos = [git_branch_summary(git_dir) for git_dir in git_dirs()]
183 print(format_summaries(infos))
184
185
186def cmd_do(argv):
187 for git_dir in git_dirs():
188 git(git_dir, *argv, may_fail=True, section_marker=True)
189
190
191def 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
203class SkipThisRepo(Exception):
204 pass
205
206
207def 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
235def rebase(git_dir):
Oliver Smithb93f5042018-11-09 10:34:36 +0100236 orig_branch = git_branch_current(git_dir)
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100237 if orig_branch is None:
238 print('Not on a branch: %s' % git_dir)
239 raise SkipThisRepo()
240
Oliver Smithb93f5042018-11-09 10:34:36 +0100241 print('Rebasing branch: ' + orig_branch)
242 ahead, behind = git_ahead_behind(git_dir, orig_branch)
243
244 if git_has_modifications(git_dir):
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100245 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 Smithb93f5042018-11-09 10:34:36 +0100260 if git_has_modifications(git_dir):
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100261 print('There still are local modifications')
262 raise SkipThisRepo()
263
Oliver Smithb93f5042018-11-09 10:34:36 +0100264 # Missing upstream branch
265 if not git_branch_exists(git_dir, 'origin/' + orig_branch):
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100266 print('there is no upstream branch for %r' % orig_branch)
267
Oliver Smithb93f5042018-11-09 10:34:36 +0100268 # 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 Hofmeyrb459b6c2018-10-31 21:35:36 +0100283 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 Smithb93f5042018-11-09 10:34:36 +0100295 # Ahead
296 elif ahead:
Neels Hofmeyrb459b6c2018-10-31 21:35:36 +0100297 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 Hofmeyrb459b6c2018-10-31 21:35:36 +0100314 return orig_branch
315
316
317def 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
340def 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
373if __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