diff options
Diffstat (limited to 'lib/git')
-rw-r--r-- | lib/git/__init__.py | 24 | ||||
-rw-r--r-- | lib/git/actor.py | 33 | ||||
-rw-r--r-- | lib/git/blob.py | 134 | ||||
-rw-r--r-- | lib/git/commit.py | 235 | ||||
-rw-r--r-- | lib/git/diff.py | 79 | ||||
-rw-r--r-- | lib/git/errors.py | 8 | ||||
-rw-r--r-- | lib/git/gitter.py | 190 | ||||
-rw-r--r-- | lib/git/head.py | 107 | ||||
-rw-r--r-- | lib/git/lazy.py | 26 | ||||
-rw-r--r-- | lib/git/method_missing.py | 21 | ||||
-rw-r--r-- | lib/git/repo.py | 435 | ||||
-rw-r--r-- | lib/git/stats.py | 17 | ||||
-rw-r--r-- | lib/git/tag.py | 85 | ||||
-rw-r--r-- | lib/git/tree.py | 89 | ||||
-rw-r--r-- | lib/git/utils.py | 5 |
15 files changed, 1488 insertions, 0 deletions
diff --git a/lib/git/__init__.py b/lib/git/__init__.py new file mode 100644 index 00000000..66ce9a45 --- /dev/null +++ b/lib/git/__init__.py @@ -0,0 +1,24 @@ +import os +import inspect + +# grab the version information +v = open(os.path.join(os.path.dirname(__file__), '..', '..', 'VERSION')) +__version__ = v.readline().strip() +v.close() + +from git.actor import Actor +from git.blob import Blob +from git.commit import Commit +from git.diff import Diff +from git.errors import InvalidGitRepositoryError, NoSuchPathError, GitCommandError +from git.gitter import Git +from git.head import Head +from git.repo import Repo +from git.stats import Stats +from git.tag import Tag +from git.tree import Tree +from git.utils import dashify +from git.utils import touch + +__all__ = [ name for name, obj in locals().items() + if not (name.startswith('_') or inspect.ismodule(obj)) ] diff --git a/lib/git/actor.py b/lib/git/actor.py new file mode 100644 index 00000000..ed21d8ba --- /dev/null +++ b/lib/git/actor.py @@ -0,0 +1,33 @@ +import re + +class Actor(object): + def __init__(self, name, email): + self.name = name + self.email = email + + def __str__(self): + return self.name + + def __repr__(self): + return '<GitPython.Actor "%s <%s>">' % (self.name, self.email) + + @classmethod + def from_string(cls, string): + """ + Create an Actor from a string. + + ``str`` + is the string, which is expected to be in regular git format + + Format + John Doe <jdoe@example.com> + + Returns + Actor + """ + if re.search(r'<.+>', string): + m = re.search(r'(.*) <(.+?)>', string) + name, email = m.groups() + return Actor(name, email) + else: + return Actor(string, None) diff --git a/lib/git/blob.py b/lib/git/blob.py new file mode 100644 index 00000000..134cb93d --- /dev/null +++ b/lib/git/blob.py @@ -0,0 +1,134 @@ +import mimetypes +import os +import re +import time +from actor import Actor +from commit import Commit + +class Blob(object): + DEFAULT_MIME_TYPE = "text/plain" + + def __init__(self, repo, **kwargs): + """ + Create an unbaked Blob containing just the specified attributes + + ``repo`` + is the Repo + + ``atts`` + is a dict of instance variable data + + Returns + GitPython.Blob + """ + self.id = None + self.mode = None + self.name = None + self._size = None + self.data_stored = None + + self.repo = repo + for k, v in kwargs.items(): + setattr(self, k, v) + + @property + def size(self): + """ + The size of this blob in bytes + + Returns + int + """ + if self._size is None: + self._size = int(self.repo.git.cat_file(self.id, **{'s': True}).rstrip()) + return self._size + + @property + def data(self): + """ + The binary contents of this blob. + + Returns + str + """ + self.data_stored = self.data_stored or self.repo.git.cat_file(self.id, **{'p': True}) + return self.data_stored + + @property + def mime_type(self): + """ + The mime type of this file (based on the filename) + + Returns + str + """ + guesses = None + if self.name: + guesses = mimetypes.guess_type(self.name) + return guesses and guesses[0] or self.DEFAULT_MIME_TYPE + + @property + def basename(self): + return os.path.basename(self.name) + + @classmethod + def blame(cls, repo, commit, file): + """ + The blame information for the given file at the given commit + + Returns + list: [GitPython.Commit, list: [<line>]] + """ + data = repo.git.blame(commit, '--', file, **{'p': True}) + commits = {} + blames = [] + info = None + + for line in data.splitlines(): + parts = re.split(r'\s+', line, 1) + if re.search(r'^[0-9A-Fa-f]{40}$', parts[0]): + if re.search(r'^([0-9A-Fa-f]{40}) (\d+) (\d+) (\d+)$', line): + m = re.search(r'^([0-9A-Fa-f]{40}) (\d+) (\d+) (\d+)$', line) + id, origin_line, final_line, group_lines = m.groups() + info = {'id': id} + blames.append([None, []]) + elif re.search(r'^([0-9A-Fa-f]{40}) (\d+) (\d+)$', line): + m = re.search(r'^([0-9A-Fa-f]{40}) (\d+) (\d+)$', line) + id, origin_line, final_line = m.groups() + info = {'id': id} + elif re.search(r'^(author|committer)', parts[0]): + if re.search(r'^(.+)-mail$', parts[0]): + m = re.search(r'^(.+)-mail$', parts[0]) + info["%s_email" % m.groups()[0]] = parts[-1] + elif re.search(r'^(.+)-time$', parts[0]): + m = re.search(r'^(.+)-time$', parts[0]) + info["%s_date" % m.groups()[0]] = time.gmtime(int(parts[-1])) + elif re.search(r'^(author|committer)$', parts[0]): + m = re.search(r'^(author|committer)$', parts[0]) + info[m.groups()[0]] = parts[-1] + elif re.search(r'^filename', parts[0]): + info['filename'] = parts[-1] + elif re.search(r'^summary', parts[0]): + info['summary'] = parts[-1] + elif parts[0] == '': + if info: + c = commits.has_key(info['id']) and commits[info['id']] + if not c: + c = Commit(repo, **{'id': info['id'], + 'author': Actor.from_string(info['author'] + ' ' + info['author_email']), + 'authored_date': info['author_date'], + 'committer': Actor.from_string(info['committer'] + ' ' + info['committer_email']), + 'committed_date': info['committer_date'], + 'message': info['summary']}) + commits[info['id']] = c + + m = re.search(r'^\t(.*)$', line) + text, = m.groups() + blames[-1][0] = c + blames[-1][1] += text + info = None + + return blames + + def __repr__(self): + return '<GitPython.Blob "%s">' % self.id diff --git a/lib/git/commit.py b/lib/git/commit.py new file mode 100644 index 00000000..701f6c04 --- /dev/null +++ b/lib/git/commit.py @@ -0,0 +1,235 @@ +import re +import time + +from actor import Actor +from lazy import LazyMixin +import tree +import diff +import stats + +class Commit(LazyMixin): + def __init__(self, repo, **kwargs): + """ + Instantiate a new Commit + + ``id`` + is the id of the commit + + ``parents`` + is a list of commit ids (will be converted into Commit instances) + + ``tree`` + is the correspdonding tree id (will be converted into a Tree object) + + ``author`` + is the author string + + ``authored_date`` + is the authored DateTime + + ``committer`` + is the committer string + + ``committed_date`` + is the committed DateTime + + ``message`` + is the first line of the commit message + + Returns + GitPython.Commit + """ + LazyMixin.__init__(self) + + self.repo = repo + self.id = None + self.tree = None + self.author = None + self.authored_date = None + self.committer = None + self.committed_date = None + self.message = None + self.parents = None + + for k, v in kwargs.items(): + setattr(self, k, v) + + if self.id: + if 'parents' in kwargs: + self.parents = map(lambda p: Commit(repo, **{'id': p}), kwargs['parents']) + if 'tree' in kwargs: + self.tree = tree.Tree(repo, **{'id': kwargs['tree']}) + + def __bake__(self): + temp = Commit.find_all(self.repo, self.id, **{'max_count': 1})[0] + self.parents = temp.parents + self.tree = temp.tree + self.author = temp.author + self.authored_date = temp.authored_date + self.committer = temp.committer + self.committed_date = temp.committed_date + self.message = temp.message + + @property + def id_abbrev(self): + return self.id[0:7] + + @classmethod + def count(cls, repo, ref): + """ + Count the number of commits reachable from this ref + + ``repo`` + is the Repo + + ``ref`` + is the ref from which to begin (SHA1 or name) + + Returns + int + """ + return len(repo.git.rev_list(ref).strip().splitlines()) + + @classmethod + def find_all(cls, repo, ref, **kwargs): + """ + Find all commits matching the given criteria. + ``repo`` + is the Repo + + ``ref`` + is the ref from which to begin (SHA1 or name) + + ``options`` + is a Hash of optional arguments to git where + ``max_count`` is the maximum number of commits to fetch + ``skip`` is the number of commits to skip + + Returns + GitPython.Commit[] + """ + options = {'pretty': 'raw'} + options.update(kwargs) + + output = repo.git.rev_list(ref, **options) + return cls.list_from_string(repo, output) + + @classmethod + def list_from_string(cls, repo, text): + """ + Parse out commit information into a list of Commit objects + + ``repo`` + is the Repo + + ``text`` + is the text output from the git command (raw format) + + Returns + GitPython.Commit[] + """ + lines = [l for l in text.splitlines() if l.strip()] + + commits = [] + + while lines: + id = lines.pop(0).split()[-1] + tree = lines.pop(0).split()[-1] + + parents = [] + while lines and re.search(r'^parent', lines[0]): + parents.append(lines.pop(0).split()[-1]) + author, authored_date = cls.actor(lines.pop(0)) + committer, committed_date = cls.actor(lines.pop(0)) + + messages = [] + while lines and re.search(r'^ {4}', lines[0]): + messages.append(lines.pop(0).strip()) + + message = messages and messages[0] or '' + + commits.append(Commit(repo, id=id, parents=parents, tree=tree, author=author, authored_date=authored_date, + committer=committer, committed_date=committed_date, message=message)) + + return commits + + @classmethod + def diff(cls, repo, a, b = None, paths = []): + """ + Show diffs between two trees: + + ``repo`` + is the Repo + + ``a`` + is a named commit + + ``b`` + is an optional named commit. Passing a list assumes you + wish to omit the second named commit and limit the diff to the + given paths. + + ``paths`` + is a list of paths to limit the diff. + + Returns + GitPython.Diff[] + """ + if isinstance(b, list): + paths = b + b = None + + if paths: + paths.insert(0, "--") + + if b: + paths.insert(0, b) + paths.insert(0, a) + text = repo.git.diff(*paths, **{'full_index': True}) + return diff.Diff.list_from_string(repo, text) + + @property + def diffs(self): + if not self.parents: + d = self.repo.git.show(self.id, **{'full_index': True, 'pretty': 'raw'}) + if re.search(r'diff --git a', d): + if not re.search(r'^diff --git a', d): + p = re.compile(r'.+?(diff --git a)', re.MULTILINE | re.DOTALL) + d = p.sub(r'diff --git a', d, 1) + else: + d = '' + return diff.Diff.list_from_string(self.repo, d) + else: + return self.diff(self.repo, self.parents[0].id, self.id) + + @property + def stats(self): + if not self.parents: + text = self.repo.git.diff(self.id, **{'numstat': True}) + text2 = "" + for line in text.splitlines(): + (insertions, deletions, filename) = line.split("\t") + text2 += "%s\t%s\t%s\n" % (deletions, insertions, filename) + text = text2 + else: + text = self.repo.git.diff(self.parents[0].id, self.id, **{'numstat': True}) + return stats.Stats.list_from_string(self.repo, text) + + def __str__(self): + """ Convert commit to string which is SHA1 """ + return self.id + + def __repr__(self): + return '<GitPython.Commit "%s">' % self.id + + @classmethod + def actor(cls, line): + """ + Parse out the actor (author or committer) info + + Returns + [str (actor name and email), time (acted at time)] + """ + m = re.search(r'^.+? (.*) (\d+) .*$', line) + actor, epoch = m.groups() + return [Actor.from_string(actor), time.gmtime(int(epoch))] diff --git a/lib/git/diff.py b/lib/git/diff.py new file mode 100644 index 00000000..075b0f87 --- /dev/null +++ b/lib/git/diff.py @@ -0,0 +1,79 @@ +import re +import commit + +class Diff(object): + """ + A Diff contains diff information between two commits. + """ + + def __init__(self, repo, a_path, b_path, a_commit, b_commit, a_mode, b_mode, new_file, deleted_file, diff): + self.repo = repo + self.a_path = a_path + self.b_path = b_path + + if not a_commit or re.search(r'^0{40}$', a_commit): + self.a_commit = None + else: + self.a_commit = commit.Commit(repo, **{'id': a_commit}) + if not b_commit or re.search(r'^0{40}$', b_commit): + self.b_commit = None + else: + self.b_commit = commit.Commit(repo, **{'id': b_commit}) + + self.a_mode = a_mode + self.b_mode = b_mode + self.new_file = new_file + self.deleted_file = deleted_file + self.diff = diff + + @classmethod + def list_from_string(cls, repo, text): + lines = text.splitlines() + a_mode = None + b_mode = None + diffs = [] + while lines: + m = re.search(r'^diff --git a/(\S+) b/(\S+)$', lines.pop(0)) + if m: + a_path, b_path = m.groups() + if re.search(r'^old mode', lines[0]): + m = re.search(r'^old mode (\d+)', lines.pop(0)) + if m: + a_mode, = m.groups() + m = re.search(r'^new mode (\d+)', lines.pop(0)) + if m: + b_mode, = m.groups() + if re.search(r'^diff --git', lines[0]): + diffs.append(Diff(repo, a_path, b_path, None, None, a_mode, b_mode, False, False, None)) + continue + + new_file = False + deleted_file = False + + if re.search(r'^new file', lines[0]): + m = re.search(r'^new file mode (.+)', lines.pop(0)) + if m: + b_mode, = m.groups() + a_mode = None + new_file = True + elif re.search(r'^deleted file', lines[0]): + m = re.search(r'^deleted file mode (.+)$', lines.pop(0)) + if m: + a_mode, = m.groups() + b_mode = None + deleted_file = True + + m = re.search(r'^index ([0-9A-Fa-f]+)\.\.([0-9A-Fa-f]+) ?(.+)?$', lines.pop(0)) + if m: + a_commit, b_commit, b_mode = m.groups() + if b_mode: + b_mode = b_mode.strip() + + diff_lines = [] + while lines and not re.search(r'^diff', lines[0]): + diff_lines.append(lines.pop(0)) + + diff = "\n".join(diff_lines) + diffs.append(Diff(repo, a_path, b_path, a_commit, b_commit, a_mode, b_mode, new_file, deleted_file, diff)) + + return diffs diff --git a/lib/git/errors.py b/lib/git/errors.py new file mode 100644 index 00000000..f3fae26b --- /dev/null +++ b/lib/git/errors.py @@ -0,0 +1,8 @@ +class InvalidGitRepositoryError(Exception): + pass + +class NoSuchPathError(Exception): + pass + +class GitCommandError(Exception): + pass diff --git a/lib/git/gitter.py b/lib/git/gitter.py new file mode 100644 index 00000000..422552a7 --- /dev/null +++ b/lib/git/gitter.py @@ -0,0 +1,190 @@ +import os +import subprocess +import re +from utils import * +from method_missing import MethodMissingMixin +from errors import GitCommandError + +# Enables debugging of GitPython's git commands +GIT_PYTHON_TRACE = os.environ.get("GIT_PYTHON_TRACE", False) + +class Git(MethodMissingMixin): + """ + The Git class manages communication with the Git binary + """ + def __init__(self, git_dir=None): + super(Git, self).__init__() + if git_dir: + self.find_git_dir(git_dir) + else: + self.find_git_dir(os.getcwd()) + + def find_git_dir(self, path): + """Find the best value for self.git_dir. + For bare repositories, this is the path to the bare repository. + For repositories with work trees, this is the work tree path. + + When barerepo.git is passed in, self.git_dir = barerepo.git + When worktree/.git is passed in, self.git_dir = worktree + When worktree is passed in, self.git_dir = worktree + """ + + path = os.path.abspath(path) + self.git_dir = path + + cdup = self.execute(["git", "rev-parse", "--show-cdup"]) + if cdup: + path = os.path.abspath(os.path.join(self.git_dir, cdup)) + else: + is_bare_repository =\ + self.rev_parse(is_bare_repository=True) == "true" + is_inside_git_dir =\ + self.rev_parse(is_inside_git_dir=True) == "true" + + if not is_bare_repository and is_inside_git_dir: + path = os.path.dirname(self.git_dir) + + self.git_dir = path + + @property + def get_dir(self): + return self.git_dir + + def execute(self, command, + istream = None, + with_status = False, + with_stderr = False, + with_exceptions = False, + with_raw_output = False, + ): + """ + Handles executing the command on the shell and consumes and returns + the returned information (stdout) + + ``command`` + The command argument list to execute + + ``istream`` + Standard input filehandle passed to subprocess.Popen. + + ``with_status`` + Whether to return a (status, str) tuple. + + ``with_stderr`` + Whether to combine stderr into the output. + + ``with_exceptions`` + Whether to raise an exception when git returns a non-zero status. + + ``with_raw_output`` + Whether to avoid stripping off trailing whitespace. + + Returns + str(output) # with_status = False (Default) + tuple(int(status), str(output)) # with_status = True + """ + + if GIT_PYTHON_TRACE: + print command + + # Allow stderr to be merged into stdout when with_stderr is True. + # Otherwise, throw stderr away. + if with_stderr: + stderr = subprocess.STDOUT + else: + stderr = subprocess.PIPE + + # Start the process + proc = subprocess.Popen(command, + cwd = self.git_dir, + stdin = istream, + stderr = stderr, + stdout = subprocess.PIPE + ) + + # Wait for the process to return + stdout_value, err = proc.communicate() + proc.stdout.close() + if proc.stderr: + proc.stderr.close() + + # Strip off trailing whitespace by default + if not with_raw_output: + stdout_value = stdout_value.rstrip() + + # Grab the exit status + status = proc.poll() + if with_exceptions and status != 0: + raise GitCommandError("%s returned exit status %d" + % (str(command), status)) + + # Allow access to the command's status code + if with_status: + return (status, stdout_value) + else: + return stdout_value + + def transform_kwargs(self, **kwargs): + """ + Transforms Python style kwargs into git command line options. + """ + args = [] + for k, v in kwargs.items(): + if len(k) == 1: + if v is True: + args.append("-%s" % k) + else: + args.append("-%s%s" % (k, v)) + else: + if v is True: + args.append("--%s" % dashify(k)) + else: + args.append("--%s=%s" % (dashify(k), v)) + return args + + def method_missing(self, method, *args, **kwargs): + """ + Run the given git command with the specified arguments and return + the result as a String + + ``method`` + is the command + + ``args`` + is the list of arguments + + ``kwargs`` + is a dict of keyword arguments. + This function accepts the same optional keyword arguments + as execute(). + + Examples + git.rev_list('master', max_count=10, header=True) + + Returns + Same as execute() + """ + + # Handle optional arguments prior to calling transform_kwargs + # otherwise these'll end up in args, which is bad. + istream = kwargs.pop("istream", None) + with_status = kwargs.pop("with_status", None) + with_stderr = kwargs.pop("with_stderr", None) + with_exceptions = kwargs.pop("with_exceptions", None) + with_raw_output = kwargs.pop("with_raw_output", None) + + # Prepare the argument list + opt_args = self.transform_kwargs(**kwargs) + ext_args = map(str, args) + args = opt_args + ext_args + + call = ["git", dashify(method)] + call.extend(args) + + return self.execute(call, + istream = istream, + with_status = with_status, + with_stderr = with_stderr, + with_exceptions = with_exceptions, + with_raw_output = with_raw_output, + ) diff --git a/lib/git/head.py b/lib/git/head.py new file mode 100644 index 00000000..58191fd8 --- /dev/null +++ b/lib/git/head.py @@ -0,0 +1,107 @@ +import commit + +class Head(object): + """ + A Head is a named reference to a Commit. Every Head instance contains a name + and a Commit object. + + Examples:: + + >>> repo = Repo("/path/to/repo") + >>> head = repo.heads[0] + + >>> head.name + 'master' + + >>> head.commit + <GitPython.Commit "1c09f116cbc2cb4100fb6935bb162daa4723f455"> + + >>> head.commit.id + '1c09f116cbc2cb4100fb6935bb162daa4723f455' + """ + + def __init__(self, name, commit): + """ + Instantiate a new Head + + `name` + is the name of the head + + `commit` + is the Commit that the head points to + + Returns + GitPython.Head + """ + self.name = name + self.commit = commit + + @classmethod + def find_all(cls, repo, **kwargs): + """ + Find all Heads + + `repo` + is the Repo + + `kwargs` + is a dict of options + + Returns + GitPython.Head[] + """ + + options = {'sort': "committerdate", + 'format': "%(refname)%00%(objectname)"} + options.update(kwargs) + + output = repo.git.for_each_ref("refs/heads", **options) + return cls.list_from_string(repo, output) + + @classmethod + def list_from_string(cls, repo, text): + """ + Parse out head information into an array of baked head objects + + ``repo`` + is the Repo + ``text`` + is the text output from the git command + + Returns + GitPython.Head[] + """ + heads = [] + + for line in text.splitlines(): + heads.append(cls.from_string(repo, line)) + + return heads + + @classmethod + def from_string(cls, repo, line): + """ + Create a new Head instance from the given string. + + ``repo`` + is the Repo + + ``line`` + is the formatted head information + + Format + name: [a-zA-Z_/]+ + <null byte> + id: [0-9A-Fa-f]{40} + + Returns + GitPython.Head + """ + print line + full_name, ids = line.split("\x00") + name = full_name.split("/")[-1] + c = commit.Commit(repo, **{'id': ids}) + return Head(name, c) + + def __repr__(self): + return '<GitPython.Head "%s">' % self.name diff --git a/lib/git/lazy.py b/lib/git/lazy.py new file mode 100644 index 00000000..66e56c2b --- /dev/null +++ b/lib/git/lazy.py @@ -0,0 +1,26 @@ +class LazyMixin(object): + lazy_properties = [] + + def __init__(self): + self.__baked__ = False + + def __getattribute__(self, attr): + val = object.__getattribute__(self, attr) + if val is not None: + return val + else: + self.__prebake__() + return object.__getattribute__(self, attr) + + def __bake__(self): + """ This method should be overridden in the derived class. """ + raise NotImplementedError(" '__bake__' method has not been implemented.") + + def __prebake__(self): + if self.__baked__: + return + self.__bake__() + self.__baked__ = True + + def __bake_it__(self): + self.__baked__ = True diff --git a/lib/git/method_missing.py b/lib/git/method_missing.py new file mode 100644 index 00000000..478ee1d3 --- /dev/null +++ b/lib/git/method_missing.py @@ -0,0 +1,21 @@ +class MethodMissingMixin(object): + """ + A Mixin' to implement the 'method_missing' Ruby-like protocol. + + This was `taken from a blog post <http://blog.iffy.us/?p=43>`_ + """ + def __getattribute__(self, attr): + try: + return object.__getattribute__(self, attr) + except: + class MethodMissing(object): + def __init__(self, wrapped, method): + self.__wrapped__ = wrapped + self.__method__ = method + def __call__(self, *args, **kwargs): + return self.__wrapped__.method_missing(self.__method__, *args, **kwargs) + return MethodMissing(self, attr) + + def method_missing(self, *args, **kwargs): + """ This method should be overridden in the derived class. """ + raise NotImplementedError(str(self.__wrapped__) + " 'method_missing' method has not been implemented.") diff --git a/lib/git/repo.py b/lib/git/repo.py new file mode 100644 index 00000000..c130650e --- /dev/null +++ b/lib/git/repo.py @@ -0,0 +1,435 @@ +import os +import re +from errors import InvalidGitRepositoryError, NoSuchPathError +from utils import touch +from gitter import Git +from head import Head +from blob import Blob +from tag import Tag +from commit import Commit +from tree import Tree + +class Repo(object): + DAEMON_EXPORT_FILE = 'git-daemon-export-ok' + + def __init__(self, path): + """ + Create a new Repo instance + + ``path`` + is the path to either the root git directory or the bare git repo + + Examples:: + + repo = Repo("/Users/mtrier/Development/git-python") + repo = Repo("/Users/mtrier/Development/git-python.git") + + Returns + ``GitPython.Repo`` + """ + epath = os.path.abspath(path) + + if os.path.exists(os.path.join(epath, '.git')): + self.path = os.path.join(epath, '.git') + self.bare = False + elif os.path.exists(epath) and re.search('\.git$', epath): + self.path = epath + self.bare = True + elif os.path.exists(epath): + raise InvalidGitRepositoryError(epath) + else: + raise NoSuchPathError(epath) + self.git = Git(self.path) + + @property + def description(self): + """ + The project's description. Taken verbatim from GIT_REPO/description + + Returns + str + """ + try: + f = open(os.path.join(self.path, 'description')) + result = f.read() + return result.rstrip() + finally: + f.close() + + @property + def heads(self): + """ + A list of ``Head`` objects representing the branch heads in + this repo + + Returns + ``GitPython.Head[]`` + """ + return Head.find_all(self) + + # alias heads + branches = heads + + @property + def tags(self): + """ + A list of ``Tag`` objects that are available in this repo + + Returns + ``GitPython.Tag[]`` + """ + return Tag.find_all(self) + + def commits(self, start = 'master', max_count = 10, skip = 0): + """ + A list of Commit objects representing the history of a given ref/commit + + ``start`` + is the branch/commit name (default 'master') + + ``max_count`` + is the maximum number of commits to return (default 10) + + ``skip`` + is the number of commits to skip (default 0) + + Returns + ``GitPython.Commit[]`` + """ + options = {'max_count': max_count, + 'skip': skip} + + return Commit.find_all(self, start, **options) + + def commits_between(self, frm, to): + """ + The Commits objects that are reachable via ``to`` but not via ``frm`` + Commits are returned in chronological order. + + ``from`` + is the branch/commit name of the younger item + + ``to`` + is the branch/commit name of the older item + + Returns + ``GitPython.Commit[]`` + """ + return Commit.find_all(self, "%s..%s" % (frm, to)).reverse() + + def commits_since(self, start = 'master', since = '1970-01-01'): + """ + The Commits objects that are newer than the specified date. + Commits are returned in chronological order. + + ``start`` + is the branch/commit name (default 'master') + + ``since`` + is a string represeting a date/time + + Returns + ``GitPython.Commit[]`` + """ + options = {'since': since} + + return Commit.find_all(self, start, **options) + + def commit_count(self, start = 'master'): + """ + The number of commits reachable by the given branch/commit + + ``start`` + is the branch/commit name (default 'master') + + Returns + int + """ + return Commit.count(self, start) + + def commit(self, id): + """ + The Commit object for the specified id + + ``id`` + is the SHA1 identifier of the commit + + Returns + GitPython.Commit + """ + options = {'max_count': 1} + + commits = Commit.find_all(self, id, **options) + + if not commits: + raise ValueError, 'Invalid identifier %s' % id + return commits[0] + + def commit_deltas_from(self, other_repo, ref = 'master', other_ref = 'master'): + """ + Returns a list of commits that is in ``other_repo`` but not in self + + Returns + ``GitPython.Commit[]`` + """ + repo_refs = self.git.rev_list(ref).strip().splitlines() + other_repo_refs = other_repo.git.rev_list(other_ref).strip().splitlines() + + diff_refs = list(set(other_repo_refs) - set(repo_refs)) + return map(lambda ref: Commit.find_all(other_repo, ref, **{'max_count': 1}[0]), diff_refs) + + def tree(self, treeish = 'master', paths = []): + """ + The Tree object for the given treeish reference + + ``treeish`` + is the reference (default 'master') + ``paths`` + is an optional Array of directory paths to restrict the tree (default []) + + Examples:: + + repo.tree('master', ['lib/']) + + + Returns + ``GitPython.Tree`` + """ + return Tree.construct(self, treeish, paths) + + def blob(self, id): + """ + The Blob object for the given id + + ``id`` + is the SHA1 id of the blob + + Returns + ``GitPython.Blob`` + """ + return Blob(self, **{'id': id}) + + def log(self, commit = 'master', path = None, **kwargs): + """ + The commit log for a treeish + + Returns + ``GitPython.Commit[]`` + """ + options = {'pretty': 'raw'} + options.update(kwargs) + if path: + arg = [commit, '--', path] + else: + arg = [commit] + commits = self.git.log(*arg, **options) + return Commit.list_from_string(self, commits) + + def diff(self, a, b, *paths): + """ + The diff from commit ``a`` to commit ``b``, optionally restricted to the given file(s) + + ``a`` + is the base commit + ``b`` + is the other commit + + ``paths`` + is an optional list of file paths on which to restrict the diff + """ + return self.git.diff(a, b, '--', *paths) + + def commit_diff(self, commit): + """ + The commit diff for the given commit + ``commit`` is the commit name/id + + Returns + ``GitPython.Diff[]`` + """ + return Commit.diff(self, commit) + + @classmethod + def init_bare(self, path, mkdir=True, **kwargs): + """ + Initialize a bare git repository at the given path + + ``path`` + is the full path to the repo (traditionally ends with /<name>.git) + + ``mkdir`` + if specified will create the repository directory if it doesn't + already exists. Creates the directory with a mode=0755. + + ``kwargs`` + is any additional options to the git init command + + Examples:: + + GitPython.Repo.init_bare('/var/git/myrepo.git') + + Returns + ``GitPython.Repo`` (the newly created repo) + """ + split = os.path.split(path) + if split[-1] == '.git' or os.path.split(split[0])[-1] == '.git': + gitpath = path + else: + gitpath = os.path.join(path, '.git') + + if mkdir and not os.path.exists(gitpath): + os.makedirs(gitpath, 0755) + + git = Git(gitpath) + output = git.init(**kwargs) + return Repo(path) + create = init_bare + + def fork_bare(self, path, **kwargs): + """ + Fork a bare git repository from this repo + + ``path`` + is the full path of the new repo (traditionally ends with /<name>.git) + + ``options`` + is any additional options to the git clone command + + Returns + ``GitPython.Repo`` (the newly forked repo) + """ + options = {'bare': True} + options.update(kwargs) + self.git.clone(self.path, path, **options) + return Repo(path) + + def archive_tar(self, treeish = 'master', prefix = None): + """ + Archive the given treeish + + ``treeish`` + is the treeish name/id (default 'master') + + ``prefix`` + is the optional prefix + + Examples:: + + >>> repo.archive_tar + <String containing tar archive> + + >>> repo.archive_tar('a87ff14') + <String containing tar archive for commit a87ff14> + + >>> repo.archive_tar('master', 'myproject/') + <String containing tar archive and prefixed with 'myproject/'> + + Returns + str (containing tar archive) + """ + options = {} + if prefix: + options['prefix'] = prefix + return self.git.archive(treeish, **options) + + def archive_tar_gz(self, treeish = 'master', prefix = None): + """ + Archive and gzip the given treeish + + ``treeish`` + is the treeish name/id (default 'master') + + ``prefix`` + is the optional prefix + + Examples:: + + >>> repo.archive_tar_gz + <String containing tar.gz archive> + + >>> repo.archive_tar_gz('a87ff14') + <String containing tar.gz archive for commit a87ff14> + + >>> repo.archive_tar_gz('master', 'myproject/') + <String containing tar.gz archive and prefixed with 'myproject/'> + + Returns + str (containing tar.gz archive) + """ + kwargs = {} + if prefix: + kwargs['prefix'] = prefix + self.git.archive(treeish, "| gzip", **kwargs) + + def enable_daemon_serve(self): + """ + Enable git-daemon serving of this repository by writing the + git-daemon-export-ok file to its git directory + + Returns + None + """ + if self.bare: + touch(os.path.join(self.path, DAEMON_EXPORT_FILE)) + else: + touch(os.path.join(self.path, '.git', DAEMON_EXPORT_FILE)) + + def disable_daemon_serve(self): + """ + Disable git-daemon serving of this repository by ensuring there is no + git-daemon-export-ok file in its git directory + + Returns + None + """ + if self.bare: + return os.remove(os.path.join(self.path, DAEMON_EXPORT_FILE)) + else: + return os.remove(os.path.join(self.path, '.git', DAEMON_EXPORT_FILE)) + + def _get_alternates(self): + """ + The list of alternates for this repo + + Returns + list[str] (pathnames of alternates) + """ + alternates_path = os.path.join(self.path, *['objects', 'info', 'alternates']) + + if os.path.exists(alternates_path): + try: + f = open(alternates_path) + alts = f.read() + finally: + f.close() + return alts.strip().splitlines() + else: + return [] + + def _set_alternates(self, alts): + """ + Sets the alternates + + ``alts`` + is the Array of String paths representing the alternates + + Returns + None + """ + for alt in alts: + if not os.path.exists(alt): + raise NoSuchPathError("Could not set alternates. Alternate path %s must exist" % alt) + + if not alts: + os.remove(os.path.join(self.path, *['objects', 'info', 'alternates'])) + else: + try: + f = open(os.path.join(self.path, *['objects', 'info', 'alternates']), 'w') + f.write("\n".join(alts)) + finally: + f.close() + + alternates = property(_get_alternates, _set_alternates) + + def __repr__(self): + return '<GitPython.Repo "%s">' % self.path diff --git a/lib/git/stats.py b/lib/git/stats.py new file mode 100644 index 00000000..95dc875e --- /dev/null +++ b/lib/git/stats.py @@ -0,0 +1,17 @@ +class Stats(object): + def __init__(self, repo, total, files): + self.repo = repo + self.total = total + self.files = files + + @classmethod + def list_from_string(cls, repo, text): + hsh = {'total': {'insertions': 0, 'deletions': 0, 'lines': 0, 'files': 0}, 'files': {}} + for line in text.splitlines(): + (insertions, deletions, filename) = line.split("\t") + hsh['total']['insertions'] += int(insertions) + hsh['total']['deletions'] += int(deletions) + hsh['total']['lines'] = (hsh['total']['deletions'] + hsh['total']['insertions']) + hsh['total']['files'] += 1 + hsh['files'][filename.strip()] = {'insertions': int(insertions), 'deletions': int(deletions)} + return Stats(repo, hsh['total'], hsh['files']) diff --git a/lib/git/tag.py b/lib/git/tag.py new file mode 100644 index 00000000..fb119f76 --- /dev/null +++ b/lib/git/tag.py @@ -0,0 +1,85 @@ +from commit import Commit + +class Tag(object): + def __init__(self, name, commit): + """ + Instantiate a new Tag + + ``name`` + is the name of the head + + ``commit`` + is the Commit that the head points to + + Returns + ``GitPython.Tag`` + """ + self.name = name + self.commit = commit + + @classmethod + def find_all(cls, repo, **kwargs): + """ + Find all Tags + + ``repo`` + is the Repo + + ``kwargs`` + is a dict of options + + Returns + ``GitPython.Tag[]`` + """ + options = {'sort': "committerdate", + 'format': "%(refname)%00%(objectname)"} + options.update(**kwargs) + + output = repo.git.for_each_ref("refs/tags", **options) + return cls.list_from_string(repo, output) + + @classmethod + def list_from_string(cls, repo, text): + """ + Parse out tag information into an array of baked Tag objects + + ``repo`` + is the Repo + + ``text`` + is the text output from the git command + + Returns + ``GitPython.Tag[]`` + """ + tags = [] + for line in text.splitlines(): + tags.append(cls.from_string(repo, line)) + return tags + + @classmethod + def from_string(cls, repo, line): + """ + Create a new Tag instance from the given string. + + ``repo`` + is the Repo + + ``line`` + is the formatted tag information + + Format + name: [a-zA-Z_/]+ + <null byte> + id: [0-9A-Fa-f]{40} + + Returns + ``GitPython.Tag`` + """ + full_name, ids = line.split("\x00") + name = full_name.split("/")[-1] + commit = Commit(repo, **{'id': ids}) + return Tag(name, commit) + + def __repr__(self): + return '<GitPython.Tag "%s">' % self.name diff --git a/lib/git/tree.py b/lib/git/tree.py new file mode 100644 index 00000000..9c4dab1d --- /dev/null +++ b/lib/git/tree.py @@ -0,0 +1,89 @@ +import os +from lazy import LazyMixin +import blob + +class Tree(LazyMixin): + def __init__(self, repo, **kwargs): + LazyMixin.__init__(self) + self.repo = repo + self.id = None + self.mode = None + self.name = None + self.contents = None + + for k, v in kwargs.items(): + setattr(self, k, v) + + def __bake__(self): + temp = Tree.construct(self.repo, self.id) + self.contents = temp.contents + + @classmethod + def construct(cls, repo, treeish, paths = []): + output = repo.git.ls_tree(treeish, *paths) + return Tree(repo, **{'id': treeish}).construct_initialize(repo, treeish, output) + + def construct_initialize(self, repo, id, text): + self.repo = repo + self.id = id + self.contents = [] + self.__baked__ = False + + for line in text.splitlines(): + self.contents.append(self.content_from_string(self.repo, line)) + + self.contents = [c for c in self.contents if c is not None] + + self.__bake_it__() + return self + + def content_from_string(self, repo, text): + """ + Parse a content item and create the appropriate object + + ``repo`` + is the Repo + + ``text`` + is the single line containing the items data in `git ls-tree` format + + Returns + ``GitPython.Blob`` or ``GitPython.Tree`` + """ + try: + mode, typ, id, name = text.expandtabs(1).split(" ", 4) + except: + return None + + if typ == "tree": + return Tree(repo, **{'id': id, 'mode': mode, 'name': name}) + elif typ == "blob": + return blob.Blob(repo, **{'id': id, 'mode': mode, 'name': name}) + elif typ == "commit": + return None + else: + raise(TypeError, "Invalid type: %s" % typ) + + def __div__(self, file): + """ + Find the named object in this tree's contents + + Examples:: + + >>> Repo('/path/to/python-git').tree/'lib' + <GitPython.Tree "6cc23ee138be09ff8c28b07162720018b244e95e"> + >>> Repo('/path/to/python-git').tree/'README.txt' + <GitPython.Blob "8b1e02c0fb554eed2ce2ef737a68bb369d7527df"> + + Returns + ``GitPython.Blob`` or ``GitPython.Tree`` or ``None`` if not found + """ + contents = [c for c in self.contents if c.name == file] + return contents and contents[0] or None + + @property + def basename(self): + os.path.basename(self.name) + + def __repr__(self): + return '<GitPython.Tree "%s">' % self.id diff --git a/lib/git/utils.py b/lib/git/utils.py new file mode 100644 index 00000000..c2140ba0 --- /dev/null +++ b/lib/git/utils.py @@ -0,0 +1,5 @@ +def dashify(string): + return string.replace('_', '-') + +def touch(filename): + open(filename, "a").close() |