blob: ac89ca64488fd32b647c8042decbcddc4ae8b5a5 [file] [log] [blame]
# Copyright 2021 The Chromium OS Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Provides some helpers for interacting with git."""
import re
import subprocess
class Git:
"""A class which provides helpers for interacting with git."""
def __init__(self, path='.'):
self._cmd = ['git', '-C', path]
def _run(self, cmd, stdin=None):
"""Runs a git command with the appropriate working dir.
Args:
cmd: The command to run (in array fmt, ie: ['log', '--oneline']).
stdin: Optionally provide stdin input to the command.
Returns:
(returncode,stdout,stderr) from the command
"""
run_cmd = self._cmd + cmd
ret = subprocess.run(run_cmd, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, input=stdin,
encoding='UTF-8')
return (ret.returncode, ret.stdout, ret.stderr)
@staticmethod
def _generate_remote_name(remote):
"""Generates a remote name unique for the given url.
The result is the remote url without the protocol and special
characters. This allows us to resolve git://git.kernel.org and
https://git.kernel.org as the same remote.
Args:
remote: The remote tree's URL (including protocol).
Returns:
The unique remote name.
"""
name = re.sub(r'([a-z]*\://)|\W', '', remote, flags=re.I)
return f'fl-{name}'
def fetch_refspec_from_remote(self, remote, refspec):
"""Fetches the given refspec from the given remote.
Args:
remote: The remote tree's URL (including protocol).
refspec: The remote refspec to fetch.
Returns:
True if the fetch was successful, False otherwise.
"""
remote_name = Git._generate_remote_name(remote)
# Ignore failures from remote add since the remote might already exist.
self._run(['remote', 'add', remote_name, remote])
ret, *_ = self._run(['fetch', remote_name, refspec])
return ret == 0
def get_commits_in_range(self, start, end, include_merges=False):
"""Get a list of commits between start and end.
Args:
start: The range's beginning refspec.
end: The range's ending refspec.
include_merges: Should the results include merge commits?
Returns:
True if successful, False otherwise.
"""
cmd = ['log', '--format=%H']
if not include_merges:
cmd += ['--no-merges']
ret, commits, _ = self._run(cmd + [f'{start}..{end}'])
if ret != 0:
return None
return commits.splitlines()
def commit_in_local_branch(self, commit, common_ancestor=None,
include_cherry_picks=False):
"""Look for the given commit in the local branch.
Args:
commit: The commit to look for.
common_ancestor: The common ancestor between local and upstream.
include_cherry_picks: Search cherry-picked commits as well.
Returns:
True if the commit is found in the local branch, False otherwise.
"""
ret, *_ = self._run(['merge-base', '--is-ancestor', commit, 'HEAD'])
if ret == 0:
return True
if not include_cherry_picks:
return False
# Get the affected files to narrow down the grep below
ret, files, _ = self._run(['diff-tree', '--no-commit-id', '--name-only',
'-r', commit])
if ret != 0:
return False
# Inspect the last 10 commits to each file and pick the file which has
# the oldest for the grep below. This will hopefully more often than
# not choose the least active file (ie: smallest history) to speed up
# the grep below. We could of course just count all the commits, but
# that would take a long time and probably [hand waving] take more time
# overall than grepping a random file from the commit.
lru = ('', -1)
for f in files.splitlines():
ret, changes, _ = self._run(['log', '-10', '--format=%ct', commit,
'--', f])
if ret != 0 or not changes:
continue
oldest = changes.splitlines()[-1]
if lru[1] == -1 or oldest < lru[1]:
lru = (f, oldest)
cmd = ['log', '--extended-regexp', '--grep',
f'(cherry.picked from( commit)? {commit})']
if common_ancestor:
cmd += [f'{common_ancestor}..']
if lru[1] != -1:
cmd += ['--', lru[0]]
ret, commits, _ = self._run(cmd)
if ret == 0 and commits:
return True
return False
def cherry_pick(self, commit, skip_empty=False):
"""Cherry picks a commit into the local branch.
Args:
commit: The hash of the commit to be cherry-picked.
skip_empty: Detect if the cherry-pick is empty and skip it.
Returns:
(ret, skipped) ret will be True on success, skipped will be True if
the patch is skipped.
"""
ret, _, err = self._run(['cherry-pick', '-s', '-x', commit])
if ret == 0:
return (True, False)
if not skip_empty:
return (False, False)
if 'The previous cherry-pick is now empty' not in err:
return (False, False)
# Double check we don't have any local changes
ret, files, _ = self._run(['status', '-s', '-uno'])
if ret != 0 or files != '':
return (False, False)
ret, *_ = self._run(['cherry-pick', '--skip'])
return (ret == 0, ret == 0)
def get_conflicting_files(self):
"""Returns a list of conflicting files in the local git tree.
Returns:
A list of files with conflicts.
"""
ret, files, _ = self._run(['status', '-s'])
if ret != 0:
return []
conflicts = []
for f in files.splitlines():
if not f.startswith('UU'):
continue
conflicts.append(f.split(' ')[1])
return conflicts
def get_commit_message(self, commit='HEAD'):
"""Returns the commit message for the given commit.
Args:
commit: The hash of the commit to be shown
Returns:
(ret, message) ret will be True on success, message will have the
commit message.
"""
ret, message, _ = self._run(['show', '--quiet', '--format=%B', commit])
return (ret == 0, message)
def set_commit_message(self, message):
"""Amends the commit at HEAD with the given commit message.
Args:
message: Message to use for the commit at HEAD.
Returns:
True if successful, False otherwise.
"""
ret, *_ = self._run(['commit', '--amend', '-F', '-'], stdin=message)
return (ret == 0, message)
def generate_change_id(self, commit='HEAD'):
"""Generates the Change-Id value for the commit at HEAD
Args:
commit: The hash of the commit to generate the Change-Id for.
Returns:
(ret, change_id) ret will be True on success, change_id is the
Change-Id for the commit at HEAD.
"""
obj = ''
ret, stdout, _ = self._run(['write-tree'])
if ret == 0 and stdout:
obj += f'tree {stdout}'
ret, stdout, _ = self._run(['rev-parse', f'{commit}^0'])
if ret == 0 and stdout:
obj += f'parent {stdout}'
ret, stdout, _ = self._run(['var', 'GIT_AUTHOR_IDENT'])
if ret == 0 and stdout:
obj += f'author {stdout}'
ret, stdout, _ = self._run(['var', 'GIT_COMMITTER_IDENT'])
if ret == 0 and stdout:
obj += f'committer {stdout}'
ret, commit_msg = self.get_commit_message(commit)
if ret:
obj += f'\n{commit_msg}'
ret, stdout, _ = self._run(['hash-object', '-t', 'commit', '--stdin'],
stdin=obj)
return (ret == 0, f'I{stdout.strip()}')
def show(self, commit):
"""Returns the 'git show' output for the given commit.
Args:
commit: The commit to show.
Returns:
A string with the 'git show' output.
"""
_, output, _ = self._run(['show', commit])
return output
def commit_diff(self, commit, path):
"""Returns the changes to a given file in the given commit.
Args:
commit: The commit to return the diff for.
path: The file path to limit the diff with.
Returns:
The diff introduced in commit.
"""
ret, output, _ = self._run(['diff-tree', '-p', commit, '--', path])
if ret != 0:
return ''
# Split out the header goop in the first 5 lines
return '\n'.join(output.splitlines()[5:])
def blame(self, path, refspec=None):
"""Returns the 'git blame' output for the given path at refspec.
Args:
path: The path of the file being blamed.
refspec: The git refspec to inspect the file at.
Returns:
A string with the 'git blame' output.
"""
cmd = ['blame']
if refspec:
cmd += [refspec]
cmd += ['--', path]
_, output, _ = self._run(cmd)
return output