blob: f9558d6b304ef599414db731103e5e5441cb8984 [file] [log] [blame]
# Copyright 2019 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.
import datetime
import hashlib
import httplib
import logging
import os
import time
import urllib
import urlparse
# pylint: disable=import-error, no-name-in-module
import certifi
from dulwich.client import HttpGitClient
from dulwich.objects import Blob
from dulwich.objects import Tree
from dulwich.pack import pack_objects_to_data
from dulwich.refs import strip_peeled_refs
from dulwich.repo import MemoryRepo as _MemoryRepo
import urllib3.exceptions
from urllib3 import PoolManager
import factory_common # pylint: disable=unused-import
from cros.factory.utils import json_utils
HEAD = 'HEAD'
DEFAULT_REMOTE_NAME = 'origin'
REF_HEADS_PREFIX = 'refs/heads/'
REF_REMOTES_PREFIX = 'refs/remotes/'
class GitUtilException(Exception):
pass
class GitUtilNoModificationException(GitUtilException):
"""Raised if no modification is made for commit."""
class MemoryRepo(_MemoryRepo):
"""Enhance MemoryRepo with push ability."""
def __init__(self, auth_cookie, *args, **kwargs):
"""Init with auth_cookie."""
_MemoryRepo.__init__(self, *args, **kwargs)
self.auth_cookie = auth_cookie
def shallow_clone(self, remote_location, branch='master'):
"""Shallow clone objects of a branch from a remote server.
Args:
remote_location: String identifying a remote server
branch: Branch
Returns:
client
"""
parsed = urlparse.urlparse(remote_location)
pool_manager = PoolManager(ca_certs=certifi.where())
pool_manager.headers['Cookie'] = self.auth_cookie
client = HttpGitClient.from_parsedurl(parsed,
config=self.get_config_stack(),
pool_manager=pool_manager)
fetch_result = client.fetch(
parsed.path, self,
determine_wants=lambda mapping: [mapping[REF_HEADS_PREFIX + branch]],
depth=1)
stripped_refs = strip_peeled_refs(fetch_result.refs)
branches = {
n[len(REF_HEADS_PREFIX):]: v for (n, v) in stripped_refs.items()
if n.startswith(REF_HEADS_PREFIX)}
self.refs.import_refs(
REF_REMOTES_PREFIX + DEFAULT_REMOTE_NAME, branches)
self[HEAD] = self[
REF_REMOTES_PREFIX + '{remote_name}/{branch}'.format(
remote_name=DEFAULT_REMOTE_NAME, branch=branch)]
return client
def recursively_add_file(self, cur, path_splits, file_name, mode, blob):
"""Add files in object store.
Since we need to collect all tree objects with modified children, a
recursively approach is applied
Args:
cur: Current tree obj
path_splits: Directories between cur and file
file_name: File name
mode: File mode in git
blob: Blob obj of the file
Returns:
A list of new object ids
"""
if path_splits:
child_name = path_splits[0]
if child_name in cur:
unused_mode, sha = cur[child_name]
sub = self[sha]
if not isinstance(sub, Tree): # if child_name exists but not a dir
raise GitUtilException
else:
# not exists, create a new tree
sub = Tree()
new_ids = self.recursively_add_file(
sub, path_splits[1:], file_name, mode, blob)
# 0o040000 is the mode of directory in git object pool
cur.add(child_name, 0o040000, sub.id)
else:
# reach the directory of the target file
if file_name in cur:
unused_mod, sha = cur[file_name]
existed_obj = self[sha]
if not isinstance(existed_obj, Blob):
# if file_name exists but not a Blob(file)
raise GitUtilException
self.object_store.add_object(blob)
new_ids = [blob.id]
cur.add(file_name, mode, blob.id)
self.object_store.add_object(cur)
new_ids.append(cur.id)
return new_ids
def add_files(self, new_files, tree=None):
"""Add files to repository.
Args:
new_files: List of (file path, mode, file content)
tree: Optional tree obj
Returns:
(updated tree, sha1 id strs of new objects)
"""
if tree is None:
head_commit = self[HEAD]
tree = self[head_commit.tree]
all_new_obj_ids = []
for (file_path, mode, content) in new_files:
path, filename = os.path.split(file_path)
# os.path.normpath('') returns '.' which is unexpected
paths = [x for x in os.path.normpath(path).split(os.sep)
if x and x != '.']
try:
new_obj_ids = self.recursively_add_file(tree, paths, filename, mode,
Blob.from_string(content))
except GitUtilException:
raise GitUtilException('Invalid filepath %r' % file_path)
all_new_obj_ids += new_obj_ids
return tree, all_new_obj_ids
def _GetChangeId(tree_id, parent_commit, author, committer, commit_msg):
"""Get change id from information of commit.
Implemented by referencing common .git/hooks/commit-msg script with some
modification, this function is used to generate hash as a Change-Id based on
the execution time and the information of the commit. Since the commit-msg
script may change, this function does not guarantee the consistency of the
Change-Id with the commit-msg script in the future.
Args:
tree_id: Tree hash
parent_commit: Parent commit
author: Author in form of "Name <email@domain>"
committer: Committer in form of "Name <email@domain>"
commit_msg: Commit message
Returns:
hash of information as change id
"""
now = int(time.mktime(datetime.datetime.now().timetuple()))
change_msg = ('tree {tree_id}\n'
'parent {parent_commit}\n'
'author {author} {now}\n'
'committer {committer} {now}\n'
'\n'
'{commit_msg}').format(
tree_id=tree_id, parent_commit=parent_commit,
author=author, committer=committer, now=now,
commit_msg=commit_msg)
change_id_input = 'commit {size}\x00{change_msg}'.format(
size=len(change_msg), change_msg=change_msg)
return 'I{}'.format(hashlib.sha1(change_id_input).hexdigest())
def CreateCL(git_url, auth_cookie, project, branch, new_files, author,
committer, commit_msg, reviewers=None, cc=None):
"""Create a CL from adding files in specified location.
Args:
git_url: HTTPS repo url
auth_cookie: Auth_cookie
project: Project name
branch: Branch needs adding file
new_files: List of (filepath, mode, bytes)
author: Author in form of "Name <email@domain>"
committer: Committer in form of "Name <email@domain>"
commit_msg: Commit message
reviewers: List of emails of reviewers
cc: List of emails of cc's
Returns:
change id
"""
def _generate_pack_data_wrapper(obj_store, new_obj_ids):
"""Patched generate_pack_data.
In client.send_pack, we customize generate_pack_data instead of using
object_store.generate_pack_data since we know what objects are needed in new
commit instead of comparing commits between local and remote which is
currently not supported if shallow clone is applied
"""
def wrapper(*unused_args, **unused_kwargs):
return pack_objects_to_data(obj_store.iter_shas(
(id, None) for id in new_obj_ids))
return wrapper
repo = MemoryRepo(auth_cookie=auth_cookie)
# only fetches last commit
client = repo.shallow_clone(git_url, branch=branch)
head_commit = repo[HEAD]
original_tree_id = head_commit.tree
updated_tree, new_obj_ids = repo.add_files(new_files)
if updated_tree.id == original_tree_id:
raise GitUtilNoModificationException
change_id = _GetChangeId(
updated_tree.id, repo.head(), author, committer, commit_msg)
new_commit = repo.do_commit(
commit_msg + '\n\nChange-Id: {change_id}'.format(change_id=change_id),
author=author, committer=committer, tree=updated_tree.id)
new_obj_ids.append(new_commit)
notification = []
if reviewers:
notification += ['r=' + email for email in reviewers]
if cc:
notification += ['cc=' + email for email in cc]
target_branch = 'refs/for/' + branch
if notification:
target_branch += '%' + ','.join(notification)
client.send_pack(
'/' + project,
# returns the only branch:hash mapping needed
lambda unused_refs: {target_branch: new_commit},
_generate_pack_data_wrapper(repo.object_store, new_obj_ids))
return change_id
def GetCommitId(git_url_prefix, project, branch, auth_cookie):
'''Get branch commit.
Use the gerrit API to get the commit id. Note that the response starts with a
magic prefix line )]}' which should be stripped.
Args:
git_url: HTTPS repo url
project: Project name
branch: Branch name
auth_cookie: Auth cookie
'''
git_url = '{git_url_prefix}/projects/{project}/branches/{branch}'.format(
git_url_prefix=git_url_prefix,
project=urllib.quote(project, safe=''),
branch=urllib.quote(branch, safe=''))
pool_manager = PoolManager(ca_certs=certifi.where())
pool_manager.headers['Cookie'] = auth_cookie
pool_manager.headers['Content-Type'] = 'application/json'
try:
r = pool_manager.urlopen('GET', git_url)
except urllib3.exceptions.HTTPError:
raise GitUtilException('Invalid url %r' % (git_url,))
if r.status != httplib.OK:
raise GitUtilException('Request unsuccessfully with code %s' %
(r.status,))
try:
stripped_json = r.data.split('\n', 1)[1]
branch_info = json_utils.LoadStr(stripped_json)
except Exception:
raise GitUtilException('Response format Error: %r' % (r.data,))
try:
commit_hash = branch_info['revision']
except KeyError as ex:
raise GitUtilException('KeyError: %r' % str(ex))
return commit_hash
def AbandonCL(review_host, auth_cookie, change_id):
"""Abandon a CL
Args:
review_host: Review host of repo
auth_cookie: Auth cookie
change_id: Change ID
"""
git_url = '{review_host}/a/changes/{change_id}/abandon'.format(
review_host=review_host,
change_id=change_id)
pool_manager = PoolManager(ca_certs=certifi.where())
pool_manager.headers['Cookie'] = auth_cookie
fp = pool_manager.urlopen(method='POST', url=git_url)
if fp.status != httplib.OK:
logging.error('HTTP Status: %d', fp.status)
raise GitUtilException('Abandon failed for change id: %r' % (change_id,))