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