1# Copyright © 2019-2020 Intel Corporation 2 3# Permission is hereby granted, free of charge, to any person obtaining a copy 4# of this software and associated documentation files (the "Software"), to deal 5# in the Software without restriction, including without limitation the rights 6# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7# copies of the Software, and to permit persons to whom the Software is 8# furnished to do so, subject to the following conditions: 9 10# The above copyright notice and this permission notice shall be included in 11# all copies or substantial portions of the Software. 12 13# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19# SOFTWARE. 20 21"""Core data structures and routines for pick.""" 22 23import asyncio 24import enum 25import json 26import pathlib 27import re 28import subprocess 29import typing 30 31import attr 32 33if typing.TYPE_CHECKING: 34 from .ui import UI 35 36 import typing_extensions 37 38 class CommitDict(typing_extensions.TypedDict): 39 40 sha: str 41 description: str 42 nominated: bool 43 nomination_type: int 44 resolution: typing.Optional[int] 45 main_sha: typing.Optional[str] 46 because_sha: typing.Optional[str] 47 notes: typing.Optional[str] = attr.ib(None) 48 49IS_FIX = re.compile(r'^\s*fixes:\s*([a-f0-9]{6,40})', flags=re.MULTILINE | re.IGNORECASE) 50# FIXME: I dislike the duplication in this regex, but I couldn't get it to work otherwise 51IS_CC = re.compile(r'^\s*cc:\s*["\']?([0-9]{2}\.[0-9])?["\']?\s*["\']?([0-9]{2}\.[0-9])?["\']?\s*\<?mesa-stable', 52 flags=re.MULTILINE | re.IGNORECASE) 53IS_REVERT = re.compile(r'This reverts commit ([0-9a-f]{40})') 54IS_BACKPORT = re.compile(r'^\s*backport-to:\s*(\d{2}\.\d),?\s*(\d{2}\.\d)?', 55 flags=re.MULTILINE | re.IGNORECASE) 56 57# XXX: hack 58SEM = asyncio.Semaphore(50) 59 60COMMIT_LOCK = asyncio.Lock() 61 62git_toplevel = subprocess.check_output(['git', 'rev-parse', '--show-toplevel'], 63 stderr=subprocess.DEVNULL).decode("ascii").strip() 64pick_status_json = pathlib.Path(git_toplevel) / '.pick_status.json' 65 66 67class PickUIException(Exception): 68 pass 69 70 71@enum.unique 72class NominationType(enum.Enum): 73 74 NONE = 0 75 CC = 1 76 FIXES = 2 77 REVERT = 3 78 BACKPORT = 4 79 80 81@enum.unique 82class Resolution(enum.Enum): 83 84 UNRESOLVED = 0 85 MERGED = 1 86 DENOMINATED = 2 87 BACKPORTED = 3 88 NOTNEEDED = 4 89 90 91async def commit_state(*, amend: bool = False, message: str = 'Update') -> bool: 92 """Commit the .pick_status.json file.""" 93 async with COMMIT_LOCK: 94 p = await asyncio.create_subprocess_exec( 95 'git', 'add', pick_status_json.as_posix(), 96 stdout=asyncio.subprocess.DEVNULL, 97 stderr=asyncio.subprocess.DEVNULL, 98 ) 99 v = await p.wait() 100 if v != 0: 101 return False 102 103 if amend: 104 cmd = ['--amend', '--no-edit'] 105 else: 106 cmd = ['--message', f'.pick_status.json: {message}'] 107 p = await asyncio.create_subprocess_exec( 108 'git', 'commit', *cmd, 109 stdout=asyncio.subprocess.DEVNULL, 110 stderr=asyncio.subprocess.DEVNULL, 111 ) 112 v = await p.wait() 113 if v != 0: 114 return False 115 return True 116 117 118@attr.s(slots=True) 119class Commit: 120 121 sha: str = attr.ib() 122 description: str = attr.ib() 123 nominated: bool = attr.ib(False) 124 nomination_type: NominationType = attr.ib(NominationType.NONE) 125 resolution: Resolution = attr.ib(Resolution.UNRESOLVED) 126 main_sha: typing.Optional[str] = attr.ib(None) 127 because_sha: typing.Optional[str] = attr.ib(None) 128 notes: typing.Optional[str] = attr.ib(None) 129 130 def to_json(self) -> 'CommitDict': 131 d: typing.Dict[str, typing.Any] = attr.asdict(self) 132 d['nomination_type'] = self.nomination_type.value 133 if self.resolution is not None: 134 d['resolution'] = self.resolution.value 135 return typing.cast('CommitDict', d) 136 137 @classmethod 138 def from_json(cls, data: 'CommitDict') -> 'Commit': 139 c = cls(data['sha'], data['description'], data['nominated'], main_sha=data['main_sha'], 140 because_sha=data['because_sha'], notes=data['notes']) 141 c.nomination_type = NominationType(data['nomination_type']) 142 if data['resolution'] is not None: 143 c.resolution = Resolution(data['resolution']) 144 return c 145 146 def date(self) -> str: 147 # Show commit date, ie. when the commit actually landed 148 # (as opposed to when it was first written) 149 return subprocess.check_output( 150 ['git', 'show', '--no-patch', '--format=%cs', self.sha], 151 stderr=subprocess.DEVNULL 152 ).decode("ascii").strip() 153 154 async def apply(self, ui: 'UI') -> typing.Tuple[bool, str]: 155 # FIXME: This isn't really enough if we fail to cherry-pick because the 156 # git tree will still be dirty 157 async with COMMIT_LOCK: 158 p = await asyncio.create_subprocess_exec( 159 'git', 'cherry-pick', '-x', self.sha, 160 stdout=asyncio.subprocess.DEVNULL, 161 stderr=asyncio.subprocess.PIPE, 162 ) 163 _, err = await p.communicate() 164 165 if p.returncode != 0: 166 return (False, err.decode()) 167 168 self.resolution = Resolution.MERGED 169 await ui.feedback(f'{self.sha} ({self.description}) applied successfully') 170 171 # Append the changes to the .pickstatus.json file 172 ui.save() 173 v = await commit_state(amend=True) 174 return (v, '') 175 176 async def abort_cherry(self, ui: 'UI', err: str) -> None: 177 await ui.feedback(f'{self.sha} ({self.description}) failed to apply\n{err}') 178 async with COMMIT_LOCK: 179 p = await asyncio.create_subprocess_exec( 180 'git', 'cherry-pick', '--abort', 181 stdout=asyncio.subprocess.DEVNULL, 182 stderr=asyncio.subprocess.DEVNULL, 183 ) 184 r = await p.wait() 185 await ui.feedback(f'{"Successfully" if r == 0 else "Failed to"} abort cherry-pick.') 186 187 async def denominate(self, ui: 'UI') -> bool: 188 self.resolution = Resolution.DENOMINATED 189 ui.save() 190 v = await commit_state(message=f'Mark {self.sha} as denominated') 191 assert v 192 await ui.feedback(f'{self.sha} ({self.description}) denominated successfully') 193 return True 194 195 async def backport(self, ui: 'UI') -> bool: 196 self.resolution = Resolution.BACKPORTED 197 ui.save() 198 v = await commit_state(message=f'Mark {self.sha} as backported') 199 assert v 200 await ui.feedback(f'{self.sha} ({self.description}) backported successfully') 201 return True 202 203 async def resolve(self, ui: 'UI') -> None: 204 self.resolution = Resolution.MERGED 205 ui.save() 206 v = await commit_state(amend=True) 207 assert v 208 await ui.feedback(f'{self.sha} ({self.description}) committed successfully') 209 210 async def update_notes(self, ui: 'UI', notes: typing.Optional[str]) -> None: 211 self.notes = notes 212 async with ui.git_lock: 213 ui.save() 214 v = await commit_state(message=f'Updates notes for {self.sha}') 215 assert v 216 await ui.feedback(f'{self.sha} ({self.description}) notes updated successfully') 217 218 219async def get_new_commits(sha: str) -> typing.List[typing.Tuple[str, str]]: 220 # Try to get the authoritative upstream main 221 p = await asyncio.create_subprocess_exec( 222 'git', 'for-each-ref', '--format=%(upstream)', 'refs/heads/main', 223 stdout=asyncio.subprocess.PIPE, 224 stderr=asyncio.subprocess.DEVNULL) 225 out, _ = await p.communicate() 226 upstream = out.decode().strip() 227 228 p = await asyncio.create_subprocess_exec( 229 'git', 'log', '--pretty=oneline', f'{sha}..{upstream}', 230 stdout=asyncio.subprocess.PIPE, 231 stderr=asyncio.subprocess.DEVNULL) 232 out, _ = await p.communicate() 233 assert p.returncode == 0, f"git log didn't work: {sha}" 234 return list(split_commit_list(out.decode().strip())) 235 236 237def split_commit_list(commits: str) -> typing.Generator[typing.Tuple[str, str], None, None]: 238 if not commits: 239 return 240 for line in commits.split('\n'): 241 v = tuple(line.split(' ', 1)) 242 assert len(v) == 2, 'this is really just for mypy' 243 yield typing.cast(typing.Tuple[str, str], v) 244 245 246async def is_commit_in_branch(sha: str) -> bool: 247 async with SEM: 248 p = await asyncio.create_subprocess_exec( 249 'git', 'merge-base', '--is-ancestor', sha, 'HEAD', 250 stdout=asyncio.subprocess.DEVNULL, 251 stderr=asyncio.subprocess.DEVNULL, 252 ) 253 await p.wait() 254 return p.returncode == 0 255 256 257async def full_sha(sha: str) -> str: 258 async with SEM: 259 p = await asyncio.create_subprocess_exec( 260 'git', 'rev-parse', sha, 261 stdout=asyncio.subprocess.PIPE, 262 stderr=asyncio.subprocess.DEVNULL, 263 ) 264 out, _ = await p.communicate() 265 if p.returncode: 266 raise PickUIException(f'Invalid Sha {sha}') 267 return out.decode().strip() 268 269 270async def resolve_nomination(commit: 'Commit', version: str) -> 'Commit': 271 async with SEM: 272 p = await asyncio.create_subprocess_exec( 273 'git', 'log', '--format=%B', '-1', commit.sha, 274 stdout=asyncio.subprocess.PIPE, 275 stderr=asyncio.subprocess.DEVNULL, 276 ) 277 _out, _ = await p.communicate() 278 assert p.returncode == 0, f'git log for {commit.sha} failed' 279 out = _out.decode() 280 281 # We give precedence to fixes and cc tags over revert tags. 282 if fix_for_commit := IS_FIX.search(out): 283 # We set the nomination_type and because_sha here so that we can later 284 # check to see if this fixes another staged commit. 285 try: 286 commit.because_sha = fixed = await full_sha(fix_for_commit.group(1)) 287 except PickUIException: 288 pass 289 else: 290 commit.nomination_type = NominationType.FIXES 291 if await is_commit_in_branch(fixed): 292 commit.nominated = True 293 return commit 294 295 if backport_to := IS_BACKPORT.search(out): 296 if version in backport_to.groups(): 297 commit.nominated = True 298 commit.nomination_type = NominationType.BACKPORT 299 return commit 300 301 if cc_to := IS_CC.search(out): 302 if cc_to.groups() == (None, None) or version in cc_to.groups(): 303 commit.nominated = True 304 commit.nomination_type = NominationType.CC 305 return commit 306 307 if revert_of := IS_REVERT.search(out): 308 # See comment for IS_FIX path 309 try: 310 commit.because_sha = reverted = await full_sha(revert_of.group(1)) 311 except PickUIException: 312 pass 313 else: 314 commit.nomination_type = NominationType.REVERT 315 if await is_commit_in_branch(reverted): 316 commit.nominated = True 317 return commit 318 319 return commit 320 321 322async def resolve_fixes(commits: typing.List['Commit'], previous: typing.List['Commit']) -> None: 323 """Determine if any of the undecided commits fix/revert a staged commit. 324 325 The are still needed if they apply to a commit that is staged for 326 inclusion, but not yet included. 327 328 This must be done in order, because a commit 3 might fix commit 2 which 329 fixes commit 1. 330 """ 331 shas: typing.Set[str] = set(c.sha for c in previous if c.nominated) 332 assert None not in shas, 'None in shas' 333 334 for commit in reversed(commits): 335 if not commit.nominated and commit.nomination_type is NominationType.FIXES: 336 commit.nominated = commit.because_sha in shas 337 338 if commit.nominated: 339 shas.add(commit.sha) 340 341 for commit in commits: 342 if (commit.nomination_type is NominationType.REVERT and 343 commit.because_sha in shas): 344 for oldc in reversed(commits): 345 if oldc.sha == commit.because_sha: 346 # In this case a commit that hasn't yet been applied is 347 # reverted, we don't want to apply that commit at all 348 oldc.nominated = False 349 oldc.resolution = Resolution.DENOMINATED 350 commit.nominated = False 351 commit.resolution = Resolution.DENOMINATED 352 shas.remove(commit.because_sha) 353 break 354 355 356async def gather_commits(version: str, previous: typing.List['Commit'], 357 new: typing.List[typing.Tuple[str, str]], cb) -> typing.List['Commit']: 358 # We create an array of the final size up front, then we pass that array 359 # to the "inner" co-routine, which is turned into a list of tasks and 360 # collected by asyncio.gather. We do this to allow the tasks to be 361 # asynchronously gathered, but to also ensure that the commits list remains 362 # in order. 363 m_commits: typing.List[typing.Optional['Commit']] = [None] * len(new) 364 tasks = [] 365 366 async def inner(commit: 'Commit', version: str, 367 commits: typing.List[typing.Optional['Commit']], 368 index: int, cb) -> None: 369 commits[index] = await resolve_nomination(commit, version) 370 cb() 371 372 for i, (sha, desc) in enumerate(new): 373 tasks.append(asyncio.ensure_future( 374 inner(Commit(sha, desc), version, m_commits, i, cb))) 375 376 await asyncio.gather(*tasks) 377 assert None not in m_commits 378 commits = typing.cast(typing.List[Commit], m_commits) 379 380 await resolve_fixes(commits, previous) 381 382 for commit in commits: 383 if commit.resolution is Resolution.UNRESOLVED and not commit.nominated: 384 commit.resolution = Resolution.NOTNEEDED 385 386 return commits 387 388 389def load() -> typing.List['Commit']: 390 if not pick_status_json.exists(): 391 return [] 392 with pick_status_json.open('r') as f: 393 raw = json.load(f) 394 return [Commit.from_json(c) for c in raw] 395 396 397def save(commits: typing.Iterable['Commit']) -> None: 398 commits = list(commits) 399 with pick_status_json.open('wt') as f: 400 json.dump([c.to_json() for c in commits], f, indent=4) 401 402 asyncio.ensure_future(commit_state(message=f'Update to {commits[0].sha}')) 403