blob: b90bfc8800eb1299299c3eecc933435f652d7a52 [file] [log] [blame]
Patrick Williamsc0f7c042017-02-23 20:41:17 -06001#!/usr/bin/env python3
Patrick Williamsc124f4f2015-09-15 14:41:29 -05002# ex:ts=4:sw=4:sts=4:et
3# -*- tab-width: 4; c-basic-offset: 4; indent-tabs-mode: nil -*-
4#
5# Copyright 2011 Intel Corporation
6# Authored-by: Yu Ke <ke.yu@intel.com>
7# Paul Eggleton <paul.eggleton@intel.com>
8# Richard Purdie <richard.purdie@intel.com>
9#
10# This program is free software; you can redistribute it and/or modify
11# it under the terms of the GNU General Public License version 2 as
12# published by the Free Software Foundation.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License along
20# with this program; if not, write to the Free Software Foundation, Inc.,
21# 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
22
23import fnmatch
24import os, sys
25import optparse
26import logging
27import subprocess
28import tempfile
Patrick Williamsc0f7c042017-02-23 20:41:17 -060029import configparser
Patrick Williamsc124f4f2015-09-15 14:41:29 -050030import re
Patrick Williamsc0f7c042017-02-23 20:41:17 -060031import copy
32import pipes
33import shutil
Patrick Williamsc124f4f2015-09-15 14:41:29 -050034from collections import OrderedDict
35from string import Template
Patrick Williamsc0f7c042017-02-23 20:41:17 -060036from functools import reduce
Patrick Williamsc124f4f2015-09-15 14:41:29 -050037
38__version__ = "0.2.1"
39
40def logger_create():
41 logger = logging.getLogger("")
42 loggerhandler = logging.StreamHandler()
43 loggerhandler.setFormatter(logging.Formatter("[%(asctime)s] %(message)s","%H:%M:%S"))
44 logger.addHandler(loggerhandler)
45 logger.setLevel(logging.INFO)
46 return logger
47
48logger = logger_create()
49
50def get_current_branch(repodir=None):
51 try:
52 if not os.path.exists(os.path.join(repodir if repodir else '', ".git")):
53 # Repo not created yet (i.e. during init) so just assume master
54 return "master"
55 branchname = runcmd("git symbolic-ref HEAD 2>/dev/null", repodir).strip()
56 if branchname.startswith("refs/heads/"):
57 branchname = branchname[11:]
58 return branchname
59 except subprocess.CalledProcessError:
60 return ""
61
62class Configuration(object):
63 """
64 Manages the configuration
65
66 For an example config file, see combo-layer.conf.example
67
68 """
69 def __init__(self, options):
70 for key, val in options.__dict__.items():
71 setattr(self, key, val)
72
73 def readsection(parser, section, repo):
74 for (name, value) in parser.items(section):
75 if value.startswith("@"):
76 self.repos[repo][name] = eval(value.strip("@"))
77 else:
78 # Apply special type transformations for some properties.
79 # Type matches the RawConfigParser.get*() methods.
Patrick Williamsc0f7c042017-02-23 20:41:17 -060080 types = {'signoff': 'boolean', 'update': 'boolean', 'history': 'boolean'}
Patrick Williamsc124f4f2015-09-15 14:41:29 -050081 if name in types:
82 value = getattr(parser, 'get' + types[name])(section, name)
83 self.repos[repo][name] = value
84
85 def readglobalsection(parser, section):
86 for (name, value) in parser.items(section):
87 if name == "commit_msg":
88 self.commit_msg_template = value
89
90 logger.debug("Loading config file %s" % self.conffile)
Patrick Williamsc0f7c042017-02-23 20:41:17 -060091 self.parser = configparser.ConfigParser()
Patrick Williamsc124f4f2015-09-15 14:41:29 -050092 with open(self.conffile) as f:
93 self.parser.readfp(f)
94
95 # initialize default values
96 self.commit_msg_template = "Automatic commit to update last_revision"
97
98 self.repos = {}
99 for repo in self.parser.sections():
100 if repo == "combo-layer-settings":
101 # special handling for global settings
102 readglobalsection(self.parser, repo)
103 else:
104 self.repos[repo] = {}
105 readsection(self.parser, repo, repo)
106
107 # Load local configuration, if available
108 self.localconffile = None
109 self.localparser = None
110 self.combobranch = None
111 if self.conffile.endswith('.conf'):
112 lcfile = self.conffile.replace('.conf', '-local.conf')
113 if os.path.exists(lcfile):
114 # Read combo layer branch
115 self.combobranch = get_current_branch()
116 logger.debug("Combo layer branch is %s" % self.combobranch)
117
118 self.localconffile = lcfile
119 logger.debug("Loading local config file %s" % self.localconffile)
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600120 self.localparser = configparser.ConfigParser()
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500121 with open(self.localconffile) as f:
122 self.localparser.readfp(f)
123
124 for section in self.localparser.sections():
125 if '|' in section:
126 sectionvals = section.split('|')
127 repo = sectionvals[0]
128 if sectionvals[1] != self.combobranch:
129 continue
130 else:
131 repo = section
132 if repo in self.repos:
133 readsection(self.localparser, section, repo)
134
135 def update(self, repo, option, value, initmode=False):
136 # If the main config has the option already, that is what we
137 # are expected to modify.
138 if self.localparser and not self.parser.has_option(repo, option):
139 parser = self.localparser
140 section = "%s|%s" % (repo, self.combobranch)
141 conffile = self.localconffile
142 if initmode and not parser.has_section(section):
143 parser.add_section(section)
144 else:
145 parser = self.parser
146 section = repo
147 conffile = self.conffile
148 parser.set(section, option, value)
149 with open(conffile, "w") as f:
150 parser.write(f)
151 self.repos[repo][option] = value
152
153 def sanity_check(self, initmode=False):
154 required_options=["src_uri", "local_repo_dir", "dest_dir", "last_revision"]
155 if initmode:
156 required_options.remove("last_revision")
157 msg = ""
158 missing_options = []
159 for name in self.repos:
160 for option in required_options:
161 if option not in self.repos[name]:
162 msg = "%s\nOption %s is not defined for component %s" %(msg, option, name)
163 missing_options.append(option)
164 # Sanitize dest_dir so that we do not have to deal with edge cases
165 # (unset, empty string, double slashes) in the rest of the code.
166 # It not being set will still be flagged as error because it is
167 # listed as required option above; that could be changed now.
168 dest_dir = os.path.normpath(self.repos[name].get("dest_dir", "."))
169 self.repos[name]["dest_dir"] = "." if not dest_dir else dest_dir
170 if msg != "":
171 logger.error("configuration file %s has the following error: %s" % (self.conffile,msg))
172 if self.localconffile and 'last_revision' in missing_options:
173 logger.error("local configuration file %s may be missing configuration for combo branch %s" % (self.localconffile, self.combobranch))
174 sys.exit(1)
175
176 # filterdiff is required by action_splitpatch, so check its availability
177 if subprocess.call("which filterdiff > /dev/null 2>&1", shell=True) != 0:
178 logger.error("ERROR: patchutils package is missing, please install it (e.g. # apt-get install patchutils)")
179 sys.exit(1)
180
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600181def runcmd(cmd,destdir=None,printerr=True,out=None,env=None):
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500182 """
183 execute command, raise CalledProcessError if fail
184 return output if succeed
185 """
186 logger.debug("run cmd '%s' in %s" % (cmd, os.getcwd() if destdir is None else destdir))
187 if not out:
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600188 out = tempfile.TemporaryFile()
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500189 err = out
190 else:
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600191 err = tempfile.TemporaryFile()
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500192 try:
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600193 subprocess.check_call(cmd, stdout=out, stderr=err, cwd=destdir, shell=isinstance(cmd, str), env=env or os.environ)
194 except subprocess.CalledProcessError as e:
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500195 err.seek(0)
196 if printerr:
197 logger.error("%s" % err.read())
198 raise e
199
200 err.seek(0)
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600201 output = err.read().decode('utf-8')
202 logger.debug("output: %s" % output.replace(chr(0), '\\0'))
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500203 return output
204
205def action_init(conf, args):
206 """
207 Clone component repositories
208 Check git is initialised; if not, copy initial data from component repos
209 """
210 for name in conf.repos:
211 ldir = conf.repos[name]['local_repo_dir']
212 if not os.path.exists(ldir):
213 logger.info("cloning %s to %s" %(conf.repos[name]['src_uri'], ldir))
214 subprocess.check_call("git clone %s %s" % (conf.repos[name]['src_uri'], ldir), shell=True)
215 if not os.path.exists(".git"):
216 runcmd("git init")
217 if conf.history:
218 # Need a common ref for all trees.
219 runcmd('git commit -m "initial empty commit" --allow-empty')
220 startrev = runcmd('git rev-parse master').strip()
221
222 for name in conf.repos:
223 repo = conf.repos[name]
224 ldir = repo['local_repo_dir']
225 branch = repo.get('branch', "master")
226 lastrev = repo.get('last_revision', None)
227 if lastrev and lastrev != "HEAD":
228 initialrev = lastrev
229 if branch:
230 if not check_rev_branch(name, ldir, lastrev, branch):
231 sys.exit(1)
232 logger.info("Copying data from %s at specified revision %s..." % (name, lastrev))
233 else:
234 lastrev = None
235 initialrev = branch
236 logger.info("Copying data from %s..." % name)
237 # Sanity check initialrev and turn it into hash (required for copying history,
238 # because resolving a name ref only works in the component repo).
239 rev = runcmd('git rev-parse %s' % initialrev, ldir).strip()
240 if rev != initialrev:
241 try:
242 refs = runcmd('git show-ref -s %s' % initialrev, ldir).split('\n')
243 if len(set(refs)) > 1:
244 # Happens for example when configured to track
245 # "master" and there is a refs/heads/master. The
246 # traditional behavior from "git archive" (preserved
247 # here) it to choose the first one. This might not be
248 # intended, so at least warn about it.
249 logger.warn("%s: initial revision '%s' not unique, picking result of rev-parse = %s" %
250 (name, initialrev, refs[0]))
251 initialrev = rev
252 except:
253 # show-ref fails for hashes. Skip the sanity warning in that case.
254 pass
255 initialrev = rev
256 dest_dir = repo['dest_dir']
257 if dest_dir != ".":
258 extract_dir = os.path.join(os.getcwd(), dest_dir)
259 if not os.path.exists(extract_dir):
260 os.makedirs(extract_dir)
261 else:
262 extract_dir = os.getcwd()
263 file_filter = repo.get('file_filter', "")
264 exclude_patterns = repo.get('file_exclude', '').split()
265 def copy_selected_files(initialrev, extract_dir, file_filter, exclude_patterns, ldir,
266 subdir=""):
267 # When working inside a filtered branch which had the
268 # files already moved, we need to prepend the
269 # subdirectory to all filters, otherwise they would
270 # not match.
271 if subdir == '.':
272 subdir = ''
273 elif subdir:
274 subdir = os.path.normpath(subdir)
275 file_filter = ' '.join([subdir + '/' + x for x in file_filter.split()])
276 exclude_patterns = [subdir + '/' + x for x in exclude_patterns]
277 # To handle both cases, we cd into the target
278 # directory and optionally tell tar to strip the path
279 # prefix when the files were already moved.
280 subdir_components = len(subdir.split(os.path.sep)) if subdir else 0
281 strip=('--strip-components=%d' % subdir_components) if subdir else ''
282 # TODO: file_filter wild cards do not work (and haven't worked before either), because
283 # a) GNU tar requires a --wildcards parameter before turning on wild card matching.
284 # b) The semantic is not as intendend (src/*.c also matches src/foo/bar.c,
285 # in contrast to the other use of file_filter as parameter of "git archive"
286 # where it only matches .c files directly in src).
287 files = runcmd("git archive %s %s | tar -x -v %s -C %s %s" %
288 (initialrev, subdir,
289 strip, extract_dir, file_filter),
290 ldir)
291 if exclude_patterns:
292 # Implement file removal by letting tar create the
293 # file and then deleting it in the file system
294 # again. Uses the list of files created by tar (easier
295 # than walking the tree).
296 for file in files.split('\n'):
297 for pattern in exclude_patterns:
298 if fnmatch.fnmatch(file, pattern):
299 os.unlink(os.path.join(*([extract_dir] + ['..'] * subdir_components + [file])))
300 break
301
302 if not conf.history:
303 copy_selected_files(initialrev, extract_dir, file_filter, exclude_patterns, ldir)
304 else:
305 # First fetch remote history into local repository.
306 # We need a ref for that, so ensure that there is one.
307 refname = "combo-layer-init-%s" % name
308 runcmd("git branch -f %s %s" % (refname, initialrev), ldir)
309 runcmd("git fetch %s %s" % (ldir, refname))
310 runcmd("git branch -D %s" % refname, ldir)
311 # Make that the head revision.
312 runcmd("git checkout -b %s %s" % (name, initialrev))
313 # Optional: cut the history by replacing the given
314 # start point(s) with commits providing the same
315 # content (aka tree), but with commit information that
316 # makes it clear that this is an artifically created
317 # commit and nothing the original authors had anything
318 # to do with.
319 since_rev = repo.get('since_revision', '')
320 if since_rev:
321 committer = runcmd('git var GIT_AUTHOR_IDENT').strip()
322 # Same time stamp, no name.
323 author = re.sub('.* (\d+ [+-]\d+)', r'unknown <unknown> \1', committer)
324 logger.info('author %s' % author)
325 for rev in since_rev.split():
326 # Resolve in component repo...
327 rev = runcmd('git log --oneline --no-abbrev-commit -n1 %s' % rev, ldir).split()[0]
328 # ... and then get the tree in current
329 # one. The commit should be in both repos with
330 # the same tree, but better check here.
331 tree = runcmd('git show -s --pretty=format:%%T %s' % rev).strip()
332 with tempfile.NamedTemporaryFile() as editor:
333 editor.write('''cat >$1 <<EOF
334tree %s
335author %s
336committer %s
337
338%s: squashed import of component
339
340This commit copies the entire set of files as found in
341%s %s
342
343For more information about previous commits, see the
344upstream repository.
345
346Commit created by combo-layer.
347EOF
348''' % (tree, author, committer, name, name, since_rev))
349 editor.flush()
350 os.environ['GIT_EDITOR'] = 'sh %s' % editor.name
351 runcmd('git replace --edit %s' % rev)
352
353 # Optional: rewrite history to change commit messages or to move files.
354 if 'hook' in repo or dest_dir != ".":
355 filter_branch = ['git', 'filter-branch', '--force']
356 with tempfile.NamedTemporaryFile() as hookwrapper:
357 if 'hook' in repo:
358 # Create a shell script wrapper around the original hook that
359 # can be used by git filter-branch. Hook may or may not have
360 # an absolute path.
361 hook = repo['hook']
362 hook = os.path.join(os.path.dirname(conf.conffile), '..', hook)
363 # The wrappers turns the commit message
364 # from stdin into a fake patch header.
365 # This is good enough for changing Subject
366 # and commit msg body with normal
367 # combo-layer hooks.
368 hookwrapper.write('''set -e
369tmpname=$(mktemp)
370trap "rm $tmpname" EXIT
371echo -n 'Subject: [PATCH] ' >>$tmpname
372cat >>$tmpname
373if ! [ $(tail -c 1 $tmpname | od -A n -t x1) == '0a' ]; then
374 echo >>$tmpname
375fi
376echo '---' >>$tmpname
377%s $tmpname $GIT_COMMIT %s
378tail -c +18 $tmpname | head -c -4
379''' % (hook, name))
380 hookwrapper.flush()
381 filter_branch.extend(['--msg-filter', 'bash %s' % hookwrapper.name])
382 if dest_dir != ".":
383 parent = os.path.dirname(dest_dir)
384 if not parent:
385 parent = '.'
386 # May run outside of the current directory, so do not assume that .git exists.
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500387 filter_branch.extend(['--tree-filter', 'mkdir -p .git/tmptree && find . -mindepth 1 -maxdepth 1 ! -name .git -print0 | xargs -0 -I SOURCE mv SOURCE .git/tmptree && mkdir -p %s && mv .git/tmptree %s' % (parent, dest_dir)])
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500388 filter_branch.append('HEAD')
389 runcmd(filter_branch)
390 runcmd('git update-ref -d refs/original/refs/heads/%s' % name)
391 repo['rewritten_revision'] = runcmd('git rev-parse HEAD').strip()
392 repo['stripped_revision'] = repo['rewritten_revision']
393 # Optional filter files: remove everything and re-populate using the normal filtering code.
394 # Override any potential .gitignore.
395 if file_filter or exclude_patterns:
396 runcmd('git rm -rf .')
397 if not os.path.exists(extract_dir):
398 os.makedirs(extract_dir)
399 copy_selected_files('HEAD', extract_dir, file_filter, exclude_patterns, '.',
400 subdir=dest_dir)
401 runcmd('git add --all --force .')
402 if runcmd('git status --porcelain'):
403 # Something to commit.
404 runcmd(['git', 'commit', '-m',
405 '''%s: select file subset
406
407Files from the component repository were chosen based on
408the following filters:
409file_filter = %s
410file_exclude = %s''' % (name, file_filter or '<empty>', repo.get('file_exclude', '<empty>'))])
411 repo['stripped_revision'] = runcmd('git rev-parse HEAD').strip()
412
413 if not lastrev:
414 lastrev = runcmd('git rev-parse %s' % initialrev, ldir).strip()
415 conf.update(name, "last_revision", lastrev, initmode=True)
416
417 if not conf.history:
418 runcmd("git add .")
419 else:
420 # Create Octopus merge commit according to http://stackoverflow.com/questions/10874149/git-octopus-merge-with-unrelated-repositoies
421 runcmd('git checkout master')
422 merge = ['git', 'merge', '--no-commit']
423 for name in conf.repos:
424 repo = conf.repos[name]
425 # Use branch created earlier.
426 merge.append(name)
427 # Root all commits which have no parent in the common
428 # ancestor in the new repository.
429 for start in runcmd('git log --pretty=format:%%H --max-parents=0 %s' % name).split('\n'):
430 runcmd('git replace --graft %s %s' % (start, startrev))
431 try:
432 runcmd(merge)
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600433 except Exception as error:
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500434 logger.info('''Merging component repository history failed, perhaps because of merge conflicts.
435It may be possible to commit anyway after resolving these conflicts.
436
437%s''' % error)
438 # Create MERGE_HEAD and MERGE_MSG. "git merge" itself
439 # does not create MERGE_HEAD in case of a (harmless) failure,
440 # and we want certain auto-generated information in the
441 # commit message for future reference and/or automation.
442 with open('.git/MERGE_HEAD', 'w') as head:
443 with open('.git/MERGE_MSG', 'w') as msg:
444 msg.write('repo: initial import of components\n\n')
445 # head.write('%s\n' % startrev)
446 for name in conf.repos:
447 repo = conf.repos[name]
448 # <upstream ref> <rewritten ref> <rewritten + files removed>
449 msg.write('combo-layer-%s: %s %s %s\n' % (name,
450 repo['last_revision'],
451 repo['rewritten_revision'],
452 repo['stripped_revision']))
453 rev = runcmd('git rev-parse %s' % name).strip()
454 head.write('%s\n' % rev)
455
456 if conf.localconffile:
457 localadded = True
458 try:
459 runcmd("git rm --cached %s" % conf.localconffile, printerr=False)
460 except subprocess.CalledProcessError:
461 localadded = False
462 if localadded:
463 localrelpath = os.path.relpath(conf.localconffile)
464 runcmd("grep -q %s .gitignore || echo %s >> .gitignore" % (localrelpath, localrelpath))
465 runcmd("git add .gitignore")
466 logger.info("Added local configuration file %s to .gitignore", localrelpath)
467 logger.info("Initial combo layer repository data has been created; please make any changes if desired and then use 'git commit' to make the initial commit.")
468 else:
469 logger.info("Repository already initialised, nothing to do.")
470
471
472def check_repo_clean(repodir):
473 """
474 check if the repo is clean
475 exit if repo is dirty
476 """
477 output=runcmd("git status --porcelain", repodir)
478 r = re.compile('\?\? patch-.*/')
479 dirtyout = [item for item in output.splitlines() if not r.match(item)]
480 if dirtyout:
481 logger.error("git repo %s is dirty, please fix it first", repodir)
482 sys.exit(1)
483
484def check_patch(patchfile):
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600485 f = open(patchfile, 'rb')
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500486 ln = f.readline()
487 of = None
488 in_patch = False
489 beyond_msg = False
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600490 pre_buf = b''
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500491 while ln:
492 if not beyond_msg:
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600493 if ln == b'---\n':
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500494 if not of:
495 break
496 in_patch = False
497 beyond_msg = True
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600498 elif ln.startswith(b'--- '):
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500499 # We have a diff in the commit message
500 in_patch = True
501 if not of:
502 print('WARNING: %s contains a diff in its commit message, indenting to avoid failure during apply' % patchfile)
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600503 of = open(patchfile + '.tmp', 'wb')
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500504 of.write(pre_buf)
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600505 pre_buf = b''
506 elif in_patch and not ln[0] in b'+-@ \n\r':
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500507 in_patch = False
508 if of:
509 if in_patch:
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600510 of.write(b' ' + ln)
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500511 else:
512 of.write(ln)
513 else:
514 pre_buf += ln
515 ln = f.readline()
516 f.close()
517 if of:
518 of.close()
519 os.rename(patchfile + '.tmp', patchfile)
520
521def drop_to_shell(workdir=None):
522 if not sys.stdin.isatty():
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600523 print("Not a TTY so can't drop to shell for resolution, exiting.")
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500524 return False
525
526 shell = os.environ.get('SHELL', 'bash')
527 print('Dropping to shell "%s"\n' \
528 'When you are finished, run the following to continue:\n' \
529 ' exit -- continue to apply the patches\n' \
530 ' exit 1 -- abort\n' % shell);
531 ret = subprocess.call([shell], cwd=workdir)
532 if ret != 0:
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600533 print("Aborting")
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500534 return False
535 else:
536 return True
537
538def check_rev_branch(component, repodir, rev, branch):
539 try:
540 actualbranch = runcmd("git branch --contains %s" % rev, repodir, printerr=False)
541 except subprocess.CalledProcessError as e:
542 if e.returncode == 129:
543 actualbranch = ""
544 else:
545 raise
546
547 if not actualbranch:
548 logger.error("%s: specified revision %s is invalid!" % (component, rev))
549 return False
550
551 branches = []
552 branchlist = actualbranch.split("\n")
553 for b in branchlist:
554 branches.append(b.strip().split(' ')[-1])
555
556 if branch not in branches:
557 logger.error("%s: specified revision %s is not on specified branch %s!" % (component, rev, branch))
558 return False
559 return True
560
561def get_repos(conf, repo_names):
562 repos = []
563 for name in repo_names:
564 if name.startswith('-'):
565 break
566 else:
567 repos.append(name)
568 for repo in repos:
569 if not repo in conf.repos:
570 logger.error("Specified component '%s' not found in configuration" % repo)
571 sys.exit(1)
572
573 if not repos:
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500574 repos = [ repo for repo in conf.repos if conf.repos[repo].get("update", True) ]
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500575
576 return repos
577
578def action_pull(conf, args):
579 """
580 update the component repos only
581 """
582 repos = get_repos(conf, args[1:])
583
584 # make sure all repos are clean
585 for name in repos:
586 check_repo_clean(conf.repos[name]['local_repo_dir'])
587
588 for name in repos:
589 repo = conf.repos[name]
590 ldir = repo['local_repo_dir']
591 branch = repo.get('branch', "master")
592 logger.info("update branch %s of component repo %s in %s ..." % (branch, name, ldir))
593 if not conf.hard_reset:
594 # Try to pull only the configured branch. Beware that this may fail
595 # when the branch is currently unknown (for example, after reconfiguring
596 # combo-layer). In that case we need to fetch everything and try the check out
597 # and pull again.
598 try:
599 runcmd("git checkout %s" % branch, ldir, printerr=False)
600 except subprocess.CalledProcessError:
601 output=runcmd("git fetch", ldir)
602 logger.info(output)
603 runcmd("git checkout %s" % branch, ldir)
604 runcmd("git pull --ff-only", ldir)
605 else:
606 output=runcmd("git pull --ff-only", ldir)
607 logger.info(output)
608 else:
609 output=runcmd("git fetch", ldir)
610 logger.info(output)
611 runcmd("git checkout %s" % branch, ldir)
612 runcmd("git reset --hard FETCH_HEAD", ldir)
613
614def action_update(conf, args):
615 """
616 update the component repos
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600617 either:
618 generate the patch list
619 apply the generated patches
620 or:
621 re-creates the entire component history and merges them
622 into the current branch with a merge commit
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500623 """
624 components = [arg.split(':')[0] for arg in args[1:]]
625 revisions = {}
626 for arg in args[1:]:
627 if ':' in arg:
628 a = arg.split(':', 1)
629 revisions[a[0]] = a[1]
630 repos = get_repos(conf, components)
631
632 # make sure combo repo is clean
633 check_repo_clean(os.getcwd())
634
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600635 # Check whether we keep the component histories. Must be
636 # set either via --history command line parameter or consistently
637 # in combo-layer.conf. Mixing modes is (currently, and probably
638 # permanently because it would be complicated) not supported.
639 if conf.history:
640 history = True
641 else:
642 history = None
643 for name in repos:
644 repo = conf.repos[name]
645 repo_history = repo.get('history', False)
646 if history is None:
647 history = repo_history
648 elif history != repo_history:
649 logger.error("'history' property is set inconsistently")
650 sys.exit(1)
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500651
652 # Step 1: update the component repos
653 if conf.nopull:
654 logger.info("Skipping pull (-n)")
655 else:
656 action_pull(conf, ['arg0'] + components)
657
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600658 if history:
659 update_with_history(conf, components, revisions, repos)
660 else:
661 update_with_patches(conf, components, revisions, repos)
662
663def update_with_patches(conf, components, revisions, repos):
664 import uuid
665 patch_dir = "patch-%s" % uuid.uuid4()
666 if not os.path.exists(patch_dir):
667 os.mkdir(patch_dir)
668
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500669 for name in repos:
670 revision = revisions.get(name, None)
671 repo = conf.repos[name]
672 ldir = repo['local_repo_dir']
673 dest_dir = repo['dest_dir']
674 branch = repo.get('branch', "master")
675 repo_patch_dir = os.path.join(os.getcwd(), patch_dir, name)
676
677 # Step 2: generate the patch list and store to patch dir
678 logger.info("Generating patches from %s..." % name)
679 top_revision = revision or branch
680 if not check_rev_branch(name, ldir, top_revision, branch):
681 sys.exit(1)
682 if dest_dir != ".":
683 prefix = "--src-prefix=a/%s/ --dst-prefix=b/%s/" % (dest_dir, dest_dir)
684 else:
685 prefix = ""
686 if repo['last_revision'] == "":
687 logger.info("Warning: last_revision of component %s is not set, starting from the first commit" % name)
688 patch_cmd_range = "--root %s" % top_revision
689 rev_cmd_range = top_revision
690 else:
691 if not check_rev_branch(name, ldir, repo['last_revision'], branch):
692 sys.exit(1)
693 patch_cmd_range = "%s..%s" % (repo['last_revision'], top_revision)
694 rev_cmd_range = patch_cmd_range
695
Patrick Williamsd8c66bc2016-06-20 12:57:21 -0500696 file_filter = repo.get('file_filter',".")
697
698 # Filter out unwanted files
699 exclude = repo.get('file_exclude', '')
700 if exclude:
701 for path in exclude.split():
702 p = "%s/%s" % (dest_dir, path) if dest_dir != '.' else path
703 file_filter += " ':!%s'" % p
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500704
705 patch_cmd = "git format-patch -N %s --output-directory %s %s -- %s" % \
706 (prefix,repo_patch_dir, patch_cmd_range, file_filter)
707 output = runcmd(patch_cmd, ldir)
708 logger.debug("generated patch set:\n%s" % output)
709 patchlist = output.splitlines()
710
711 rev_cmd = "git rev-list --no-merges %s -- %s" % (rev_cmd_range, file_filter)
712 revlist = runcmd(rev_cmd, ldir).splitlines()
713
714 # Step 3: Call repo specific hook to adjust patch
715 if 'hook' in repo:
716 # hook parameter is: ./hook patchpath revision reponame
717 count=len(revlist)-1
718 for patch in patchlist:
719 runcmd("%s %s %s %s" % (repo['hook'], patch, revlist[count], name))
720 count=count-1
721
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500722 # Step 4: write patch list and revision list to file, for user to edit later
723 patchlist_file = os.path.join(os.getcwd(), patch_dir, "patchlist-%s" % name)
724 repo['patchlist'] = patchlist_file
725 f = open(patchlist_file, 'w')
726 count=len(revlist)-1
727 for patch in patchlist:
728 f.write("%s %s\n" % (patch, revlist[count]))
729 check_patch(os.path.join(patch_dir, patch))
730 count=count-1
731 f.close()
732
733 # Step 5: invoke bash for user to edit patch and patch list
734 if conf.interactive:
735 print('You may now edit the patch and patch list in %s\n' \
736 'For example, you can remove unwanted patch entries from patchlist-*, so that they will be not applied later' % patch_dir);
737 if not drop_to_shell(patch_dir):
738 sys.exit(1)
739
740 # Step 6: apply the generated and revised patch
741 apply_patchlist(conf, repos)
742 runcmd("rm -rf %s" % patch_dir)
743
744 # Step 7: commit the updated config file if it's being tracked
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600745 commit_conf_file(conf, components)
746
747def conf_commit_msg(conf, components):
748 # create the "components" string
749 component_str = "all components"
750 if len(components) > 0:
751 # otherwise tell which components were actually changed
752 component_str = ", ".join(components)
753
754 # expand the template with known values
755 template = Template(conf.commit_msg_template)
756 msg = template.substitute(components = component_str)
757 return msg
758
759def commit_conf_file(conf, components, commit=True):
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500760 relpath = os.path.relpath(conf.conffile)
761 try:
762 output = runcmd("git status --porcelain %s" % relpath, printerr=False)
763 except:
764 # Outside the repository
765 output = None
766 if output:
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500767 if output.lstrip().startswith("M"):
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600768 logger.info("Committing updated configuration file")
769 if commit:
770 msg = conf_commit_msg(conf, components)
771 runcmd('git commit -m'.split() + [msg, relpath])
772 else:
773 runcmd('git add %s' % relpath)
774 return True
775 return False
Patrick Williamsc124f4f2015-09-15 14:41:29 -0500776
777def apply_patchlist(conf, repos):
778 """
779 apply the generated patch list to combo repo
780 """
781 for name in repos:
782 repo = conf.repos[name]
783 lastrev = repo["last_revision"]
784 prevrev = lastrev
785
786 # Get non-blank lines from patch list file
787 patchlist = []
788 if os.path.exists(repo['patchlist']) or not conf.interactive:
789 # Note: we want this to fail here if the file doesn't exist and we're not in
790 # interactive mode since the file should exist in this case
791 with open(repo['patchlist']) as f:
792 for line in f:
793 line = line.rstrip()
794 if line:
795 patchlist.append(line)
796
797 ldir = conf.repos[name]['local_repo_dir']
798 branch = conf.repos[name].get('branch', "master")
799 branchrev = runcmd("git rev-parse %s" % branch, ldir).strip()
800
801 if patchlist:
802 logger.info("Applying patches from %s..." % name)
803 linecount = len(patchlist)
804 i = 1
805 for line in patchlist:
806 patchfile = line.split()[0]
807 lastrev = line.split()[1]
808 patchdisp = os.path.relpath(patchfile)
809 if os.path.getsize(patchfile) == 0:
810 logger.info("(skipping %d/%d %s - no changes)" % (i, linecount, patchdisp))
811 else:
812 cmd = "git am --keep-cr %s-p1 %s" % ('-s ' if repo.get('signoff', True) else '', patchfile)
813 logger.info("Applying %d/%d: %s" % (i, linecount, patchdisp))
814 try:
815 runcmd(cmd)
816 except subprocess.CalledProcessError:
817 logger.info('Running "git am --abort" to cleanup repo')
818 runcmd("git am --abort")
819 logger.error('"%s" failed' % cmd)
820 logger.info("Please manually apply patch %s" % patchdisp)
821 logger.info("Note: if you exit and continue applying without manually applying the patch, it will be skipped")
822 if not drop_to_shell():
823 if prevrev != repo['last_revision']:
824 conf.update(name, "last_revision", prevrev)
825 sys.exit(1)
826 prevrev = lastrev
827 i += 1
828 # Once all patches are applied, we should update
829 # last_revision to the branch head instead of the last
830 # applied patch. The two are not necessarily the same when
831 # the last commit is a merge commit or when the patches at
832 # the branch head were intentionally excluded.
833 #
834 # If we do not do that for a merge commit, the next
835 # combo-layer run will only exclude patches reachable from
836 # one of the merged branches and try to re-apply patches
837 # from other branches even though they were already
838 # copied.
839 #
840 # If patches were intentionally excluded, the next run will
841 # present them again instead of skipping over them. This
842 # may or may not be intended, so the code here is conservative
843 # and only addresses the "head is merge commit" case.
844 if lastrev != branchrev and \
845 len(runcmd("git show --pretty=format:%%P --no-patch %s" % branch, ldir).split()) > 1:
846 lastrev = branchrev
847 else:
848 logger.info("No patches to apply from %s" % name)
849 lastrev = branchrev
850
851 if lastrev != repo['last_revision']:
852 conf.update(name, "last_revision", lastrev)
853
854def action_splitpatch(conf, args):
855 """
856 generate the commit patch and
857 split the patch per repo
858 """
859 logger.debug("action_splitpatch")
860 if len(args) > 1:
861 commit = args[1]
862 else:
863 commit = "HEAD"
864 patchdir = "splitpatch-%s" % commit
865 if not os.path.exists(patchdir):
866 os.mkdir(patchdir)
867
868 # filerange_root is for the repo whose dest_dir is root "."
869 # and it should be specified by excluding all other repo dest dir
870 # like "-x repo1 -x repo2 -x repo3 ..."
871 filerange_root = ""
872 for name in conf.repos:
873 dest_dir = conf.repos[name]['dest_dir']
874 if dest_dir != ".":
875 filerange_root = '%s -x "%s/*"' % (filerange_root, dest_dir)
876
877 for name in conf.repos:
878 dest_dir = conf.repos[name]['dest_dir']
879 patch_filename = "%s/%s.patch" % (patchdir, name)
880 if dest_dir == ".":
881 cmd = "git format-patch -n1 --stdout %s^..%s | filterdiff -p1 %s > %s" % (commit, commit, filerange_root, patch_filename)
882 else:
883 cmd = "git format-patch --no-prefix -n1 --stdout %s^..%s -- %s > %s" % (commit, commit, dest_dir, patch_filename)
884 runcmd(cmd)
885 # Detect empty patches (including those produced by filterdiff above
886 # that contain only preamble text)
887 if os.path.getsize(patch_filename) == 0 or runcmd("filterdiff %s" % patch_filename) == "":
888 os.remove(patch_filename)
889 logger.info("(skipping %s - no changes)", name)
890 else:
891 logger.info(patch_filename)
892
Patrick Williamsc0f7c042017-02-23 20:41:17 -0600893def update_with_history(conf, components, revisions, repos):
894 '''Update all components with full history.
895
896 Works by importing all commits reachable from a component's
897 current head revision. If those commits are rooted in an already
898 imported commit, their content gets mixed with the content of the
899 combined repo of that commit (new or modified files overwritten,
900 removed files removed).
901
902 The last commit is an artificial merge commit that merges all the
903 updated components into the combined repository.
904
905 The HEAD ref only gets updated at the very end. All intermediate work
906 happens in a worktree which will get garbage collected by git eventually
907 after a failure.
908 '''
909 # Remember current HEAD and what we need to add to it.
910 head = runcmd("git rev-parse HEAD").strip()
911 additional_heads = {}
912
913 # Track the mapping between original commit and commit in the
914 # combined repo. We do not have to distinguish between components,
915 # because commit hashes are different anyway. Often we can
916 # skip find_revs() entirely (for example, when all new commits
917 # are derived from the last imported revision).
918 #
919 # Using "head" (typically the merge commit) instead of the actual
920 # commit for the component leads to a nicer history in the combined
921 # repo.
922 old2new_revs = {}
923 for name in repos:
924 repo = conf.repos[name]
925 revision = repo['last_revision']
926 if revision:
927 old2new_revs[revision] = head
928
929 def add_p(parents):
930 '''Insert -p before each entry.'''
931 parameters = []
932 for p in parents:
933 parameters.append('-p')
934 parameters.append(p)
935 return parameters
936
937 # Do all intermediate work with a separate work dir and index,
938 # chosen via env variables (can't use "git worktree", it is too
939 # new). This is useful (no changes to current work tree unless the
940 # update succeeds) and required (otherwise we end up temporarily
941 # removing the combo-layer hooks that we currently use when
942 # importing a new component).
943 #
944 # Not cleaned up after a failure at the moment.
945 wdir = os.path.join(os.getcwd(), ".git", "combo-layer")
946 windex = wdir + ".index"
947 if os.path.isdir(wdir):
948 shutil.rmtree(wdir)
949 os.mkdir(wdir)
950 wenv = copy.deepcopy(os.environ)
951 wenv["GIT_WORK_TREE"] = wdir
952 wenv["GIT_INDEX_FILE"] = windex
953 # This one turned out to be needed in practice.
954 wenv["GIT_OBJECT_DIRECTORY"] = os.path.join(os.getcwd(), ".git", "objects")
955 wargs = {"destdir": wdir, "env": wenv}
956
957 for name in repos:
958 revision = revisions.get(name, None)
959 repo = conf.repos[name]
960 ldir = repo['local_repo_dir']
961 dest_dir = repo['dest_dir']
962 branch = repo.get('branch', "master")
963 hook = repo.get('hook', None)
964 largs = {"destdir": ldir, "env": None}
965 file_include = repo.get('file_filter', '').split()
966 file_include.sort() # make sure that short entries like '.' come first.
967 file_exclude = repo.get('file_exclude', '').split()
968
969 def include_file(file):
970 if not file_include:
971 # No explicit filter set, include file.
972 return True
973 for filter in file_include:
974 if filter == '.':
975 # Another special case: include current directory and thus all files.
976 return True
977 if os.path.commonprefix((filter, file)) == filter:
978 # Included in directory or direct file match.
979 return True
980 # Check for wildcard match *with* allowing * to match /, i.e.
981 # src/*.c does match src/foobar/*.c. That's not how it is done elsewhere
982 # when passing the filtering to "git archive", but it is unclear what
983 # the intended semantic is (the comment on file_exclude that "append a * wildcard
984 # at the end" to match the full content of a directories implies that
985 # slashes are indeed not special), so here we simply do what's easy to
986 # implement in Python.
987 logger.debug('fnmatch(%s, %s)' % (file, filter))
988 if fnmatch.fnmatchcase(file, filter):
989 return True
990 return False
991
992 def exclude_file(file):
993 for filter in file_exclude:
994 if fnmatch.fnmatchcase(file, filter):
995 return True
996 return False
997
998 def file_filter(files):
999 '''Clean up file list so that only included files remain.'''
1000 index = 0
1001 while index < len(files):
1002 file = files[index]
1003 if not include_file(file) or exclude_file(file):
1004 del files[index]
1005 else:
1006 index += 1
1007
1008
1009 # Generate the revision list.
1010 logger.info("Analyzing commits from %s..." % name)
1011 top_revision = revision or branch
1012 if not check_rev_branch(name, ldir, top_revision, branch):
1013 sys.exit(1)
1014
1015 last_revision = repo['last_revision']
1016 rev_list_args = "--full-history --sparse --topo-order --reverse"
1017 if not last_revision:
1018 logger.info("Warning: last_revision of component %s is not set, starting from the first commit" % name)
1019 rev_list_args = rev_list_args + ' ' + top_revision
1020 else:
1021 if not check_rev_branch(name, ldir, last_revision, branch):
1022 sys.exit(1)
1023 rev_list_args = "%s %s..%s" % (rev_list_args, last_revision, top_revision)
1024
1025 # By definition, the current HEAD contains the latest imported
1026 # commit of each component. We use that as initial mapping even
1027 # though the commits do not match exactly because
1028 # a) it always works (in contrast to find_revs, which relies on special
1029 # commit messages)
1030 # b) it is faster than find_revs, which will only be called on demand
1031 # and can be skipped entirely in most cases
1032 # c) last but not least, the combined history looks nicer when all
1033 # new commits are rooted in the same merge commit
1034 old2new_revs[last_revision] = head
1035
1036 # We care about all commits (--full-history and --sparse) and
1037 # we want reconstruct the topology and thus do not care
1038 # about ordering by time (--topo-order). We ask for the ones
1039 # we need to import first to be listed first (--reverse).
1040 revs = runcmd("git rev-list %s" % rev_list_args, **largs).split()
1041 logger.debug("To be imported: %s" % revs)
1042 # Now 'revs' contains all revisions reachable from the top revision.
1043 # All revisions derived from the 'last_revision' definitely are new,
1044 # whereas the others may or may not have been imported before. For
1045 # a linear history in the component, that second set will be empty.
1046 # To distinguish between them, we also get the shorter list
1047 # of revisions starting at the ancestor.
1048 if last_revision:
1049 ancestor_revs = runcmd("git rev-list --ancestry-path %s" % rev_list_args, **largs).split()
1050 else:
1051 ancestor_revs = []
1052 logger.debug("Ancestors: %s" % ancestor_revs)
1053
1054 # Now import each revision.
1055 logger.info("Importing commits from %s..." % name)
1056 def import_rev(rev):
1057 global scanned_revs
1058
1059 # If it is part of the new commits, we definitely need
1060 # to import it. Otherwise we need to check, we might have
1061 # imported it before. If it was imported and we merely
1062 # fail to find it because commit messages did not track
1063 # the mapping, then we end up importing it again. So
1064 # combined repos using "updating with history" really should
1065 # enable the "From ... rev:" commit header modifications.
1066 if rev not in ancestor_revs and rev not in old2new_revs and not scanned_revs:
1067 logger.debug("Revision %s triggers log analysis." % rev)
1068 find_revs(old2new_revs, head)
1069 scanned_revs = True
1070 new_rev = old2new_revs.get(rev, None)
1071 if new_rev:
1072 return new_rev
1073
1074 # If the commit is not in the original list of revisions
1075 # to be imported, then it must be a parent of one of those
1076 # commits and it was skipped during earlier imports or not
1077 # found. Importing such merge commits leads to very ugly
1078 # history (long cascade of merge commits which all point
1079 # to to older commits) when switching from "update via
1080 # patches" to "update with history".
1081 #
1082 # We can avoid importing merge commits if all non-merge commits
1083 # reachable from it were already imported. In that case we
1084 # can root the new commits in the current head revision.
1085 def is_imported(prev):
1086 parents = runcmd("git show --no-patch --pretty=format:%P " + prev, **largs).split()
1087 if len(parents) > 1:
1088 for p in parents:
1089 if not is_imported(p):
1090 logger.debug("Must import %s because %s is not imported." % (rev, p))
1091 return False
1092 return True
1093 elif prev in old2new_revs:
1094 return True
1095 else:
1096 logger.debug("Must import %s because %s is not imported." % (rev, prev))
1097 return False
1098 if rev not in revs and is_imported(rev):
1099 old2new_revs[rev] = head
1100 return head
1101
1102 # Need to import rev. Collect some information about it.
1103 logger.debug("Importing %s" % rev)
1104 (parents, author_name, author_email, author_timestamp, body) = \
1105 runcmd("git show --no-patch --pretty=format:%P%x00%an%x00%ae%x00%at%x00%B " + rev, **largs).split(chr(0))
1106 parents = parents.split()
1107 if parents:
1108 # Arbitrarily pick the first parent as base. It may or may not have
1109 # been imported before. For example, if the parent is a merge commit
1110 # and previously the combined repository used patching as update
1111 # method, then the actual merge commit parent never was imported.
1112 # To cover this, We recursively import parents.
1113 parent = parents[0]
1114 new_parent = import_rev(parent)
1115 # Clean index and working tree. TODO: can we combine this and the
1116 # next into one command with less file IO?
1117 # "git reset --hard" does not work, it changes HEAD of the parent
1118 # repo, which we wanted to avoid. Probably need to keep
1119 # track of the rev that corresponds to the index and use apply_commit().
1120 runcmd("git rm -q --ignore-unmatch -rf .", **wargs)
1121 # Update index and working tree to match the parent.
1122 runcmd("git checkout -q -f %s ." % new_parent, **wargs)
1123 else:
1124 parent = None
1125 # Clean index and working tree.
1126 runcmd("git rm -q --ignore-unmatch -rf .", **wargs)
1127
1128 # Modify index and working tree such that it mirrors the commit.
1129 apply_commit(parent, rev, largs, wargs, dest_dir, file_filter=file_filter)
1130
1131 # Now commit.
1132 new_tree = runcmd("git write-tree", **wargs).strip()
1133 env = copy.deepcopy(wenv)
1134 env['GIT_AUTHOR_NAME'] = author_name
1135 env['GIT_AUTHOR_EMAIL'] = author_email
1136 env['GIT_AUTHOR_DATE'] = author_timestamp
1137 if hook:
1138 # Need to turn the verbatim commit message into something resembling a patch header
1139 # for the hook.
1140 with tempfile.NamedTemporaryFile(delete=False) as patch:
1141 patch.write('Subject: [PATCH] ')
1142 patch.write(body)
1143 patch.write('\n---\n')
1144 patch.close()
1145 runcmd([hook, patch.name, rev, name])
1146 with open(patch.name) as f:
1147 body = f.read()[len('Subject: [PATCH] '):][:-len('\n---\n')]
1148
1149 # We can skip non-merge commits that did not change any files. Those are typically
1150 # the result of file filtering, although they could also have been introduced
1151 # intentionally upstream, in which case we drop some information here.
1152 if len(parents) == 1:
1153 parent_rev = import_rev(parents[0])
1154 old_tree = runcmd("git show -s --pretty=format:%T " + parent_rev, **wargs).strip()
1155 commit = old_tree != new_tree
1156 if not commit:
1157 new_rev = parent_rev
1158 else:
1159 commit = True
1160 if commit:
1161 new_rev = runcmd("git commit-tree".split() + add_p([import_rev(p) for p in parents]) +
1162 ["-m", body, new_tree],
1163 env=env).strip()
1164 old2new_revs[rev] = new_rev
1165
1166 return new_rev
1167
1168 if revs:
1169 for rev in revs:
1170 import_rev(rev)
1171 # Remember how to update our current head. New components get added,
1172 # updated components get the delta between current head and the updated component
1173 # applied.
1174 additional_heads[old2new_revs[revs[-1]]] = head if repo['last_revision'] else None
1175 repo['last_revision'] = revs[-1]
1176
1177 # Now construct the final merge commit. We create the tree by
1178 # starting with the head and applying the changes from each
1179 # components imported head revision.
1180 if additional_heads:
1181 runcmd("git reset --hard", **wargs)
1182 for rev, base in additional_heads.items():
1183 apply_commit(base, rev, wargs, wargs, None)
1184
1185 # Commit with all component branches as parents as well as the previous head.
1186 logger.info("Writing final merge commit...")
1187 msg = conf_commit_msg(conf, components)
1188 new_tree = runcmd("git write-tree", **wargs).strip()
1189 new_rev = runcmd("git commit-tree".split() +
1190 add_p([head] + list(additional_heads.keys())) +
1191 ["-m", msg, new_tree],
1192 **wargs).strip()
1193 # And done! This is the first time we change the HEAD in the actual work tree.
1194 runcmd("git reset --hard %s" % new_rev)
1195
1196 # Update and stage the (potentially modified)
1197 # combo-layer.conf, but do not commit separately.
1198 for name in repos:
1199 repo = conf.repos[name]
1200 rev = repo['last_revision']
1201 conf.update(name, "last_revision", rev)
1202 if commit_conf_file(conf, components, False):
1203 # Must augment the previous commit.
1204 runcmd("git commit --amend -C HEAD")
1205
1206
1207scanned_revs = False
1208def find_revs(old2new, head):
1209 '''Construct mapping from original commit hash to commit hash in
1210 combined repo by looking at the commit messages. Depends on the
1211 "From ... rev: ..." convention.'''
1212 logger.info("Analyzing log messages to find previously imported commits...")
1213 num_known = len(old2new)
1214 log = runcmd("git log --grep='From .* rev: [a-fA-F0-9][a-fA-F0-9]*' --pretty=format:%H%x00%B%x00 " + head).split(chr(0))
1215 regex = re.compile(r'From .* rev: ([a-fA-F0-9]+)')
1216 for new_rev, body in zip(*[iter(log)]* 2):
1217 # Use the last one, in the unlikely case there are more than one.
1218 rev = regex.findall(body)[-1]
1219 if rev not in old2new:
1220 old2new[rev] = new_rev.strip()
1221 logger.info("Found %d additional commits, leading to: %s" % (len(old2new) - num_known, old2new))
1222
1223
1224def apply_commit(parent, rev, largs, wargs, dest_dir, file_filter=None):
1225 '''Compare revision against parent, remove files deleted in the
1226 commit, re-write new or modified ones. Moves them into dest_dir.
1227 Optionally filters files.
1228 '''
1229 if not dest_dir:
1230 dest_dir = "."
1231 # -r recurses into sub-directories, given is the full overview of
1232 # what changed. We do not care about copy/edits or renames, so we
1233 # can disable those with --no-renames (but we still parse them,
1234 # because it was not clear from git documentation whether C and M
1235 # lines can still occur).
1236 logger.debug("Applying changes between %s and %s in %s" % (parent, rev, largs["destdir"]))
1237 delete = []
1238 update = []
1239 if parent:
1240 # Apply delta.
1241 changes = runcmd("git diff-tree --no-commit-id --no-renames --name-status -r --raw -z %s %s" % (parent, rev), **largs).split(chr(0))
1242 for status, name in zip(*[iter(changes)]*2):
1243 if status[0] in "ACMRT":
1244 update.append(name)
1245 elif status[0] in "D":
1246 delete.append(name)
1247 else:
1248 logger.error("Unknown status %s of file %s in revision %s" % (status, name, rev))
1249 sys.exit(1)
1250 else:
1251 # Copy all files.
1252 update.extend(runcmd("git ls-tree -r --name-only -z %s" % rev, **largs).split(chr(0)))
1253
1254 # Include/exclude files as define in the component config.
1255 # Both updated and deleted file lists get filtered, because it might happen
1256 # that a file gets excluded, pulled from a different component, and then the
1257 # excluded file gets deleted. In that case we must keep the copy.
1258 if file_filter:
1259 file_filter(update)
1260 file_filter(delete)
1261
1262 # We export into a tar archive here and extract with tar because it is simple (no
1263 # need to implement file and symlink writing ourselves) and gives us some degree
1264 # of parallel IO. The downside is that we have to pass the list of files via
1265 # command line parameters - hopefully there will never be too many at once.
1266 if update:
1267 target = os.path.join(wargs["destdir"], dest_dir)
1268 if not os.path.isdir(target):
1269 os.makedirs(target)
1270 quoted_target = pipes.quote(target)
1271 # os.sysconf('SC_ARG_MAX') is lying: running a command with
1272 # string length 629343 already failed with "Argument list too
1273 # long" although SC_ARG_MAX = 2097152. "man execve" explains
1274 # the limitations, but those are pretty complicated. So here
1275 # we just hard-code a fixed value which is more likely to work.
1276 max_cmdsize = 64 * 1024
1277 while update:
1278 quoted_args = []
1279 unquoted_args = []
1280 cmdsize = 100 + len(quoted_target)
1281 while update:
1282 quoted_next = pipes.quote(update[0])
1283 size_next = len(quoted_next) + len(dest_dir) + 1
1284 logger.debug('cmdline length %d + %d < %d?' % (cmdsize, size_next, os.sysconf('SC_ARG_MAX')))
1285 if cmdsize + size_next < max_cmdsize:
1286 quoted_args.append(quoted_next)
1287 unquoted_args.append(update.pop(0))
1288 cmdsize += size_next
1289 else:
1290 logger.debug('Breaking the cmdline at length %d' % cmdsize)
1291 break
1292 logger.debug('Final cmdline length %d / %d' % (cmdsize, os.sysconf('SC_ARG_MAX')))
1293 cmd = "git archive %s %s | tar -C %s -xf -" % (rev, ' '.join(quoted_args), quoted_target)
1294 logger.debug('First cmdline length %d' % len(cmd))
1295 runcmd(cmd, **largs)
1296 cmd = "git add -f".split() + [os.path.join(dest_dir, x) for x in unquoted_args]
1297 logger.debug('Second cmdline length %d' % reduce(lambda x, y: x + len(y), cmd, 0))
1298 runcmd(cmd, **wargs)
1299 if delete:
1300 for path in delete:
1301 if dest_dir:
1302 path = os.path.join(dest_dir, path)
1303 runcmd("git rm -f --ignore-unmatch".split() + [os.path.join(dest_dir, x) for x in delete], **wargs)
1304
Patrick Williamsc124f4f2015-09-15 14:41:29 -05001305def action_error(conf, args):
1306 logger.info("invalid action %s" % args[0])
1307
1308actions = {
1309 "init": action_init,
1310 "update": action_update,
1311 "pull": action_pull,
1312 "splitpatch": action_splitpatch,
1313}
1314
1315def main():
1316 parser = optparse.OptionParser(
1317 version = "Combo Layer Repo Tool version %s" % __version__,
1318 usage = """%prog [options] action
1319
1320Create and update a combination layer repository from multiple component repositories.
1321
1322Action:
1323 init initialise the combo layer repo
1324 update [components] get patches from component repos and apply them to the combo repo
1325 pull [components] just pull component repos only
1326 splitpatch [commit] generate commit patch and split per component, default commit is HEAD""")
1327
1328 parser.add_option("-c", "--conf", help = "specify the config file (conf/combo-layer.conf is the default).",
1329 action = "store", dest = "conffile", default = "conf/combo-layer.conf")
1330
1331 parser.add_option("-i", "--interactive", help = "interactive mode, user can edit the patch list and patches",
1332 action = "store_true", dest = "interactive", default = False)
1333
1334 parser.add_option("-D", "--debug", help = "output debug information",
1335 action = "store_true", dest = "debug", default = False)
1336
1337 parser.add_option("-n", "--no-pull", help = "skip pulling component repos during update",
1338 action = "store_true", dest = "nopull", default = False)
1339
1340 parser.add_option("--hard-reset",
1341 help = "instead of pull do fetch and hard-reset in component repos",
1342 action = "store_true", dest = "hard_reset", default = False)
1343
1344 parser.add_option("-H", "--history", help = "import full history of components during init",
1345 action = "store_true", default = False)
1346
1347 options, args = parser.parse_args(sys.argv)
1348
1349 # Dispatch to action handler
1350 if len(args) == 1:
1351 logger.error("No action specified, exiting")
1352 parser.print_help()
1353 elif args[1] not in actions:
1354 logger.error("Unsupported action %s, exiting\n" % (args[1]))
1355 parser.print_help()
1356 elif not os.path.exists(options.conffile):
1357 logger.error("No valid config file, exiting\n")
1358 parser.print_help()
1359 else:
1360 if options.debug:
1361 logger.setLevel(logging.DEBUG)
1362 confdata = Configuration(options)
1363 initmode = (args[1] == 'init')
1364 confdata.sanity_check(initmode)
1365 actions.get(args[1], action_error)(confdata, args[1:])
1366
1367if __name__ == "__main__":
1368 try:
1369 ret = main()
1370 except Exception:
1371 ret = 1
1372 import traceback
Patrick Williamsd8c66bc2016-06-20 12:57:21 -05001373 traceback.print_exc()
Patrick Williamsc124f4f2015-09-15 14:41:29 -05001374 sys.exit(ret)