contrib: add helper for working with Gerrit refs/meta/config
BUG=None
TEST=None
Change-Id: I2018b68c7eb7fdfec2a7bf9a25804a2b2be7dccc
Reviewed-on: https://chromium-review.googlesource.com/c/chromiumos/chromite/+/2669330
Reviewed-by: Jason Clinton <jclinton@chromium.org>
Reviewed-by: Mike Frysinger <vapier@chromium.org>
Commit-Queue: Mike Frysinger <vapier@chromium.org>
Tested-by: Mike Frysinger <vapier@chromium.org>
diff --git a/contrib/gob-meta-config-checkout.py b/contrib/gob-meta-config-checkout.py
new file mode 100755
index 0000000..8e1d95c
--- /dev/null
+++ b/contrib/gob-meta-config-checkout.py
@@ -0,0 +1,215 @@
+#!/usr/bin/env python3
+# Copyright 2021 The Chromium OS Authors. All rights reserved.
+# Use of this source code is governed by a BSD-style license that can be
+# found in the LICENSE file.
+
+"""Generate tree for working with refs/meta/config.
+
+# To checkout refs/meta/config for all projects in the chromium GoB:
+$ ./gob-meta-config-checkout.py -o ~/src/gob/chromium chromium
+
+Rerunning the command on an existing output will refresh & update new projects.
+"""
+
+import argparse
+import configparser
+import contextlib
+import functools
+import io
+import multiprocessing
+from pathlib import Path
+import re
+import subprocess
+import sys
+from typing import Callable, Iterable, List, Tuple
+import urllib.request
+
+
+assert sys.version_info >= (3, 7), 'Python 3.7+ required'
+
+
+# This uses PEP8 4-space indent.
+# pylint: disable=bad-indentation
+
+
+CSI_ERASE_LINE = '\x1b[2K'
+
+
+class GitConfig:
+ """Access to .git/config settings."""
+
+ def __init__(self, path: Path):
+ self.path = path / '.git' / 'config'
+ self.config = configparser.ConfigParser()
+ self.read()
+
+ def read(self):
+ if self.path.exists():
+ self.config.read(self.path)
+
+ @staticmethod
+ def key_to_section_option(key: str) -> Tuple[str, str]:
+ section, option = key.split('.', 1)
+ if section in {'remote', 'branch'}:
+ qual, option = option.split('.', 1)
+ section = f'{section} "{qual}"'
+ return (section, option)
+
+ def get(self, key: str):
+ return self.config.get(*self.key_to_section_option(key))
+
+ def set(self, key: str, value: str):
+ run(['git', 'config', key, value], cwd=self.path.parent)
+ self.read()
+
+ def exists(self, key: str) -> bool:
+ return self.config.has_option(*self.key_to_section_option(key))
+
+ def setdefault(self, key: str, value: str):
+ if not self.exists(key):
+ self.set(key, value)
+
+
+def run(cmd: List[str], auto_output=True, **kwargs):
+ """Hook around subprocess.run for logging."""
+ cwd = kwargs.get('cwd')
+ assert cwd is not None, f'{cmd} missing cwd='
+ # print(cmd, f'cwd={cwd}', flush=True)
+ kwargs.setdefault('check', True)
+ if 'capture_output' not in kwargs:
+ kwargs.setdefault('stdout', subprocess.PIPE)
+ kwargs.setdefault('stderr', subprocess.STDOUT)
+ kwargs.setdefault('encoding', 'utf-8')
+ ret = subprocess.run(cmd, **kwargs) # pylint: disable=subprocess-run-check
+ if auto_output and not kwargs.get('capture_output'):
+ output = ret.stdout.strip()
+ if output and 'using GSLB fallback backend' not in output:
+ print(output)
+ return ret
+
+
+def get_hook_commit_msg(opts: argparse.Namespace) -> Path:
+ """Get a cache of the commit-msg hook."""
+ commit_msg = opts.output / '.commit-msg'
+ if not commit_msg.exists():
+ response = urllib.request.urlopen(
+ 'https://gerrit-review.googlesource.com/tools/hooks/commit-msg')
+ commit_msg.write_bytes(response.read())
+ commit_msg.chmod(0o755)
+ return commit_msg
+
+
+def create_repo(opts: argparse.Namespace, repo: Path):
+ """Initialize |repo|."""
+ path = opts.output / repo
+ gitdir = path / '.git'
+ hooks = gitdir / 'hooks'
+ commit_msg = hooks / 'commit-msg'
+ head = gitdir / 'HEAD'
+
+ # Only run the init steps once.
+ if commit_msg.exists():
+ run(['git', 'pull', '-q'], cwd=path, auto_output=False)
+ return
+
+ path.mkdir(parents=True, exist_ok=True)
+ if not gitdir.exists():
+ run(['git', 'init', '-q', path], cwd=path)
+ for hook in hooks.glob('*.sample'):
+ hook.unlink()
+ config = GitConfig(path)
+ uri = f'rpc://{opts.gob}/{repo}'
+ if not config.exists('remote.origin.url'):
+ run(['git', 'remote', 'add', 'origin', uri], cwd=path)
+ config.set('remote.origin.fetch',
+ '+refs/meta/config:refs/remotes/origin/meta-config')
+ if head.read_text().strip() != 'ref: refs/heads/meta-config':
+ result = run(['git', 'fetch', '-q', 'origin'], cwd=path, check=False,
+ auto_output=False)
+ if result.returncode:
+ # 128 means refs/heads/meta-config doesn't exist which is OK?
+ if result.returncode != 128:
+ result.check_returncode()
+ return
+ run(['git', 'checkout', '-q', '-b', 'meta-config',
+ 'remotes/origin/meta-config'], cwd=path)
+ if not config.exists('remote.review.url'):
+ config.set('remote.review.url', uri)
+ if not config.exists('remote.review.push'):
+ config.set('remote.review.push', 'HEAD:refs/for/refs/meta/config')
+
+ # Do this last as a marker that we finished initializing.
+ if not commit_msg.exists():
+ commit_msg.symlink_to(get_hook_commit_msg(opts))
+
+
+def capture_output(func: Callable, repo: Path):
+ output = io.StringIO()
+ with contextlib.redirect_stderr(sys.stdout):
+ with contextlib.redirect_stdout(output):
+ try:
+ func(repo)
+ except subprocess.CalledProcessError as e:
+ output.write(f'\n{repo}: {e.cmd}={e.returncode}: {e.stdout}')
+ except Exception as e:
+ output.write(f'\n{repo}: Exception: {e}')
+ return (repo, output.getvalue())
+
+
+def get_repos(gob: str) -> Iterable[Path]:
+ """Get all the repos on this host."""
+ result = run(['gob-ctl', 'list', gob], cwd='/', encoding='utf-8',
+ capture_output=True)
+ # Pull out lines like:
+ # repo: "chromium/chromiumos/platform2"
+ REPO_RE = re.compile('^ *repo: "(.*)"')
+ for line in result.stdout.splitlines():
+ m = REPO_RE.match(line)
+ if m:
+ repo = m.group(1)
+ assert repo.startswith(f'{gob}/')
+ yield Path(repo[len(gob) + 1:])
+
+
+def get_parser() -> argparse.ArgumentParser:
+ """Get CLI parser."""
+ parser = argparse.ArgumentParser(
+ description=__doc__,
+ formatter_class=argparse.RawDescriptionHelpFormatter)
+ parser.add_argument(
+ '-j', '--jobs', type=int, default=min(8, multiprocessing.cpu_count()),
+ help='Number of jobs to run in parallel (default: %(default)s)')
+ parser.add_argument(
+ '--output', type=Path,
+ help='The root directory to write to')
+ parser.add_argument('gob', help='The GoB hostname')
+ return parser
+
+
+def main(argv):
+ """The main entry point for scripts."""
+ parser = get_parser()
+ opts = parser.parse_args(argv)
+ if not opts.output:
+ opts.output = Path.cwd() / opts.gob
+
+ # Cache the hook once.
+ get_hook_commit_msg(opts)
+
+ func = functools.partial(create_repo, opts)
+ repos = sorted(get_repos(opts.gob))
+ capture = functools.partial(capture_output, func)
+ pool = multiprocessing.Pool(opts.jobs)
+
+ finished = 0
+ num_repos = len(repos)
+ for (repo, output) in pool.imap_unordered(capture, repos):
+ finished += 1
+ print(CSI_ERASE_LINE + '\r', end='')
+ print(f'[{finished}/{num_repos}] {repo}', output,
+ end='\n' if output else '', flush=not output)
+ print()
+
+
+if __name__ == '__main__':
+ sys.exit(main(sys.argv[1:]))