From 629e31827573d127d0773b04eef116c081694029 Mon Sep 17 00:00:00 2001 From: Petr Hosek Date: Mon, 4 Nov 2013 14:58:08 +0000 Subject: [PATCH] Add Blame support --- src/blame.c | 384 +++++++++++++++++++++++++++++++++++++++++++++ src/blame.h | 38 +++++ src/pygit2.c | 15 ++ src/repository.c | 67 ++++++++ src/repository.h | 2 + src/types.h | 24 +++ test/test_blame.py | 111 +++++++++++++ 7 files changed, 641 insertions(+) create mode 100644 src/blame.c create mode 100644 src/blame.h create mode 100644 test/test_blame.py diff --git a/src/blame.c b/src/blame.c new file mode 100644 index 0000000..d5545bf --- /dev/null +++ b/src/blame.c @@ -0,0 +1,384 @@ +/* + * Copyright 2010-2013 The pygit2 contributors + * + * This file is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License, version 2, + * as published by the Free Software Foundation. + * + * In addition to the permissions in the GNU General Public License, + * the authors give you unlimited permission to link the compiled + * version of this file into combinations with other programs, + * and to distribute those combinations without any restriction + * coming from the use of this file. (The General Public License + * restrictions do apply in other respects; for example, they cover + * modification of the file, and distribution when not linked into + * a combined executable.) + * + * This file 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; see the file COPYING. If not, write to + * the Free Software Foundation, 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301, USA. + */ + +#define PY_SSIZE_T_CLEAN +#include +#include +#include "error.h" +#include "types.h" +#include "utils.h" +#include "signature.h" +#include "blame.h" + +extern PyObject *GitError; + +extern PyTypeObject BlameType; +extern PyTypeObject BlameIterType; +extern PyTypeObject BlameHunkType; + +PyObject* +wrap_blame(git_blame *blame, Repository *repo) +{ + Blame *py_blame; + + py_blame = PyObject_New(Blame, &BlameType); + if (py_blame) { + Py_INCREF(repo); + py_blame->repo = repo; + py_blame->blame = blame; + } + + return (PyObject*) py_blame; +} + +#include +PyObject* +wrap_blame_hunk(const git_blame_hunk *hunk, Blame *blame) +{ + BlameHunk *py_hunk = NULL; + + if (!hunk) + Py_RETURN_NONE; + + py_hunk = PyObject_New(BlameHunk, &BlameHunkType); + if (py_hunk != NULL) { + py_hunk->lines_in_hunk = hunk->lines_in_hunk; + py_hunk->final_commit_id = git_oid_allocfmt(&hunk->final_commit_id); + py_hunk->final_start_line_number = hunk->final_start_line_number; + py_hunk->final_signature = hunk->final_signature != NULL ? + git_signature_dup(hunk->final_signature) : NULL; + py_hunk->orig_commit_id = git_oid_allocfmt(&hunk->orig_commit_id); + py_hunk->orig_path = hunk->orig_path != NULL ? + strdup(hunk->orig_path) : NULL; + py_hunk->orig_start_line_number = hunk->orig_start_line_number; + py_hunk->orig_signature = hunk->orig_signature != NULL ? + git_signature_dup(hunk->orig_signature) : NULL; + py_hunk->boundary = hunk->boundary; + } + + return (PyObject*) py_hunk; +} + +PyDoc_STRVAR(BlameHunk_final_committer__doc__, "Final committer."); + +PyObject * +BlameHunk_final_committer__get__(BlameHunk *self) +{ + if (!self->final_signature) + Py_RETURN_NONE; + + return build_signature((Object*) self, self->final_signature, "utf-8"); +} + +PyDoc_STRVAR(BlameHunk_orig_committer__doc__, "Origin committer."); + +PyObject * +BlameHunk_orig_committer__get__(BlameHunk *self) +{ + if (!self->orig_signature) + Py_RETURN_NONE; + + return build_signature((Object*) self, self->orig_signature, "utf-8"); +} + +static int +BlameHunk_init(BlameHunk *self, PyObject *args, PyObject *kwds) +{ + self->final_commit_id = NULL; + self->final_signature = NULL; + self->orig_commit_id = NULL; + self->orig_path = NULL; + self->orig_signature = NULL; + + return 0; +} + +static void +BlameHunk_dealloc(BlameHunk *self) +{ + free(self->final_commit_id); + if (self->final_signature) + git_signature_free(self->final_signature); + free(self->orig_commit_id); + if (self->orig_path) + free(self->orig_path); + if (self->orig_signature) + git_signature_free(self->orig_signature); + PyObject_Del(self); +} + +PyMemberDef BlameHunk_members[] = { + MEMBER(BlameHunk, lines_in_hunk, T_UINT, "Number of lines."), + MEMBER(BlameHunk, final_commit_id, T_STRING, "Last changed oid."), + MEMBER(BlameHunk, final_start_line_number, T_UINT, "final start line no."), + MEMBER(BlameHunk, orig_commit_id, T_STRING, "oid where hunk was found."), + MEMBER(BlameHunk, orig_path, T_STRING, "Origin path."), + MEMBER(BlameHunk, orig_start_line_number, T_UINT, "Origin start line no."), + MEMBER(BlameHunk, boundary, T_BOOL, "Tracked to a boundary commit."), + {NULL} +}; + +PyGetSetDef BlameHunk_getseters[] = { + GETTER(BlameHunk, final_committer), + GETTER(BlameHunk, orig_committer), + {NULL} +}; + +PyDoc_STRVAR(BlameHunk__doc__, "Blame Hunk object."); + +PyTypeObject BlameHunkType = { + PyVarObject_HEAD_INIT(NULL, 0) + "_pygit2.BlameHunk", /* tp_name */ + sizeof(BlameHunk), /* tp_basicsize */ + 0, /* tp_itemsize */ + (destructor)BlameHunk_dealloc, /* tp_dealloc */ + 0, /* tp_print */ + 0, /* tp_getattr */ + 0, /* tp_setattr */ + 0, /* tp_compare */ + 0, /* tp_repr */ + 0, /* tp_as_number */ + 0, /* tp_as_sequence */ + 0, /* tp_as_mapping */ + 0, /* tp_hash */ + 0, /* tp_call */ + 0, /* tp_str */ + 0, /* tp_getattro */ + 0, /* tp_setattro */ + 0, /* tp_as_buffer */ + Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, /* tp_flags */ + BlameHunk__doc__, /* tp_doc */ + 0, /* tp_traverse */ + 0, /* tp_clear */ + 0, /* tp_richcompare */ + 0, /* tp_weaklistoffset */ + 0, /* tp_iter */ + 0, /* tp_iternext */ + 0, /* tp_methods */ + BlameHunk_members, /* tp_members */ + BlameHunk_getseters, /* tp_getset */ + 0, /* tp_base */ + 0, /* tp_dict */ + 0, /* tp_descr_get */ + 0, /* tp_descr_set */ + 0, /* tp_dictoffset */ + (initproc)BlameHunk_init, /* tp_init */ + 0, /* tp_alloc */ + 0, /* tp_new */ +}; + + +PyObject * +BlameIter_iternext(BlameIter *self) +{ + if (self->i < self->n) + return wrap_blame_hunk(git_blame_get_hunk_byindex( + self->blame->blame, self->i++), self->blame); + + PyErr_SetNone(PyExc_StopIteration); + return NULL; +} + +static void +BlameIter_dealloc(BlameIter *self) +{ + Py_CLEAR(self->blame); + PyObject_Del(self); +} + + +PyDoc_STRVAR(BlameIter__doc__, "Blame iterator object."); + +PyTypeObject BlameIterType = { + PyVarObject_HEAD_INIT(NULL, 0) + "_pygit2.BlameIter", /* tp_name */ + sizeof(BlameIter), /* tp_basicsize */ + 0, /* tp_itemsize */ + (destructor)BlameIter_dealloc, /* tp_dealloc */ + 0, /* tp_print */ + 0, /* tp_getattr */ + 0, /* tp_setattr */ + 0, /* tp_compare */ + 0, /* tp_repr */ + 0, /* tp_as_number */ + 0, /* tp_as_sequence */ + 0, /* tp_as_mapping */ + 0, /* tp_hash */ + 0, /* tp_call */ + 0, /* tp_str */ + 0, /* tp_getattro */ + 0, /* tp_setattro */ + 0, /* tp_as_buffer */ + Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, /* tp_flags */ + BlameIter__doc__, /* tp_doc */ + 0, /* tp_traverse */ + 0, /* tp_clear */ + 0, /* tp_richcompare */ + 0, /* tp_weaklistoffset */ + PyObject_SelfIter, /* tp_iter */ + (iternextfunc) BlameIter_iternext, /* tp_iternext */ +}; + + +PyObject * +Blame_iter(Blame *self) +{ + BlameIter *iter; + + iter = PyObject_New(BlameIter, &BlameIterType); + if (iter != NULL) { + Py_INCREF(self); + iter->blame = self; + iter->i = 0; + iter->n = git_blame_get_hunk_count(self->blame); + } + return (PyObject*)iter; +} + +Py_ssize_t +Blame_len(Blame *self) +{ + assert(self->blame); + return (Py_ssize_t)git_blame_get_hunk_count(self->blame); +} + +PyObject * +Blame_getitem(Blame *self, PyObject *value) +{ + size_t i; + const git_blame_hunk *hunk; + + if (PyLong_Check(value) < 0) { + PyErr_SetObject(PyExc_IndexError, value); + return NULL; + } + + i = PyLong_AsUnsignedLong(value); + if (PyErr_Occurred()) { + PyErr_SetObject(PyExc_IndexError, value); + return NULL; + } + + hunk = git_blame_get_hunk_byindex(self->blame, i); + if (!hunk) { + PyErr_SetObject(PyExc_IndexError, value); + return NULL; + } + + return wrap_blame_hunk(hunk, self); +} + +PyDoc_STRVAR(Blame_for_line__doc__, + "for_line(line_no) -> hunk\n" + "\n" + "Returns the blame hunk data for the given \"line_no\" in blame.\n" + "\n" + "Arguments:\n" + "\n" + "line_no\n" + " Line number, countings starts with 1."); + +PyObject * +Blame_for_line(Blame *self, PyObject *args) +{ + size_t line_no; + const git_blame_hunk *hunk; + + if (!PyArg_ParseTuple(args, "I", &line_no)) + return NULL; + + hunk = git_blame_get_hunk_byline(self->blame, line_no); + if (!hunk) { + PyErr_SetObject(PyExc_IndexError, args); + return NULL; + } + + return wrap_blame_hunk(hunk, self); +} + +static void +Blame_dealloc(Blame *self) +{ + git_blame_free(self->blame); + Py_CLEAR(self->repo); + PyObject_Del(self); +} + +PyMappingMethods Blame_as_mapping = { + (lenfunc)Blame_len, /* mp_length */ + (binaryfunc)Blame_getitem, /* mp_subscript */ + 0, /* mp_ass_subscript */ +}; + +static PyMethodDef Blame_methods[] = { + METHOD(Blame, for_line, METH_VARARGS), + {NULL} +}; + + +PyDoc_STRVAR(Blame__doc__, "Blame objects."); + +PyTypeObject BlameType = { + PyVarObject_HEAD_INIT(NULL, 0) + "_pygit2.Blame", /* tp_name */ + sizeof(Blame), /* tp_basicsize */ + 0, /* tp_itemsize */ + (destructor)Blame_dealloc, /* tp_dealloc */ + 0, /* tp_print */ + 0, /* tp_getattr */ + 0, /* tp_setattr */ + 0, /* tp_compare */ + 0, /* tp_repr */ + 0, /* tp_as_number */ + 0, /* tp_as_sequence */ + &Blame_as_mapping, /* tp_as_mapping */ + 0, /* tp_hash */ + 0, /* tp_call */ + 0, /* tp_str */ + 0, /* tp_getattro */ + 0, /* tp_setattro */ + 0, /* tp_as_buffer */ + Py_TPFLAGS_DEFAULT | Py_TPFLAGS_BASETYPE, /* tp_flags */ + Blame__doc__, /* tp_doc */ + 0, /* tp_traverse */ + 0, /* tp_clear */ + 0, /* tp_richcompare */ + 0, /* tp_weaklistoffset */ + (getiterfunc)Blame_iter, /* tp_iter */ + 0, /* tp_iternext */ + Blame_methods, /* tp_methods */ + 0, /* tp_members */ + 0, /* tp_getset */ + 0, /* tp_base */ + 0, /* tp_dict */ + 0, /* tp_descr_get */ + 0, /* tp_descr_set */ + 0, /* tp_dictoffset */ + 0, /* tp_init */ + 0, /* tp_alloc */ + 0, /* tp_new */ +}; diff --git a/src/blame.h b/src/blame.h new file mode 100644 index 0000000..a6f1e53 --- /dev/null +++ b/src/blame.h @@ -0,0 +1,38 @@ +/* + * Copyright 2010-2013 The pygit2 contributors + * + * This file is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License, version 2, + * as published by the Free Software Foundation. + * + * In addition to the permissions in the GNU General Public License, + * the authors give you unlimited permission to link the compiled + * version of this file into combinations with other programs, + * and to distribute those combinations without any restriction + * coming from the use of this file. (The General Public License + * restrictions do apply in other respects; for example, they cover + * modification of the file, and distribution when not linked into + * a combined executable.) + * + * This file 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; see the file COPYING. If not, write to + * the Free Software Foundation, 51 Franklin Street, Fifth Floor, + * Boston, MA 02110-1301, USA. + */ + +#ifndef INCLUDE_pygit2_blame_h +#define INCLUDE_pygit2_blame_h + +#define PY_SSIZE_T_CLEAN +#include +#include +#include "types.h" + +PyObject* wrap_blame(git_blame *blame, Repository *repo); + +#endif diff --git a/src/pygit2.c b/src/pygit2.c index 20fd27a..cf24120 100644 --- a/src/pygit2.c +++ b/src/pygit2.c @@ -64,6 +64,9 @@ extern PyTypeObject SignatureType; extern PyTypeObject RemoteType; extern PyTypeObject NoteType; extern PyTypeObject NoteIterType; +extern PyTypeObject BlameType; +extern PyTypeObject BlameIterType; +extern PyTypeObject BlameHunkType; @@ -418,6 +421,18 @@ moduleinit(PyObject* m) INIT_TYPE(RemoteType, NULL, NULL) ADD_TYPE(m, Remote) + /* Blame */ + INIT_TYPE(BlameType, NULL, NULL) + INIT_TYPE(BlameIterType, NULL, NULL) + INIT_TYPE(BlameHunkType, NULL, NULL) + ADD_TYPE(m, Blame) + ADD_TYPE(m, BlameHunk) + ADD_CONSTANT_INT(m, GIT_BLAME_NORMAL) + ADD_CONSTANT_INT(m, GIT_BLAME_TRACK_COPIES_SAME_FILE) + ADD_CONSTANT_INT(m, GIT_BLAME_TRACK_COPIES_SAME_COMMIT_MOVES) + ADD_CONSTANT_INT(m, GIT_BLAME_TRACK_COPIES_SAME_COMMIT_COPIES) + ADD_CONSTANT_INT(m, GIT_BLAME_TRACK_COPIES_ANY_COMMIT_COPIES) + /* Global initialization of libgit2 */ git_threads_init(); diff --git a/src/repository.c b/src/repository.c index facc0ce..88f34a1 100644 --- a/src/repository.c +++ b/src/repository.c @@ -37,6 +37,7 @@ #include "repository.h" #include "remote.h" #include "branch.h" +#include "blame.h" #include extern PyObject *GitError; @@ -1443,6 +1444,71 @@ Repository_lookup_note(Repository *self, PyObject* args) return (PyObject*) wrap_note(self, &annotated_id, ref); } +PyDoc_STRVAR(Repository_blame__doc__, + "blame(path, [flags, min_match_characters, newest_commit, oldest_commit,\n" + " min_line, max_line]) -> blame\n" + "\n" + "Get the blame for a single file.\n" + "\n" + "Arguments:\n" + "\n" + "path\n" + " A path to file to consider.\n" + "flags\n" + " A GIT_BLAME_* constant.\n" + "min_match_characters\n" + " The number of alphanum chars that must be detected as moving/copying\n" + " within a file for it to associate those lines with the parent commit.\n" + "newest_commit\n" + " The id of the newest commit to consider.\n" + "oldest_commit\n" + " The id of the oldest commit to consider.\n" + "min_line\n" + " The first line in the file to blame.\n" + "max_line\n" + " The last line in the file to blame.\n" + "\n" + "Examples::\n" + "\n" + " repo.blame('foo.c', flags=GIT_BLAME_TRACK_COPIES_SAME_FILE)"); + +PyObject* Repository_blame(Repository *self, PyObject *args, PyObject *kwds) +{ + git_blame_options opts = GIT_BLAME_OPTIONS_INIT; + git_blame *blame; + char *path; + PyObject *value1 = NULL; + PyObject *value2 = NULL; + int err; + char *keywords[] = {"flags", "min_match_characters", "newest_commit", + "oldest_commit", "min_line", "max_line", NULL}; + + if (!PyArg_ParseTupleAndKeywords(args, kwds, "s|IHOOII", keywords, + &path, &opts.flags, + &opts.min_match_characters, + &value1, &value2, + &opts.min_line, &opts.max_line)) + return NULL; + + if (value1) { + err = py_oid_to_git_oid_expand(self->repo, value1, &opts.newest_commit); + if (err < 0) + return NULL; + } + if (value2) { + err = py_oid_to_git_oid_expand(self->repo, value2, &opts.oldest_commit); + if (err < 0) + return NULL; + } + + err = git_blame_file(&blame, self->repo, path, NULL); + if (err < 0) + return Error_set(err); + + return wrap_blame(blame, self); +} + + PyMethodDef Repository_methods[] = { METHOD(Repository, create_blob, METH_VARARGS), METHOD(Repository, create_blob_fromworkdir, METH_VARARGS), @@ -1472,6 +1538,7 @@ PyMethodDef Repository_methods[] = { METHOD(Repository, lookup_branch, METH_VARARGS), METHOD(Repository, listall_branches, METH_VARARGS), METHOD(Repository, create_branch, METH_VARARGS), + METHOD(Repository, blame, METH_VARARGS | METH_KEYWORDS), {NULL} }; diff --git a/src/repository.h b/src/repository.h index fd9c524..3c60948 100644 --- a/src/repository.h +++ b/src/repository.h @@ -67,4 +67,6 @@ PyObject* Repository_status(Repository *self, PyObject *args); PyObject* Repository_status_file(Repository *self, PyObject *value); PyObject* Repository_TreeBuilder(Repository *self, PyObject *args); +PyObject* Repository_blame(Repository *self, PyObject *args, PyObject *kwds); + #endif diff --git a/src/types.h b/src/types.h index 6c2136d..04782eb 100644 --- a/src/types.h +++ b/src/types.h @@ -194,4 +194,28 @@ typedef struct { SIMPLE_TYPE(Remote, git_remote, remote) +/* git_blame */ +SIMPLE_TYPE(Blame, git_blame, blame) + +typedef struct { + PyObject_HEAD + Blame* blame; + size_t i; + size_t n; +} BlameIter; + +typedef struct { + PyObject_HEAD + unsigned lines_in_hunk; + char* final_commit_id; + unsigned final_start_line_number; + git_signature* final_signature; + char* orig_commit_id; + char* orig_path; + unsigned orig_start_line_number; + git_signature* orig_signature; + char boundary; +} BlameHunk; + + #endif diff --git a/test/test_blame.py b/test/test_blame.py new file mode 100644 index 0000000..909e7c4 --- /dev/null +++ b/test/test_blame.py @@ -0,0 +1,111 @@ +# -*- coding: UTF-8 -*- +# +# Copyright 2010-2013 The pygit2 contributors +# +# This file is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License, version 2, +# as published by the Free Software Foundation. +# +# In addition to the permissions in the GNU General Public License, +# the authors give you unlimited permission to link the compiled +# version of this file into combinations with other programs, +# and to distribute those combinations without any restriction +# coming from the use of this file. (The General Public License +# restrictions do apply in other respects; for example, they cover +# modification of the file, and distribution when not linked into +# a combined executable.) +# +# This file 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; see the file COPYING. If not, write to +# the Free Software Foundation, 51 Franklin Street, Fifth Floor, +# Boston, MA 02110-1301, USA. + +"""Tests for Blame objects.""" + +from __future__ import absolute_import +from __future__ import unicode_literals +import unittest +import pygit2 +from pygit2 import Signature +from pygit2 import GIT_DIFF_INCLUDE_UNMODIFIED +from pygit2 import GIT_DIFF_IGNORE_WHITESPACE, GIT_DIFF_IGNORE_WHITESPACE_EOL +from . import utils +from itertools import chain +from datetime import datetime + +PATH = 'hello.txt' + +HUNKS = [ + ('acecd5ea2924a4b900e7e149496e1f4b57976e51', 1, + Signature('J. David Ibañez', 'jdavid@itaapy.com', + 1297179898, 60, encoding='utf-8'), True), + ('6aaa262e655dd54252e5813c8e5acd7780ed097d', 2, + Signature('J. David Ibañez', 'jdavid@itaapy.com', + 1297696877, 60, encoding='utf-8'), False), + ('4ec4389a8068641da2d6578db0419484972284c8', 3, + Signature('J. David Ibañez', 'jdavid@itaapy.com', + 1297696908, 60, encoding='utf-8'), False) +] + +class BlameTest(utils.RepoTestCase): + + def test_blame_index(self): + repo = self.repo + blame = repo.blame(PATH) + + self.assertEqual(len(blame), 3) + + for i, hunk in enumerate(blame): + self.assertEqual(hunk.lines_in_hunk, 1) + self.assertEqual(HUNKS[i][0], hunk.final_commit_id) + self.assertEqual(HUNKS[i][1], hunk.final_start_line_number) + self.assertEqualSignature(HUNKS[i][2], hunk.final_committer) + self.assertEqual(hunk.orig_commit_id, + '0000000000000000000000000000000000000000') + self.assertEqual(hunk.orig_path, PATH) + self.assertEqual(HUNKS[i][1], hunk.orig_start_line_number) + self.assertIsNone(hunk.orig_committer) + self.assertEqual(HUNKS[i][3], hunk.boundary) + + def test_blame_with_invalid_index(self): + repo = self.repo + blame = repo.blame(PATH) + + with self.assertRaises(IndexError): + blame[100000] + blame[-1] + + def test_blame_for_line(self): + repo = self.repo + blame = repo.blame(PATH) + + for i, line in zip(range(0, 2), range(1, 3)): + hunk = blame.for_line(line) + + self.assertEqual(hunk.lines_in_hunk, 1) + self.assertEqual(HUNKS[i][0], hunk.final_commit_id) + self.assertEqual(HUNKS[i][1], hunk.final_start_line_number) + self.assertEqualSignature(HUNKS[i][2], hunk.final_committer) + self.assertEqual(hunk.orig_commit_id, + '0000000000000000000000000000000000000000') + self.assertEqual(hunk.orig_path, PATH) + self.assertEqual(HUNKS[i][1], hunk.orig_start_line_number) + self.assertIsNone(hunk.orig_committer) + self.assertEqual(HUNKS[i][3], hunk.boundary) + + def test_blame_with_invalid_line(self): + repo = self.repo + blame = repo.blame(PATH) + + with self.assertRaises(IndexError): + blame.for_line(0) + blame.for_line(100000) + blame.for_line(-1) + +if __name__ == '__main__': + unittest.main()