Permalink
Browse files

Initial commit

  • Loading branch information...
0 parents commit 8563952fed4d095160dd3ff52e8559429f5ae0b0 Adam Simpkins committed Feb 5, 2010
5 .gitignore
@@ -0,0 +1,5 @@
+*.pyc
+*.pyo
+
+# The /build directory is created by setup.py
+/build
1 INSTALL
@@ -0,0 +1 @@
+To install, run "python setup.py install" from the root of the source tree.
202 LICENSE
@@ -0,0 +1,202 @@
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
+
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
+
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
8 README.rst
@@ -0,0 +1,8 @@
+git-review is a tool for reviewing diffs in a git repository.
+
+It provides a simple CLI for stepping through the modified files, and viewing
+the differences with an external diff tool. This is very convenient if you
+prefer using an interactive side-by-side diff viewer. Although you could also
+use the ``GIT_EXTERNAL_DIFF`` environment variable with ``git diff``,
+git-review provides much more flexibility for moving between files and
+selecting which versions to diff.
30 setup.py
@@ -0,0 +1,30 @@
+#!/usr/bin/python -tt
+#
+# Copyright 2010 Facebook, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+#
+from distutils.core import setup
+
+setup(name='git-review',
+ version='1.0',
+ license='Apache 2.0',
+ description='Utilities for reviewing changes in git repositories',
+ package_dir = { '' : 'src' },
+ packages=[
+ 'gitreview',
+ 'gitreview.git',
+ 'gitreview.cli',
+ 'gitreview.review',
+ ],
+ scripts=['src/git-review'])
178 src/git-review
@@ -0,0 +1,178 @@
+#!/usr/local/bin/python -tt
+#
+# Copyright 2009-2010 Facebook, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+#
+"""
+git-review - a tool to review changes in a git repository
+
+This tool provides an interactive shell for reviewing diffs in a git
+repository. It accepts arguments similar to "git diff" for specifying the
+diffs to review. When started, it walks the user through each file changed,
+prompting to open an external diff program or text editor for each file.
+
+Configuration:
+
+- GIT_REVIEW_DIFF
+ If set, this environment variable specifies the program to use to view diffs
+ for modified files. If unset, the default diff program is tkdiff when
+ DISPLAY is set, and "vimdiff -R" when DISPLAY is unset.
+
+- GIT_REVIEW_VIEW, GIT_EDITOR, VISUAL, EDITOR
+ These environment variables are checked in order to find the program to use
+ to view new files. If none of these are set, vi is used.
+"""
+
+import optparse
+import os
+import sys
+
+import gitreview.git as git
+import gitreview.review as review
+
+RETCODE_SUCCESS = 0
+RETCODE_ARGUMENTS_ERROR = 1
+
+f_progname = os.path.basename(sys.argv[0])
+
+
+class OptionsError(Exception):
+ pass
+
+
+class UsageError(Exception):
+ pass
+
+
+class NoDiffsError(Exception):
+ pass
+
+
+class Options(optparse.OptionParser):
+ def __init__(self):
+ optparse.OptionParser.__init__(self, add_help_option=False,
+ usage='%prog [options]')
+ self.add_option('-c', '--commit',
+ action='store', dest='commit', default=None,
+ help='Diff the specified commit against its parent')
+ self.add_option('--cached',
+ action='store_true', dest='cached', default=False,
+ help='Diff against the index instead of the working '
+ 'tree')
+ self.add_option('--git-dir',
+ action='store', dest='gitDir',
+ metavar='DIRECTORY', default=None,
+ help='Path to the git repository directory')
+ self.add_option('--work-tree',
+ action='store', dest='workTree',
+ metavar='DIRECTORY', default=None,
+ help='Path to the git repository working tree')
+ self.add_option('-?', '--help',
+ action='callback', callback=self.__helpCallback,
+ help='Print this help message and exit')
+
+ def __helpCallback(self, option, opt, value, parser):
+ raise UsageError()
+
+ def error(self, msg):
+ # This is called automatically by optparse when an error in the command
+ # line options is encountered.
+ raise OptionsError(msg)
+
+ def printUsage(self, file = sys.stdout):
+ self.print_usage(file = file)
+
+ def printHelp(self, file = sys.stdout):
+ self.print_help(file = file)
+
+ def __getattr__(self, name):
+ if name.startswith('__'):
+ raise AttributeError(name)
+
+ # Allow attributes of self.__options to be accessed directly
+ return getattr(self.__options, name)
+
+ def parseArgv(self, argv):
+ # parse the options
+ (self.__options, args) = self.parse_args(argv[1:])
+
+ # Parse the commit arguments
+ if self.__options.commit is not None:
+ # If --commit was specified, diff that commit against its parent
+ if self.__options.cached:
+ msg = '--commit and --cached are mutually exclusive'
+ raise OptionsError(msg)
+ if args:
+ msg = ('additional commit arguments may not be specified '
+ 'with --commit')
+ raise OptionsError(msg)
+
+ self.parentCommit = self.__options.commit + '^'
+ self.childCommit = self.__options.commit
+ else:
+ # Default parent and child commits, if not otherwise specified
+ if self.__options.cached:
+ self.parentCommit = git.COMMIT_HEAD
+ self.childCommit = git.COMMIT_INDEX
+ else:
+ self.parentCommit = git.COMMIT_INDEX
+ self.childCommit = git.COMMIT_WD
+
+ # Parse the remaining arguments
+ num_args = len(args)
+ if num_args == 1:
+ self.parentCommit = args[0]
+ elif num_args == 2:
+ if self.__options.cached:
+ msg = 'cannot specify --cached with two commits'
+ raise OptionsError(msg)
+ self.parentCommit = args[0]
+ self.childCommit = args[1]
+ elif num_args > 2:
+ raise OptionsError('trailing arguments: ' + ' '.join(args[2:]))
+
+
+def error_msg(msg):
+ sys.stderr.write('%s: error: %s\n' % (f_progname, msg))
+
+
+def warning_msg(msg):
+ sys.stderr.write('%s: warning: %s\n' % (f_progname, msg))
+
+
+def main(argv):
+ # Parse the command line options
+ options = Options()
+ try:
+ options.parseArgv(argv)
+ except OptionsError, error:
+ options.printUsage(sys.stderr)
+ error_msg(error)
+ return RETCODE_ARGUMENTS_ERROR
+ except UsageError, error:
+ options.printHelp()
+ return RETCODE_SUCCESS
+
+ # Get a Repository object
+ repo = git.get_repo(git_dir=options.gitDir,
+ working_dir=options.workTree)
+
+ diff = repo.getDiff(options.parentCommit, options.childCommit)
+ rev = review.Review(repo, diff)
+
+ return review.CliReviewer(rev).run()
+
+
+if __name__ == '__main__':
+ sys.exit(main(sys.argv))
16 src/gitreview/__init__.py
@@ -0,0 +1,16 @@
+#!/usr/bin/python -tt
+#
+# Copyright 2010 Facebook, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+#
326 src/gitreview/cli/__init__.py
@@ -0,0 +1,326 @@
+#!/usr/bin/python -tt
+#
+# Copyright 2009-2010 Facebook, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+#
+import readline
+import sys
+import traceback
+
+from .exceptions import *
+from . import tokenize
+
+# Import everything from our command and args submodules
+# into the top-level namespace
+from .command import *
+from .args import *
+
+
+class CLI(object):
+ """
+ Class for implementing command line interfaces.
+
+ (We define our own rather than using the standard Python cmd module,
+ since cmd.Cmd doesn't provide all the features we want.)
+ """
+ def __init__(self):
+ # Configuration, modifiable by subclasses
+ self.completekey = 'tab'
+ self.prompt = '> '
+
+ # Normally, empty lines and EOF won't be stored in self.prevLine
+ # (the contents of self.prevLine remain be unchanged when one of these
+ # is input). self.rememberEmptyLine can be set to True to override
+ # this behavior.
+ #
+ # Setting this to True will
+ # implementation of self.emptyline
+ # If self.rememberEmptyLine is True,
+ # self.prevLine will be updated
+ self.rememberEmptyLine = False
+
+ # State, modifiable by subclasses
+ self.stop = False
+ self.line = None
+ self.cmd = None
+ self.args = None
+ self.prevLine = None
+ self.commands = {}
+
+ # Private state
+ self.__oldCompleter = None
+
+ def addCommand(self, name, command):
+ if self.commands.has_key(name):
+ raise KeyError('command %r already exists' % (name,))
+ self.commands[name] = command
+
+ def getCommand(self, name):
+ """
+ cli.getCommand(name) --> entry
+
+ Get a command entry, based on the command name, or an unambiguous
+ prefix of the command name.
+
+ Raises NoSuchCommandError if there is no command matching this name
+ or starting with this prefix. Raises AmbiguousCommandError if the
+ name does not exactly match a command name, and there are multiple
+ commands that start with this prefix.
+ """
+ # First see if we have an exact match for this command
+ try:
+ return self.commands[name]
+ except KeyError:
+ # Fall through
+ pass
+
+ # Perform completion to see how many commands match this prefix
+ matches = self.completeCommand(name)
+ if not matches:
+ raise NoSuchCommandError(name)
+ if len(matches) > 1:
+ raise AmbiguousCommandError(name, matches)
+ return self.commands[matches[0]]
+
+ def output(self, msg='', newline=True):
+ # XXX: We always write to sys.stdout for now.
+ # This isn't configurable, since they python readline module
+ # always uses sys.stdin and sys.stdout
+ sys.stdout.write(msg)
+ if newline:
+ sys.stdout.write('\n')
+
+ def outputError(self, msg):
+ sys.stderr.write('error: %s\n' % (msg,))
+
+ def readline(self):
+ try:
+ return raw_input(self.prompt)
+ except EOFError:
+ return None
+
+ def loop(self):
+ # Always reset self.stop to False
+ self.stop = False
+
+ rc = None
+ self.setupReadline()
+ try:
+ while not self.stop:
+ line = self.readline()
+ rc = self.runCommand(line)
+ finally:
+ self.cleanupReadline()
+
+ return rc
+
+ def loopOnce(self):
+ # Note: loopOnce ignores self.stop
+ # It doesn't reset it if it is True
+
+ rc = None
+ self.setupReadline()
+ try:
+ line = self.readline()
+ rc = self.runCommand(line)
+ finally:
+ self.cleanupReadline()
+
+ return rc
+
+ def runCommand(self, line, store=True):
+ if line == None:
+ return self.handleEof()
+
+ if not line:
+ return self.handleEmptyLine()
+
+ (cmd_name, args) = self.parseLine(line)
+ rc = self.invokeCommand(cmd_name, args, line)
+
+ # If store is true, store the line as self.prevLine
+ # However, don't remember EOF or empty lines, unless
+ # self.rememberEmptyLine is set.
+ if store and (line or self.rememberEmptyLine):
+ self.prevLine = line
+
+ return rc
+
+ def invokeCommand(self, cmd_name, args, line):
+ try:
+ cmd_entry = self.getCommand(cmd_name)
+ except NoSuchCommandError, ex:
+ return self.handleUnknownCommand(cmd_name)
+ except AmbiguousCommandError, ex:
+ return self.handleAmbiguousCommand(cmd_name, ex.matches)
+
+ try:
+ return cmd_entry.run(self, cmd_name, args, line)
+ except:
+ return self.handleCommandException()
+
+ def handleEof(self):
+ self.output()
+ self.stop = True
+ return 0
+
+ def handleEmptyLine(self):
+ # By default, re-execute the last command.
+ #
+ # This would behave oddly when self.rememberEmptyLine is True, though,
+ # so do nothing if rememberEmptyLine is set. (With rememberEmptyLine
+ # on, the first time an empty line is entered would re-execute the
+ # previous commands. Subsequent empty lines would do nothing,
+ # though.)
+ if self.rememberEmptyLine:
+ return 0
+
+ # If prevLine is None (either no command has been run yet, or the
+ # prevous command was EOF), or if it is empty, do nothing.
+ if not self.prevLine:
+ return 0
+
+ # Re-execute self.prevLine
+ return self.runCommand(self.prevLine)
+
+ def handleUnknownCommand(self, cmd):
+ self.outputError('%s: no such command' % (cmd,))
+ return -1
+
+ def handleAmbiguousCommand(self, cmd, matches):
+ self.outputError('%s: ambiguous command: %s' % (cmd, matches))
+ return -1
+
+ def handleCommandException(self):
+ ex = sys.exc_info()[1]
+ if isinstance(ex, CommandArgumentsError):
+ # CommandArgumentsError indicates the user entered
+ # invalid arguments. Just print a normal error message,
+ # with no traceback.
+ self.outputError(ex)
+ return -1
+
+ tb = traceback.format_exc()
+ self.outputError(tb)
+ return -2
+
+ def complete(self, text, state):
+ if state == 0:
+ try:
+ self.completions = self.getCompletions(text)
+ except:
+ self.outputError('error getting completions')
+ tb = traceback.format_exc()
+ self.outputError(tb)
+ return None
+
+ try:
+ return self.completions[state]
+ except IndexError:
+ return None
+
+ def getCompletions(self, text):
+ # strip the string down to just the part before endidx
+ # Things after endidx never affect our completion behavior
+ line = readline.get_line_buffer()
+ begidx = readline.get_begidx()
+ endidx = readline.get_endidx()
+ line = line[:endidx]
+
+ (cmd_name, args, part) = self.parsePartialLine(line)
+ if part == None:
+ part = ''
+
+ if cmd_name == None:
+ assert not args
+ matches = self.completeCommand(part, add_space=True)
+ else:
+ try:
+ command = self.getCommand(cmd_name)
+ except (NoSuchCommandError, AmbiguousCommandError), ex:
+ # Not a valid command. No matches
+ return None
+
+ matches = command.complete(self, cmd_name, args, part)
+
+ # Massage matches to look like what readline expects
+ # (since readline doesn't know about our exact tokenization routine)
+ ret = []
+ part_len = len(part)
+ for match in matches:
+ add_space = False
+ if isinstance(match, tuple):
+ (match, add_space) = match
+
+ # The command should only return strings that start with
+ # the specified partial string. Check just in case, and ignore
+ # anything that doesn't match
+ if not match.startswith(part):
+ # XXX: It would be nice to raise an exception or print a
+ # warning somehow, to let the command developer know that they
+ # screwed up and we are ignoring some of the results.
+ continue
+
+ readline_match = text + tokenize.escape_arg(match[len(part):])
+ if add_space:
+ readline_match += ' '
+ ret.append(readline_match)
+
+ return ret
+
+ def completeCommand(self, text, add_space=False):
+ matches = [cmd_name for cmd_name in self.commands.keys()
+ if cmd_name.startswith(text)]
+ if add_space:
+ matches = [(match, True) for match in matches]
+ return matches
+
+ def parseLine(self, line):
+ """
+ cli.parseLine(line) --> (cmd, args)
+
+ Returns a tuple consisting of the command name, and the arguments
+ to pass to the command function. Default behavior is to tokenize the
+ line, and return (tokens[0], tokens)
+ """
+ tokenizer = tokenize.SimpleTokenizer(line)
+ tokens = tokenizer.getTokens()
+ return (tokens[0], tokens)
+
+ def parsePartialLine(self, line):
+ """
+ cli.parseLine(line) --> (cmd, args, partial_arg)
+
+ Returns a tuple consisting of the command name, and the arguments
+ to pass to the command function. Default behavior is to tokenize the
+ line, and return (tokens[0], tokens)
+ """
+ tokenizer = tokenize.SimpleTokenizer(line)
+ tokens = tokenizer.getTokens(stop_at_end=False)
+ if tokens:
+ cmd_name = tokens[0]
+ else:
+ cmd_name = None
+ return (cmd_name, tokens, tokenizer.getPartialToken())
+
+ def setupReadline(self):
+ self.oldCompleter = readline.get_completer()
+ readline.set_completer(self.complete)
+ readline.parse_and_bind(self.completekey+": complete")
+
+ def cleanupReadline(self):
+ if self.__oldCompleter:
+ readline.set_completer(self.__oldCompleter)
+ else:
+ readline.set_completer(lambda text, state: None)
166 src/gitreview/cli/args.py
@@ -0,0 +1,166 @@
+#!/usr/bin/python -tt
+#
+# Copyright 2009-2010 Facebook, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+#
+from .exceptions import *
+from . import command
+from . import tokenize
+
+
+class ParsedArgs(object):
+ pass
+
+
+class ArgCommand(command.Command):
+ def __init__(self, args, help):
+ self.argTypes = args
+ self.helpText = help
+
+ def run(self, cli_obj, name, args, line):
+ args = args[1:]
+ num_args = len(args)
+ num_arg_types = len(self.argTypes)
+
+ if num_args > num_arg_types:
+ trailing_args = args[num_arg_types:]
+ msg = 'trailing arguments: ' + tokenize.escape_args(trailing_args)
+ raise CommandArgumentsError(msg)
+
+ parsed_args = ParsedArgs()
+ for n in range(num_args):
+ arg_type = self.argTypes[n]
+ value = arg_type.parse(cli_obj, args[n])
+ setattr(parsed_args, arg_type.getName(), value)
+
+ if num_args < num_arg_types:
+ # Make sure the remaining options are optional
+ # (The next argument must be marked as optional.
+ # The optional flag on arguments after this doesn't matter.)
+ arg_type = self.argTypes[num_args]
+ if not arg_type.isOptional():
+ msg = 'missing %s' % (arg_type.getHrName(),)
+ raise CommandArgumentsError(msg)
+
+ for n in range(num_args, num_arg_types):
+ arg_type = self.argTypes[n]
+ setattr(parsed_args, arg_type.getName(), arg_type.getDefaultValue())
+
+ return self.runParsed(cli_obj, name, parsed_args)
+
+ def help(self, cli_obj, name, args, line):
+ args = args[1:]
+ syntax = name
+ end = ''
+ for arg in self.argTypes:
+ if arg.isOptional():
+ syntax += ' [<%s>' % (arg.getName(),)
+ end += ']'
+ else:
+ syntax += ' <%s>' % (arg.getName(),)
+ syntax += end
+
+ cli_obj.output(syntax)
+ if not self.helpText:
+ return
+
+ # FIXME: do nicer formatting of the help message
+ cli_obj.output()
+ cli_obj.output(self.helpText)
+
+ def complete(self, cli_obj, name, args, text):
+ args = args[1:]
+ index = len(args)
+ try:
+ arg_type = self.argTypes[index]
+ except IndexError:
+ return []
+
+ return arg_type.complete(cli_obj, text)
+
+
+class Argument(object):
+ def __init__(self, name, **kwargs):
+ self.name = name
+ self.hrName = name
+ self.default = None
+ self.optional = False
+
+ for (kwname, kwvalue) in kwargs.items():
+ if kwname == 'default':
+ self.default = kwvalue
+ elif kwname == 'hr_name':
+ self.hrName = kwvalue
+ elif kwname == 'optional':
+ self.optional = kwvalue
+ else:
+ raise TypeError('unknown keyword argument %r' % (kwname,))
+
+ def getName(self):
+ return self.name
+
+ def getHrName(self):
+ """
+ arg.getHrName() --> string
+
+ Get the human-readable name.
+ """
+ return self.hrName
+
+ def isOptional(self):
+ return self.optional
+
+ def getDefaultValue(self):
+ return self.default
+
+ def complete(self, cli_obj, text):
+ return []
+
+
+class StringArgument(Argument):
+ def parse(self, cli_obj, arg):
+ return arg
+
+
+class IntArgument(Argument):
+ def __init__(self, name, **kwargs):
+ self.min = None
+ self.max = None
+
+ arg_kwargs = {}
+ for (kwname, kwvalue) in kwargs.items():
+ if kwname == 'min':
+ self.min = kwvalue
+ elif kwname == 'max':
+ self.max = max
+ else:
+ arg_kwargs[kwname] = kwvalue
+
+ Argument.__init__(self, name, **arg_kwargs)
+
+ def parse(self, cli_obj, arg):
+ try:
+ value = int(arg)
+ except ValueError:
+ msg = '%s must be an integer' % (self.getHrName(),)
+ raise CommandArgumentsError(msg)
+
+ if self.min != None and value < self.min:
+ msg = '%s must be greater than %s' % (self.getHrName(), self.min)
+ raise CommandArgumentsError(msg)
+ if self.max != None and value > self.max:
+ msg = '%s must be less than %s' % (self.getHrName(), self.max)
+ raise CommandArgumentsError(msg)
+
+ return value
53 src/gitreview/cli/command.py
@@ -0,0 +1,53 @@
+#!/usr/bin/python -tt
+#
+# Copyright 2009-2010 Facebook, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+#
+from .exceptions import *
+
+
+class Command(object):
+ def run(self, cli, name, args, line):
+ raise NotImplementedError('subclasses of Command must implement run()')
+
+ def help(self, cli, name, args, line):
+ raise NotImplementedError('subclasses of Command must implement help()')
+
+ def complete(self, cli, name, args, text):
+ # By default, no completion is performed
+ return []
+
+
+class HelpCommand(Command):
+ def run(self, cli_obj, name, args, line):
+ if len(args) < 2:
+ for cmd_name in cli_obj.commands:
+ cli_obj.output(cmd_name)
+ else:
+ cmd_name = args[1]
+ try:
+ cmd = cli_obj.getCommand(cmd_name)
+ cmd.help(cli_obj, cmd_name, args[1:], line)
+ except (NoSuchCommandError, AmbiguousCommandError), ex:
+ cli_obj.outputError(ex)
+
+ def help(self, cli_obj, name, args, line):
+ cli_obj.output('%s [<command>]' % (args[0],))
+ cli_obj.output()
+ cli_obj.output('Display help')
+
+ def complete(self, cli_obj, name, args, text):
+ if len(args) == 1:
+ return cli_obj.completeCommand(text, add_space=True)
+ return []
37 src/gitreview/cli/exceptions.py
@@ -0,0 +1,37 @@
+#!/usr/bin/python -tt
+#
+# Copyright 2009-2010 Facebook, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+#
+class CLIError(Exception):
+ pass
+
+
+class NoSuchCommandError(CLIError):
+ def __init__(self, cmd_name):
+ CLIError.__init__(self, 'no such command %r' % (cmd_name,))
+ self.cmd = cmd_name
+
+
+class AmbiguousCommandError(CLIError):
+ def __init__(self, cmd_name, matches):
+ msg = 'ambiguous command %r: possible matches: %r' % \
+ (cmd_name, matches)
+ CLIError.__init__(self, msg)
+ self.cmd = cmd_name
+ self.matches = matches
+
+
+class CommandArgumentsError(CLIError):
+ pass
218 src/gitreview/cli/tokenize.py
@@ -0,0 +1,218 @@
+#!/usr/bin/python -tt
+#
+# Copyright 2009-2010 Facebook, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+#
+import re
+
+class TokenizationError(Exception):
+ pass
+
+
+class PartialTokenError(TokenizationError):
+ def __init__(self, token, msg):
+ TokenizationError.__init__(self, msg)
+ self.token = token
+ self.error = msg
+
+
+class State(object):
+ def handleChar(self, tokenizer, char):
+ raise NotImplementedError()
+
+ def handleEnd(self, tokenizer):
+ raise NotImplementedError()
+
+
+class EscapeState(State):
+ def handleChar(self, tokenizer, char):
+ tokenizer.addToToken(char)
+ tokenizer.popState()
+
+ def handleEnd(self, tokenizer):
+ # XXX: We could treat this as an indication to continue on to the next
+ # line.
+ msg = 'unterminated escape sequence'
+ raise PartialTokenError(tokenizer.getPartialToken(), msg)
+
+
+class QuoteState(State):
+ def __init__(self, quote_char, escape_chars = '\\'):
+ State.__init__(self)
+ self.quote = quote_char
+ self.escapeChars = escape_chars
+
+ def handleChar(self, tokenizer, char):
+ if char == self.quote:
+ tokenizer.popState()
+ elif char in self.escapeChars:
+ tokenizer.pushState(EscapeState())
+ else:
+ tokenizer.addToToken(char)
+
+ def handleEnd(self, tokenizer):
+ msg = 'unterminated quote'
+ raise PartialTokenError(tokenizer.getPartialToken(), msg)
+
+
+class NormalState(State):
+ def __init__(self):
+ State.__init__(self)
+ self.quoteChars = '"\''
+ self.escapeChars = '\\'
+ self.delimChars = ' \t\n'
+
+ def handleChar(self, tokenizer, char):
+ if char in self.escapeChars:
+ tokenizer.pushState(EscapeState())
+ elif char in self.quoteChars:
+ tokenizer.addToToken('')
+ tokenizer.pushState(QuoteState(char, self.escapeChars))
+ elif char in self.delimChars:
+ tokenizer.endToken()
+ else:
+ tokenizer.addToToken(char)
+
+ def handleEnd(self, tokenizer):
+ tokenizer.endToken()
+
+
+class Tokenizer(object):
+ """
+ A class for tokenizing strings.
+
+ It isn't particularly efficient. Performance-wise, it is probably quite
+ slow. However, it is intended to be very customizable. It provides many
+ hooks to allow subclasses to override and extend its behavior.
+ """
+ STATE_NORMAL = 0
+ STATE_IN_QUOTE = 1
+
+ def __init__(self, state, value):
+ self.value = value
+ self.index = 0
+ self.end = len(self.value)
+
+ if isinstance(state, list):
+ self.stateStack = state[:]
+ else:
+ self.stateStack = [state]
+
+ self.currentToken = None
+ self.tokens = []
+
+ self.__processedEnd = False
+
+ def getTokens(self, stop_at_end=True):
+ tokens = []
+
+ while True:
+ token = self.getNextToken(stop_at_end)
+ if token == None:
+ break
+ tokens.append(token)
+
+ return tokens
+
+ def getNextToken(self, stop_at_end=True):
+ # If we don't currently have any tokens to process,
+ # call self.processNextChar()
+ while not self.tokens:
+ if (not stop_at_end) and self.index >= self.end:
+ # If stop_at_end is True, we let processNextChar()
+ # handle the end of string as normal. However, if stop_at_end
+ # is False, the string value we have received so far is partial
+ # (the caller might append more to it later), so return None
+ # here without handling the end of the string.
+ return None
+ if self.__processedEnd:
+ # If there are no more tokens and we've already reached
+ # the end of the string, return None
+ return None
+ self.processNextChar()
+
+ return self.__popToken()
+
+ def __popToken(self):
+ token = self.tokens[0]
+ del self.tokens[0]
+ return token
+
+ def getPartialToken(self):
+ return self.currentToken
+
+ def processNextChar(self):
+ if self.index >= self.end:
+ if self.__processedEnd:
+ raise IndexError()
+ self.__processedEnd = True
+ state = self.stateStack[-1]
+ state.handleEnd(self)
+ return
+
+ char = self.value[self.index]
+ self.index += 1
+
+ state = self.stateStack[-1]
+ state.handleChar(self, char)
+
+ def pushState(self, state):
+ self.stateStack.append(state)
+
+ def popState(self):
+ self.stateStack.pop()
+ if not self.stateStack:
+ raise Exception('cannot pop last state')
+
+ def addToToken(self, char):
+ if self.currentToken == None:
+ self.currentToken = char
+ else:
+ self.currentToken += char
+
+ def endToken(self):
+ if self.currentToken == None:
+ return
+
+ self.tokens.append(self.currentToken)
+ self.currentToken = None
+
+
+class SimpleTokenizer(Tokenizer):
+ def __init__(self, value):
+ Tokenizer.__init__(self, [NormalState()], value)
+
+
+def escape_arg(arg):
+ """
+ escape_arg(arg) --> escaped_arg
+
+ This performs string escaping that can be used with SimpleTokenizer.
+ (It isn't sufficient for passing strings to a shell.)
+ """
+ if arg.find('"') >= 0:
+ if arg.find("'") >= 0:
+ s = re.sub(r'\\', r'\\\\', arg)
+ s = re.sub("'", "\\'", s)
+ return "'%s'" % (s,)
+ else:
+ return "'%s'" % (arg,)
+ elif arg.find("'") >= 0:
+ return '"%s"' % (arg,)
+ else:
+ return arg
+
+
+def escape_args(args):
+ return ' '.join([escape_arg(a) for a in args])
13 src/gitreview/git/README
@@ -0,0 +1,13 @@
+This package provides a python API for accessing a git repository. It is very
+similar in purpose to the GitPython library: http://gitorious.org/git-python
+Eventually it would be nice to switch to using GitPython, since it has a larger
+community supporting it.
+
+One of the bigger things that will probably require changing are the special
+":wd" and ":0" commit names used by this package. The review code currently
+uses these to access the working directory and index as if they were commits.
+The review code will probably need to migrate away from this behavior in order
+to switch to GitPython.
+
+There may also be a few features in this package that need to be merged into
+GitPython in order to make the switch.
156 src/gitreview/git/__init__.py
@@ -0,0 +1,156 @@
+#!/usr/bin/python -tt
+#
+# Copyright 2009-2010 Facebook, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+#
+"""
+This is a python package for interacting with git repositories.
+"""
+
+import os
+
+# Import all of the constants and exception types into the current namespace
+from .constants import *
+from .exceptions import *
+
+from . import obj
+from . import commit
+from . import config
+from . import diff
+from . import repo
+
+
+def is_git_dir(path):
+ """
+ is_git_dir(path) --> bool
+
+ Determine if the specified directory is the root of a git repository
+ directory.
+ """
+ # Check to see if the object directory exists.
+ # This is normally a directory called "objects" inside the git directory,
+ # but it can be overridden with the GIT_OBJECT_DIRECTORY environment
+ # variable.
+ if os.environ.has_key('GIT_OBJECT_DIRECTORY'):
+ object_dir = os.environ['GIT_OBJECT_DIRECTORY']
+ else:
+ object_dir = os.path.join(path, 'objects')
+ if not os.path.isdir(object_dir):
+ return False
+
+ # Check for the refs directory
+ if not os.path.isdir(os.path.join(path, 'refs')):
+ return False
+
+ return True
+
+
+def _get_git_dir(git_dir=None, cwd=None):
+ """
+ _get_git_dir(dir=None, cwd=None) --> (git_dir, working_dir)
+
+ Attempt to find the git directory, similarly to the way git does.
+ git_dir should be the git directory explicitly specified on the command
+ line, or None if not explicitly specified.
+
+ If git_dir is not explicitly specified, the GIT_DIR environment variable
+ will be checked. If that is not specified, the current working directory
+ and its parent directories will be searched for a git directory.
+
+ Returns a tuple containing the git directory, and the default working
+ directory. (The default working directory is only to be used if the
+ repository is not bare, and the working directory was not specified
+ explicitly via some other mechanism.) The default working directory
+ may be None if there is no default working directory.
+ """
+ if cwd is None:
+ cwd = os.getcwd()
+
+ # If git_dir wasn't explicitly specified, but GIT_DIR is set in the
+ # environment, use that.
+ if git_dir == None and os.environ.has_key('GIT_DIR'):
+ git_dir = os.environ['GIT_DIR']
+
+ # If the git directory was explicitly specified, use that.
+ # The default working directory is the current working directory
+ if git_dir != None:
+ if not is_git_dir(git_dir):
+ raise NotARepoError(git_dir)
+ return (git_dir, cwd)
+
+ # Otherwise, attempt to find the git directory by searching up from
+ # the current working directory.
+ ceiling_dirs = []
+ if os.environ.has_key('GIT_CEILING_DIRECTORIES'):
+ ceiling_dirs = os.environ['GIT_CEILING_DIRECTORIES'].split(':')
+ ceiling_dirs.append(os.path.sep) # Add the root directory
+
+ dir = os.path.normpath(cwd)
+ while True:
+ # Check to see if this directory contains a .git directory
+ #
+ # TODO: git also accepts regular files called .git that contain
+ # "gitdir: <path>"
+ git_dir = os.path.join(dir, '.git')
+ if os.path.isdir(git_dir):
+ if is_git_dir(git_dir):
+ return (git_dir, dir)
+
+ # Check to see if this directory looks like a git directory
+ if is_git_dir(dir):
+ return (dir, None)
+
+ # Walk up to the parent directory before looping again
+ (parent_dir, rest) = os.path.split(dir)
+
+ # If the parent_dir is one of the ceiling directories,
+ # we should stop before examining it. The current directory
+ # does not appear to be inside a git repository.
+ if parent_dir in ceiling_dirs:
+ raise NotARepoError(cwd)
+
+ dir = parent_dir
+
+
+def get_repo(git_dir=None, working_dir=None):
+ """
+ get_repo(git_dir=None) --> Repository object
+
+ Create a Repository object. The repository is found similarly to the way
+ git itself works:
+ - If git_dir is specified, that is used as the git directory
+ - Otherwise, if the GIT_DIR environment variable is set, that is used as
+ the git directory
+ - Otherwise, the current working directory and its parents are searched to
+ find the git directory
+ """
+ # Find the git directory and the default working directory
+ (git_dir, default_working_dir) = _get_git_dir(git_dir)
+
+ # Load the git configuration for this repository
+ git_config = config.load(git_dir)
+
+ # If working_dir wasn't explicitly specified, but GIT_WORK_TREE is set in
+ # the environment, use that.
+ if working_dir == None and os.environ.has_key('GIT_WORK_TREE'):
+ working_dir = os.environ['GIT_WORK_TREE']
+
+ if working_dir == None:
+ is_bare = git_config.getBool('core.bare', False)
+ if is_bare:
+ working_dir = None
+ else:
+ working_dir = git_config.get('core.worktree', default_working_dir)
+
+ return repo.Repository(git_dir, working_dir, git_config)
322 src/gitreview/git/commit.py
@@ -0,0 +1,322 @@
+#!/usr/bin/python -tt
+#
+# Copyright 2009-2010 Facebook, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+#
+import datetime
+import os
+import time
+
+from .. import proc
+
+from .exceptions import *
+from . import constants
+from . import obj as git_obj
+
+
+class GitTimezone(datetime.tzinfo):
+ """
+ This class represents the timezone part of a git timestamp.
+ Timezones are represented as "-HHMM" or "+HHMM".
+ """
+ def __init__(self, tz_str):
+ self.name = tz_str
+
+ tz = int(tz_str)
+ min_offset = tz % 100
+ hour_offset = tz / 100
+ self.offset = datetime.timedelta(hours = hour_offset,
+ minutes = min_offset)
+
+ def utcoffset(self, dt):
+ return self.offset
+
+ def dst(self, dt):
+ return datetime.timedelta(0)
+
+ def tzname(self, dt):
+ return self.name
+
+
+class AuthorInfo(object):
+ """
+ An AuthorInfo object represents the committer or author information
+ associated with a commit. It contains a name, email address, and
+ timestamp.
+ """
+ def __init__(self, real_name, email, timestamp):
+ self.realName = real_name
+ self.email = email
+ self.timestamp = timestamp
+
+ def __str__(self):
+ return '%s <%s> %s' % (self.realName, self.email, self.timestamp)
+
+
+class Commit(git_obj.Object):
+ """
+ This class represents a git commit.
+
+ Commit objects always contain fully parsed commit information.
+ """
+ def __init__(self, repo, sha1, tree, parents, author, committer, comment):
+ git_obj.Object.__init__(self, repo, sha1, constants.OBJ_COMMIT)
+ self.tree = tree
+ self.parents = parents
+ self.author = author
+ self.committer = committer
+ self.comment = comment
+
+ def __str__(self):
+ return str(self.sha1)
+
+ def __eq__(self, other):
+ if isinstance(other, Commit):
+ # If other is a Commit object, compare the SHA1 hashes
+ return self.sha1 == other.sha1
+ elif isinstance(other, str):
+ # If other is a Commit string, it should be a SHA1 hash
+ # XXX: In the future, we could also check to see if the string
+ # is a ref name, and compare using that.
+ return self.sha1 == other
+ return False
+
+ def getSha1(self):
+ return self.sha1
+
+ def getTree(self):
+ return self.tree
+
+ def getParents(self):
+ return self.parents
+
+ def getAuthor(self):
+ return self.author
+
+ def getCommitter(self):
+ return self.committer
+
+ def getComment(self):
+ return self.comment
+
+ def getSummary(self):
+ return self.comment.split('\n', 1)[0]
+
+
+def _parse_timestamp(value):
+ # Note: we may raise ValueError to the caller
+ (timestamp_str, tz_str) = value.split(' ', 1)
+
+ timestamp = int(timestamp_str)
+ tz = GitTimezone(tz_str)
+
+ return datetime.datetime.fromtimestamp(timestamp, tz)
+
+
+def _parse_author(commit_name, value, type):
+ try:
+ (real_name, rest) = value.split(' <', 1)
+ except ValueError:
+ msg = 'error parsing %s: no email address found' % (type,)
+ raise BadCommitError(commit_name, msg)
+
+ try:
+ (email, rest) = rest.split('> ', 1)
+ except ValueError:
+ msg = 'error parsing %s: unterminated email address' % (type,)
+ raise BadCommitError(commit_name, msg)
+
+ try:
+ timestamp = _parse_timestamp(rest)
+ except ValueError:
+ msg = 'error parsing %s: malformatted timestamp' % (type,)
+ raise BadCommitError(commit_name, msg)
+
+ return AuthorInfo(real_name, email, timestamp)
+
+
+def _parse_header(commit_name, header):
+ tree = None
+ parents = []
+ author = None
+ committer = None
+
+ # We accept the headers in any order.
+ # git itself requires them to be tree, parents, author, committer
+ for line in header.split('\n'):
+ try:
+ (name, value) = line.split(' ', 1)
+ except ValueError:
+ msg = 'bad commit header line %r' % (line)
+ raise BadCommitError(commit_name, msg)
+
+ if name == 'tree':
+ if tree:
+ msg = 'multiple trees specified'
+ raise BadCommitError(commit_name, msg)
+ tree = value
+ elif name == 'parent':
+ parents.append(value)
+ elif name == 'author':
+ if author:
+ msg = 'multiple authors specified'
+ raise BadCommitError(commit_name, msg)
+ author = _parse_author(commit_name, value, name)
+ elif name == 'committer':
+ if committer:
+ msg = 'multiple committers specified'
+ raise BadCommitError(commit_name, msg)
+ committer = _parse_author(commit_name, value, name)
+ else:
+ msg = 'unknown header field %r' % (name,)
+ raise BadCommitError(commit_name, msg)
+
+ if not tree:
+ msg = 'no tree specified'
+ raise BadCommitError(commit_name, msg)
+ if not author:
+ msg = 'no author specified'
+ raise BadCommitError(commit_name, msg)
+ if not committer:
+ msg = 'no committer specified'
+ raise BadCommitError(commit_name, msg)
+
+ return (tree, parents, author, committer)
+
+
+def _get_current_tzinfo():
+ if time.daylight:
+ tz_sec = time.altzone
+ else:
+ tz_sec = time.daylight
+ tz_min = (abs(tz_sec) / 60) % 60
+ tz_hour = abs(tz_sec) / 3600
+ if tz_sec > 0:
+ tz_hour *= -1
+ tz_str = '%+02d%02d' % (tz_hour, tz_min)
+ return GitTimezone(tz_str)
+
+
+def _get_bogus_author():
+ # we could use datetime.datetime.now(),
+ # but this way we don't get microseconds, so it looks more like a regular
+ # git timestamp
+ now = time.localtime()
+ current_tz = _get_current_tzinfo()
+ timestamp = datetime.datetime(now.tm_year, now.tm_mon, now.tm_mday,
+ now.tm_hour, now.tm_min, now.tm_sec, 0,
+ current_tz)
+
+ return AuthorInfo('No Author Yet', 'nobody@localhost', timestamp)
+
+
+def get_index_commit(repo):
+ """
+ get_index_commit(repo) --> commit
+
+ Get a fake Commit object representing the changes currently in the index.
+ """
+ tree = os.path.join(repo.getGitDir(), 'index')
+ parents = [constants.COMMIT_HEAD]
+ author = _get_bogus_author()
+ committer = _get_bogus_author()
+ comment = 'Uncommitted changes in the index'
+ # XXX: it might be better to define a separate class for this
+ return Commit(repo, constants.COMMIT_INDEX, tree, parents,
+ author, committer, comment)
+
+
+def get_working_dir_commit(repo):
+ """
+ get_working_dir_commit(repo) --> commit
+
+ Get a fake Commit object representing the changes currently in the working
+ directory.
+ """
+ tree = repo.getWorkingDir()
+ if not tree:
+ tree = '<none>'
+ parents = [constants.COMMIT_INDEX]
+ author = _get_bogus_author()
+ committer = _get_bogus_author()
+ comment = 'Uncomitted changes in the working directory'
+ # XXX: it might be better to define a separate class for this
+ return Commit(repo, constants.COMMIT_WD, tree, parents, author, committer,
+ comment)
+
+
+def get_commit(repo, name):
+ # Handle the special internal commit names COMMIT_INDEX and COMMIT_WD
+ if name == constants.COMMIT_INDEX:
+ return get_index_commit(repo)
+ elif name == constants.COMMIT_WD:
+ return get_working_dir_commit(repo)
+
+ # Get the SHA1 value for this commit.
+ sha1 = repo.getCommitSha1(name)
+
+ # Run "git cat-file commit <name>"
+ cmd = ['cat-file', 'commit', str(name)]
+ out = repo.runSimpleGitCmd(cmd)
+
+ # Split the header and body
+ try:
+ (header, body) = out.split('\n\n', 1)
+ except ValueError:
+ # split() resulted in just one value
+ # Treat it as headers, with an empty body
+ header = out
+ if header and header[-1] == '\n':
+ header = header[:-1]
+ body = ''
+
+ # Parse the header
+ (tree, parents, author, committer) = _parse_header(name, header)
+
+ return Commit(repo, sha1, tree, parents, author, committer, body)
+
+
+def split_rev_name(name):
+ """
+ Split a revision name into a ref name and suffix.
+
+ The suffix starts at the first '^' or '~' character. These characters
+ may not be part of a ref name. See git-rev-parse(1) for full details.
+
+ For example:
+ split_ref_name('HEAD^^') --> ('HEAD', '^^')
+ split_ref_name('HEAD~10') --> ('HEAD', '~')
+ split_ref_name('master') --> ('master', '')
+ split_ref_name('master^{1}') --> ('master', '^{1}')
+ """
+ # This command shouldn't be called with commit ranges.
+ if name.find('..') > 0:
+ raise BadRevisionNameError(name, 'specifies a commit range, '
+ 'not a single commit')
+
+ caret_idx = name.find('^')
+ tilde_idx = name.find('~')
+ if caret_idx < 0:
+ if tilde_idx < 0:
+ # No suffix
+ return (name, '')
+ else:
+ idx = tilde_idx
+ else:
+ if tilde_idx < 0:
+ idx = caret_idx
+ else:
+ idx = min(caret_idx, tilde_idx)
+
+ return (name[:idx], name[idx:])
125 src/gitreview/git/config.py
@@ -0,0 +1,125 @@
+#!/usr/bin/python -tt
+#
+# Copyright 2009-2010 Facebook, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+#
+from .. import proc
+
+from .exceptions import *
+from . import constants
+
+
+class Config(object):
+ def __init__(self):
+ self.__contents = {}
+
+ def get(self, name, default=NoSuchConfigError):
+ try:
+ value_list = self.__contents[name]
+ except KeyError:
+ if default == NoSuchConfigError:
+ raise NoSuchConfigError(name)
+ return default
+
+ if len(value_list) != 1:
+ # self.__contents shouldn't contain any empty value lists,
+ # so we assume the problem is that there is more than 1 value
+ raise MultipleConfigError(name)
+
+ return value_list[0]
+
+ def getAll(self, name):
+ try:
+ return self.__contents[name]
+ except KeyError:
+ raise NoSuchConfigError(name)
+
+ def getBool(self, name, default=NoSuchConfigError):
+ try:
+ # Don't pass default to self.get()
+ # If name isn't present, we want to return default as-is,
+ # rather without trying to convert it to a bool below.
+ value = self.get(name)
+ except NoSuchConfigError:
+ if default == NoSuchConfigError:
+ raise # re-raise the original error
+ return default
+
+ if value.lower() == "true":
+ return True
+ elif value.lower() == "false":
+ return False
+
+ try:
+ int_value = int(value)
+ except ValueError:
+ raise BadConfigError(name, value)
+
+ if int_value == 1:
+ return True
+ elif int_value == 0:
+ return False
+
+ raise BadConfigError(name, value)
+
+ def set(self, name, value):
+ self.__contents[name] = [value]
+
+ def add(self, name, value):
+ if self.__contents.has_key(name):
+ self.__contents[name].append(value)
+ else:
+ self.__contents[name] = [value]
+
+
+
+def parse(config_output):
+ config = Config()
+
+ lines = config_output.split('\n')
+ for line in lines:
+ if not line:
+ continue
+ (name, value) = line.split('=', 1)
+ config.add(name, value)
+
+ return config
+
+
+def _load(where):
+ cmd = [constants.GIT_EXE, where, 'config', '--list']
+ cmd_out = proc.run_simple_cmd(cmd)
+ return parse(cmd_out)
+
+
+def load(git_dir):
+ # This will return the merged configuration from the specified repository,
+ # as well as the user's global config and the system config
+ where = '--git-dir=' + str(git_dir)
+ return _load(where)
+
+
+def load_file(path):
+ where = '--file=' + str(path)
+ return _load(where)
+
+
+def load_global(path):
+ where = '--global'
+ return _load(where)
+
+
+def load_system(path):
+ where = '--system'
+ return _load(where)
33 src/gitreview/git/constants.py
@@ -0,0 +1,33 @@
+#!/usr/bin/python -tt
+#
+# Copyright 2009-2010 Facebook, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+#
+
+GIT_EXE = 'git'
+
+# Constants for commit names
+COMMIT_HEAD = 'HEAD'
+# COMMIT_WORKING_DIR is not supported by git; it is used only
+# internally by our code.
+COMMIT_WORKING_DIR = COMMIT_WD = ':wd'
+# COMMIT_INDEX is not supported by git; it is used only
+# internally by our code.
+COMMIT_INDEX = ':0'
+
+# Object types
+OBJ_COMMIT = 'commit'
+OBJ_TREE = 'tree'
+OBJ_BLOB = 'blob'
+OBJ_TAG = 'tag'
366 src/gitreview/git/diff.py
@@ -0,0 +1,366 @@
+#!/usr/bin/python -tt
+#
+# Copyright 2009-2010 Facebook, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+#
+import re
+import UserDict
+
+from .. import proc
+
+from .exceptions import *
+from . import constants
+
+
+class Status(object):
+ ADDED = 'A'
+ COPIED = 'C'
+ DELETED = 'D'
+ MODIFIED = 'M'
+ RENAMED = 'R'
+ TYPE_CHANGED = 'T'
+ UNMERGED = 'U'
+ # internally, git also defines 'X' for unknown
+
+ def __init__(self, str_value):
+ if str_value == 'A':
+ self.status = self.ADDED
+ elif str_value.startswith('C'):
+ self.status = self.COPIED
+ self.similarityIndex = self.__parseSimIndex(str_value[1:])
+ elif str_value == 'D':
+ self.status = self.DELETED
+ elif str_value == 'M':
+ self.status = self.MODIFIED
+ elif str_value.startswith('R'):
+ self.status = self.RENAMED
+ self.similarityIndex = self.__parseSimIndex(str_value[1:])
+ elif str_value == 'T':
+ self.status = self.TYPE_CHANGED
+ elif str_value == 'U':
+ self.status = self.UNMERGED
+ else:
+ raise ValueError('unknown status type %r' % (str_value))
+
+ def __parseSimIndex(self, sim_index_str):
+ similarity_index = int(sim_index_str)
+ if similarity_index < 0 or similarity_index > 100:
+ raise ValueError('invalid similarity index %r' % (sim_index_str))
+ return similarity_index
+
+ def getChar(self):
+ """
+ Get the single character representation of this status.
+ """
+ return self.status
+
+ def getDescription(self):
+ """
+ Get the text description of this status.
+ """
+ if self.status == self.ADDED:
+ return 'added'
+ elif self.status == self.COPIED:
+ return 'copied'
+ elif self.status == self.DELETED:
+ return 'deleted'
+ elif self.status == self.MODIFIED:
+ return 'modified'
+ elif self.status == self.RENAMED:
+ return 'renamed'
+ elif self.status == self.TYPE_CHANGED:
+ return 'type changed'
+ elif self.status == self.UNMERGED:
+ return 'unmerged'
+
+ raise ValueError(self.status)
+
+ def __str__(self):
+ if self.status == self.RENAMED or self.status == self.COPIED:
+ return '%s%03d' % (self.status, self.similarityIndex)
+ return self.status
+
+ def __repr__(self):
+ return 'Status(%s)' % (self,)
+
+ def __eq__(self, other):
+ if isinstance(other, Status):
+ # Note: we ignore the similarty index for renames and copies
+ return self.status == other.status
+ # Compare self.status to other.
+ # This allows (status_obj == Status.RENAMED) to work
+ return self.status == other
+
+
+class BlobInfo(object):
+ """Info about a git blob"""
+ def __init__(self, sha1, path, mode):
+ self.sha1 = sha1
+ self.path = path
+ self.mode = mode
+
+
+class DiffEntry(object):
+ def __init__(self, old_mode, new_mode, old_sha1, new_sha1, status,
+ old_path, new_path):
+ self.old = BlobInfo(old_sha1, old_path, old_mode)
+ self.new = BlobInfo(new_sha1, new_path, new_mode)
+ self.status = status
+
+ def __str__(self):
+ if self.status == Status.RENAMED or self.status == Status.COPIED:
+ return 'DiffEntry(%s: %s --> %s)' % \
+ (self.status, self.old.path, self.new.path)
+ else:
+ return 'DiffEntry(%s: %s)' % (self.status, self.getPath())
+
+ def reverse(self):
+ tmp_info = self.old
+ self.old = self.new
+ self.new = tmp_info
+
+ if self.status == Status.ADDED:
+ self.status = Status(Status.DELETED)
+ elif self.status == Status.COPIED:
+ self.status = Status(Status.DELETED)
+ self.new = BlobInfo('0000000000000000000000000000000000000000',
+ None, '000000')
+ elif self.status == Status.DELETED:
+ # Note: we have no way to tell if the file deleted is similar to
+ # an existing file, so we can't tell if the reversed operation
+ # should be Status.COPIED or Status.ADDED. This shouldn't really
+ # be a big issue in practice, however. Needing to reverse info
+ # should be rare, and failing to detect a copy isn't a big deal.
+ self.status = Status(Status.ADDED)
+
+ def getPath(self):
+ if self.new.path:
+ return self.new.path
+ # new.path is None when the status is Status.DELETED,
+ # so return old.path
+ return self.old.path
+
+
+class DiffFileList(UserDict.DictMixin):
+ def __init__(self, parent, child):
+ self.parent = parent
+ self.child = child
+ self.entries = {}
+
+ def add(self, entry):
+ path = entry.getPath()
+ if self.entries.has_key(path):
+ # For unmerged files, "git diff --raw" will output a "U"
+ # line, with the SHA1 IDs set to all 0.
+ # Depending on how the file was changed, it will usually also
+ # output a normal "M" line, too.
+ #
+ # For unmerged entries, merge these two entries.
+ old_entry = self.entries[path]
+ if entry.status == Status.UNMERGED:
+ # Just update the status on the old_entry to UNMERGED.
+ # Keep all other data from the old entry.
+ old_entry.status = Status.UNMERGED
+ return
+ elif old_entry.status == Status.UNMERGED:
+ # Update the new entry's status to Status.UNMERGED, then
+ # fall through and overwrite the old, unmerged entry
+ entry.status = old_entry.status
+ pass
+ else:
+ # We don't expect duplicate entries in any other case.
+ msg = 'diff list already contains an entry for %s' % (path,)
+ raise GitError(msg)
+ self.entries[path] = entry
+
+ def __repr__(self):
+ return 'DiffFileList(' + repr(self.entries) + ')'
+
+ def __getitem__(self, key):
+ return self.entries[key]
+
+ def keys(self):
+ return self.entries.keys()
+
+ def __delitem__(self, key):
+ raise TypeError('DiffFileList is non-modifiable')
+
+ def __setitem__(self, key, value):
+ raise TypeError('DiffFileList is non-modifiable')
+
+ def __iter__(self):
+ # By default, iterate over the values instead of the keys
+ # XXX: This violates the standard pythonic dict-like behavior
+ return self.entries.itervalues()
+
+ def iterkeys(self):
+ # UserDict.DictMixin implements iterkeys() using __iter__
+ # Our __iter__ implementation iterates over values, though,
+ # so we need to redefine iterkeys()
+ return self.entries.iterkeys()
+
+ def __len__(self):
+ return len(self.entries)
+
+ def __nonzero__(self):
+ return bool(self.entries)
+
+
+def get_diff_list(repo, parent, child, paths=None):
+ # Compute the args to specify the commits to 'git diff'
+ reverse = False
+ if parent == constants.COMMIT_WD:
+ if child == constants.COMMIT_WD:
+ # No diffs
+ commit_args = None
+ elif child == constants.COMMIT_INDEX:
+ commit_args = []
+ reverse = True
+ else:
+ commit_args = [str(child)]
+ reverse = True
+ elif parent == constants.COMMIT_INDEX:
+ if child == constants.COMMIT_WD:
+ commit_args = []
+ elif child == constants.COMMIT_INDEX:
+ # No diffs
+ commit_args = None
+ else:
+ commit_args = ['--cached', str(child)]
+ reverse = True
+ elif child == constants.COMMIT_WD:
+ commit_args = [str(parent)]
+ elif child == constants.COMMIT_INDEX:
+ commit_args = ['--cached', str(parent)]
+ else:
+ commit_args = [str(parent), str(child)]
+
+ # The arguments to select by path
+ if paths == None:
+ path_args = []
+ elif not paths:
+ # If paths is the empty list, there is nothing to diff
+ path_args = None
+ else:
+ path_args = paths
+
+ if commit_args == None or path_args == None:
+ # No diffs
+ out = ''
+ else:
+ cmd = ['diff', '--raw', '--abbrev=40', '-z', '-C'] + \
+ commit_args + ['--'] + path_args
+ try:
+ out = repo.runSimpleGitCmd(cmd)
+ except proc.CmdFailedError, ex:
+ match = re.search("bad revision '(.*)'\n", ex.stderr)
+ if match:
+ bad_rev = match.group(1)
+ raise NoSuchCommitError(bad_rev)
+ raise
+
+ fields = out.split('\0')
+ # When the diff is non-empty, it will have a terminating '\0'
+ # Remove the empty field after the last '\0'
+ if fields and not fields[-1]:
+ del fields[-1]
+ num_fields = len(fields)
+
+ entries = DiffFileList(parent, child)
+
+ n = 0
+ while n < num_fields:
+ field = fields[n]
+ # The field should start with ':'
+ if not field or field[0] != ':':
+ msg = 'unexpected output from git diff: ' \
+ 'missing : at start of field %d (%r)' % \
+ (n, field)
+ raise GitError(msg)
+
+ # Split the field into its components
+ parts = field.split(' ')
+ try:
+ (old_mode_str, new_mode_str,
+ old_sha1, new_sha1, status_str) = parts
+ # Strip the leading ':' from old_mode_str
+ old_mode_str = old_mode_str[1:]
+ except ValueError:
+ msg = 'unexpected output from git diff: ' \
+ 'unexpected number of components in field %d (%r)' % \
+ (n, field)
+ raise GitError(msg)
+
+ # Parse the mode fields
+ try:
+ old_mode = int(old_mode_str, 8)
+ except ValueError:
+ msg = 'unexpected output from git diff: ' \
+ 'invalid old mode %r in field %d' % (old_mode_str, n)
+ raise GitError(msg)
+ try:
+ new_mode = int(new_mode_str, 8)
+ except ValueError:
+ msg = 'unexpected output from git diff: ' \
+ 'invalid new mode %r in field %d' % (new_mode_str, n)
+ raise GitError(msg)
+
+ # Parse the status
+ try:
+ status = Status(status_str)
+ except ValueError:
+ msg = 'unexpected output from git diff: ' \
+ 'invalid status %r in field %d' % (status_str, n)
+ raise GitError(msg)
+
+ # Advance n to read the first file name
+ n += 1
+ if n >= num_fields:
+ msg = 'unexpected output from git diff: ' \
+ 'missing file name for field %d' % (n - 1,)
+ raise GitError(msg)
+
+ # Read the file name(s)
+ if status == Status.RENAMED or status == Status.COPIED:
+ old_name = fields[n]
+ # Advance n to read the second file name
+ n += 1
+ if n >= num_fields:
+ msg = 'unexpected output from git diff: ' \
+ 'missing second file name for field %d' % (n,)
+ raise GitError(msg)
+ new_name = fields[n]
+ else:
+ name = fields[n]
+ if status == Status.DELETED:
+ old_name = name
+ new_name = None
+ elif status == Status.ADDED:
+ old_name = None
+ new_name = name
+ else:
+ old_name = name
+ new_name = name
+
+ # Create the DiffEntry
+ entry = DiffEntry(old_mode, new_mode, old_sha1, new_sha1,
+ status, old_name, new_name)
+ if reverse:
+ entry.reverse()
+ entries.add(entry)
+
+ # Advance n, to prepare for the next iteration around the loop
+ n += 1
+
+ return entries
114 src/gitreview/git/exceptions.py
@@ -0,0 +1,114 @@
+#!/usr/bin/python -tt
+#
+# Copyright 2009-2010 Facebook, Inc.
+#
+# Licensed under the Apache License, Version 2.0 (the "License"); you may
+# not use this file except in compliance with the License. You may obtain
+# a copy of the License at
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
+# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
+# License for the specific language governing permissions and limitations
+# under the License.
+#
+class GitError(Exception):
+ pass
+
+
+class NotARepoError(GitError):
+ def __init__(self, repo):
+ msg = 'not a git repository: %s' % (repo,)
+ GitError.__init__(self, msg)
+ self.repo = repo
+
+
+class NoWorkingDirError(GitError):
+ def __init__(self, repo, msg=None):
+ if msg is None:
+ msg = '%s does not have a working directory' % (repo,)
+ GitError.__init__(self, msg)
+ self.repo = repo
+
+
+class NoSuchConfigError(GitError):
+ def __init__(self, name):
+ msg = 'no config value set for "%s"' % (name,)
+ GitError.__init__(self, msg)
+ self.name = name
+
+
+class BadConfigError(GitError):
+ def __init__(self, name, value=None):
+ if value is None:
+ msg = 'bad config value for "%s"' % (name,)
+ else:
+ msg = 'bad config value for "%s": "%s"' % (name, value)
+ GitError.__init__(self, msg)
+ self.name = name
+ self.value = value
+
+
+class MultipleConfigError(GitError):
+ def __init__(self, name):
+ msg = 'multiple config values set for "%s"' % (name,)
+ GitError.__init__(self, msg)
+ self.name = name
+
+
+class BadCommitError(GitError):
+ def __init__(self, commit_name, msg):
+ GitError.__init__(self, 'bad commit %r: %s' % (commit_name, msg))
+ self.commit = commit_name
+ self.msg = msg
+
+
+class NoSuchObjectError(GitError):
+ def __init__(self, name, type='object'):
+ GitError.__init__(self)
+ self.type = type
+ self.name = name
+
+ def __str__(self):
+ return 'no such %s %r' % (self.type, self.name)
+
+
+class NoSuchCommitError(NoSuchObjectError):
+ def __init__(self, name):
+ NoSuchObjectError.__init__(self, name, 'commit')
+
+
+class NoSuchBlobError(NoSuchObjectError):
+ def __init__(self, name):
+ NoSuchObjectError.__init__(self, name, 'blob')
+
+
+class NotABlobError(GitError):
+ def __init__(self, name):
+ GitError.__init__(self, '%r does not refer to a blob' % (name))
+ self.name = name
+
+class BadRevisionNameError(GitError):
+ def __init__(self, name, msg):
+ GitError.__init__(self, 'bad revision name %r: %s' % (name, msg))
+ self.name = name
+ self.msg = msg
+
+
+class AmbiguousArgumentError(GitError):
+ def __init__(self, arg_name, reason):
+ GitError.__init__(self, 'ambiguous argument %r: %s' %
+ (arg_name, reason))
+ self.argName = arg_name
+ self.reason = reason
+
+
+class PatchFailedError(GitError):
+ def __init__(self, msg):
+ full_msg = 'failed to apply patch'
+ if msg:
+ full_msg = ':\n '.join([full_msg] + msg.splitlines())
+ GitError.__init__(self, full_msg)
+ self.msg = msg
56 src/gitreview/git/obj.py
@@ -0,0