diff options
-rwxr-xr-x | git-coverage | 384 |
1 files changed, 384 insertions, 0 deletions
diff --git a/git-coverage b/git-coverage new file mode 100755 index 0000000..ac837c9 --- /dev/null +++ b/git-coverage @@ -0,0 +1,384 @@ +#!/usr/bin/env python + +import fnmatch +import getopt +import os +import re +import subprocess +import sys + +COVERAGE_EXTENSIONS = [".c", ".cpp", ".cc"] + +# Portions of the code: +# +# Copyright (C) 2005-2010 Aaron Bentley, Canonical Ltd +# <aaron.bentley@utoronto.ca> +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA +# + +BINARY_FILES_RE = re.compile('Binary files (.*) and (.*) differ\n') + +class MalformedHunkHeader(Exception): + def __init__(self, desc, line): + self.desc = desc + self.line = line + + def __str__(self): + return "Malformed hunk header. %(desc)s\n%(line)r" % self.__dict__ + +class BadPatch(Exception): + def __init(self, message): + self.message = message + + def __str__(self): + return self.message + + +def get_patch_names(iter_lines): + try: + line = iter_lines.next() + if not line.startswith("--- "): + raise BadPatch("No orig name: %s" % line) + else: + orig_name = line[4:].rstrip("\n") + except StopIteration: + raise BadPatch("No orig line") + try: + line = iter_lines.next() + if not line.startswith("+++ "): + raise BadPatch("No mod name") + else: + mod_name = line[4:].rstrip("\n") + except StopIteration: + raise BadPatch("No mod line") + return (orig_name, mod_name) + + +def parse_range(textrange): + tmp = textrange.split(',') + if len(tmp) == 1: + pos = tmp[0] + range = "1" + else: + (pos, range) = tmp + pos = int(pos) + range = int(range) + return (pos, range) + + +def hunk_from_header(line): + matches = re.match(r'\@\@ ([^@]*) \@\@( (.*))?\n', line) + if matches is None: + raise MalformedHunkHeader("Does not match format.", line) + try: + (orig, mod) = matches.group(1).split(" ") + except (ValueError, IndexError), e: + raise MalformedHunkHeader(str(e), line) + if not orig.startswith('-') or not mod.startswith('+'): + raise MalformedHunkHeader("Positions don't start with + or -.", line) + try: + (orig_pos, orig_range) = parse_range(orig[1:]) + (mod_pos, mod_range) = parse_range(mod[1:]) + except (ValueError, IndexError), e: + raise MalformedHunkHeader(str(e), line) + if mod_range < 0 or orig_range < 0: + raise MalformedHunkHeader("Hunk range is negative", line) + tail = matches.group(3) + return Hunk(orig_pos, orig_range, mod_pos, mod_range, tail) + + +class HunkLine: + def __init__(self, contents): + self.contents = contents + + +class ContextLine(HunkLine): + def __init__(self, contents): + HunkLine.__init__(self, contents) + + +class InsertLine(HunkLine): + def __init__(self, contents): + HunkLine.__init__(self, contents) + + +class RemoveLine(HunkLine): + def __init__(self, contents): + HunkLine.__init__(self, contents) + + +def parse_line(line): + if line.startswith("\n"): + return ContextLine(line) + elif line.startswith(" "): + return ContextLine(line[1:]) + elif line.startswith("+"): + return InsertLine(line[1:]) + elif line.startswith("-"): + return RemoveLine(line[1:]) + else: + raise BadPatch("Unknown line type" % line) + + +class Hunk: + def __init__(self, orig_pos, orig_range, mod_pos, mod_range, tail=None): + self.orig_pos = orig_pos + self.orig_range = orig_range + self.mod_pos = mod_pos + self.mod_range = mod_range + self.tail = tail + self.lines = [] + + +def iter_hunks(iter_lines, allow_dirty=False): + hunk = None + for line in iter_lines: + if line == "\n": + if hunk is not None: + yield hunk + hunk = None + continue + if hunk is not None: + yield hunk + try: + hunk = hunk_from_header(line) + except MalformedHunkHeader: + if allow_dirty: + # If the line isn't a hunk header, then we've reached the end + # of this patch and there's "junk" at the end. Ignore the + # rest of this patch. + return + raise + orig_size = 0 + mod_size = 0 + while orig_size < hunk.orig_range or mod_size < hunk.mod_range: + hunk_line = parse_line(iter_lines.next()) + hunk.lines.append(hunk_line) + if isinstance(hunk_line, (RemoveLine, ContextLine)): + orig_size += 1 + if isinstance(hunk_line, (InsertLine, ContextLine)): + mod_size += 1 + if hunk is not None: + yield hunk + + +class Patch(object): + def __init__(self, oldname, newname): + self.oldname = oldname + self.newname = newname + self.hunks = [] + + +def parse_patch(iter_lines, allow_dirty=False): + (orig_name, mod_name) = get_patch_names(iter_lines) + patch = Patch(orig_name, mod_name) + for hunk in iter_hunks(iter_lines, allow_dirty): + patch.hunks.append(hunk) + return patch + + +def iter_file_patch(iter_lines, allow_dirty=False): + saved_lines = [] + orig_range = 0 + beginning = True + for line in iter_lines: + if BINARY_FILES_RE.match(line): + continue + if line.startswith('=== ') or line.startswith('*** '): + continue + if line.startswith('#'): + continue + elif orig_range > 0: + if line.startswith('-') or line.startswith(' '): + orig_range -= 1 + elif line.startswith('--- '): + if allow_dirty and beginning: + # Patches can have "junk" at the beginning + # Stripping junk from the end of patches is handled when we + # parse the patch + beginning = False + elif len(saved_lines) > 0: + yield saved_lines + saved_lines = [] + elif line.startswith('@@'): + hunk = hunk_from_header(line) + orig_range = hunk.orig_range + saved_lines.append(line) + if len(saved_lines) > 0: + yield saved_lines + + +# +# End of code from bzr +# --------------------------------------------------------------------------- + +def iter_process_lines(argv): + proc = subprocess.Popen(argv, stdout=subprocess.PIPE) + while True: + line = proc.stdout.readline() + if line != "": + yield line + else: + return + +def iter_desired_coverage(iter_lines): + for fil in iter_file_patch(iter_lines, allow_dirty=True): + patch = parse_patch(fil.__iter__(), allow_dirty=True) + filename = os.path.normpath(patch.newname.split("/", 1)[1]) + (root, ext) = os.path.splitext(filename) + if ext not in COVERAGE_EXTENSIONS: + continue + for hunk in patch.hunks: + offset = hunk.mod_pos + for line in hunk.lines: + if isinstance(line, RemoveLine): + yield (filename, offset, line.contents) + elif isinstance(line, InsertLine): + yield (filename, offset, line.contents) + offset += 1 + else: + offset += 1 + +def find_all_gcno_files(directory): + paths = [] + def visit(paths, dirname, names): + for name in names: + path = os.path.normpath(os.path.join(dirname, name)) + if os.path.isdir(path): + continue + if not fnmatch.fnmatch(name, "*.gcno"): + continue + (base, ext) = os.path.splitext(path) + if not os.path.exists(base + ".gcda"): + continue + paths.append(path) + os.path.walk(".", visit, paths) + return paths + + +def match_gcno_files(filename, gcno_cache): + matches = [] + (directory, base) = os.path.split(filename) + (base, ext) = os.path.splitext(base) + match = "%s/*%s.gcno" % (directory, base) + for gcno in gcno_cache: + if fnmatch.fnmatch(gcno, match): + matches.append(gcno) + return matches + +CREATING_RE = re.compile(".*'(.+\.gcov)'.*") + +def gcov_lines_for_files(filename, gcno_cache): + gcno = match_gcno_files(filename, gcno_cache) + if not gcno: + return + + # gcov wants to be in the directory with the source files + # so we make all the gcno paths absolute and change to that + # directory + + absgcno = [os.path.abspath(path) for path in gcno] + (directory, base) = os.path.split(filename) + + oldpwd = os.getcwd() + os.chdir(directory) + + # We scrape the output of the command for the names of the + # gcov files created, which we process, and then remove + gcovs = [] + + cmd = ['gcov', '--preserve-paths', '--relative-only'] + for line in iter_process_lines(cmd + absgcno): + match = CREATING_RE.match(line) + if not match: + continue + gcov = match.group(1) + if os.path.exists(gcov): + gcovs.append(os.path.abspath(gcov)) + + # Because we change the directory, we have to take care not + # to yield while the current directory is changed + + os.chdir(oldpwd) + + for gcov in gcovs: + with open(gcov, 'r') as f: + for l in f: + yield l + os.unlink(gcov) + + +def gcov_coverage_lines(gcov_iter): + coverage = { } + for line in gcov_iter: + + # Each gcov coverage output line looks something like this + # coverage: lineno: remainder is actual line content + parts = line.split(':', 2) + if len(parts) != 3: + continue + + covered = parts[0].strip() + try: + no = int(parts[1].strip()) + if covered == '-': + count = 0 + else: + count = int(covered) + coverage[no] = parts[2] + except ValueError: + pass + return coverage + + +def main(argv): + try: + opts, args = getopt.getopt(argv[1:], "h", ["help"]) + except getopt.GetoptError, err: + print str(err) + return 2 + for o, a in opts: + if o in ("-h", "--help"): + usage() + return 0 + else: + assert False, "unhandled option" + + if len(args) == 0: + cmd = ['git', 'diff', 'HEAD'] + else: + cmd = ['git', 'show'] + args + + # First process all the lines that we need + needed_coverage = { } + iterator = iter_process_lines(cmd) + for (filename, no, content) in iter_desired_coverage(iterator): + if filename not in needed_coverage: + needed_coverage[filename] = { } + needed_coverage[filename][no] = content + + gcno_cache = find_all_gcno_files(".") + + for (filename, needed) in needed_coverage.items(): + gcov = gcov_lines_for_files(filename, gcno_cache) + coverage = gcov_coverage_lines(gcov) + + for (no, content) in needed.items(): + if no not in coverage: + print filename, no, content, + +if __name__ == '__main__': + sys.exit(main(sys.argv)) |