1 # Copyright © 2019-2020 Intel Corporation
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:
10 # The above copyright notice and this permission notice shall be included in
11 # all copies or substantial portions of the Software.
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
21 """Core data structures and routines for pick."""
33 if typing
.TYPE_CHECKING
:
36 import typing_extensions
38 class CommitDict(typing_extensions
.TypedDict
):
43 nomination_type
: typing
.Optional
[int]
44 resolution
: typing
.Optional
[int]
45 master_sha
: typing
.Optional
[str]
46 because_sha
: typing
.Optional
[str]
48 IS_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
50 IS_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)
52 IS_REVERT = re.compile(r'This reverts commit ([0-9a-f]{40})')
55 SEM = asyncio.Semaphore(50)
57 COMMIT_LOCK = asyncio.Lock()
59 git_toplevel = subprocess.check_output(['git', 'rev-parse', '--show-toplevel'],
60 stderr=subprocess.DEVNULL).decode("ascii
").strip()
61 pick_status_json = pathlib.Path(git_toplevel) / '.pick_status.json'
64 class PickUIException(Exception):
69 class NominationType(enum.Enum):
77 class Resolution(enum.Enum):
86 async 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,
99 cmd = ['--amend', '--no-edit']
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,
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)
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)
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'])
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,
150 _, err = await p.communicate()
152 if p.returncode != 0:
153 return (False, err.decode())
155 self.resolution = Resolution.MERGED
156 await ui.feedback(f'{self.sha} ({self.description}) applied successfully')
158 # Append the changes to the .pickstatus.json file
160 v = await commit_state(amend=True)
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,
172 await ui.feedback(f'{"Successfully
" if r == 0 else "Failed to
"} abort cherry-pick.')
174 async def denominate(self, ui: 'UI') -> bool:
175 self.resolution = Resolution.DENOMINATED
177 v = await commit_state(message=f'Mark {self.sha} as denominated')
179 await ui.feedback(f'{self.sha} ({self.description}) denominated successfully')
182 async def backport(self, ui: 'UI') -> bool:
183 self.resolution = Resolution.BACKPORTED
185 v = await commit_state(message=f'Mark {self.sha} as backported')
187 await ui.feedback(f'{self.sha} ({self.description}) backported successfully')
190 async def resolve(self, ui: 'UI') -> None:
191 self.resolution = Resolution.MERGED
193 v = await commit_state(amend=True)
195 await ui.feedback(f'{self.sha} ({self.description}) committed successfully')
198 async 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()
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()))
216 def split_commit_list(commits: str) -> typing.Generator[typing.Tuple[str, str], None, None]:
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)
225 async def is_commit_in_branch(sha: str) -> bool:
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,
233 return p.returncode == 0
236 async def full_sha(sha: str) -> str:
238 p = await asyncio.create_subprocess_exec(
239 'git
', 'rev
-parse
', sha,
240 stdout=asyncio.subprocess.PIPE,
241 stderr=asyncio.subprocess.DEVNULL,
243 out, _ = await p.communicate()
245 raise PickUIException(f'Invalid Sha {sha}
')
246 return out.decode().strip()
249 async def resolve_nomination(commit: 'Commit
', version: str) -> 'Commit
':
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,
256 _out, _ = await p.communicate()
257 assert p.returncode == 0, f'git log
for {commit
.sha
} failed
'
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)
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.
267 commit.because_sha = fixed = await full_sha(m.group(1))
268 except PickUIException:
271 commit.nomination_type = NominationType.FIXES
272 if await is_commit_in_branch(fixed):
273 commit.nominated = True
276 m = IS_CC.search(out)
278 if m.groups() == (None, None) or version in m.groups():
279 commit.nominated = True
280 commit.nomination_type = NominationType.CC
283 m = IS_REVERT.search(out)
285 # See comment for IS_FIX path
287 commit.because_sha = reverted = await full_sha(m.group(1))
288 except PickUIException:
291 commit.nomination_type = NominationType.REVERT
292 if await is_commit_in_branch(reverted):
293 commit.nominated = True
299 async 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.
302 The are still needed if they apply to a commit that is staged for
303 inclusion, but not yet included.
305 This must be done in order, because a commit 3 might fix commit 2 which
308 shas: typing.Set[str] = set(c.sha for c in previous if c.nominated)
309 assert None not in shas, 'None in shas
'
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
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
)
333 async 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
340 m_commits
: typing
.List
[typing
.Optional
['Commit']] = [None] * len(new
)
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
)
349 for i
, (sha
, desc
) in enumerate(new
):
350 tasks
.append(asyncio
.ensure_future(
351 inner(Commit(sha
, desc
), version
, m_commits
, i
, cb
)))
353 await asyncio
.gather(*tasks
)
354 assert None not in m_commits
355 commits
= typing
.cast(typing
.List
[Commit
], m_commits
)
357 await resolve_fixes(commits
, previous
)
359 for commit
in commits
:
360 if commit
.resolution
is Resolution
.UNRESOLVED
and not commit
.nominated
:
361 commit
.resolution
= Resolution
.NOTNEEDED
366 def load() -> typing
.List
['Commit']:
367 if not pick_status_json
.exists():
369 with pick_status_json
.open('r') as f
:
371 return [Commit
.from_json(c
) for c
in raw
]
374 def 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)
379 asyncio
.ensure_future(commit_state(message
=f
'Update to {commits[0].sha}'))