""" Convenience class for handling git repos."""
from __future__ import absolute_import
import datetime
import subprocess
import os
import six
import sys
from getpass import getuser
from platform import node
from pynag.Utils import grep
from pynag.Utils import bytes2str
from pynag.errors import PynagError
from six.moves import filter
from six.moves import map
class GitError(PynagError):
"""Base class for errors in this module."""
class GitRepo(object):
def __init__(self, directory, auto_init=True, author_name="Pynag User", author_email=None):
""" Python Wrapper around Git command line.
Args:
Directory (str): Path to the directory does the git repo reside in (i.e. '/etc/nagios')
auto_init (bool): If True and directory does not contain a git repo, create it automatically
author_name (str): Full name of the author making changes
author_email (str): Email used for commit messages, if None, then use username@hostname
"""
self.directory = directory
# Who made the change
if author_name is None or author_name.strip() == '':
author_name = "Pynag User"
if author_email is None or author_email.strip() == '':
author_email = "%s@%s" % (getuser(), node())
self.author_name = author_name
self.author_email = author_email
# Which program did the change
#self.source = source
# Every string in self.messages indicated a line in the eventual commit
# message
self.messages = []
self.ignore_errors = False
self._update_author()
if auto_init:
try:
self._run_command('git status --short')
except PynagError:
t, e = sys.exc_info()[:2]
if e.errorcode == 128:
self.init()
#self._run_command('git status --short')
self._is_dirty = self.is_dirty # Backwards compatibility
def _update_author(self):
""" Updates environment variables GIT_AUTHOR_NAME and EMAIL
Returns:
None
"""
os.environ['GIT_AUTHOR_NAME'] = self.author_name
os.environ['GIT_AUTHOR_EMAIL'] = self.author_email
def _run_command(self, command):
""" Run a specified command from the command line. Return stdout
Args:
command (str): command to execute
Returns:
stdout of the executed command
"""
cwd = self.directory
proc = subprocess.Popen(command, cwd=cwd, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE,)
stdout, stderr = proc.communicate('through stdin to stdout')
stdout = bytes2str(stdout)
stderr = bytes2str(stderr)
returncode = proc.returncode
if returncode > 0 and self.ignore_errors is False:
errorstring = "Command '%s' returned exit status %s.\n stdout: %s \n stderr: %s\n Current user: %s"
errorstring = errorstring % (command, returncode, stdout, stderr, getuser())
raise GitError(errorstring, errorcode=returncode, errorstring=stderr)
return stdout
def is_up_to_date(self):
""" Returns True if all files in git repo are fully commited
Returns:
bool. Git repo is up-to-date
True -- All files are commited
False -- At least one file is not commited
"""
return len(self.get_uncommited_files()) == 0
def get_valid_commits(self):
""" Returns a list of all commit ids from git log
Returns:
List of all valid commit hashes
"""
return [x.get('hash') for x in self.log()]
def get_uncommited_files(self):
""" Returns a list of files that are have unstaged changes
Returns:
List. All files that have unstaged changes.
"""
output = self._run_command("git status --porcelain")
result = []
output = bytes2str(output)
for line in output.split('\n'):
line = line.split(None, 1)
if len(line) < 2:
continue
status, filename = line[0], line[1]
# If file has been renamed, git status shows output in the form of:
# R nrpe.cfg -> nrpe.cfg~
# We want only the last part of the filename
if status == 'R':
filename = filename.split('->')[1].strip()
# If there are special characters in the name, git will double-quote the output
# We will remove those quotes, but we cannot use strip because it will damage:
# files like this: "\"filename with actual doublequotes\""
if filename.startswith('"') and filename.endswith('"'):
filename = filename[1:-1]
result.append({'status': status, 'filename': filename})
return result
def log(self, **kwargs):
""" Returns a log of previous commits. Log is is a list of dict objects.
Any arguments provided will be passed directly to pynag.Utils.grep() to filter the results.
Args:
kwargs: Arguments passed to pynag.Utils.grep()
Returns:
List of dicts. Log of previous commits.
Examples:
self.log(author_name='nagiosadmin')
self.log(comment__contains='localhost')
"""
raw_log = self._run_command("git log --pretty='%H\t%an\t%ae\t%at\t%s'")
result = []
for line in raw_log.splitlines():
line = bytes2str(line)
hash, author, authoremail, authortime, comment = line.split("\t", 4)
result.append({
"hash": hash,
"author_name": author,
"author_email": authoremail,
"author_time": datetime.datetime.fromtimestamp(float(authortime)),
"timestamp": float(authortime),
"comment": comment,
})
return grep(result, **kwargs)
def diff(self, commit_id_or_filename=None):
""" Returns diff (as outputted by "git diff") for filename or commit id.
If commit_id_or_filename is not specified. show diff against all uncommited files.
Args:
commit_id_or_filename (str): git commit id or file to diff with
Returns:
str. git diff for filename or commit id
Raises:
GitError: Invalid commit id or filename was given
"""
if commit_id_or_filename in ('', None):
command = "git diff"
elif os.path.exists(commit_id_or_filename):
commit_id_or_filename = commit_id_or_filename.replace("'", r"\'")
command = "git diff '%s'" % commit_id_or_filename
elif commit_id_or_filename in self.get_valid_commits():
commit_id_or_filename = commit_id_or_filename.replace("'", r"\'")
command = "git diff '%s'" % commit_id_or_filename
else:
raise GitError("%s is not a valid commit id or filename" % commit_id_or_filename)
# Clean single quotes from parameters:
return self._run_command(command)
def show(self, commit_id,):
""" Returns output from "git show" for a specified commit_id
Args:
commit_id (str): Commit id of the commit to display (``git show``)
Returns:
str. Output of ``git show commit_id``
Raises:
GitError: Invalid commit_id was given
"""
if commit_id not in self.get_valid_commits():
raise GitError("%s is not a valid commit id" % commit_id)
command = "git show %s" % commit_id
return self._run_command(command)
def init(self):
""" Initilizes a new git repo (i.e. run "git init") """
self._update_author()
self._run_command("git init")
# Only do initial commit if there are files in the directory
if not os.listdir(self.directory) == ['.git']:
self.commit(message='Initial Commit')
def _git_add(self, filename):
""" Deprecated, use self.add() instead. """
return self.add(filename)
def _git_commit(self, filename, message, filelist=None):
""" Deprecated. Use self.commit() instead."""
if filelist is None:
filelist = []
if not filename is None:
filelist.append(filename)
return self.commit(message=message, filelist=filelist)
def pre_save(self, object_definition, message):
""" Commits object_definition.get_filename() if it has any changes.
This function is called by :py:class:`pynag.Model.EventHandlers` before
calling :py:meth:`pynag.Utils.GitRepo.save`
Args:
object_definition (pynag.Model.ObjectDefinition): object to commit changes
message (str): git commit message as specified in ``git commit -m``
A message from the authors:
*"Since this is still here, either i forgot to remove it, or because
it is here for backwards compatibility, palli"*
"""
filename = object_definition.get_filename()
if self.is_dirty(filename):
self._git_add(filename)
self._git_commit(filename,
message="External changes commited in %s '%s'" %
(object_definition.object_type, object_definition.get_shortname()))
def save(self, object_definition, message):
""" Commits object_definition.get_filename() if it has any changes.
This function is called by :py:class:`pynag.Model.EventHandlers`
Args:
object_definition (pynag.Model.ObjectDefinition): object to commit changes
message (str): git commit message as specified in ``git commit -m``
"""
filename = object_definition.get_filename()
if len(self.messages) > 0:
message = [message, '\n'] + self.messages
message = '\n'.join(message)
self._git_add(filename)
if self.is_dirty(filename):
self._git_commit(filename, message)
self.messages = []
def is_dirty(self, filename):
""" Returns True if filename needs to be committed to git
Args:
filename (str): file to check
"""
command = "git status --porcelain '%s'" % filename
output = self._run_command(command)
# Return True if there is any output
return len(output) > 0
def write(self, object_definition, message):
""" This method is called whenever :py:class:`pynag.Model.EventHandlers`
is called.
Args:
object_definition (pynag.Model.ObjectDefinition): Object to write to file.
message (str): git commit message as specified in ``git commit -m``
"""
# When write is called ( something was written to file )
# We will log it in a buffer, and commit when save() is called.
self.messages.append(" * %s" % message)
def revert(self, commit):
""" Revert some existing commits works like "git revert" """
commit = commit.replace(r"'", r"\'")
command = "git revert --no-edit -- '%s'" % commit
return self._run_command(command)
def commit(self, message='commited by pynag', filelist=None, author=None):
""" Commit files with "git commit"
Args:
message (str): Message used for the git commit
filelist (list of strings): List of filenames to commit (if None,
then commit all files in the repo)
author (str): Author to use for git commit. If any is specified,
overwrite self.author_name and self.author_email
Returns:
stdout from the "git commit" shell command.
"""
# Lets escape all single quotes from the message
message = message.replace("'", r"\'")
if author is None:
author = "%s <%s>" % (self.author_name, self.author_email)
# Escape all single quotes in author:
author = author.replace("'", r"\'")
if filelist is None:
# If no files provided, commit everything
self.add('.')
command = "git commit -a -m '%s' --author='%s'" % (message, author)
return self._run_command(command=command)
elif isinstance(filelist, str):
# in case filelist was provided as a string, consider to be only
# one file
filelist = [filelist]
# Remove from commit list files that have not changed:
filelist = [x for x in filelist if self.is_dirty(x)]
# Run "git add" on every file. Just in case they are untracked
self.ignore_errors = True
for i in filelist:
self.add(i)
self.ignore_errors = False
# Change ['file1','file2'] into the string """ 'file1' 'file2' """
filestring = ''
# Escape all single quotes in filenames
filelist = [x.replace("'", r"\'") for x in filelist]
# Wrap filename inside single quotes:
filelist = ["'%s'" % x for x in filelist]
# If filelist is empty, we have nothing to commit and we will return as
# opposed to throwing error
if not filelist:
return
# Create a space seperated string with the filenames
filestring = ' '.join(filelist)
command = "git commit -m '%s' --author='%s' -- %s" % (message, author, filestring)
return self._run_command(command=command)
def add(self, filename):
""" Run git add on filename
Args:
filename (str): name of one file to add,
Returns:
str. The stdout from "git add" shell command.
"""
# Escape all single quotes in filename:
filename = filename.replace("'", r"\'")
command = "git add -- '%s'" % filename
return self._run_command(command)