summaryrefslogtreecommitdiff
path: root/lib/git
diff options
context:
space:
mode:
Diffstat (limited to 'lib/git')
-rw-r--r--lib/git/__init__.py24
-rw-r--r--lib/git/actor.py33
-rw-r--r--lib/git/blob.py134
-rw-r--r--lib/git/commit.py235
-rw-r--r--lib/git/diff.py79
-rw-r--r--lib/git/errors.py8
-rw-r--r--lib/git/gitter.py190
-rw-r--r--lib/git/head.py107
-rw-r--r--lib/git/lazy.py26
-rw-r--r--lib/git/method_missing.py21
-rw-r--r--lib/git/repo.py435
-rw-r--r--lib/git/stats.py17
-rw-r--r--lib/git/tag.py85
-rw-r--r--lib/git/tree.py89
-rw-r--r--lib/git/utils.py5
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()