Further testing release script.
[jarrpa/prequel.git] / scripts / git_do_release.py
1 #!/usr/bin/env python
2 # ============================================================================ #
3 #                         $Name: git_do_release.py$
4 #
5 #
6 # Copyright (C) 2012 Jose A. Rivera <jarrpa@redhat.com>
7 #
8 # $Date: 2012-06-05 16:33:21 -0500$
9 #
10 # ---------------------------------------------------------------------------- #
11 #
12 # Description: A (relatively) simple release-management script.
13 #
14 # ---------------------------------------------------------------------------- #
15 #
16 # License:
17 #
18 #   This program is free software: you can redistribute it and/or modify
19 #   it under the terms of the GNU General Public License as published by
20 #   the Free Software Foundation, either version 3 of the License, or
21 #   (at your option) any later version.
22 #
23 #   This program is distributed in the hope that it will be useful,
24 #   but WITHOUT ANY WARRANTY; without even the implied warranty of
25 #   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
26 #   GNU General Public License for more details.
27 #
28 #   You should have received a copy of the GNU General Public License
29 #   along with this program.  If not, see <http://www.gnu.org/licenses/>.
30 #
31 # ---------------------------------------------------------------------------- #
32 #
33 # Notes:
34 #
35 # ============================================================================ #
36 '''This script is intended to be used within a git repository.'''
37
38 import copy as _copy
39 import sys
40 import argparse
41 from tempfile import TemporaryFile
42 from git_utils import *
43 from ConfigParser import SafeConfigParser
44
45 # Globals ----------------------------------------------------------------------
46 #
47 verstr   = '%(prog)s Pre-Alpha'
48 idstr    = '$Id: git_do_release.py 2012-06-05 16:33:21 -0500 Jose A. Rivera$'
49 copystr  = 'Copyright (C) 2012 Jose A. Rivera <jarrpa@redhat.com>'
50 gplstr   = '  GNU General Public License, version 3'
51 version  = '\n'.join([verstr, idstr[1:-1], copystr, gplstr])
52 defaults = { 'release'  : None,
53              'config'   : 'release.conf',
54              'branches' : ['master'],
55              'message'  : None,
56              'refiles'  : ['*release*'],
57              'repaths'  : { 'Globals': [''] },
58              'rebranch' : 'releases',
59            }
60 help     = { 'release'   : 'The descriptive string to use for this ' + \
61                            'release. Will be generated if not specified.',
62              'config'    : 'A path to an optional configuration file. ' + \
63                            'Default is \'' + defaults['config'] + '\'.',
64              'branches'  : 'A comma-separated list of additional branches ' + \
65                            'to accept as valid branches for a release. ' + \
66                            'Default is \'' + ','.join(defaults['branches']) + \
67                            '\'.',
68              'exbranches': 'A comma-separated list of branches to exclude ' + \
69                            'as valid branches for a release.',
70              'message'   : 'The commit message for the release. Will be ' + \
71                            'prompted for if not supplied.',
72              'refiles'   : 'A comma-separated list of Unix shell-style ' + \
73                            'globs indicating paths to release files, ' + \
74                            'relative to the repository root. Default is \'' + \
75                            ','.join(defaults['refiles']) + '\'.',
76              'repaths'   : 'A comma-separated list of paths to directories ' + \
77                            'or files that will be included in the release. ' + \
78                            'An empty string indicates the entire ' + \
79                            'repository tree. The first entry in the list ' + \
80                            'is also used as the directory in which to ' + \
81                            'create a release file if one is not found. ' + \
82                            'Default is \'' + \
83                            ','.join(defaults['repaths']['Globals']) + '\'.',
84              'rebranch'  : 'The name of the branch which tracks project ' + \
85                            'releases. Default is \''+defaults['rebranch']+'\'',
86              'verbose'   : 'Verbose output. Additional \'v\'s increases ' + \
87                            'verbosity.',
88            }
89 verbose  = 0
90
91
92 # Templates --------------------------------------------------------------------
93 #
94 dot_h = '#ifndef RELEASE_H\n#define RELEASE_H\n#define RELEASE ""\n' + \
95         '#endif /* RELEASE_H */'
96
97 # Classes ----------------------------------------------------------------------
98 #
99 class RePathsAction(argparse._AppendAction):
100   '''A special argparse.Action class which takes in a string of information 
101   for building a dictionary. The expected format is "<key>:<values>", where 
102   key is a dict key and values is a comma-separated list of values that will 
103   be split into a list and stored as the value of the key.'''
104   def __call__(self, parser, namespace, values, option_string=None):
105     items = _copy.copy(argparse._ensure_value(namespace, self.dest, {}))
106     values = values.split(':')
107     if len(values) == 2 and values[0]:
108       branch = values[0]
109       values = values[1]
110     else:
111       branch = 'Globals'
112       values = values[0]
113     values = values.split(',')
114     items[branch] = values
115     setattr(namespace, self.dest, items)
116
117
118 class SplitAppendAction(argparse._AppendAction):
119   '''A special argparse.Action class which takes in a string of comma-separated
120   values, splits them into a list, and adds that list to the list of default
121   values.'''
122   def __call__(self, parser, namespace, values, option_string=None):
123     items = _copy.copy(argparse._ensure_value(namespace, self.dest, []))
124     values = values.split(',')
125     for value in values:
126       if value in items:
127         values.pop(values.index(value))
128     items += values
129     setattr(namespace, self.dest, items)
130
131
132 class SplitRemoveAction(argparse._AppendAction):
133   '''A special argparse.Action class which takes in a string of comma-separated
134   values, splits them into a list, and removes removes those values from the
135   list of default values.'''
136   def __call__(self, parser, namespace, values, option_string=None):
137     items = _copy.copy(argparse._ensure_value(namespace, self.dest, []))
138     values = values.split(',')
139     for value in values:
140       if value in items:
141         items.pop(items.index(value))
142     setattr(namespace, self.dest, items)
143
144
145 class SuperVersionAction(argparse._VersionAction):
146   '''A special argparse.Action class which augments the version information
147   output capabilities of the program.'''
148   def __call__(self, parser, namespace, values, option_string=None):
149     vals = self.version.split('\n')
150     self.version = vals[0]
151     if hasattr( namespace, 'verbose' ):
152       for i in range(1,len(vals)):
153         if namespace.verbose >= i:
154           self.version += '\n' + vals[i]
155     super( SuperVersionAction, self).__call__( parser=parser,
156                                                namespace=namespace,
157                                                values=values,
158                                                option_string=option_string )
159
160
161 # Functions --------------------------------------------------------------------
162 #
163 def update_release_number( release, fileglobs, pathsdict ):
164   '''docstring'''
165   import fnmatch
166   import os
167   
168   # Get the script name from the file ID. NOTE: This is the filename the script
169   # had on its last commit. If this changes, make sure to commit the script
170   # before running it.
171   global idstr
172   script = idstr.split()[1]
173   
174   # Parse the fileglobs.
175   files = []
176   for fileglob in fileglobs:
177     if '/' in fileglob:
178       files.append(fileglob.rsplit('/',1))
179     else:
180       files.append(['',fileglob])
181   
182   # Search for matching files.
183   matches = []
184   reporoot = git('rev-parse --show-toplevel')
185   if 'Globals' in pathsdict.keys() and pathsdict['Globals']:
186     paths = pathsdict['Globals']
187   else:
188     paths = ['']
189
190   for p in paths:
191     for root, dirnames, filenames in os.walk(os.path.join(reporoot, p)):
192       relroot = root[len(reporoot)+1:]
193       if '.git' in dirnames:
194         dirnames.remove('.git')
195       if script in filenames:
196         filenames.remove(script)
197       for file in files:
198         if not file[0] or file[0] == relroot:
199           for filename in fnmatch.filter(filenames, file[1]):
200             matches.append(os.path.join(root, filename))
201   
202   # If no release file is found, create one.
203   # TODO: Properly support multiple languages. Currently only support C/C++.
204   # TODO: Support multiple release files.
205   if not matches:
206     global dot_h
207     newfn = os.path.join(reporoot, os.path.join(paths[0], 'release.h'))
208     newfile = open(newfn,'w+b')
209     newfile.write(dot_h)
210     newfile.seek(0)
211     print newfile.read()
212     newfile.close()
213     newfile = open(newfn)
214     print newfile.read()
215     sys.exit(1)    
216     matches.append(newfn)
217   elif len(matches) > 1:
218     release = None
219     matches = []
220
221   # Detect and, if provided, update release number
222   for match in matches:
223     reltemp = TemporaryFile()
224     relfile = open(match,'rb')
225     for line in relfile.readline():
226       if line.startswith('#define RELEASE '):
227         relist = line.split('"')
228         if len(relist) == 3:
229           if release is None or not release:
230             try:
231               restr = relist[1].rsplit('.',1)
232               restr[1] = str(int(restr[1])+1)
233               release = '.'.join(restr)
234             except:
235               release = None
236           relist[1] = release if release is not None else ''
237           line = '"'.join(relist)
238         else:
239           release = None
240       reltemp.write(line)
241     relfile.close()
242     relfile = open(match,'w+b')
243     relfile.write(reltemp.read())
244     relfile.close()
245     reltemp.close()
246
247   return release
248
249
250 def do_release( release=defaults['release'],
251                 branches=defaults['branches'],
252                 message=defaults['message'],
253                 refiles=defaults['refiles'],
254                 repaths=defaults['repaths'],
255                 rebranch=defaults['rebranch'] ):
256   '''docstring'''
257   # Check current working branch.
258   cur_branch = git('symbolic-ref --short -q HEAD')
259   if cur_branch not in branches:
260     sys.exit( 'FATAL ERROR: Not currently working with an approved branch. ' + \
261               'Must be one of: ' + ' '.join( branches ) )
262   
263   # Check that local repository has a release branch and that we are not
264   # currently working on the release branch.
265   has_release = False
266   on_release  = False
267   for branch in git('branch').split('\n'):
268     if rebranch in branch:
269       has_release = True
270       if branch[0] == '*':
271         on_release = True
272   if not has_release:
273     sys.exit( 'FATAL ERROR: Local repository does not have a release ' + \
274               'branch. Expecting a branch named \'' +  rebranch + '\'.' )
275   if on_release:
276     sys.exit( 'FATAL ERROR: This script should not be run on the release ' + \
277               'branch. Please remove \'' +  rebranch + '\' from the list ' + \
278               'of approved branches or reconfigure the release branch.' )
279   
280   # Check if working tree and staging area are clean. If not, either quit,
281   # stash the changes, or add the changes to the current commit.
282   status = git('status --porcelain').split('\n')
283   for line in status:
284     if not line:
285       status.pop(status.index(line))
286   if len(status):
287     tmp = raw_input( 'You have the following uncommitted changes in your\n' + \
288                      'environment:\n\n' + '\n'.join(status) + '\n\n' + \
289                      'What would you like to do? [(Q)uit/(s)tash/(c)ommit]: ' )
290     if not tmp:
291       tmp = 'Q'
292     if tmp[0] in ['Q','q']:
293       sys.exit( 'Release script terminated by user.' )
294     elif tmp[0] in ['S','s']:
295       git('stash')
296     elif tmp[0] in ['C','c']:
297       git('add -A')
298   
299   # TODO: Merge all specified branches.
300   
301   # Update release number.
302   release = update_release_number( release, refiles, repaths )
303   if release is None:
304     sys.exit( 'FATAL ERROR: Unable to detect release string. Please provide' + \
305               ' one in a config file or on the command line.' )
306   
307   # Commit release changes to current branch.
308   git('commit -a', interactive=True)
309   
310   # Generate release diff.
311   # TODO: Allow release diffs from multiple branches
312   difftmp = TemporaryFile()
313   difftmp.write( git('diff ' + rebranch + ' HEAD -- ' + \
314                      ' '.join(repaths['Globals'])) )
315   
316   # Switch to release branch, apply diffs, commit and tag the new release.
317   git('checkout ' + rebranch)
318   git('apply', input=difftmp)
319   difftmp.close()
320   if message is None or not message:
321     git('commit -a', interactive=True)
322     message = git('log -n 1 --pretty=\'format:%B\'')
323   else:
324     git('commit -am ' + message)
325   git('tag -m \'' + message + '\' ' + release)
326   
327   # Return to original working branch.
328   git('checkout ' + cur_branch)
329   git('tag -m \'' + message + '\' ' + release + '-' + cur_branch)
330   
331   # Done.
332
333
334 # Mainline ---------------------------------------------------------------------
335 #
336 def main():
337   '''Mainline. Reads the config file and parses command line arguments.'''
338   global version
339   global defaults
340   global help
341   global verbose
342   
343   release  = defaults['release']
344   branches = defaults['branches']
345   refiles  = defaults['refiles']
346   repaths  = defaults['repaths']
347   rebranch = defaults['rebranch']
348   message  = defaults['message']
349   
350   parser = argparse.ArgumentParser( description=__doc__,
351                                     epilog='',
352                                     formatter_class=argparse.RawDescriptionHelpFormatter,
353                                   )
354   parser.add_argument( '-V', '--version',
355                        action=SuperVersionAction,
356                        version=version,
357                      )
358   parser.add_argument( '-v', '--verbose',
359                        action='count',
360                        help=help['verbose'],
361                      )
362   parser.add_argument( 'release',
363                        default=defaults['release'],
364                        nargs='?',
365                        help=help['release'],
366                      )
367   parser.add_argument( '-c', '--config',
368                        default=defaults['config'],
369                        help=help['config'],
370                      )
371   parser.add_argument( '-b', '--branches',
372                        action=SplitAppendAction,
373                        default=defaults['branches'],
374                        help=help['branches'],
375                      )
376   parser.add_argument( '-e', '--exclude-branches',
377                        action=SplitRemoveAction,
378                        dest='branches',
379                        help=help['exbranches'],
380                      )
381   parser.add_argument( '-f', '--release-files',
382                        action=SplitAppendAction,
383                        default=defaults['refiles'],
384                        help=help['refiles'],
385                      )
386   parser.add_argument( '-m', '--message',
387                        default=defaults['message'],
388                        help=help['message'],
389                      )
390   parser.add_argument( '-p', '--release-paths',
391                        action=RePathsAction,
392                        default=defaults['repaths'],
393                        help=help['repaths'],
394                      )
395   parser.add_argument( '-r', '--release-branch',
396                        default=defaults['rebranch'],
397                        help=help['rebranch'],
398                      )
399   args   = parser.parse_args()
400
401   config = SafeConfigParser()
402   if config.read(args.config):
403     if config.has_section('Globals'):
404       if config.has_option('Globals', 'release'):
405         release = config.get('Globals', 'release')
406       if config.has_option('Globals', 'branches'):
407         branches = config.get('Globals', 'branches').split(',')
408       if config.has_option('Globals', 'message'):
409         message = config.get('Globals', 'message')
410       if config.has_option('Globals', 'release-files'):
411         refiles = config.get('Globals', 'release-files').split(',')
412       if config.has_option('Globals', 'release-paths'):
413         repaths = config.get('Globals', 'release-paths').split(',')
414       if config.has_option('Globals', 'release-branch'):
415         rebranch = config.get('Globals', 'release-branch')
416   
417   if args.release != defaults['release']:
418     release = args.release
419   if args.branches != defaults['branches']:
420     branches = args.branches
421   if args.message != defaults['message']:
422     message = args.message
423   if args.release_files != defaults['refiles']:
424     refiles = args.release_files
425   if args.release_paths != defaults['repaths']:
426     repaths = args.release_paths
427   if args.release_branch != defaults['rebranch']:
428     rebranch = args.release_branch
429   
430   do_release( release=release,
431               branches=branches,
432               message=message,
433               refiles=refiles,
434               repaths=repaths,
435               rebranch=rebranch )
436   # End main()
437
438
439 if __name__ == '__main__':
440   main()
441
442 # ============================================================================ #