#!/usr/bin/env python # # A git command that opens an editor to stage or unstage files. # # Home page: # # https://github.com/s3rvac/git-edit-index # # License: # # The MIT License (MIT) # # Copyright (c) 2015 Petr Zemek and contributors. # # Permission is hereby granted, free of charge, to any person obtaining a # copy of this software and associated documentation files (the "Software"), # to deal in the Software without restriction, including without limitation # the rights to use, copy, modify, merge, publish, distribute, sublicense, # and/or sell copies of the Software, and to permit persons to whom the # Software is furnished to do so, subject to the following conditions: # # The above copyright notice and this permission notice shall be included in # all copies or substantial portions of the Software. # # THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR # IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, # FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL # THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER # LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING # FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER # DEALINGS IN THE SOFTWARE. # import argparse import os import re import shutil import subprocess import sys import tempfile # In Python 2.x, we have to use raw_input() instead of input() because the # latter evaluates the entered string while the former just returns it. In # Python 3.x, raw_input() was renamed to input() and the evaluating version was # removed. try: from __builtin__ import raw_input as input except ImportError: # Import input() even in Python 3.x to make the mocking of # 'git_edit_index.input' in unit tests working. from builtins import input __version__ = '0.7' class Index(list): """Representation of a git index. It is represented as a list of index entries. """ def entry_for(self, file): """Returns the entry for the given file.""" for entry in self: if entry.file == file: return entry return NoIndexEntry(file) @classmethod def from_text(cls, text, line_sep='\n'): """Creates an index from the given text.""" index = cls() for line in text.split(line_sep): entry = IndexEntry.from_line(line) if entry is not None: index.append(entry) return index def __repr__(self): return '{}({})'.format( self.__class__.__name__, super(Index, self).__repr__() ) def __str__(self): # When there are entries, ensure that the resulting string ends with a # newline. Otherwise, when the last entry does not end with a newline, # some editors may have problems displaying it. s = '\n'.join(str(entry) for entry in self) return s + '\n' if s else s class IndexEntry(object): """Representation of an entry in the git index.""" def __init__(self, status, file): self.status = status self.file = file @classmethod def from_line(cls, line): """Returns an index entry from the given line of text.""" # Format (the spaces before and after 'M' are relevant, see `man # git-status` for more info): # # git format | our format | meaning # ---------------------------------------- # M FILE | A FILE | added file # M FILE | M FILE | modified file # D FILE | D FILE | deleted file # ?? FILE | ? FILE | untracked file # !! FILE | ! FILE | ignored file # # The script also supports the following custom status that is not # present in git: # # - | P FILE | use --patch with add/reset # m = re.match(r'(.{2} ?)(.+)', line) if m is None: return None status, file = m.groups() status = status.upper() if re.match(r'(M |A)', status): return cls('A', file) elif re.match(r'( M|M)', status): return cls('M', file) elif re.match(r'( D|D)', status): return cls('D', file) elif re.match(r'\?', status): return cls('?', file) elif re.match(r'!', status): return cls('!', file) elif re.match(r'P', status): return cls('P', file) return None def __repr__(self): return '{}({!r}, {!r})'.format( self.__class__.__name__, self.status, self.file ) def __str__(self): return '{} {}'.format(self.status, self.file) class NoIndexEntry(IndexEntry): """Representation of an entry that does not exist. This class utilizes the Null object design pattern. """ def __init__(self, file): IndexEntry.__init__(self, status=None, file=file) def __repr__(self): return '{}({!r})'.format( self.__class__.__name__, self.file ) def __str__(self): return '- {}'.format(self.file) def current_index(show_ignored=None): """Returns the current index of the git repository.""" return Index.from_text(git_status(show_ignored=show_ignored), line_sep='\0') def git_status(show_ignored=None): """Returns the current status of the git repository as text, where individual lines are separated by the null byte. """ # Arguments --porcelain and -z are needed to make the output # parsing-friendly. They (1) cause paths to be shown relatively from # the repository's root (thus disregarding user's preferences), (2) use # the null byte to separate lines instead of LF, and (3) prevent # formatting of special characters. See `main git-status` for more # details. cmd = ['git', 'status', '--porcelain', '-z'] if show_ignored is not None: cmd.append('--ignored=' + show_ignored) return subprocess.check_output(cmd, universal_newlines=True) def edit_index(index): """Edits the given index by showing it to the user in an editor and returns the edited version. The original index is not modified. """ # We need to use a temporary file to store the current index and show it to # the user in an editor. tmp_fd, tmp_path = tempfile.mkstemp(prefix='git-edit-index-') try: # Due to file locking on Windows, we need to write the index and close # the temporary file before we open the editor. Otherwise, the editor # would not be able to read or change the file. # # In Python 2.7, os.fdopen() does not support passing 'mode' as a # keyword argument (see #3), so we have to pass it as a positional # argument. with os.fdopen(tmp_fd, 'w') as f: f.write(str(index)) subprocess.call(editor_cmd() + [tmp_path]) with open(tmp_path, mode='r') as f: return Index.from_text(f.read(), line_sep='\n') finally: os.remove(tmp_path) def editor_cmd(): """Returns a command to start an editor.""" editor = subprocess.check_output( ['git', 'var', 'GIT_EDITOR'], universal_newlines=True ) # The editor may include parameters (such as 'gvim -f'), so split it to # get a complete command. return editor.split() def reflect_index_changes(orig_index, new_index): """Reflects changes in the given index.""" for orig_entry, new_entry in changed_entries(orig_index, new_index): reflect_index_change(orig_entry, new_entry) def changed_entries(orig_index, new_index): """Generates entries that differ in the given indexes as pairs (original entry, new entry). """ for orig_entry in orig_index: new_entry = new_index.entry_for(orig_entry.file) if orig_entry.status != new_entry.status: yield orig_entry, new_entry def reflect_index_change(orig_entry, new_entry): """Reflects the change of the given entry. This function assumes that the status of the given entry has changed. """ if new_entry.status is None: # The file is not present in the new index, so either delete it or # revert the changes done to the file since the last commit, depending # on its original status. if orig_entry.status == 'A': perform_git_action('reset', orig_entry.file) # Ignore stderr because the file might originally be untracked, in # which case the checkout would result in an error ('error: # pathspec X did not match any file(s) known to git'). perform_git_action('checkout', orig_entry.file, ignore_stderr=True) elif orig_entry.status in ['D', 'M']: perform_git_action('checkout', orig_entry.file) elif orig_entry.status in ['?', '!']: remove(orig_entry.file) elif new_entry.status == 'A': perform_git_action(['add', '-f'], new_entry.file) elif new_entry.status in ['D', 'M']: perform_git_action('reset', new_entry.file) elif new_entry.status == '?': perform_git_action(['rm', '--cached'], new_entry.file) # 'P' is a custom status that is not present in git. It signalizes that # add/reset with --patch should be used. elif new_entry.status == 'P': # Do not ignore stdout because --patch needs it to print the hunks. if orig_entry.status == 'A': perform_git_action(['reset', '--patch'], new_entry.file, ignore_stdout=False) elif orig_entry.status in ['D', 'M']: perform_git_action(['add', '--patch'], new_entry.file, ignore_stdout=False) def full_path_to(file): """Returns an absolute path to the given file.""" # We need to use full paths to files because `git status --porcelain` shows # paths relative to the repository's root. return os.path.join(repository_path(), file) def remove(file): """Removes the given file, symlink, or directory from the filesytem.""" file = full_path_to(file) if os.path.islink(file) or os.path.isfile(file): os.remove(file) else: shutil.rmtree(file) def perform_git_action(action, file, ignore_stdout=True, ignore_stderr=False): """Performs the given git action over the given file.""" if isinstance(action, str): action = [action] # '--' prevents confusion when the file looks like a branch or tag. subprocess.call( ['git'] + action + ['--', full_path_to(file)], stdout=subprocess.PIPE if ignore_stdout else None, stderr=subprocess.PIPE if ignore_stderr else None ) def repository_path(): """Returns a path to the top-level directory of the repository. """ path = subprocess.check_output( ['git', 'rev-parse', '--show-toplevel'], universal_newlines=True ) return path.strip() def should_reflect_changes_on_empty_buffer(): """Should we reflect changes when the editor buffer is empty?""" OPTION = 'git-edit-index.onEmptyBuffer' on_empty_buffer = value_for_config_option(OPTION) if on_empty_buffer == 'act': return True elif on_empty_buffer == 'nothing': return False elif on_empty_buffer == 'ask' or on_empty_buffer is None: return ask_user_whether_reflect_changes_on_empty_buffer() handle_unsupported_config_option_value(OPTION, on_empty_buffer) def ask_user_whether_reflect_changes_on_empty_buffer(): """Asks the user whether changes should be reflected when the editor buffer is empty. """ answer = input('The buffer is empty. Apply the changes? [y/N] ') return answer.lower() == 'y' def handle_unsupported_config_option_value(option, value): """Handles a situation when the given value of the given option is unsupported. """ # React in the same way as git reacts when it encounters an invalid value. error_msg = 'error: unsupported config value {!r} for {!r}\n'.format( value, option ) sys.stderr.write(error_msg) sys.exit(1) def value_for_config_option(option): """Returns the value of the given config option. If there is no value for the given option, None is returned. """ try: value = subprocess.check_output( ['git', 'config', option], universal_newlines=True ) return value.strip() except subprocess.CalledProcessError: # The given option does not exist (`git config` ends with a non-zero # return code in such a case). return None def parse_args(argv): """Parses the given argument list.""" parser = argparse.ArgumentParser( description=(""" Opens an editor to stage or unstage files in a git repository. """) ) parser.add_argument( '-V', '--version', action='version', version='%(prog)s {}'.format(__version__) ) parser.add_argument( '--ignored', nargs='?', dest='ignored', const='traditional', choices=('traditional', 'no', 'matching'), help='show ignored files as well' ) return parser.parse_args(argv) def main(argv): args = parse_args(argv[1:]) orig_index = current_index(show_ignored=args.ignored) if not orig_index: # There is nothing in the index, so do not bother the user with an # empty editor. return new_index = edit_index(orig_index) if not new_index: if not should_reflect_changes_on_empty_buffer(): return reflect_index_changes(orig_index, new_index) if __name__ == '__main__': try: main(sys.argv) except subprocess.CalledProcessError as ex: # An external command (e.g. git) failed. For example, this may happen # when `git edit-index` is run outside of a git repository. In such a # case, simply exit the script because we assume that the failed # command has printed an error to stderr. For example, when git fails, # it prints an error to stderr. It would be pointless to show the # backtrace to the user because that would only clutter the screen, # thus making the real error less apparent. sys.exit(ex.returncode)