# Copyright (C) 2018 The Android Open Source Project
#
# Licensed under the Apache License, Version 2.0 (the 'License');
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an 'AS IS' BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""Helper functions to communicate with Git."""
import datetime
import re
import subprocess
from pathlib
import Path
from urllib.parse
import urlparse
import hashtags
import reviewers
UNWANTED_TAGS = [
"*alpha*",
"*Alpha*",
"*beta*",
"*Beta*",
"*rc*",
"*RC*",
"*test*"]
COMMIT_PATTERN = r
'^[a-f0-9]{40}$'
COMMIT_RE = re.compile(COMMIT_PATTERN)
GITHUB_NETLOC =
'github.com'
GITHUB_BRANCH_DIVIDER =
'/tree/'
def fetch(proj_path: Path, remote_name: str, branch: str |
None =
None) ->
None:
"""Runs git fetch.
Args:
proj_path: Path to Git repository.
remote_name: A string to specify remote names.
"""
cmd = [
'git',
'fetch',
'--tags', remote_name] + ([branch]
if branch
is not None else [])
subprocess.run(cmd, capture_output=
True, cwd=proj_path, check=
True)
def add_remote(proj_path: Path, name: str, url: str) ->
None:
"""Adds a git remote.
Args:
proj_path: Path to Git repository.
name: Name of the new remote.
url: Url of the new remote.
"""
cmd = [
'git',
'remote',
'add', name, url]
subprocess.run(cmd, cwd=proj_path, check=
True)
def remove_remote(proj_path: Path, name: str) ->
None:
"""Removes a git remote."""
cmd = [
'git',
'remote',
'remove', name]
subprocess.run(cmd, cwd=proj_path, check=
True)
def list_remotes(proj_path: Path) -> dict[str, str]:
"""Lists all Git remotes.
Args:
proj_path: Path to Git repository.
Returns:
A dict
from remote name to remote url.
"""
def parse_remote(line: str) -> tuple[str, str]:
split = line.split()
return split[
0], split[
1]
cmd = [
'git',
'remote',
'-v']
out = subprocess.run(cmd, capture_output=
True, cwd=proj_path, check=
True,
text=
True).stdout
lines = out.splitlines()
return dict([parse_remote(line)
for line
in lines])
def detect_default_branch(proj_path: Path, remote_name: str) -> str:
"""Gets the name of the upstream's default branch to use."""
cmd = [
'git',
'remote',
'show', remote_name]
out = subprocess.run(cmd, capture_output=
True, cwd=proj_path, check=
True,
text=
True).stdout
lines = out.splitlines()
for line
in lines:
if "HEAD branch" in line:
return line.split()[-
1]
raise RuntimeError(
f
"Could not find HEAD branch in 'git remote show {remote_name}'"
)
def get_sha_for_revision(proj_path: Path, revision: str) -> str:
"""Gets the hash SHA for a revision, whether it's a tag or a hash SHA"""
cmd = [
'git',
'rev-list',
'-n',
'1', revision]
try:
return subprocess.run(cmd, capture_output=
True, cwd=proj_path,
check=
True, text=
True).stdout.strip()
except subprocess.CalledProcessError
as ex:
return ex.stderr
def get_most_recent_tag(proj_path: Path, branch: str) -> str |
None:
"""Finds the most recent tag that is reachable from HEAD."""
cmd = [
'git',
'describe',
'--tags', branch,
'--abbrev=0'] + \
[f
'--exclude={unwanted_tag}' for unwanted_tag
in UNWANTED_TAGS]
try:
out = subprocess.run(cmd, capture_output=
True, cwd=proj_path, check=
True,
text=
True).stdout.strip()
return out
except subprocess.CalledProcessError
as ex:
if "fatal: No names found" in ex.stderr:
return None
if "fatal: No tags can describe" in ex.stderr:
return None
raise
# pylint: disable=redefined-outer-name
def get_commit_time(proj_path: Path, commit: str) -> datetime.datetime:
"""Gets commit time of one commit."""
cmd = [
'git',
'show',
'-s',
'--format=%ct', commit]
out = subprocess.run(cmd, capture_output=
True, cwd=proj_path, check=
True,
text=
True).stdout
return datetime.datetime.fromtimestamp(int(out.strip()))
def list_remote_branches(proj_path: Path, remote_name: str) -> list[str]:
"""Lists all branches for a remote."""
cmd = [
'git',
'branch',
'-r']
lines = subprocess.run(cmd, capture_output=
True, cwd=proj_path, check=
True,
text=
True).stdout.splitlines()
stripped = [line.strip()
for line
in lines]
remote_path = remote_name +
'/'
return [
line[len(remote_path):]
for line
in stripped
if line.startswith(remote_path)
]
def list_local_branches(proj_path: Path) -> list[str]:
"""Lists all local branches."""
cmd = [
'git',
'branch',
'--format=%(refname:short)']
lines = subprocess.run(cmd, capture_output=
True, cwd=proj_path, check=
True,
text=
True).stdout.splitlines()
return lines
# pylint: disable=redefined-outer-name
def is_commit(commit: str) -> bool:
"""Whether a string looks like a SHA1 hash."""
return bool(COMMIT_RE.match(commit))
def merge(proj_path: Path, branch: str) ->
None:
"""Merges a branch."""
try:
cmd = [
'git',
'merge', branch,
'--no-commit']
subprocess.run(cmd, cwd=proj_path, check=
True)
except subprocess.CalledProcessError
as err:
if hasattr(err,
"output"):
print(err.output)
if not merge_conflict(proj_path):
raise
def merge_conflict(proj_path: Path) -> bool:
"""Checks if there was a merge conflict."""
cmd = [
'git',
'ls-files',
'--unmerged']
out = subprocess.run(cmd, capture_output=
True, cwd=proj_path, check=
True,
text=
True).stdout
return bool(out)
def add_file(proj_path: Path, file_name: str) ->
None:
"""Stages a file."""
cmd = [
'git',
'add', file_name]
subprocess.run(cmd, cwd=proj_path, check=
True)
def remove_gitmodules(proj_path: Path) ->
None:
"""Deletes .gitmodules files."""
cmd = [
'find',
'.',
'-name',
'.gitmodules',
'-delete']
subprocess.run(cmd, cwd=proj_path, check=
True)
def delete_branch(proj_path: Path, branch_name: str) ->
None:
"""Force delete a branch."""
cmd = [
'git',
'branch',
'-D', branch_name]
subprocess.run(cmd, cwd=proj_path, check=
True)
def start_branch(proj_path: Path, branch_name: str) ->
None:
"""Starts a new repo branch."""
subprocess.run([
'repo',
'start', branch_name], cwd=proj_path, check=
True)
def repo_sync(proj_path: Path,) ->
None:
"""Downloads new changes and updates the working files in the local environment."""
subprocess.run([
'repo',
'sync',
'.'], cwd=proj_path, check=
True)
def commit(proj_path: Path, message: str, no_verify: bool) ->
None:
"""Commits changes."""
cmd = [
'git',
'commit',
'-m', message] + ([
'--no-verify']
if no_verify
is True else [])
subprocess.run(cmd, cwd=proj_path, check=
True)
def commit_amend(proj_path: Path) ->
None:
"""Commits changes."""
cmd = [
'git',
'commit',
'--amend',
'--no-edit']
subprocess.run(cmd, cwd=proj_path, check=
True)
def checkout(proj_path: Path, branch_name: str) ->
None:
"""Checkouts a branch."""
cmd = [
'git',
'checkout', branch_name]
subprocess.run(cmd, cwd=proj_path, check=
True)
def detach_to_android_head(proj_path: Path) ->
None:
"""Detaches the project HEAD back to the manifest revision."""
# -d detaches the project back to the manifest revision without updating.
# -l avoids fetching new revisions from the remote. This might be superfluous with
# -d, but I'm not sure, and it certainly doesn't harm anything.
subprocess.run([
'repo',
'sync',
'-l',
'-d', proj_path], cwd=proj_path, check=
True)
def push(proj_path: Path, remote_name: str, has_errors: bool) ->
None:
"""Pushes change to remote."""
cmd = [
'git',
'push', remote_name,
'HEAD:refs/for/main',
'-o',
'banned-words~skip']
if revs := reviewers.find_reviewers(str(proj_path)):
cmd.extend([
'-o', revs])
if tag := hashtags.find_hashtag(proj_path):
cmd.extend([
'-o',
't=' + tag])
if has_errors:
cmd.extend([
'-o',
'l=Verified-1'])
subprocess.run(cmd, cwd=proj_path, check=
True)
def reset_hard(proj_path: Path) ->
None:
"""Resets current HEAD and discards changes to tracked files."""
cmd = [
'git',
'reset',
'--hard']
subprocess.run(cmd, cwd=proj_path, check=
True)
def clean(proj_path: Path) ->
None:
"""Removes untracked files and directories."""
cmd = [
'git',
'clean',
'-fdx']
subprocess.run(cmd, cwd=proj_path, check=
True)
def is_valid_url(proj_path: Path, url: str) -> bool:
cmd = [
'git',
"ls-remote", url]
return subprocess.run(cmd, cwd=proj_path, check=
False, stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
start_new_session=
True).returncode ==
0
def list_remote_tags(proj_path: Path, remote_name: str) -> list[str]:
"""Lists tags in a remote repository."""
cmd = [
'git',
"ls-remote",
"--tags", remote_name]
out = subprocess.run(cmd, capture_output=
True, cwd=proj_path, check=
True,
text=
True).stdout
lines = out.splitlines()
return lines
def diff_stat(proj_path: Path, diff_filter: str, revision: str) -> str:
try:
cmd = [
'git',
'diff', revision,
'--stat', f
'--diff-filter={diff_filter}']
out = subprocess.run(cmd, capture_output=
True, cwd=proj_path,
check=
True, text=
True).stdout
return out
except subprocess.CalledProcessError
as err:
return f
"Could not calculate the diff: {err}"
def diff_name_only(proj_path: Path, diff_filter: str, revision: str) -> str:
try:
cmd = [
'git',
'diff', revision,
'--name-only', f
'--diff-filter={diff_filter}']
out = subprocess.run(cmd, capture_output=
True, cwd=proj_path,
check=
True, text=
True).stdout
return out
except subprocess.CalledProcessError
as err:
return f
"Could not calculate the diff: {err}"
def is_ancestor(proj_path: Path, ancestor: str, child: str) -> bool:
cmd = [
'git',
'merge-base',
'--is-ancestor', ancestor, child]
# https://git-scm.com/docs/git-merge-base#Documentation/git-merge-base.txt---is-ancestor
# Exit status of 0 means yes, 1 means no, and all others mean an error occurred.
# Although a commit is an ancestor of itself, we don't want to return True
# if ancestor points to the same commit as child.
if get_sha_for_revision(proj_path, ancestor) == child:
return False
try:
subprocess.run(
cmd,
cwd=proj_path,
text=
True,
stderr=subprocess.STDOUT,
check=
True,
stdout=subprocess.PIPE
)
return True
except subprocess.CalledProcessError
as ex:
if ex.returncode ==
1:
return False
raise
def list_branches_with_commit(proj_path: Path, commit: str, remote_name: str) -> list[str
]:
"""Lists upstream branches which contain the specified commit"""
cmd = ['git', 'branch', '-r', '--contains', commit]
out = subprocess.run(cmd, capture_output=True, cwd=proj_path, check=True,
text=True).stdout
lines = out.splitlines()
remote_branches = [line for line in lines if remote_name in line]
return remote_branches
def merge_base(proj_path: Path, branch1: str, branch2: str) -> str | None:
"""Finds as good common ancestors as possible between branch1 and branch2"""
try:
cmd = ['git', 'merge-base', branch1, branch2]
out = subprocess.run(cmd, capture_output=True, cwd=proj_path,
check=True, text=True).stdout.strip()
return out
except:
return None
def get_tag_for_revision(proj_path: Path, sha: str) -> str | None:
"""Give an object a human-readable name based on an available ref.
using --tags to find any tag found in refs/tags namespace.
"""
try:
cmd = ['git', 'describe', '--exact-match', '--tags', sha]
out = subprocess.run(cmd, capture_output=True, cwd=proj_path,
check=True, text=True).stdout.strip()
return out
except:
return None
def find_non_default_branch(identifier_value: str) -> str | None:
parsed_url = urlparse(identifier_value)
_, divider, branch = parsed_url.path.partition(GITHUB_BRANCH_DIVIDER)
if parsed_url.netloc == GITHUB_NETLOC and divider == GITHUB_BRANCH_DIVIDER:
return branch
return None