1#!/usr/bin/env python3 2# Copyright 2022 The ChromiumOS Authors 3# Use of this source code is governed by a BSD-style license that can be 4# found in the LICENSE file. 5 6import argparse 7import functools 8import re 9import sys 10from argh import arg # type: ignore 11from os import chdir 12from pathlib import Path 13from typing import List, Optional, Tuple 14 15from impl.common import CROSVM_ROOT, GerritChange, cmd, confirm, run_commands 16 17USAGE = """\ 18./tools/cl [upload|rebase|status|prune] 19 20Upload changes to the upstream crosvm gerrit. 21 22Multiple projects have their own downstream repository of crosvm and tooling 23to upload to those. 24 25This tool allows developers to send commits to the upstream gerrit review site 26of crosvm and helps rebase changes if needed. 27 28You need to be on a local branch tracking a remote one. `repo start` does this 29for AOSP and chromiumos, or you can do this yourself: 30 31 $ git checkout -b mybranch --track origin/main 32 33Then to upload commits you have made: 34 35 [mybranch] $ ./tools/cl upload 36 37If you are tracking a different branch (e.g. aosp/main or cros/chromeos), the upload may 38fail if your commits do not apply cleanly. This tool can help rebase the changes, it will 39create a new branch tracking origin/main and cherry-picks your commits. 40 41 [mybranch] $ ./tools/cl rebase 42 [mybranch-upstream] ... resolve conflicts 43 [mybranch-upstream] $ git add . 44 [mybranch-upstream] $ git cherry-pick --continue 45 [mybranch-upstream] $ ./tools/cl upload 46 47""" 48 49GERRIT_URL = "https://chromium-review.googlesource.com" 50CROSVM_URL = "https://chromium.googlesource.com/crosvm/crosvm" 51 52git = cmd("git") 53curl = cmd("curl --silent --fail") 54chmod = cmd("chmod") 55 56 57class LocalChange(object): 58 sha: str 59 title: str 60 branch: str 61 62 def __init__(self, sha: str, title: str): 63 self.sha = sha 64 self.title = title 65 66 @classmethod 67 def list_changes(cls, branch: str): 68 upstream = get_upstream(branch) 69 for line in git(f'log "--format=%H %s" --first-parent {upstream}..{branch}').lines(): 70 sha_title = line.split(" ", 1) 71 yield cls(sha_title[0], sha_title[1]) 72 73 @functools.cached_property 74 def change_id(self): 75 msg = git("log -1 --format=email", self.sha).stdout() 76 match = re.search("^Change-Id: (I[a-f0-9]+)", msg, re.MULTILINE) 77 if not match: 78 return None 79 return match.group(1) 80 81 @functools.cached_property 82 def gerrit(self): 83 if not self.change_id: 84 return None 85 results = GerritChange.query("project:crosvm/crosvm", self.change_id) 86 if len(results) > 1: 87 raise Exception(f"Multiple gerrit changes found for commit {self.sha}: {self.title}.") 88 return results[0] if results else None 89 90 @property 91 def status(self): 92 if not self.gerrit: 93 return "NOT_UPLOADED" 94 else: 95 return self.gerrit.status 96 97 98def get_upstream(branch: str = ""): 99 try: 100 return git(f"rev-parse --abbrev-ref --symbolic-full-name {branch}@{{u}}").stdout() 101 except: 102 return None 103 104 105def list_local_branches(): 106 return git("for-each-ref --format=%(refname:short) refs/heads").lines() 107 108 109def get_active_upstream(): 110 upstream = get_upstream() 111 if not upstream: 112 default_upstream = "origin/main" 113 if confirm(f"You are not tracking an upstream branch. Set upstream to {default_upstream}?"): 114 git(f"branch --set-upstream-to {default_upstream}").fg() 115 upstream = get_upstream() 116 if not upstream: 117 raise Exception("You are not tracking an upstream branch.") 118 parts = upstream.split("/") 119 if len(parts) != 2: 120 raise Exception(f"Your upstream branch '{upstream}' is not remote.") 121 return (parts[0], parts[1]) 122 123 124def prerequisites(): 125 if not git("remote get-url origin").success(): 126 print("Setting up origin") 127 git("remote add origin", CROSVM_URL).fg() 128 if git("remote get-url origin").stdout() != CROSVM_URL: 129 print("Your remote 'origin' does not point to the main crosvm repository.") 130 if confirm(f"Do you want to fix it?"): 131 git("remote set-url origin", CROSVM_URL).fg() 132 else: 133 sys.exit(1) 134 135 # Install gerrit commit hook 136 hooks_dir = Path(git("rev-parse --git-path hooks").stdout()) 137 hook_path = hooks_dir / "commit-msg" 138 if not hook_path.exists(): 139 hook_path.parent.mkdir(exist_ok=True) 140 curl(f"{GERRIT_URL}/tools/hooks/commit-msg").write_to(hook_path) 141 chmod("+x", hook_path).fg() 142 143 144def print_branch_summary(branch: str): 145 upstream = get_upstream(branch) 146 if not upstream: 147 print("Branch", branch, "is not tracking an upstream branch") 148 print() 149 return 150 print("Branch", branch, "tracking", upstream) 151 changes = [*LocalChange.list_changes(branch)] 152 for change in changes: 153 if change.gerrit: 154 print(" ", change.status, change.title, f"({change.gerrit.short_url()})") 155 else: 156 print(" ", change.status, change.title) 157 158 if not changes: 159 print(" No changes") 160 print() 161 162 163def status(): 164 """ 165 Lists all branches and their local commits. 166 """ 167 for branch in list_local_branches(): 168 print_branch_summary(branch) 169 170 171def prune(force: bool = False): 172 """ 173 Deletes branches with changes that have been submitted or abandoned 174 """ 175 current_branch = git("branch --show-current").stdout() 176 branches_to_delete = [ 177 branch 178 for branch in list_local_branches() 179 if branch != current_branch 180 and get_upstream(branch) is not None 181 and all( 182 change.status in ["ABANDONED", "MERGED"] for change in LocalChange.list_changes(branch) 183 ) 184 ] 185 if not branches_to_delete: 186 print("No obsolete branches to delete.") 187 return 188 189 print("Obsolete branches:") 190 print() 191 for branch in branches_to_delete: 192 print_branch_summary(branch) 193 194 if force or confirm("Do you want to delete the above branches?"): 195 git("branch", "-D", *branches_to_delete).fg() 196 197 198def rebase(): 199 """ 200 Rebases changes from the current branch onto origin/main. 201 202 Will create a new branch called 'current-branch'-upstream tracking origin/main. Changes from 203 the current branch will then be rebased into the -upstream branch. 204 """ 205 branch_name = git("branch --show-current").stdout() 206 upstream_branch_name = branch_name + "-upstream" 207 208 if git("rev-parse", upstream_branch_name).success(): 209 print(f"Overwriting existing branch {upstream_branch_name}") 210 git("log -n1", upstream_branch_name).fg() 211 212 git("fetch -q origin main").fg() 213 git("checkout -B", upstream_branch_name, "origin/main").fg() 214 215 print(f"Cherry-picking changes from {branch_name}") 216 git(f"cherry-pick {branch_name}@{{u}}..{branch_name}").fg() 217 218 219def upload( 220 dry_run: bool = False, 221 reviewer: Optional[str] = None, 222 auto_submit: bool = False, 223 submit: bool = False, 224 try_: bool = False, 225): 226 """ 227 Uploads changes to the crosvm main branch. 228 """ 229 remote, branch = get_active_upstream() 230 changes = [*LocalChange.list_changes("HEAD")] 231 if not changes: 232 print("No changes to upload") 233 return 234 235 print("Uploading to origin/main:") 236 for change in changes: 237 print(" ", change.sha, change.title) 238 print() 239 240 if len(changes) > 1: 241 if not confirm("Uploading {} changes, continue?".format(len(changes))): 242 return 243 244 if (remote, branch) != ("origin", "main"): 245 print(f"WARNING! Your changes are based on {remote}/{branch}, not origin/main.") 246 print("If gerrit rejects your changes, try `./tools/cl rebase -h`.") 247 print() 248 if not confirm("Upload anyway?"): 249 return 250 print() 251 252 extra_args: List[str] = [] 253 if auto_submit: 254 extra_args.append("l=Auto-Submit+1") 255 try_ = True 256 if try_: 257 extra_args.append("l=Commit-Queue+1") 258 if submit: 259 extra_args.append(f"l=Commit-Queue+2") 260 if reviewer: 261 extra_args.append(f"r={reviewer}") 262 263 git(f"push origin HEAD:refs/for/main%{','.join(extra_args)}").fg(dry_run=dry_run) 264 265 266if __name__ == "__main__": 267 chdir(CROSVM_ROOT) 268 prerequisites() 269 run_commands(upload, rebase, status, prune, usage=USAGE) 270