# 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 shutil import subprocess from pathlib import Path import hashtags import reviewers def fetch(proj_path: Path, remote_names: list[str]) -> None: """Runs git fetch. Args: proj_path: Path to Git repository. remote_names: Array of string to specify remote names. """ cmd = ['git', 'fetch', '--tags', '--multiple'] + remote_names 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_branch(proj_path: Path, branch: str): """Gets the hash SHA for a branch.""" cmd = ['git', 'rev-parse', branch] return subprocess.run(cmd, capture_output=True, cwd=proj_path, check=True, text=True).stdout.strip() def get_commits_ahead(proj_path: Path, branch: str, base_branch: str) -> list[str]: """Lists commits in `branch` but not `base_branch`.""" cmd = [ 'git', 'rev-list', '--left-only', '--ancestry-path', 'f{branch}...{base_branch}' ] out = subprocess.run(cmd, capture_output=True, cwd=proj_path, check=True, text=True).stdout return out.splitlines() # 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 def list_remote_tags(proj_path: Path, remote_name: str) -> list[str]: """Lists all tags for a remote.""" regex = re.compile(r".*refs/tags/(?P[^\^]*).*") def parse_remote_tag(line: str) -> str: if (m := regex.match(line)) is not None: return m.group("tag") raise ValueError(f"Could not parse tag from {line}") cmd = ['git', "ls-remote", "--tags", remote_name] lines = subprocess.run(cmd, capture_output=True, cwd=proj_path, check=True, text=True).stdout.splitlines() tags = [parse_remote_tag(line) for line in lines] return list(set(tags)) COMMIT_PATTERN = r'^[a-f0-9]{40}$' COMMIT_RE = re.compile(COMMIT_PATTERN) # 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 tree_uses_pore(proj_path: Path) -> bool: """Returns True if the tree uses pore rather than repo. https://github.com/jmgao/pore """ if shutil.which("pore") is None: # Fast path for users that don't have pore installed, since that's almost # everyone. return False if proj_path == Path(proj_path.root): return False if (proj_path / ".pore").exists(): return True return tree_uses_pore(proj_path.parent) def start_branch(proj_path: Path, branch_name: str) -> None: """Starts a new repo branch.""" repo = 'repo' if tree_uses_pore(proj_path): repo = 'pore' cmd = [repo, 'start', branch_name] subprocess.run(cmd, cwd=proj_path, check=True) def commit(proj_path: Path, message: str) -> None: """Commits changes.""" cmd = ['git', 'commit', '-m', message] 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 push(proj_path: Path, remote_name: str, has_errors: bool) -> None: """Pushes change to remote.""" cmd = ['git', 'push', remote_name, 'HEAD:refs/for/master'] 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, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, start_new_session=True).returncode == 0