xref: /aosp_15_r20/external/crosvm/tools/cl (revision bb4ee6a4ae7042d18b07a98463b9c8b875e44b39)
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"
51CROSVM_SSO = "sso://chromium/crosvm/crosvm"
52
53git = cmd("git")
54curl = cmd("curl --silent --fail")
55chmod = cmd("chmod")
56
57
58class LocalChange(object):
59    sha: str
60    title: str
61    branch: str
62
63    def __init__(self, sha: str, title: str):
64        self.sha = sha
65        self.title = title
66
67    @classmethod
68    def list_changes(cls, branch: str):
69        upstream = get_upstream(branch)
70        for line in git(f'log "--format=%H %s" --first-parent {upstream}..{branch}').lines():
71            sha_title = line.split(" ", 1)
72            yield cls(sha_title[0], sha_title[1])
73
74    @functools.cached_property
75    def change_id(self):
76        msg = git("log -1 --format=email", self.sha).stdout()
77        match = re.search("^Change-Id: (I[a-f0-9]+)", msg, re.MULTILINE)
78        if not match:
79            return None
80        return match.group(1)
81
82    @functools.cached_property
83    def gerrit(self):
84        if not self.change_id:
85            return None
86        results = GerritChange.query("project:crosvm/crosvm", self.change_id)
87        if len(results) > 1:
88            raise Exception(f"Multiple gerrit changes found for commit {self.sha}: {self.title}.")
89        return results[0] if results else None
90
91    @property
92    def status(self):
93        if not self.gerrit:
94            return "NOT_UPLOADED"
95        else:
96            return self.gerrit.status
97
98
99def get_upstream(branch: str = ""):
100    try:
101        return git(f"rev-parse --abbrev-ref --symbolic-full-name {branch}@{{u}}").stdout()
102    except:
103        return None
104
105
106def list_local_branches():
107    return git("for-each-ref --format=%(refname:short) refs/heads").lines()
108
109
110def get_active_upstream():
111    upstream = get_upstream()
112    if not upstream:
113        default_upstream = "origin/main"
114        if confirm(f"You are not tracking an upstream branch. Set upstream to {default_upstream}?"):
115            git(f"branch --set-upstream-to {default_upstream}").fg()
116            upstream = get_upstream()
117    if not upstream:
118        raise Exception("You are not tracking an upstream branch.")
119    parts = upstream.split("/")
120    if len(parts) != 2:
121        raise Exception(f"Your upstream branch '{upstream}' is not remote.")
122    return (parts[0], parts[1])
123
124
125def prerequisites():
126    if not git("remote get-url origin").success():
127        print("Setting up origin")
128        git("remote add origin", CROSVM_URL).fg()
129    if git("remote get-url origin").stdout() not in [CROSVM_URL, CROSVM_SSO]:
130        print("Your remote 'origin' does not point to the main crosvm repository.")
131        if confirm(f"Do you want to fix it?"):
132            git("remote set-url origin", CROSVM_URL).fg()
133        else:
134            sys.exit(1)
135
136    # Install gerrit commit hook
137    hooks_dir = Path(git("rev-parse --git-path hooks").stdout())
138    hook_path = hooks_dir / "commit-msg"
139    if not hook_path.exists():
140        hook_path.parent.mkdir(exist_ok=True)
141        curl(f"{GERRIT_URL}/tools/hooks/commit-msg").write_to(hook_path)
142        chmod("+x", hook_path).fg()
143
144
145def print_branch_summary(branch: str):
146    upstream = get_upstream(branch)
147    if not upstream:
148        print("Branch", branch, "is not tracking an upstream branch")
149        print()
150        return
151    print("Branch", branch, "tracking", upstream)
152    changes = [*LocalChange.list_changes(branch)]
153    for change in changes:
154        if change.gerrit:
155            print(" ", change.status, change.title, f"({change.gerrit.short_url()})")
156        else:
157            print(" ", change.status, change.title)
158
159    if not changes:
160        print("  No changes")
161    print()
162
163
164def status():
165    """
166    Lists all branches and their local commits.
167    """
168    for branch in list_local_branches():
169        print_branch_summary(branch)
170
171
172def prune(force: bool = False):
173    """
174    Deletes branches with changes that have been submitted or abandoned
175    """
176    current_branch = git("branch --show-current").stdout()
177    branches_to_delete = [
178        branch
179        for branch in list_local_branches()
180        if branch != current_branch
181        and get_upstream(branch) is not None
182        and all(
183            change.status in ["ABANDONED", "MERGED"] for change in LocalChange.list_changes(branch)
184        )
185    ]
186    if not branches_to_delete:
187        print("No obsolete branches to delete.")
188        return
189
190    print("Obsolete branches:")
191    print()
192    for branch in branches_to_delete:
193        print_branch_summary(branch)
194
195    if force or confirm("Do you want to delete the above branches?"):
196        git("branch", "-D", *branches_to_delete).fg()
197
198
199def rebase():
200    """
201    Rebases changes from the current branch onto origin/main.
202
203    Will create a new branch called 'current-branch'-upstream tracking origin/main. Changes from
204    the current branch will then be rebased into the -upstream branch.
205    """
206    branch_name = git("branch --show-current").stdout()
207    upstream_branch_name = branch_name + "-upstream"
208
209    if git("rev-parse", upstream_branch_name).success():
210        print(f"Overwriting existing branch {upstream_branch_name}")
211        git("log -n1", upstream_branch_name).fg()
212
213    git("fetch -q origin main").fg()
214    git("checkout -B", upstream_branch_name, "origin/main").fg()
215
216    print(f"Cherry-picking changes from {branch_name}")
217    git(f"cherry-pick {branch_name}@{{u}}..{branch_name}").fg()
218
219
220def upload(
221    dry_run: bool = False,
222    reviewer: Optional[str] = None,
223    auto_submit: bool = False,
224    submit: bool = False,
225    try_: bool = False,
226):
227    """
228    Uploads changes to the crosvm main branch.
229    """
230    remote, branch = get_active_upstream()
231    changes = [*LocalChange.list_changes("HEAD")]
232    if not changes:
233        print("No changes to upload")
234        return
235
236    print("Uploading to origin/main:")
237    for change in changes:
238        print(" ", change.sha, change.title)
239    print()
240
241    if len(changes) > 1:
242        if not confirm("Uploading {} changes, continue?".format(len(changes))):
243            return
244
245    if (remote, branch) != ("origin", "main"):
246        print(f"WARNING! Your changes are based on {remote}/{branch}, not origin/main.")
247        print("If gerrit rejects your changes, try `./tools/cl rebase -h`.")
248        print()
249        if not confirm("Upload anyway?"):
250            return
251        print()
252
253    extra_args: List[str] = []
254    if auto_submit:
255        extra_args.append("l=Auto-Submit+1")
256        try_ = True
257    if try_:
258        extra_args.append("l=Commit-Queue+1")
259    if submit:
260        extra_args.append(f"l=Commit-Queue+2")
261    if reviewer:
262        extra_args.append(f"r={reviewer}")
263
264    git(f"push origin HEAD:refs/for/main%{','.join(extra_args)}").fg(dry_run=dry_run)
265
266
267if __name__ == "__main__":
268    chdir(CROSVM_ROOT)
269    prerequisites()
270    run_commands(upload, rebase, status, prune, usage=USAGE)
271