blob: 8393255d3f1b6ce1d9fc0cbc0d4281a31e90bf85 [file] [log] [blame]
# Copyright 2017 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
import collections
import os
import os.path
import re
import subprocess
def _get_diff_base():
"""Returns the newest local commit that is also in the upstream branch, or
"HEAD" if no such commit can be found. If no upstream branch is set, assumes
that origin/master is the upstream.
"""
try:
with open(os.devnull, 'w') as devnull:
try:
upstream = subprocess.check_output([
"git", "rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"
], stderr = devnull).strip()
except subprocess.CalledProcessError:
upstream = "origin/master"
# Get local commits not in upstream.
local_commits = filter(
len,
subprocess.check_output(
["git", "rev-list", "HEAD", "^" + upstream, "--"]).split("\n"))
if not local_commits:
return "HEAD"
# Return parent of the oldest commit.
return subprocess.check_output(
["git", "rev-parse", local_commits[-1] + "^"],
stderr = devnull).strip()
except subprocess.CalledProcessError:
return "HEAD"
def get_git_root():
"""Returns the path of the root of the git repository."""
return subprocess.check_output(["git", "rev-parse",
"--show-toplevel"]).strip()
def get_diff_files():
"""Returns absolute paths to files that are locally modified, staged or
touched by any commits introduced on the local branch.
"""
list_command = [
"git", "diff-index", "--name-only",
_get_diff_base()
]
git_root_path = get_git_root()
paths = filter(len, subprocess.check_output(list_command).split("\n"))
return [ os.path.join(git_root_path, x) for x in paths ]
def get_modified_lines(paths):
"""Returns ranges of lines modified in the specified files.
Args:
paths: A sequence of filenames to check for modified lines. Typically
a subset of the paths returned by get_diff_files().
Returns:
A dict mapping elements of `paths` to sequences of 1-indexed
(start-line-number, num-modified-lines) tuples.
"""
# For a line like |+++ b/path/to/file.py|, extracts the filename
# "path/to/file.py" in group 1, intentionally skipping the 'b/' component.
filename_line_re = re.compile(r'^\+\+\+ [^/]+/(.*)')
# For lines like
# @@ -9,1 +10,2 @@ Trailing text
# or
# @@ -11,0 +12 @@ Trailing text
# extracts the second field ('10,2' or '12' in the examples) in group 1.
line_number_re = re.compile(r'^@@ -[0-9,]+ \+([0-9,]+) @@')
git_root_path = get_git_root()
# Diff output may be large, so stream the output rather than using
# communicate().
diff_command = ["git", "diff-index", "-U0", _get_diff_base()] + paths
p = subprocess.Popen(diff_command, stdout=subprocess.PIPE)
# Set up return dict. All input paths should have entries even if there
# are no diffs.
paths_to_ranges = collections.OrderedDict()
for path in paths:
paths_to_ranges[path] = []
ranges = None # Pointer to the current file's entry in `paths_to_ranges`
with p.stdout: # 'with' will close stdout on any exception
for line in p.stdout:
m = filename_line_re.match(line)
if m:
# Use absolute paths as keys.
fname = os.path.join(git_root_path, m.group(1))
ranges = paths_to_ranges[fname]
continue
m = line_number_re.match(line)
if m:
fields = m.group(1).split(',')
if len(fields) == 1:
# If there's only one number,
# the range contains a single line.
fields.append('1')
start = int(fields[0])
count = int(fields[1])
if count > 0: # If zero, the range was deleted.
ranges.append((start, count))
if p.wait():
# Non-zero exit status.
raise subprocess.CalledProcessError(p.returncode, diff_command)
return paths_to_ranges
def get_all_files():
"""Returns absolute paths to all files in the git repo under the current
working directory.
"""
list_command = ["git", "ls-files"]
paths = filter(len, subprocess.check_output(list_command).split("\n"))
return [ os.path.abspath(x) for x in paths ]