blob: ab19cb9aa37ebd8836b76f448171bea54b4ac6a7 [file] [log] [blame]
Brad Bishop6e60e8b2018-02-01 10:27:11 -05001#!/usr/bin/python3
2#
3# Helper script for committing data to git and pushing upstream
4#
5# Copyright (c) 2017, Intel Corporation.
6#
7# This program is free software; you can redistribute it and/or modify it
8# under the terms and conditions of the GNU General Public License,
9# version 2, as published by the Free Software Foundation.
10#
11# This program is distributed in the hope it will be useful, but WITHOUT
12# ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
13# FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
14# more details.
15#
16import argparse
17import glob
18import json
19import logging
20import math
21import os
22import re
23import sys
24from collections import namedtuple, OrderedDict
25from datetime import datetime, timedelta, tzinfo
26from operator import attrgetter
27
28# Import oe and bitbake libs
29scripts_path = os.path.dirname(os.path.realpath(__file__))
30sys.path.append(os.path.join(scripts_path, 'lib'))
31import scriptpath
32scriptpath.add_bitbake_lib_path()
33scriptpath.add_oe_lib_path()
34
35from oeqa.utils.git import GitRepo, GitError
36from oeqa.utils.metadata import metadata_from_bb
37
38
39# Setup logging
40logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
41log = logging.getLogger()
42
43
44class ArchiveError(Exception):
45 """Internal error handling of this script"""
46
47
48def format_str(string, fields):
49 """Format string using the given fields (dict)"""
50 try:
51 return string.format(**fields)
52 except KeyError as err:
53 raise ArchiveError("Unable to expand string '{}': unknown field {} "
54 "(valid fields are: {})".format(
55 string, err, ', '.join(sorted(fields.keys()))))
56
57
58def init_git_repo(path, no_create, bare):
59 """Initialize local Git repository"""
60 path = os.path.abspath(path)
61 if os.path.isfile(path):
62 raise ArchiveError("Invalid Git repo at {}: path exists but is not a "
63 "directory".format(path))
64 if not os.path.isdir(path) or not os.listdir(path):
65 if no_create:
66 raise ArchiveError("No git repo at {}, refusing to create "
67 "one".format(path))
68 if not os.path.isdir(path):
69 try:
70 os.mkdir(path)
71 except (FileNotFoundError, PermissionError) as err:
72 raise ArchiveError("Failed to mkdir {}: {}".format(path, err))
73 if not os.listdir(path):
74 log.info("Initializing a new Git repo at %s", path)
75 repo = GitRepo.init(path, bare)
76 try:
77 repo = GitRepo(path, is_topdir=True)
78 except GitError:
79 raise ArchiveError("Non-empty directory that is not a Git repository "
80 "at {}\nPlease specify an existing Git repository, "
81 "an empty directory or a non-existing directory "
82 "path.".format(path))
83 return repo
84
85
86def git_commit_data(repo, data_dir, branch, message, exclude, notes):
87 """Commit data into a Git repository"""
88 log.info("Committing data into to branch %s", branch)
89 tmp_index = os.path.join(repo.git_dir, 'index.oe-git-archive')
90 try:
91 # Create new tree object from the data
92 env_update = {'GIT_INDEX_FILE': tmp_index,
93 'GIT_WORK_TREE': os.path.abspath(data_dir)}
94 repo.run_cmd('add .', env_update)
95
96 # Remove files that are excluded
97 if exclude:
98 repo.run_cmd(['rm', '--cached'] + [f for f in exclude], env_update)
99
100 tree = repo.run_cmd('write-tree', env_update)
101
102 # Create new commit object from the tree
103 parent = repo.rev_parse(branch)
104 git_cmd = ['commit-tree', tree, '-m', message]
105 if parent:
106 git_cmd += ['-p', parent]
107 commit = repo.run_cmd(git_cmd, env_update)
108
109 # Create git notes
110 for ref, filename in notes:
111 ref = ref.format(branch_name=branch)
112 repo.run_cmd(['notes', '--ref', ref, 'add',
113 '-F', os.path.abspath(filename), commit])
114
115 # Update branch head
116 git_cmd = ['update-ref', 'refs/heads/' + branch, commit]
117 if parent:
118 git_cmd.append(parent)
119 repo.run_cmd(git_cmd)
120
121 # Update current HEAD, if we're on branch 'branch'
122 if not repo.bare and repo.get_current_branch() == branch:
123 log.info("Updating %s HEAD to latest commit", repo.top_dir)
124 repo.run_cmd('reset --hard')
125
126 return commit
127 finally:
128 if os.path.exists(tmp_index):
129 os.unlink(tmp_index)
130
131
132def expand_tag_strings(repo, name_pattern, msg_subj_pattern, msg_body_pattern,
133 keywords):
134 """Generate tag name and message, with support for running id number"""
135 keyws = keywords.copy()
136 # Tag number is handled specially: if not defined, we autoincrement it
137 if 'tag_number' not in keyws:
138 # Fill in all other fields than 'tag_number'
139 keyws['tag_number'] = '{tag_number}'
140 tag_re = format_str(name_pattern, keyws)
141 # Replace parentheses for proper regex matching
142 tag_re = tag_re.replace('(', '\(').replace(')', '\)') + '$'
143 # Inject regex group pattern for 'tag_number'
144 tag_re = tag_re.format(tag_number='(?P<tag_number>[0-9]{1,5})')
145
146 keyws['tag_number'] = 0
147 for existing_tag in repo.run_cmd('tag').splitlines():
148 match = re.match(tag_re, existing_tag)
149
150 if match and int(match.group('tag_number')) >= keyws['tag_number']:
151 keyws['tag_number'] = int(match.group('tag_number')) + 1
152
153 tag_name = format_str(name_pattern, keyws)
154 msg_subj= format_str(msg_subj_pattern.strip(), keyws)
155 msg_body = format_str(msg_body_pattern, keyws)
156 return tag_name, msg_subj + '\n\n' + msg_body
157
158
159def parse_args(argv):
160 """Parse command line arguments"""
161 parser = argparse.ArgumentParser(
162 description="Commit data to git and push upstream",
163 formatter_class=argparse.ArgumentDefaultsHelpFormatter)
164
165 parser.add_argument('--debug', '-D', action='store_true',
166 help="Verbose logging")
167 parser.add_argument('--git-dir', '-g', required=True,
168 help="Local git directory to use")
169 parser.add_argument('--no-create', action='store_true',
170 help="If GIT_DIR is not a valid Git repository, do not "
171 "try to create one")
172 parser.add_argument('--bare', action='store_true',
173 help="Initialize a bare repository when creating a "
174 "new one")
175 parser.add_argument('--push', '-p', nargs='?', default=False, const=True,
176 help="Push to remote")
177 parser.add_argument('--branch-name', '-b',
178 default='{hostname}/{branch}/{machine}',
179 help="Git branch name (pattern) to use")
180 parser.add_argument('--no-tag', action='store_true',
181 help="Do not create Git tag")
182 parser.add_argument('--tag-name', '-t',
183 default='{hostname}/{branch}/{machine}/{commit_count}-g{commit}/{tag_number}',
184 help="Tag name (pattern) to use")
185 parser.add_argument('--commit-msg-subject',
186 default='Results of {branch}:{commit} on {hostname}',
187 help="Subject line (pattern) to use in the commit message")
188 parser.add_argument('--commit-msg-body',
189 default='branch: {branch}\ncommit: {commit}\nhostname: {hostname}',
190 help="Commit message body (pattern)")
191 parser.add_argument('--tag-msg-subject',
192 default='Test run #{tag_number} of {branch}:{commit} on {hostname}',
193 help="Subject line (pattern) of the tag message")
194 parser.add_argument('--tag-msg-body',
195 default='',
196 help="Tag message body (pattern)")
197 parser.add_argument('--exclude', action='append', default=[],
198 help="Glob to exclude files from the commit. Relative "
199 "to DATA_DIR. May be specified multiple times")
200 parser.add_argument('--notes', nargs=2, action='append', default=[],
201 metavar=('GIT_REF', 'FILE'),
202 help="Add a file as a note under refs/notes/GIT_REF. "
203 "{branch_name} in GIT_REF will be expanded to the "
204 "actual target branch name (specified by "
205 "--branch-name). This option may be specified "
206 "multiple times.")
207 parser.add_argument('data_dir', metavar='DATA_DIR',
208 help="Data to commit")
209 return parser.parse_args(argv)
210
211
212def main(argv=None):
213 """Script entry point"""
214 args = parse_args(argv)
215 if args.debug:
216 log.setLevel(logging.DEBUG)
217
218 try:
219 if not os.path.isdir(args.data_dir):
220 raise ArchiveError("Not a directory: {}".format(args.data_dir))
221
222 data_repo = init_git_repo(args.git_dir, args.no_create, args.bare)
223
224 # Get keywords to be used in tag and branch names and messages
225 metadata = metadata_from_bb()
226 keywords = {'hostname': metadata['hostname'],
227 'branch': metadata['layers']['meta']['branch'],
228 'commit': metadata['layers']['meta']['commit'],
229 'commit_count': metadata['layers']['meta']['commit_count'],
230 'machine': metadata['config']['MACHINE']}
231
232 # Expand strings early in order to avoid getting into inconsistent
233 # state (e.g. no tag even if data was committed)
234 commit_msg = format_str(args.commit_msg_subject.strip(), keywords)
235 commit_msg += '\n\n' + format_str(args.commit_msg_body, keywords)
236 branch_name = format_str(args.branch_name, keywords)
237 tag_name = None
238 if not args.no_tag and args.tag_name:
239 tag_name, tag_msg = expand_tag_strings(data_repo, args.tag_name,
240 args.tag_msg_subject,
241 args.tag_msg_body, keywords)
242
243 # Commit data
244 commit = git_commit_data(data_repo, args.data_dir, branch_name,
245 commit_msg, args.exclude, args.notes)
246
247 # Create tag
248 if tag_name:
249 log.info("Creating tag %s", tag_name)
250 data_repo.run_cmd(['tag', '-a', '-m', tag_msg, tag_name, commit])
251
252 # Push data to remote
253 if args.push:
254 cmd = ['push', '--tags']
255 # If no remote is given we push with the default settings from
256 # gitconfig
257 if args.push is not True:
258 notes_refs = ['refs/notes/' + ref.format(branch_name=branch_name)
259 for ref, _ in args.notes]
260 cmd.extend([args.push, branch_name] + notes_refs)
261 log.info("Pushing data to remote")
262 data_repo.run_cmd(cmd)
263
264 except ArchiveError as err:
265 log.error(str(err))
266 return 1
267
268 return 0
269
270if __name__ == "__main__":
271 sys.exit(main())