1# Copyright (C) 2018 The Android Open Source Project 2# 3# Licensed under the Apache License, Version 2.0 (the 'License'); 4# you may not use this file except in compliance with the License. 5# You may obtain a copy of the License at 6# 7# http://www.apache.org/licenses/LICENSE-2.0 8# 9# Unless required by applicable law or agreed to in writing, software 10# distributed under the License is distributed on an 'AS IS' BASIS, 11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12# See the License for the specific language governing permissions and 13# limitations under the License. 14"""Helper functions to communicate with Git.""" 15 16import datetime 17import re 18import subprocess 19from pathlib import Path 20 21import hashtags 22import reviewers 23 24UNWANTED_TAGS = ["*alpha*", "*Alpha*", "*beta*", "*Beta*", "*rc*", "*RC*", "*test*"] 25 26 27def fetch(proj_path: Path, remote_name: str, branch: str | None = None) -> None: 28 """Runs git fetch. 29 30 Args: 31 proj_path: Path to Git repository. 32 remote_name: A string to specify remote names. 33 """ 34 cmd = ['git', 'fetch', '--tags', remote_name] + ([branch] if branch is not None else []) 35 subprocess.run(cmd, capture_output=True, cwd=proj_path, check=True) 36 37 38def add_remote(proj_path: Path, name: str, url: str) -> None: 39 """Adds a git remote. 40 41 Args: 42 proj_path: Path to Git repository. 43 name: Name of the new remote. 44 url: Url of the new remote. 45 """ 46 cmd = ['git', 'remote', 'add', name, url] 47 subprocess.run(cmd, cwd=proj_path, check=True) 48 49 50def remove_remote(proj_path: Path, name: str) -> None: 51 """Removes a git remote.""" 52 cmd = ['git', 'remote', 'remove', name] 53 subprocess.run(cmd, cwd=proj_path, check=True) 54 55 56def list_remotes(proj_path: Path) -> dict[str, str]: 57 """Lists all Git remotes. 58 59 Args: 60 proj_path: Path to Git repository. 61 62 Returns: 63 A dict from remote name to remote url. 64 """ 65 def parse_remote(line: str) -> tuple[str, str]: 66 split = line.split() 67 return split[0], split[1] 68 69 cmd = ['git', 'remote', '-v'] 70 out = subprocess.run(cmd, capture_output=True, cwd=proj_path, check=True, 71 text=True).stdout 72 lines = out.splitlines() 73 return dict([parse_remote(line) for line in lines]) 74 75 76def detect_default_branch(proj_path: Path, remote_name: str) -> str: 77 """Gets the name of the upstream's default branch to use.""" 78 cmd = ['git', 'remote', 'show', remote_name] 79 out = subprocess.run(cmd, capture_output=True, cwd=proj_path, check=True, 80 text=True).stdout 81 lines = out.splitlines() 82 for line in lines: 83 if "HEAD branch" in line: 84 return line.split()[-1] 85 raise RuntimeError( 86 f"Could not find HEAD branch in 'git remote show {remote_name}'" 87 ) 88 89 90def get_sha_for_branch(proj_path: Path, branch: str): 91 """Gets the hash SHA for a branch.""" 92 cmd = ['git', 'rev-parse', branch] 93 return subprocess.run(cmd, capture_output=True, cwd=proj_path, check=True, 94 text=True).stdout.strip() 95 96 97def get_most_recent_tag(proj_path: Path, branch: str) -> str | None: 98 """Finds the most recent tag that is reachable from HEAD.""" 99 cmd = ['git', 'describe', '--tags', branch, '--abbrev=0'] + \ 100 [f'--exclude={unwanted_tag}' for unwanted_tag in UNWANTED_TAGS] 101 try: 102 out = subprocess.run(cmd, capture_output=True, cwd=proj_path, check=True, 103 text=True).stdout.strip() 104 return out 105 except subprocess.CalledProcessError as ex: 106 if "fatal: No names found" in ex.stderr: 107 return None 108 if "fatal: No tags can describe" in ex.stderr: 109 return None 110 raise 111 112 113# pylint: disable=redefined-outer-name 114def get_commit_time(proj_path: Path, commit: str) -> datetime.datetime: 115 """Gets commit time of one commit.""" 116 cmd = ['git', 'show', '-s', '--format=%ct', commit] 117 out = subprocess.run(cmd, capture_output=True, cwd=proj_path, check=True, 118 text=True).stdout 119 return datetime.datetime.fromtimestamp(int(out.strip())) 120 121 122def list_remote_branches(proj_path: Path, remote_name: str) -> list[str]: 123 """Lists all branches for a remote.""" 124 cmd = ['git', 'branch', '-r'] 125 lines = subprocess.run(cmd, capture_output=True, cwd=proj_path, check=True, 126 text=True).stdout.splitlines() 127 stripped = [line.strip() for line in lines] 128 remote_path = remote_name + '/' 129 return [ 130 line[len(remote_path):] for line in stripped 131 if line.startswith(remote_path) 132 ] 133 134 135def list_local_branches(proj_path: Path) -> list[str]: 136 """Lists all local branches.""" 137 cmd = ['git', 'branch', '--format=%(refname:short)'] 138 lines = subprocess.run(cmd, capture_output=True, cwd=proj_path, check=True, 139 text=True).stdout.splitlines() 140 return lines 141 142 143COMMIT_PATTERN = r'^[a-f0-9]{40}$' 144COMMIT_RE = re.compile(COMMIT_PATTERN) 145 146 147# pylint: disable=redefined-outer-name 148def is_commit(commit: str) -> bool: 149 """Whether a string looks like a SHA1 hash.""" 150 return bool(COMMIT_RE.match(commit)) 151 152 153def merge(proj_path: Path, branch: str) -> None: 154 """Merges a branch.""" 155 try: 156 cmd = ['git', 'merge', branch, '--no-commit'] 157 subprocess.run(cmd, cwd=proj_path, check=True) 158 except subprocess.CalledProcessError as err: 159 if hasattr(err, "output"): 160 print(err.output) 161 if not merge_conflict(proj_path): 162 raise 163 164 165def merge_conflict(proj_path: Path) -> bool: 166 """Checks if there was a merge conflict.""" 167 cmd = ['git', 'ls-files', '--unmerged'] 168 out = subprocess.run(cmd, capture_output=True, cwd=proj_path, check=True, 169 text=True).stdout 170 return bool(out) 171 172 173def add_file(proj_path: Path, file_name: str) -> None: 174 """Stages a file.""" 175 cmd = ['git', 'add', file_name] 176 subprocess.run(cmd, cwd=proj_path, check=True) 177 178 179def remove_gitmodules(proj_path: Path) -> None: 180 """Deletes .gitmodules files.""" 181 cmd = ['find', '.', '-name', '.gitmodules', '-delete'] 182 subprocess.run(cmd, cwd=proj_path, check=True) 183 184 185def delete_branch(proj_path: Path, branch_name: str) -> None: 186 """Force delete a branch.""" 187 cmd = ['git', 'branch', '-D', branch_name] 188 subprocess.run(cmd, cwd=proj_path, check=True) 189 190 191def start_branch(proj_path: Path, branch_name: str) -> None: 192 """Starts a new repo branch.""" 193 subprocess.run(['repo', 'start', branch_name], cwd=proj_path, check=True) 194 195 196def commit(proj_path: Path, message: str, no_verify: bool) -> None: 197 """Commits changes.""" 198 cmd = ['git', 'commit', '-m', message] + (['--no-verify'] if no_verify is True else []) 199 subprocess.run(cmd, cwd=proj_path, check=True) 200 201 202def commit_amend(proj_path: Path) -> None: 203 """Commits changes.""" 204 cmd = ['git', 'commit', '--amend', '--no-edit'] 205 subprocess.run(cmd, cwd=proj_path, check=True) 206 207 208def checkout(proj_path: Path, branch_name: str) -> None: 209 """Checkouts a branch.""" 210 cmd = ['git', 'checkout', branch_name] 211 subprocess.run(cmd, cwd=proj_path, check=True) 212 213 214def detach_to_android_head(proj_path: Path) -> None: 215 """Detaches the project HEAD back to the manifest revision.""" 216 # -d detaches the project back to the manifest revision without updating. 217 # -l avoids fetching new revisions from the remote. This might be superfluous with 218 # -d, but I'm not sure, and it certainly doesn't harm anything. 219 subprocess.run(['repo', 'sync', '-l', '-d', proj_path], cwd=proj_path, check=True) 220 221 222def push(proj_path: Path, remote_name: str, has_errors: bool) -> None: 223 """Pushes change to remote.""" 224 cmd = ['git', 'push', remote_name, 'HEAD:refs/for/main', '-o', 'banned-words~skip'] 225 if revs := reviewers.find_reviewers(str(proj_path)): 226 cmd.extend(['-o', revs]) 227 if tag := hashtags.find_hashtag(proj_path): 228 cmd.extend(['-o', 't=' + tag]) 229 if has_errors: 230 cmd.extend(['-o', 'l=Verified-1']) 231 subprocess.run(cmd, cwd=proj_path, check=True) 232 233 234def reset_hard(proj_path: Path) -> None: 235 """Resets current HEAD and discards changes to tracked files.""" 236 cmd = ['git', 'reset', '--hard'] 237 subprocess.run(cmd, cwd=proj_path, check=True) 238 239 240def clean(proj_path: Path) -> None: 241 """Removes untracked files and directories.""" 242 cmd = ['git', 'clean', '-fdx'] 243 subprocess.run(cmd, cwd=proj_path, check=True) 244 245 246def is_valid_url(proj_path: Path, url: str) -> bool: 247 cmd = ['git', "ls-remote", url] 248 return subprocess.run(cmd, cwd=proj_path, check=False, stdin=subprocess.DEVNULL, 249 stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, 250 start_new_session=True).returncode == 0 251 252 253def list_remote_tags(proj_path: Path, remote_name: str) -> list[str]: 254 """Lists tags in a remote repository.""" 255 cmd = ['git', "ls-remote", "--tags", remote_name] 256 out = subprocess.run(cmd, capture_output=True, cwd=proj_path, check=True, 257 text=True).stdout 258 lines = out.splitlines() 259 return lines 260 261 262def diff(proj_path: Path, diff_filter: str, revision: str) -> str: 263 try: 264 cmd = ['git', 'diff', revision, '--stat', f'--diff-filter={diff_filter}'] 265 out = subprocess.run(cmd, capture_output=True, cwd=proj_path, 266 check=True, text=True).stdout 267 return out 268 except subprocess.CalledProcessError as err: 269 return f"Could not calculate the diff: {err}" 270 271 272def is_ancestor(proj_path: Path, ancestor: str, child: str) -> bool: 273 cmd = ['git', 'merge-base', '--is-ancestor', ancestor, child] 274 # https://git-scm.com/docs/git-merge-base#Documentation/git-merge-base.txt---is-ancestor 275 # Exit status of 0 means yes, 1 means no, and all others mean an error occurred. 276 # Although a commit is an ancestor of itself, we don't want to return True 277 # if ancestor points to the same commit as child. 278 if get_sha_for_branch(proj_path, ancestor) == child: 279 return False 280 try: 281 subprocess.run( 282 cmd, 283 cwd=proj_path, 284 text=True, 285 stderr=subprocess.STDOUT, 286 check=True, 287 stdout=subprocess.PIPE 288 ) 289 return True 290 except subprocess.CalledProcessError as ex: 291 if ex.returncode == 1: 292 return False 293 raise 294