James Moger
2014-05-05 590e80d7624cbd10ea6890decdb1dcbc1c1c9417
commit | author | age
5e3521 1 #!/usr/bin/env python3
JM 2 #
3 # Barnum, a Patchset Tool (pt)
4 #
5 # This Git wrapper script is designed to reduce the ceremony of working with Gitblit patchsets.
6 #
7 # Copyright 2014 gitblit.com.
8 #
9 # Licensed under the Apache License, Version 2.0 (the "License");
10 # you may not use this file except in compliance with the License.
11 # You may obtain a copy of the License at
12 #
13 #     http://www.apache.org/licenses/LICENSE-2.0
14 #
15 # Unless required by applicable law or agreed to in writing, software
16 # distributed under the License is distributed on an "AS IS" BASIS,
17 # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
18 # See the License for the specific language governing permissions and
19 # limitations under the License.
20 #
21 #
22 # Usage:
23 #
24 #    pt fetch <id> [-p,--patchset <n>]
25 #    pt checkout <id> [-p,--patchset <n>] [-f,--force]
26 #    pt pull <id> [-p,--patchset <n>]
27 #    pt push [<id>] [-i,--ignore] [-f,--force] [-m,--milestone <milestone>] [-t,--topic <topic>] [-cc <user> <user>]
28 #    pt start <topic> | <id>
29 #    pt propose [new | <branch> | <id>] [-i,--ignore] [-m,--milestone <milestone>] [-t,--topic <topic>] [-cc <user> <user>]
30 #    pt cleanup [<id>]
31 #
32
33 __author__ = 'James Moger'
9e55e7 34 __version__ = '1.0.6'
5e3521 35
JM 36 import subprocess
37 import argparse
38 import errno
39 import sys
40
41
42 def fetch(args):
43     """
44     fetch(args)
45
46     Fetches the specified patchset for the ticket from the specified remote.
47     """
48
49     __resolve_remote(args)
50
51     # fetch the patchset from the remote repository
52
53     if args.patchset is None:
54         # fetch all current ticket patchsets
55         print("Fetching ticket patchsets from the '{}' repository".format(args.remote))
56         if args.quiet:
021c70 57             __call(['git', 'fetch', '-p', args.remote, '--quiet'])
5e3521 58         else:
021c70 59             __call(['git', 'fetch', '-p', args.remote])
5e3521 60     else:
JM 61         # fetch specific patchset
62         __resolve_patchset(args)
63         print("Fetching ticket {} patchset {} from the '{}' repository".format(args.id, args.patchset, args.remote))
64         patchset_ref = 'refs/tickets/{:02d}/{:d}/{:d}'.format(args.id % 100, args.id, args.patchset)
65         if args.quiet:
66             __call(['git', 'fetch', args.remote, patchset_ref, '--quiet'])
67         else:
68             __call(['git', 'fetch', args.remote, patchset_ref])
69
70     return
71
72
73 def checkout(args):
74     """
75     checkout(args)
76
77     Checkout the patchset on a named branch.
78     """
79
80     __resolve_uncommitted_changes_checkout(args)
81     fetch(args)
82
83     # collect local branch names
84     branches = []
85     for branch in __call(['git', 'branch']):
86         if branch[0] == '*':
87             branches.append(branch[1:].strip())
88         else:
89             branches.append(branch.strip())
90
91     if args.patchset is None or args.patchset is 0:
92         branch = 'ticket/{:d}'.format(args.id)
93         illegals = set(branches) & {'ticket'}
94     else:
95         branch = 'patchset/{:d}/{:d}'.format(args.id, args.patchset)
96         illegals = set(branches) & {'patchset', 'patchset/{:d}'.format(args.id)}
97
98     # ensure there are no local branch names that will interfere with branch creation
99     if len(illegals) > 0:
100         print('')
101         print('Sorry, can not complete the checkout for ticket {}.'.format(args.id))
102         print("The following branches are blocking '{}' branch creation:".format(branch))
103         for illegal in illegals:
104             print('  ' + illegal)
105         exit(errno.EINVAL)
106
107     if args.patchset is None or args.patchset is 0:
108         # checkout the current ticket patchset
109         if args.force:
110             __call(['git', 'checkout', '-B', branch, '{}/{}'.format(args.remote, branch)])
111         else:
112             __call(['git', 'checkout', branch])
113     else:
114         # checkout a specific patchset
115         __checkout(args.remote, args.id, args.patchset, branch, args.force)
116
117     return
118
119
120 def pull(args):
121     """
122     pull(args)
123
124     Pull (fetch & merge) a ticket patchset into the current branch.
125     """
126
127     __resolve_uncommitted_changes_checkout(args)
128     __resolve_remote(args)
129
130     # reset the checkout before pulling
131     __call(['git', 'reset', '--hard'])
132
133     # pull the patchset from the remote repository
134     if args.patchset is None or args.patchset is 0:
135         print("Pulling ticket {} from the '{}' repository".format(args.id, args.remote))
136         patchset_ref = 'ticket/{:d}'.format(args.id)
137     else:
138         __resolve_patchset(args)
139         print("Pulling ticket {} patchset {} from the '{}' repository".format(args.id, args.patchset, args.remote))
140         patchset_ref = 'refs/tickets/{:02d}/{:d}/{:d}'.format(args.id % 100, args.id, args.patchset)
141
142     if args.squash:
143         __call(['git', 'pull', '--squash', '--no-log', '--no-rebase', args.remote, patchset_ref], echo=True)
144     else:
145         __call(['git', 'pull', '--commit', '--no-ff', '--no-log', '--no-rebase', args.remote, patchset_ref], echo=True)
146
147     return
148
149
150 def push(args):
151     """
152     push(args)
153
154     Push your patchset update or a patchset rewrite.
155     """
156
157     if args.id is None:
158         # try to determine ticket and patchset from current branch name
159         for line in __call(['git', 'status', '-b', '-s']):
160             if line[0:2] == '##':
161                 branch = line[2:].strip()
162                 segments = branch.split('/')
163                 if len(segments) >= 2:
164                     if segments[0] == 'ticket' or segments[0] == 'patchset':
165                         if '...' in segments[1]:
166                             args.id = int(segments[1][:segments[1].index('...')])
167                         else:
168                             args.id = int(segments[1])
169                         args.patchset = None
170
171     if args.id is None:
172         print('Please specify a ticket id for the push command.')
173         exit(errno.EINVAL)
174
175     __resolve_uncommitted_changes_push(args)
176     __resolve_remote(args)
177
178     if args.force:
179        # rewrite a patchset for an existing ticket
180         push_ref = 'refs/for/' + str(args.id)
181     else:
182         # fast-forward update to an existing patchset
183         push_ref = 'refs/heads/ticket/{:d}'.format(args.id)
184
185     ref_params = __get_pushref_params(args)
186     ref_spec = 'HEAD:' + push_ref + ref_params
187
188     print("Pushing your patchset to the '{}' repository".format(args.remote))
189     __call(['git', 'push', args.remote, ref_spec], echo=True)
190     return
191
192
193 def start(args):
194     """
195     start(args)
196
197     Start development of a topic on a new branch.
198     """
199
200     # collect local branch names
201     branches = []
202     for branch in __call(['git', 'branch']):
203         if branch[0] == '*':
204             branches.append(branch[1:].strip())
205         else:
206             branches.append(branch.strip())
207
208     branch = 'topic/' + args.topic
209     illegals = set(branches) & {'topic', branch}
210
211     # ensure there are no local branch names that will interfere with branch creation
212     if len(illegals) > 0:
213         print('Sorry, can not complete the creation of the topic branch.')
214         print("The following branches are blocking '{}' branch creation:".format(branch))
215         for illegal in illegals:
216             print('  ' + illegal)
217         exit(errno.EINVAL)
218
219     __call(['git', 'checkout', '-b', branch])
220
221     return
222
223
224 def propose(args):
225     """
226     propose_patchset(args)
227
228     Push a patchset to create a new proposal ticket or to attach a proposal patchset to an existing ticket.
229     """
230
231     __resolve_uncommitted_changes_push(args)
232     __resolve_remote(args)
233
234     curr_branch = None
235     push_ref = None
236     if args.target is None:
237         # see if the topic is a ticket id
238         # else default to new
239         for branch in __call(['git', 'branch']):
240             if branch[0] == '*':
241                 curr_branch = branch[1:].strip()
242                 if curr_branch.startswith('topic/'):
243                     topic = curr_branch[6:].strip()
244                     try:
245                         int(topic)
246                         push_ref = topic
247                     except ValueError:
248                         pass
249         if push_ref is None:
250             push_ref = 'new'
251     else:
252         push_ref = args.target
253
254     try:
255         # check for current patchset and current branch
256         args.id = int(push_ref)
257         args.patchset = __get_current_patchset(args.remote, args.id)
258         if args.patchset > 0:
259             print('You can not propose a patchset for ticket {} because it already has one.'.format(args.id))
260
261             # check current branch for accidental propose instead of push
262             for line in __call(['git', 'status', '-b', '-s']):
263                 if line[0:2] == '##':
264                     branch = line[2:].strip()
265                     segments = branch.split('/')
266                     if len(segments) >= 2:
267                         if segments[0] == 'ticket':
268                             if '...' in segments[1]:
269                                 args.id = int(segments[1][:segments[1].index('...')])
270                             else:
271                                 args.id = int(segments[1])
272                             args.patchset = None
273                             print("You are on the '{}' branch, perhaps you meant to push instead?".format(branch))
274                         elif segments[0] == 'patchset':
275                             args.id = int(segments[1])
276                             args.patchset = int(segments[2])
277                             print("You are on the '{}' branch, perhaps you meant to push instead?".format(branch))
278             exit(errno.EINVAL)
279     except ValueError:
280         pass
281
282     ref_params = __get_pushref_params(args)
283     ref_spec = 'HEAD:refs/for/{}{}'.format(push_ref, ref_params)
284
285     print("Pushing your proposal to the '{}' repository".format(args.remote))
286     for line in __call(['git', 'push', args.remote, ref_spec, '-q'], echo=True, err=subprocess.STDOUT):
287         fields = line.split(':')
288         if fields[0] == 'remote' and fields[1].strip().startswith('--> #'):
289             # set the upstream branch configuration
290             args.id = int(fields[1].strip()[len('--> #'):])
021c70 291             __call(['git', 'fetch', '-p', args.remote])
d69d73 292             __call(['git', 'branch', '-u', '{}/ticket/{:d}'.format(args.remote, args.id)])
5e3521 293             break
JM 294
295     return
296
297
298 def cleanup(args):
299     """
300     cleanup(args)
301
302     Removes local branches for the ticket.
303     """
304
305     if args.id is None:
306         branches = __call(['git', 'branch', '--list', 'ticket/*'])
307         branches += __call(['git', 'branch', '--list', 'patchset/*'])
308     else:
309         branches = __call(['git', 'branch', '--list', 'ticket/{:d}'.format(args.id)])
310         branches += __call(['git', 'branch', '--list', 'patchset/{:d}/*'.format(args.id)])
311
312     if len(branches) == 0:
313         print("No local branches found for ticket {}, cleanup skipped.".format(args.id))
314         return
315
316     if not args.force:
317         print('Cleanup would remove the following local branches for ticket {}.'.format(args.id))
318         for branch in branches:
319             if branch[0] == '*':
320                 print('  ' + branch[1:].strip() + ' (skip)')
321             else:
322                 print('  ' + branch)
323         print("To discard these local branches, repeat this command with '--force'.")
324         exit(errno.EINVAL)
325
326     for branch in branches:
327         if branch[0] == '*':
328             print('Skipped {} because it is the current branch.'.format(branch[1:].strip()))
329             continue
330         __call(['git', 'branch', '-D', branch.strip()], echo=True)
331
332     return
333
334
335 def __resolve_uncommitted_changes_checkout(args):
336     """
337     __resolve_uncommitted_changes_checkout(args)
338
339     Ensures the current checkout has no uncommitted changes that would be discarded by a checkout or pull.
340     """
341
342     status = __call(['git', 'status', '--porcelain'])
343     for line in status:
344         if not args.force and line[0] != '?':
345             print('Your local changes to the following files would be overwritten by {}:'.format(args.command))
346             print('')
347             for state in status:
348                 print(state)
349             print('')
350             print("To discard your local changes, repeat the {} with '--force'.".format(args.command))
351             print('NOTE: forcing a {} will HARD RESET your working directory!'.format(args.command))
352             exit(errno.EINVAL)
353
354
355 def __resolve_uncommitted_changes_push(args):
356     """
357     __resolve_uncommitted_changes_push(args)
358
359     Ensures the current checkout has no uncommitted changes that should be part of a propose or push.
360     """
361
362     status = __call(['git', 'status', '--porcelain'])
363     for line in status:
364         if not args.ignore and line[0] != '?':
365             print('You have local changes that have not been committed:')
366             print('')
367             for state in status:
368                 print(state)
369             print('')
370             print("To ignore these uncommitted changes, repeat the {} with '--ignore'.".format(args.command))
371             exit(errno.EINVAL)
372
373
374 def __resolve_remote(args):
375     """
376     __resolve_remote(args)
377
378     Identifies the git remote to use for fetching and pushing patchsets by parsing .git/config.
379     """
380
381     remotes = __call(['git', 'remote'])
382
383     if len(remotes) == 0:
384         # no remotes defined
385         print("Please define a Git remote")
386         exit(errno.EINVAL)
387     elif len(remotes) == 1:
388         # only one remote, use it
389         args.remote = remotes[0]
390         return
391     else:
392         # multiple remotes, read .git/config
393         output = __call(['git', 'config', '--local', 'patchsets.remote'], fail=False)
394         preferred = output[0] if len(output) > 0 else ''
395
396         if len(preferred) == 0:
397             print("You have multiple remote repositories and you have not configured 'patchsets.remote'.")
398             print("")
399             print("Available remote repositories:")
400             for remote in remotes:
401                 print('  ' + remote)
402             print("")
403             print("Please set the remote repository to use for patchsets.")
404             print("  git config --local patchsets.remote <remote>")
405             exit(errno.EINVAL)
406         else:
407             try:
408                 remotes.index(preferred)
409             except ValueError:
410                 print("The '{}' repository specified in 'patchsets.remote' is not configured!".format(preferred))
411                 print("")
412                 print("Available remotes:")
413                 for remote in remotes:
414                     print('  ' + remote)
415                 print("")
416                 print("Please set the remote repository to use for patchsets.")
417                 print("  git config --local patchsets.remote <remote>")
418                 exit(errno.EINVAL)
419
420             args.remote = preferred
421     return
422
423
424 def __resolve_patchset(args):
425     """
426     __resolve_patchset(args)
427
428     Resolves the current patchset or validates the the specified patchset exists.
429     """
430     if args.patchset is None:
431         # resolve current patchset
432         args.patchset = __get_current_patchset(args.remote, args.id)
433
434         if args.patchset == 0:
435             # there are no patchsets for the ticket or the ticket does not exist
436             print("There are no patchsets for ticket {} in the '{}' repository".format(args.id, args.remote))
437             exit(errno.EINVAL)
438     else:
439         # validate specified patchset
440         args.patchset = __validate_patchset(args.remote, args.id, args.patchset)
441
442         if args.patchset == 0:
443             # there are no patchsets for the ticket or the ticket does not exist
444             print("Patchset {} for ticket {} can not be found in the '{}' repository".format(args.patchset, args.id, args.remote))
445             exit(errno.EINVAL)
446
447     return
448
449
450 def __validate_patchset(remote, ticket, patchset):
451     """
452     __validate_patchset(remote, ticket, patchset)
453
454     Validates that the specified ticket patchset exists.
455     """
456
457     nps = 0
458     patchset_ref = 'refs/tickets/{:02d}/{:d}/{:d}'.format(ticket % 100, ticket, patchset)
459     for line in __call(['git', 'ls-remote', remote, patchset_ref]):
460         ps = int(line.split('/')[4])
461         if ps > nps:
462             nps = ps
463
464     if nps == patchset:
465         return patchset
466     return 0
467
468
469 def __get_current_patchset(remote, ticket):
470     """
471     __get_current_patchset(remote, ticket)
472
473     Determines the most recent patchset for the ticket by listing the remote patchset refs
474     for the ticket and parsing the patchset numbers from the resulting set.
475     """
476
477     nps = 0
478     patchset_refs = 'refs/tickets/{:02d}/{:d}/*'.format(ticket % 100, ticket)
479     for line in __call(['git', 'ls-remote', remote, patchset_refs]):
480         ps = int(line.split('/')[4])
481         if ps > nps:
482             nps = ps
483
484     return nps
485
486
487 def __checkout(remote, ticket, patchset, branch, force=False):
488     """
489     __checkout(remote, ticket, patchset, branch)
490     __checkout(remote, ticket, patchset, branch, force)
491
492     Checkout the patchset on a detached head or on a named branch.
493     """
494
495     has_branch = False
496     on_branch = False
497
498     if branch is None or len(branch) == 0:
499         # checkout the patchset on a detached head
500         print('Checking out ticket {} patchset {} on a detached HEAD'.format(ticket, patchset))
501         __call(['git', 'checkout', 'FETCH_HEAD'], echo=True)
502         return
503     else:
504         # checkout on named branch
505
506         # determine if we are already on the target branch
507         for line in __call(['git', 'branch', '--list', branch]):
508             has_branch = True
509             if line[0] == '*':
510                 # current branch (* name)
511                 on_branch = True
512
513         if not has_branch:
514             if force:
515                 # force the checkout the patchset to the new named branch
516                 # used when there are local changes to discard
517                 print("Forcing checkout of ticket {} patchset {} on named branch '{}'".format(ticket, patchset, branch))
518                 __call(['git', 'checkout', '-b', branch, 'FETCH_HEAD', '--force'], echo=True)
519             else:
520                 # checkout the patchset to the new named branch
521                 __call(['git', 'checkout', '-b', branch, 'FETCH_HEAD'], echo=True)
522             return
523
524         if not on_branch:
525             # switch to existing local branch
526             __call(['git', 'checkout', branch], echo=True)
527
528         #
529         # now we are on the local branch for the patchset
530         #
531
532         if force:
533             # reset HEAD to FETCH_HEAD, this drops any local changes
534             print("Forcing checkout of ticket {} patchset {} on named branch '{}'".format(ticket, patchset, branch))
535             __call(['git', 'reset', '--hard', 'FETCH_HEAD'], echo=True)
536             return
537         else:
538             # try to merge the existing ref with the FETCH_HEAD
539             merge = __call(['git', 'merge', '--ff-only', branch, 'FETCH_HEAD'], echo=True, fail=False)
540             if len(merge) is 1:
541                 up_to_date = merge[0].lower().index('up-to-date') > 0
542                 if up_to_date:
543                     return
544             elif len(merge) is 0:
545                 print('')
546                 print("Your '{}' branch has diverged from patchset {} on the '{}' repository.".format(branch, patchset, remote))
547                 print('')
548                 print("To discard your local changes, repeat the checkout with '--force'.")
549                 print('NOTE: forcing a checkout will HARD RESET your working directory!')
550                 exit(errno.EINVAL)
551     return
552
553
554 def __get_pushref_params(args):
555     """
556     __get_pushref_params(args)
557
558     Returns the push ref parameters for ticket field assignments.
559     """
560
561     params = []
562
563     if args.milestone is not None:
564         params.append('m=' + args.milestone)
565
566     if args.topic is not None:
567         params.append('t=' + args.topic)
568     else:
569         for branch in __call(['git', 'branch']):
570             if branch[0] == '*':
571                 b = branch[1:].strip()
572                 if b.startswith('topic/'):
573                     topic = b[len('topic/'):]
574                     try:
575                         # ignore ticket id topics
576                         int(topic)
577                     except:
578                         # topic is a string
579                         params.append('t=' + topic)
580
581     if args.responsible is not None:
582         params.append('r=' + args.responsible)
583
584     if args.cc is not None:
585         for cc in args.cc:
586             params.append('cc=' + cc)
587
588     if len(params) > 0:
589         return '%' + ','.join(params)
590
591     return ''
592
593
594 def __call(cmd_args, echo=False, fail=True, err=None):
595     """
596     __call(cmd_args)
597
598     Executes the specified command as a subprocess.  The output is parsed and returned as a list
599     of strings.  If the process returns a non-zero exit code, the script terminates with that
600     exit code.  Std err of the subprocess is passed-through to the std err of the parent process.
601     """
602
603     p = subprocess.Popen(cmd_args, stdout=subprocess.PIPE, stderr=err, universal_newlines=True)
604     lines = []
605     for line in iter(p.stdout.readline, b''):
606         line_str = str(line).strip()
607         if len(line_str) is 0:
608             break
609         lines.append(line_str)
610         if echo:
611             print(line_str)
612     p.wait()
613     if fail and p.returncode is not 0:
614         exit(p.returncode)
615
616     return lines
617
618 #
619 # define the acceptable arguments and their usage/descriptions
620 #
621
622 # force argument
623 force_arg = argparse.ArgumentParser(add_help=False)
624 force_arg.add_argument('-f', '--force', default=False, help='force the command to complete', action='store_true')
625
626 # quiet argument
627 quiet_arg = argparse.ArgumentParser(add_help=False)
628 quiet_arg.add_argument('-q', '--quiet', default=False, help='suppress git stderr output', action='store_true')
629
630 # ticket & patchset arguments
631 ticket_args = argparse.ArgumentParser(add_help=False)
632 ticket_args.add_argument('id', help='the ticket id', type=int)
633 ticket_args.add_argument('-p', '--patchset', help='the patchset number', type=int)
634
635 # push refspec arguments
636 push_args = argparse.ArgumentParser(add_help=False)
637 push_args.add_argument('-i', '--ignore', default=False, help='ignore uncommitted changes', action='store_true')
638 push_args.add_argument('-m', '--milestone', help='set the milestone')
639 push_args.add_argument('-r', '--responsible', help='set the responsible user')
640 push_args.add_argument('-t', '--topic', help='set the topic')
641 push_args.add_argument('-cc', nargs='+', help='specify accounts to add to the watch list')
642
643 # the commands
644 parser = argparse.ArgumentParser(description='a Patchset Tool for Gitblit Tickets')
645 parser.add_argument('--version', action='version', version='%(prog)s {}'.format(__version__))
646 commands = parser.add_subparsers(dest='command', title='commands')
647
648 fetch_parser = commands.add_parser('fetch', help='fetch a patchset', parents=[ticket_args, quiet_arg])
649 fetch_parser.set_defaults(func=fetch)
650
651 checkout_parser = commands.add_parser('checkout', aliases=['co'],
652                                       help='fetch & checkout a patchset to a branch',
653                                       parents=[ticket_args, force_arg, quiet_arg])
654 checkout_parser.set_defaults(func=checkout)
655
656 pull_parser = commands.add_parser('pull',
657                                   help='fetch & merge a patchset into the current branch',
658                                   parents=[ticket_args, force_arg])
659 pull_parser.add_argument('-s', '--squash',
660                          help='squash the pulled patchset into your working directory',
661                          default=False,
662                          action='store_true')
663 pull_parser.set_defaults(func=pull)
664
665 push_parser = commands.add_parser('push', aliases=['up'],
666                                   help='upload your patchset changes',
667                                   parents=[push_args, force_arg])
668 push_parser.add_argument('id', help='the ticket id', nargs='?', type=int)
669 push_parser.set_defaults(func=push)
670
671 propose_parser = commands.add_parser('propose', help='propose a new ticket or the first patchset', parents=[push_args])
672 propose_parser.add_argument('target', help="the ticket id, 'new', or the integration branch", nargs='?')
673 propose_parser.set_defaults(func=propose)
674
675 cleanup_parser = commands.add_parser('cleanup', aliases=['rm'],
676                                      help='remove local ticket branches',
677                                      parents=[force_arg])
678 cleanup_parser.add_argument('id', help='the ticket id', nargs='?', type=int)
679 cleanup_parser.set_defaults(func=cleanup)
680
681 start_parser = commands.add_parser('start', help='start a new branch for the topic or ticket')
682 start_parser.add_argument('topic', help="the topic or ticket id")
683 start_parser.set_defaults(func=start)
684
685 if len(sys.argv) < 2:
686     parser.parse_args(['--help'])
687 else:
688     # parse the command-line arguments
689     script_args = parser.parse_args()
690
691     # exec the specified command
692     script_args.func(script_args)