xref: /aosp_15_r20/external/mesa3d/bin/pick/core.py (revision 6104692788411f58d303aa86923a9ff6ecaded22)
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