2 # ============================================================================ #
3 # $Name: git_do_release.py$
6 # Copyright (C) 2012 Jose A. Rivera <jarrpa@redhat.com>
8 # $Date: 2012-06-05 16:33:21 -0500$
10 # ---------------------------------------------------------------------------- #
12 # Description: A (relatively) simple release-management script.
14 # ---------------------------------------------------------------------------- #
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.
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.
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/>.
31 # ---------------------------------------------------------------------------- #
35 # ============================================================================ #
36 '''This script is intended to be used within a git repository.'''
41 from tempfile import TemporaryFile
42 from git_utils import *
43 from ConfigParser import SafeConfigParser
45 # Globals ----------------------------------------------------------------------
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'],
56 'refiles' : ['*release*'],
57 'repaths' : { 'Globals': [''] },
58 'rebranch' : 'releases',
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']) + \
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. ' + \
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 ' + \
92 # Templates --------------------------------------------------------------------
94 dot_h = '#ifndef RELEASE_H\n#define RELEASE_H\n#define RELEASE ""\n' + \
95 '#endif /* RELEASE_H */'
97 # Classes ----------------------------------------------------------------------
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]:
113 values = values.split(',')
114 items[branch] = values
115 setattr(namespace, self.dest, items)
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
122 def __call__(self, parser, namespace, values, option_string=None):
123 items = _copy.copy(argparse._ensure_value(namespace, self.dest, []))
124 values = values.split(',')
127 values.pop(values.index(value))
129 setattr(namespace, self.dest, items)
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(',')
141 items.pop(items.index(value))
142 setattr(namespace, self.dest, items)
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,
158 option_string=option_string )
161 # Functions --------------------------------------------------------------------
163 def update_release_number( release, fileglobs, pathsdict ):
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
172 script = idstr.split()[1]
174 # Parse the fileglobs.
176 for fileglob in fileglobs:
178 files.append(fileglob.rsplit('/',1))
180 files.append(['',fileglob])
182 # Search for matching files.
184 reporoot = git('rev-parse --show-toplevel')
185 if 'Globals' in pathsdict.keys() and pathsdict['Globals']:
186 paths = pathsdict['Globals']
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)
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))
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.
207 newfn = os.path.join(reporoot, os.path.join(paths[0], 'release.h'))
208 newfile = open(newfn,'w+b')
213 newfile = open(newfn)
216 matches.append(newfn)
217 elif len(matches) > 1:
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('"')
229 if release is None or not release:
231 restr = relist[1].rsplit('.',1)
232 restr[1] = str(int(restr[1])+1)
233 release = '.'.join(restr)
236 relist[1] = release if release is not None else ''
237 line = '"'.join(relist)
242 relfile = open(match,'w+b')
243 relfile.write(reltemp.read())
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'] ):
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 ) )
263 # Check that local repository has a release branch and that we are not
264 # currently working on the release branch.
267 for branch in git('branch').split('\n'):
268 if rebranch in branch:
273 sys.exit( 'FATAL ERROR: Local repository does not have a release ' + \
274 'branch. Expecting a branch named \'' + rebranch + '\'.' )
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.' )
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')
285 status.pop(status.index(line))
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]: ' )
292 if tmp[0] in ['Q','q']:
293 sys.exit( 'Release script terminated by user.' )
294 elif tmp[0] in ['S','s']:
296 elif tmp[0] in ['C','c']:
299 # TODO: Merge all specified branches.
301 # Update release number.
302 release = update_release_number( release, refiles, repaths )
304 sys.exit( 'FATAL ERROR: Unable to detect release string. Please provide' + \
305 ' one in a config file or on the command line.' )
307 # Commit release changes to current branch.
308 git('commit -a', interactive=True)
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'])) )
316 # Switch to release branch, apply diffs, commit and tag the new release.
317 git('checkout ' + rebranch)
318 git('apply', input=difftmp)
320 if message is None or not message:
321 git('commit -a', interactive=True)
322 message = git('log -n 1 --pretty=\'format:%B\'')
324 git('commit -am ' + message)
325 git('tag -m \'' + message + '\' ' + release)
327 # Return to original working branch.
328 git('checkout ' + cur_branch)
329 git('tag -m \'' + message + '\' ' + release + '-' + cur_branch)
334 # Mainline ---------------------------------------------------------------------
337 '''Mainline. Reads the config file and parses command line arguments.'''
343 release = defaults['release']
344 branches = defaults['branches']
345 refiles = defaults['refiles']
346 repaths = defaults['repaths']
347 rebranch = defaults['rebranch']
348 message = defaults['message']
350 parser = argparse.ArgumentParser( description=__doc__,
352 formatter_class=argparse.RawDescriptionHelpFormatter,
354 parser.add_argument( '-V', '--version',
355 action=SuperVersionAction,
358 parser.add_argument( '-v', '--verbose',
360 help=help['verbose'],
362 parser.add_argument( 'release',
363 default=defaults['release'],
365 help=help['release'],
367 parser.add_argument( '-c', '--config',
368 default=defaults['config'],
371 parser.add_argument( '-b', '--branches',
372 action=SplitAppendAction,
373 default=defaults['branches'],
374 help=help['branches'],
376 parser.add_argument( '-e', '--exclude-branches',
377 action=SplitRemoveAction,
379 help=help['exbranches'],
381 parser.add_argument( '-f', '--release-files',
382 action=SplitAppendAction,
383 default=defaults['refiles'],
384 help=help['refiles'],
386 parser.add_argument( '-m', '--message',
387 default=defaults['message'],
388 help=help['message'],
390 parser.add_argument( '-p', '--release-paths',
391 action=RePathsAction,
392 default=defaults['repaths'],
393 help=help['repaths'],
395 parser.add_argument( '-r', '--release-branch',
396 default=defaults['rebranch'],
397 help=help['rebranch'],
399 args = parser.parse_args()
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')
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
430 do_release( release=release,
439 if __name__ == '__main__':
442 # ============================================================================ #