pick-ui: compute .pick_status.json path only once
[mesa.git] / bin / pick / core.py
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
23 import asyncio
24 import enum
25 import json
26 import pathlib
27 import re
28 import typing
29
30 import attr
31
32 if typing.TYPE_CHECKING:
33 from .ui import UI
34
35 import typing_extensions
36
37 class CommitDict(typing_extensions.TypedDict):
38
39 sha: str
40 description: str
41 nominated: bool
42 nomination_type: typing.Optional[int]
43 resolution: typing.Optional[int]
44 master_sha: typing.Optional[str]
45 because_sha: typing.Optional[str]
46
47 IS_FIX = re.compile(r'^\s*fixes:\s*([a-f0-9]{6,40})', flags=re.MULTILINE | re.IGNORECASE)
48 # FIXME: I dislike the duplication in this regex, but I couldn't get it to work otherwise
49 IS_CC = re.compile(r'^\s*cc:\s*["\']?([0-9]{2}\.[0-9])?["\']?\s*["\']?([0-9]{2}\.[0-9])?["\']?\s*\<?mesa-stable',
50 flags=re.MULTILINE | re.IGNORECASE)
51 IS_REVERT = re.compile(r'This reverts commit ([0-9a-f]{40})')
52
53 # XXX: hack
54 SEM = asyncio.Semaphore(50)
55
56 COMMIT_LOCK = asyncio.Lock()
57
58 pick_status_json = pathlib.Path(__file__).parent.parent.parent / '.pick_status.json'
59
60
61 class PickUIException(Exception):
62 pass
63
64
65 @enum.unique
66 class NominationType(enum.Enum):
67
68 CC = 0
69 FIXES = 1
70 REVERT = 2
71
72
73 @enum.unique
74 class Resolution(enum.Enum):
75
76 UNRESOLVED = 0
77 MERGED = 1
78 DENOMINATED = 2
79 BACKPORTED = 3
80 NOTNEEDED = 4
81
82
83 async def commit_state(*, amend: bool = False, message: str = 'Update') -> bool:
84 """Commit the .pick_status.json file."""
85 async with COMMIT_LOCK:
86 p = await asyncio.create_subprocess_exec(
87 'git', 'add', pick_status_json.as_posix(),
88 stdout=asyncio.subprocess.DEVNULL,
89 stderr=asyncio.subprocess.DEVNULL,
90 )
91 v = await p.wait()
92 if v != 0:
93 return False
94
95 if amend:
96 cmd = ['--amend', '--no-edit']
97 else:
98 cmd = ['--message', f'.pick_status.json: {message}']
99 p = await asyncio.create_subprocess_exec(
100 'git', 'commit', *cmd,
101 stdout=asyncio.subprocess.DEVNULL,
102 stderr=asyncio.subprocess.DEVNULL,
103 )
104 v = await p.wait()
105 if v != 0:
106 return False
107 return True
108
109
110 @attr.s(slots=True)
111 class Commit:
112
113 sha: str = attr.ib()
114 description: str = attr.ib()
115 nominated: bool = attr.ib(False)
116 nomination_type: typing.Optional[NominationType] = attr.ib(None)
117 resolution: Resolution = attr.ib(Resolution.UNRESOLVED)
118 master_sha: typing.Optional[str] = attr.ib(None)
119 because_sha: typing.Optional[str] = attr.ib(None)
120
121 def to_json(self) -> 'CommitDict':
122 d: typing.Dict[str, typing.Any] = attr.asdict(self)
123 if self.nomination_type is not None:
124 d['nomination_type'] = self.nomination_type.value
125 if self.resolution is not None:
126 d['resolution'] = self.resolution.value
127 return typing.cast('CommitDict', d)
128
129 @classmethod
130 def from_json(cls, data: 'CommitDict') -> 'Commit':
131 c = cls(data['sha'], data['description'], data['nominated'], master_sha=data['master_sha'], because_sha=data['because_sha'])
132 if data['nomination_type'] is not None:
133 c.nomination_type = NominationType(data['nomination_type'])
134 if data['resolution'] is not None:
135 c.resolution = Resolution(data['resolution'])
136 return c
137
138 async def apply(self, ui: 'UI') -> typing.Tuple[bool, str]:
139 # FIXME: This isn't really enough if we fail to cherry-pick because the
140 # git tree will still be dirty
141 async with COMMIT_LOCK:
142 p = await asyncio.create_subprocess_exec(
143 'git', 'cherry-pick', '-x', self.sha,
144 stdout=asyncio.subprocess.DEVNULL,
145 stderr=asyncio.subprocess.PIPE,
146 )
147 _, err = await p.communicate()
148
149 if p.returncode != 0:
150 return (False, err.decode())
151
152 self.resolution = Resolution.MERGED
153 await ui.feedback(f'{self.sha} ({self.description}) applied successfully')
154
155 # Append the changes to the .pickstatus.json file
156 ui.save()
157 v = await commit_state(amend=True)
158 return (v, '')
159
160 async def abort_cherry(self, ui: 'UI', err: str) -> None:
161 await ui.feedback(f'{self.sha} ({self.description}) failed to apply\n{err}')
162 async with COMMIT_LOCK:
163 p = await asyncio.create_subprocess_exec(
164 'git', 'cherry-pick', '--abort',
165 stdout=asyncio.subprocess.DEVNULL,
166 stderr=asyncio.subprocess.DEVNULL,
167 )
168 r = await p.wait()
169 await ui.feedback(f'{"Successfully" if r == 0 else "Failed to"} abort cherry-pick.')
170
171 async def denominate(self, ui: 'UI') -> bool:
172 self.resolution = Resolution.DENOMINATED
173 ui.save()
174 v = await commit_state(message=f'Mark {self.sha} as denominated')
175 assert v
176 await ui.feedback(f'{self.sha} ({self.description}) denominated successfully')
177 return True
178
179 async def backport(self, ui: 'UI') -> bool:
180 self.resolution = Resolution.BACKPORTED
181 ui.save()
182 v = await commit_state(message=f'Mark {self.sha} as backported')
183 assert v
184 await ui.feedback(f'{self.sha} ({self.description}) backported successfully')
185 return True
186
187 async def resolve(self, ui: 'UI') -> None:
188 self.resolution = Resolution.MERGED
189 ui.save()
190 v = await commit_state(amend=True)
191 assert v
192 await ui.feedback(f'{self.sha} ({self.description}) committed successfully')
193
194
195 async def get_new_commits(sha: str) -> typing.List[typing.Tuple[str, str]]:
196 # Try to get the authoritative upstream master
197 p = await asyncio.create_subprocess_exec(
198 'git', 'for-each-ref', '--format=%(upstream)', 'refs/heads/master',
199 stdout=asyncio.subprocess.PIPE,
200 stderr=asyncio.subprocess.DEVNULL)
201 out, _ = await p.communicate()
202 upstream = out.decode().strip()
203
204 p = await asyncio.create_subprocess_exec(
205 'git', 'log', '--pretty=oneline', f'{sha}..{upstream}',
206 stdout=asyncio.subprocess.PIPE,
207 stderr=asyncio.subprocess.DEVNULL)
208 out, _ = await p.communicate()
209 assert p.returncode == 0, f"git log didn't work: {sha}"
210 return list(split_commit_list(out.decode().strip()))
211
212
213 def split_commit_list(commits: str) -> typing.Generator[typing.Tuple[str, str], None, None]:
214 if not commits:
215 return
216 for line in commits.split('\n'):
217 v = tuple(line.split(' ', 1))
218 assert len(v) == 2, 'this is really just for mypy'
219 yield typing.cast(typing.Tuple[str, str], v)
220
221
222 async def is_commit_in_branch(sha: str) -> bool:
223 async with SEM:
224 p = await asyncio.create_subprocess_exec(
225 'git', 'merge-base', '--is-ancestor', sha, 'HEAD',
226 stdout=asyncio.subprocess.DEVNULL,
227 stderr=asyncio.subprocess.DEVNULL,
228 )
229 await p.wait()
230 return p.returncode == 0
231
232
233 async def full_sha(sha: str) -> str:
234 async with SEM:
235 p = await asyncio.create_subprocess_exec(
236 'git', 'rev-parse', sha,
237 stdout=asyncio.subprocess.PIPE,
238 stderr=asyncio.subprocess.DEVNULL,
239 )
240 out, _ = await p.communicate()
241 if p.returncode:
242 raise PickUIException(f'Invalid Sha {sha}')
243 return out.decode().strip()
244
245
246 async def resolve_nomination(commit: 'Commit', version: str) -> 'Commit':
247 async with SEM:
248 p = await asyncio.create_subprocess_exec(
249 'git', 'log', '--format=%B', '-1', commit.sha,
250 stdout=asyncio.subprocess.PIPE,
251 stderr=asyncio.subprocess.DEVNULL,
252 )
253 _out, _ = await p.communicate()
254 assert p.returncode == 0, f'git log for {commit.sha} failed'
255 out = _out.decode()
256
257 # We give precedence to fixes and cc tags over revert tags.
258 # XXX: not having the walrus operator available makes me sad :=
259 m = IS_FIX.search(out)
260 if m:
261 # We set the nomination_type and because_sha here so that we can later
262 # check to see if this fixes another staged commit.
263 try:
264 commit.because_sha = fixed = await full_sha(m.group(1))
265 except PickUIException:
266 pass
267 else:
268 commit.nomination_type = NominationType.FIXES
269 if await is_commit_in_branch(fixed):
270 commit.nominated = True
271 return commit
272
273 m = IS_CC.search(out)
274 if m:
275 if m.groups() == (None, None) or version in m.groups():
276 commit.nominated = True
277 commit.nomination_type = NominationType.CC
278 return commit
279
280 m = IS_REVERT.search(out)
281 if m:
282 # See comment for IS_FIX path
283 try:
284 commit.because_sha = reverted = await full_sha(m.group(1))
285 except PickUIException:
286 pass
287 else:
288 commit.nomination_type = NominationType.REVERT
289 if await is_commit_in_branch(reverted):
290 commit.nominated = True
291 return commit
292
293 return commit
294
295
296 async def resolve_fixes(commits: typing.List['Commit'], previous: typing.List['Commit']) -> None:
297 """Determine if any of the undecided commits fix/revert a staged commit.
298
299 The are still needed if they apply to a commit that is staged for
300 inclusion, but not yet included.
301
302 This must be done in order, because a commit 3 might fix commit 2 which
303 fixes commit 1.
304 """
305 shas: typing.Set[str] = set(c.sha for c in previous if c.nominated)
306 assert None not in shas, 'None in shas'
307
308 for commit in reversed(commits):
309 if not commit.nominated and commit.nomination_type is NominationType.FIXES:
310 commit.nominated = commit.because_sha in shas
311
312 if commit.nominated:
313 shas.add(commit.sha)
314
315 for commit in commits:
316 if (commit.nomination_type is NominationType.REVERT and
317 commit.because_sha in shas):
318 for oldc in reversed(commits):
319 if oldc.sha == commit.because_sha:
320 # In this case a commit that hasn't yet been applied is
321 # reverted, we don't want to apply that commit at all
322 oldc.nominated = False
323 oldc.resolution = Resolution.DENOMINATED
324 commit.nominated = False
325 commit.resolution = Resolution.DENOMINATED
326 shas.remove(commit.because_sha)
327 break
328
329
330 async def gather_commits(version: str, previous: typing.List['Commit'],
331 new: typing.List[typing.Tuple[str, str]], cb) -> typing.List['Commit']:
332 # We create an array of the final size up front, then we pass that array
333 # to the "inner" co-routine, which is turned into a list of tasks and
334 # collected by asyncio.gather. We do this to allow the tasks to be
335 # asynchronously gathered, but to also ensure that the commits list remains
336 # in order.
337 m_commits: typing.List[typing.Optional['Commit']] = [None] * len(new)
338 tasks = []
339
340 async def inner(commit: 'Commit', version: str,
341 commits: typing.List[typing.Optional['Commit']],
342 index: int, cb) -> None:
343 commits[index] = await resolve_nomination(commit, version)
344 cb()
345
346 for i, (sha, desc) in enumerate(new):
347 tasks.append(asyncio.ensure_future(
348 inner(Commit(sha, desc), version, m_commits, i, cb)))
349
350 await asyncio.gather(*tasks)
351 assert None not in m_commits
352 commits = typing.cast(typing.List[Commit], m_commits)
353
354 await resolve_fixes(commits, previous)
355
356 for commit in commits:
357 if commit.resolution is Resolution.UNRESOLVED and not commit.nominated:
358 commit.resolution = Resolution.NOTNEEDED
359
360 return commits
361
362
363 def load() -> typing.List['Commit']:
364 if not pick_status_json.exists():
365 return []
366 with pick_status_json.open('r') as f:
367 raw = json.load(f)
368 return [Commit.from_json(c) for c in raw]
369
370
371 def save(commits: typing.Iterable['Commit']) -> None:
372 commits = list(commits)
373 with pick_status_json.open('wt') as f:
374 json.dump([c.to_json() for c in commits], f, indent=4)
375
376 asyncio.ensure_future(commit_state(message=f'Update to {commits[0].sha}'))