diff --git a/.gitignore b/.gitignore index 11f92e28..217a73cc 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /settings.conf /logs/*.log -/_source/ \ No newline at end of file +/_source/ +/_data/ \ No newline at end of file diff --git a/couchpotato/cli.py b/couchpotato/cli.py index 575f0972..3abc8f5a 100644 --- a/couchpotato/cli.py +++ b/couchpotato/cli.py @@ -23,6 +23,8 @@ def cmd_couchpotato(base_path, args): dest = 'quiet', help = "Don't log to console") parser.add_argument('-d', '--daemon', action = 'store_true', dest = 'daemonize', help = 'Daemonize the app') + parser.add_argument('-g', '--nogit', action = 'store_true', + dest = 'git', help = 'Running from git') options = parser.parse_args(args) @@ -46,6 +48,7 @@ def cmd_couchpotato(base_path, args): # Register environment settings from couchpotato.environment import Env Env.get('settings').setFile(os.path.join(options.data_dir, 'settings.conf')) + Env.set('uses_git', not options.git) Env.set('app_dir', base_path) Env.set('data_dir', options.data_dir) Env.set('log_path', os.path.join(log_dir, 'CouchPotato.log')) diff --git a/couchpotato/core/downloaders/blackhole/main.py b/couchpotato/core/downloaders/blackhole/main.py index 21712d34..922dbcee 100644 --- a/couchpotato/core/downloaders/blackhole/main.py +++ b/couchpotato/core/downloaders/blackhole/main.py @@ -34,6 +34,7 @@ class Blackhole(Downloader): log.debug('Failed download file: %s' % data.get('name')) return False else: + log.info('Downloading: %s' % data.get('url')) file = urllib.urlopen(data.get('url')).read() with open(fullPath, 'wb') as f: diff --git a/couchpotato/core/notifications/base.py b/couchpotato/core/notifications/base.py index ea577ab3..85c762e4 100644 --- a/couchpotato/core/notifications/base.py +++ b/couchpotato/core/notifications/base.py @@ -1,20 +1,47 @@ +from couchpotato.api import addApiView from couchpotato.core.event import addEvent from couchpotato.core.helpers.request import jsonified +from couchpotato.core.logger import CPLog from couchpotato.core.plugins.base import Plugin +log = CPLog(__name__) + class Notification(Plugin): default_title = 'CouchPotato' test_message = 'ZOMG Lazors Pewpewpew!' + listen_to = [] + dont_listen_to = [] + def __init__(self): addEvent('notify', self.notify) + addEvent('notify.%s' % self.getName().lower(), self.notify) - def notify(self, message = '', data = {}): + addApiView(self.testNotifyName(), self.test) + + def notify(self, message = '', data = {}, type = ''): pass def test(self): - success = self.notify(message = self.test_message) - return jsonified({'success': success}) + test_type = self.testNotifyName() + + log.info('Sending test to %s' % test_type) + + success = self.notify( + message = self.test_message, + data = {}, + type = test_type + ) + + #return jsonified({'success': success}) + + def dontNotify(self, type = ''): + return (not type in self.listen_to and len(self.listen_to) == 0 and type != self.testNotifyName()) \ + or type in self.dont_listen_to \ + or self.isDisabled() + + def testNotifyName(self): + return 'notify.%s.test' % self.getName().lower() diff --git a/couchpotato/core/notifications/core/main.py b/couchpotato/core/notifications/core/main.py index cbb272e9..1756d2fd 100644 --- a/couchpotato/core/notifications/core/main.py +++ b/couchpotato/core/notifications/core/main.py @@ -2,13 +2,13 @@ from couchpotato.api import addApiView from couchpotato.core.event import addEvent, fireEvent from couchpotato.core.helpers.request import jsonified from couchpotato.core.logger import CPLog -from couchpotato.core.plugins.base import Plugin +from couchpotato.core.notifications.base import Notification import time log = CPLog(__name__) -class CoreNotifier(Plugin): +class CoreNotifier(Notification): messages = [] @@ -21,7 +21,7 @@ class CoreNotifier(Plugin): self.registerStatic(__file__) - def notify(self, message = '', data = {}): + def notify(self, message = '', data = {}, type = None): self.add(data = { 'message': message, 'raw': data, diff --git a/couchpotato/core/notifications/growl/main.py b/couchpotato/core/notifications/growl/main.py index c17f270a..76d44936 100644 --- a/couchpotato/core/notifications/growl/main.py +++ b/couchpotato/core/notifications/growl/main.py @@ -12,19 +12,13 @@ log = CPLog(__name__) class Growl(Notification): - def __init__(self): - addEvent('notify', self.notify) - addEvent('notify.growl', self.notify) - - addApiView('notify.growl.test', self.test) + listen_to = ['movie.downloaded', 'movie.snatched'] def conf(self, attr): return Env.setting(attr, 'growl') - def notify(self, message = '', data = {}): - - if self.isDisabled(): - return + def notify(self, message = '', data = {}, type = None): + if self.dontNotify(type): return hosts = [x.strip() for x in self.conf('host').split(",")] password = self.conf('password') diff --git a/couchpotato/core/notifications/history/__init__.py b/couchpotato/core/notifications/history/__init__.py new file mode 100644 index 00000000..f4c53efa --- /dev/null +++ b/couchpotato/core/notifications/history/__init__.py @@ -0,0 +1,6 @@ +from .main import History + +def start(): + return History() + +config = [] diff --git a/couchpotato/core/notifications/history/main.py b/couchpotato/core/notifications/history/main.py new file mode 100644 index 00000000..05d38b8d --- /dev/null +++ b/couchpotato/core/notifications/history/main.py @@ -0,0 +1,33 @@ +from couchpotato import get_session +from couchpotato.core.event import addEvent +from couchpotato.core.logger import CPLog +from couchpotato.core.notifications.base import Notification +from couchpotato.core.settings.model import History as Hist +import time + +log = CPLog(__name__) + + +class History(Notification): + + listen_to = ['movie.downloaded', 'movie.snatched', 'movie.renaming.'] + + def __init__(self): + + addEvent('notify', self.notify) + + addEvent('app.load', self.test) + + + def notify(self, message = '', data = {}, type = None): + if self.dontNotify(type): return + + db = get_session() + history = Hist( + added = int(time.time()), + message = message, + type = type, + release_id = data.get('id', 0) + ) + db.add(history) + db.commit() diff --git a/couchpotato/core/notifications/history/static/history.js b/couchpotato/core/notifications/history/static/history.js new file mode 100644 index 00000000..e69de29b diff --git a/couchpotato/core/notifications/nmj/main.py b/couchpotato/core/notifications/nmj/main.py index 1098d010..32c143e2 100644 --- a/couchpotato/core/notifications/nmj/main.py +++ b/couchpotato/core/notifications/nmj/main.py @@ -1,5 +1,4 @@ from couchpotato.api import addApiView -from couchpotato.core.event import addEvent from couchpotato.core.helpers.request import getParams, jsonified from couchpotato.core.logger import CPLog from couchpotato.core.notifications.base import Notification @@ -19,11 +18,11 @@ log = CPLog(__name__) class NMJ(Notification): - def __init__(self): - addEvent('notify', self.notify) - addEvent('notify.nmj', self.notify) + listen_to = ['movie.downloaded', 'movie.snatched'] + + def __init__(self): + super(NMJ, self).__init__() - addApiView('notify.nmj.test', self.test) addApiView('notify.nmj.auto_config', self.autoConfig) def conf(self, attr): @@ -76,10 +75,8 @@ class NMJ(Notification): 'mount': mount, }) - def notify(self, message = '', data = {}): - - if self.isDisabled(): - return False + def notify(self, message = '', data = {}, type = None): + if self.dontNotify(type): return host = self.conf('host') mount = self.conf('mount') diff --git a/couchpotato/core/notifications/notifo/main.py b/couchpotato/core/notifications/notifo/main.py index 0bea3673..feafc3b4 100644 --- a/couchpotato/core/notifications/notifo/main.py +++ b/couchpotato/core/notifications/notifo/main.py @@ -16,19 +16,13 @@ class Notifo(Notification): url = 'https://api.notifo.com/v1/send_notification' - def __init__(self): - addEvent('notify', self.notify) - addEvent('notify.notifo', self.notify) - - addApiView('notify.notifo.test', self.test) + listen_to = ['movie.downloaded', 'movie.snatched'] def conf(self, attr): return Env.setting(attr, 'notifo') - def notify(self, message = '', data = {}): - - if self.isDisabled(): - return False + def notify(self, message = '', data = {}, type = None): + if self.dontNotify(type): return try: data = urllib.urlencode({ diff --git a/couchpotato/core/notifications/plex/main.py b/couchpotato/core/notifications/plex/main.py index 6756ef01..b93e114b 100644 --- a/couchpotato/core/notifications/plex/main.py +++ b/couchpotato/core/notifications/plex/main.py @@ -10,16 +10,10 @@ log = CPLog(__name__) class Plex(Notification): - def __init__(self): - addEvent('notify', self.notify) - addEvent('notify.plex', self.notify) + listen_to = ['movie.downloaded', 'movie.snatched'] - addApiView('notify.plex.test', self.test) - - def notify(self, message = '', data = {}): - - if self.isDisabled(): - return + def notify(self, message = '', data = {}, type = None): + if self.dontNotify(type): return log.info('Sending notification to Plex') hosts = [x.strip() for x in self.conf('host').split(",")] diff --git a/couchpotato/core/notifications/prowl/main.py b/couchpotato/core/notifications/prowl/main.py index 5886323b..a43ab5e9 100644 --- a/couchpotato/core/notifications/prowl/main.py +++ b/couchpotato/core/notifications/prowl/main.py @@ -1,5 +1,3 @@ -from couchpotato.api import addApiView -from couchpotato.core.event import addEvent from couchpotato.core.helpers.encoding import toUnicode from couchpotato.core.logger import CPLog from couchpotato.core.notifications.base import Notification @@ -11,16 +9,10 @@ log = CPLog(__name__) class Prowl(Notification): - def __init__(self): - addEvent('notify', self.notify) - addEvent('notify.prowl', self.notify) + listen_to = ['movie.downloaded', 'movie.snatched'] - addApiView('notify.prowl.test', self.test) - - def notify(self, message = '', data = {}): - - if self.isDisabled(): - return + def notify(self, message = '', data = {}, type = None): + if self.dontNotify(type): return http_handler = HTTPSConnection('api.prowlapp.com') diff --git a/couchpotato/core/notifications/xbmc/main.py b/couchpotato/core/notifications/xbmc/main.py index 6b86e6e7..ead59b38 100644 --- a/couchpotato/core/notifications/xbmc/main.py +++ b/couchpotato/core/notifications/xbmc/main.py @@ -1,5 +1,3 @@ -from couchpotato.api import addApiView -from couchpotato.core.event import addEvent from couchpotato.core.logger import CPLog from couchpotato.core.notifications.base import Notification import base64 @@ -11,16 +9,10 @@ log = CPLog(__name__) class XBMC(Notification): - def __init__(self): - addEvent('notify', self.notify) - addEvent('notify.xbmc', self.notify) + listen_to = ['movie.downloaded', 'movie.snatched'] - addApiView('notify.xbmc.test', self.test) - - def notify(self, message = '', data = {}): - - if self.isDisabled(): - return + def notify(self, message = '', data = {}, type = None): + if self.dontNotify(type): return for host in [x.strip() for x in self.conf('host').split(",")]: self.send({'command': 'ExecBuiltIn', 'parameter': 'Notification(CouchPotato, %s)' % message}, host) diff --git a/couchpotato/core/plugins/base.py b/couchpotato/core/plugins/base.py index 0d103219..4a804490 100644 --- a/couchpotato/core/plugins/base.py +++ b/couchpotato/core/plugins/base.py @@ -8,7 +8,7 @@ import os.path import re -class Plugin(): +class Plugin(object): def conf(self, attr, default = None): return Env.setting(attr, self.getName().lower(), default = default) @@ -39,4 +39,4 @@ class Plugin(): return not self.isEnabled() def isEnabled(self): - return self.conf('enabled') + return self.conf('enabled') or self.conf('enabled') == None diff --git a/couchpotato/core/plugins/renamer/main.py b/couchpotato/core/plugins/renamer/main.py index 9d4d5caf..c31a5e90 100644 --- a/couchpotato/core/plugins/renamer/main.py +++ b/couchpotato/core/plugins/renamer/main.py @@ -149,6 +149,10 @@ class Renamer(Plugin): if multiple: cd += 1 + # Notify on download + download_message = 'Download of %s (%s) successful.' % (group['library']['titles'][0]['title'], replacements['quality']) + fireEvent('notify', type = 'movie.downloaded', message = download_message, data = replacements) + # Before renaming, remove the lower quality files db = get_session() library = db.query(Library).filter_by(identifier = group['library']['identifier']).first() @@ -172,6 +176,10 @@ class Renamer(Plugin): for rename_me in rename_files: filename = os.path.basename(rename_me) rename_files[rename_me] = rename_me.replace(filename, '_EXISTS_%s' % filename) + + # Notify on rename fail + download_message = 'Renaming of %s (%s) canceled, exists in %s already.' % (movie.library.titles[0].title, group['meta_data']['quality']['label'], release.quality.label) + fireEvent('notify', type = 'movie.renaming.canceled', message = download_message, data = group) break @@ -200,7 +208,7 @@ class Renamer(Plugin): #print rename_me, rename_files[rename_me] - # Search for trailers + # Search for trailers etc fireEvent('renamer.after', group) def moveFile(self, old, dest, suppress = True): diff --git a/couchpotato/core/plugins/searcher/main.py b/couchpotato/core/plugins/searcher/main.py index c61e88d2..74dfbbe7 100644 --- a/couchpotato/core/plugins/searcher/main.py +++ b/couchpotato/core/plugins/searcher/main.py @@ -91,7 +91,6 @@ class Searcher(Plugin): print successful if successful: - log.info('Downloading of %s successful.' % nzb.get('name')) # Mark release as snatched db = get_session() @@ -105,6 +104,9 @@ class Searcher(Plugin): mvie.status_id = snatched_status.get('id') db.commit() + log.info('Downloading of %s successful.' % nzb.get('name')) + fireEvent('notify', type = 'movie.snatched', message = 'Downloading of %s successful.' % nzb.get('name'), data = rls.to_dict()) + return True return False diff --git a/couchpotato/core/plugins/updater/__init__.py b/couchpotato/core/plugins/updater/__init__.py new file mode 100644 index 00000000..80cca632 --- /dev/null +++ b/couchpotato/core/plugins/updater/__init__.py @@ -0,0 +1,30 @@ +from .main import Updater + +def start(): + return Updater() + +config = [{ + 'name': 'updater', + 'groups': [ + { + 'tab': 'general', + 'name': 'updater', + 'label': 'Updater', + 'git_only': True, + 'options': [ + { + 'name': 'enabled', + 'default': True, + 'type': 'enabler', + 'description': 'Enable periodic update checking', + }, + { + 'name': 'automatic', + 'default': False, + 'type': 'enabler', + 'description': 'Automaticly update when update is available', + }, + ], + }, + ], +}] diff --git a/couchpotato/core/plugins/updater/main.py b/couchpotato/core/plugins/updater/main.py new file mode 100644 index 00000000..2f0a4e44 --- /dev/null +++ b/couchpotato/core/plugins/updater/main.py @@ -0,0 +1,79 @@ +from couchpotato.core.event import addEvent, fireEvent +from couchpotato.core.logger import CPLog +from couchpotato.core.plugins.base import Plugin +from couchpotato.environment import Env +from git.repository import LocalRepository +import time + +log = CPLog(__name__) + + +class Updater(Plugin): + + git = 'git://github.com/CouchPotato/CouchPotato.git' + + running = False + version = None + updateFailed = False + updateAvailable = False + updateVersion = None + lastCheck = 0 + + def __init__(self): + + self.repo = LocalRepository(Env.get('app_dir')) + + fireEvent('schedule.interval', 'updater.check', self.check, hours = 6) + + addEvent('app.load', self.check) + + def getVersion(self): + + if not self.version: + try: + output = self.repo.getHead() # Yes, please + log.debug('Git version output: %s' % output.hash) + self.version = output.hash + except Exception, e: + log.error('Failed using GIT updater, running from source, you need to have GIT installed. %s' % e) + return 'No GIT' + + return self.version + + def check(self): + + if self.updateAvailable or self.isDisabled(): + return + + current_branch = self.repo.getCurrentBranch().name + + for branch in self.repo.getRemoteByName('origin').getBranches(): + if current_branch == branch.name: + + local = self.repo.getHead() + remote = branch.getHead() + + if local.getDate() < remote.getDate(): + if self.conf('automatic') and not self.updateFailed: + self.doUpdate() + else: + self.updateAvailable = True + self.updateVersion = remote.hash + + self.lastCheck = time.time() + + def doUpdate(self): + try: + log.info('Updating to latest version'); + self.repo.pull() + return True + except Exception, e: + log.error('Failed updating via GIT: %s' % e) + + self.updateFailed = True + + return False + + def isEnabled(self): + return Plugin.isEnabled(self) and Env.get('uses_git') + \ No newline at end of file diff --git a/couchpotato/core/providers/base.py b/couchpotato/core/providers/base.py index d7941af4..b5d8d298 100644 --- a/couchpotato/core/providers/base.py +++ b/couchpotato/core/providers/base.py @@ -64,7 +64,7 @@ class Provider(Plugin): data = urllib2.urlopen(url).read() except IOError, e: - log.debug(e) + log.error('Failed opening url, %s: %s' % (url, e)) data = '' self.last_use = time.time() diff --git a/couchpotato/core/providers/movie/couchpotato/__init__.py b/couchpotato/core/providers/movie/couchpotato/__init__.py new file mode 100644 index 00000000..37d9eca9 --- /dev/null +++ b/couchpotato/core/providers/movie/couchpotato/__init__.py @@ -0,0 +1,6 @@ +from .main import CouchPotatoApi + +def start(): + return CouchPotatoApi() + +config = [] diff --git a/couchpotato/core/providers/movie/couchpotato/main.py b/couchpotato/core/providers/movie/couchpotato/main.py new file mode 100644 index 00000000..5c68f55a --- /dev/null +++ b/couchpotato/core/providers/movie/couchpotato/main.py @@ -0,0 +1,27 @@ +from couchpotato.core.event import addEvent +from couchpotato.core.logger import CPLog +from couchpotato.core.providers.base import MovieProvider +from flask.helpers import json + +log = CPLog(__name__) + + +class CouchPotatoApi(MovieProvider): + + apiUrl = 'http://couchpotatoapp.com/api/%s/%s/' + + def __init__(self): + + addEvent('provider.movie.release_date', self.releaseDate) + + def releaseDate(self, imdb_id): + + data = self.urlopen(self.apiUrl % ('eta', id)) + + try: + dates = json.loads(data) + log.info('Found ETA for %s: %s' % (imdb_id, dates)) + except Exception, e: + log.error('Error getting ETA for %s: %s' % (imdb_id, e)) + + return dates \ No newline at end of file diff --git a/couchpotato/core/providers/nzb/nzbmatrix/main.py b/couchpotato/core/providers/nzb/nzbmatrix/main.py index 952f4fc2..9c227243 100644 --- a/couchpotato/core/providers/nzb/nzbmatrix/main.py +++ b/couchpotato/core/providers/nzb/nzbmatrix/main.py @@ -111,7 +111,7 @@ class NZBMatrix(NZBProvider, RSS): return results def getApiExt(self): - return '&username=%s&apikey=%s' % (self.conf('username'), self.conf('apikey')) + return '&username=%s&apikey=%s' % (self.conf('username'), self.conf('api_key')) def isEnabled(self): return NZBProvider.isEnabled(self) and self.conf('username') and self.conf('api_key') diff --git a/couchpotato/core/settings/model.py b/couchpotato/core/settings/model.py index 2d10b0f0..d9e11a1e 100644 --- a/couchpotato/core/settings/model.py +++ b/couchpotato/core/settings/model.py @@ -2,8 +2,8 @@ from elixir.entity import Entity from elixir.fields import Field from elixir.options import options_defaults from elixir.relationships import OneToMany, ManyToOne -from libs.elixir.options import using_options -from libs.elixir.relationships import ManyToMany +from elixir.options import using_options +from elixir.relationships import ManyToMany from sqlalchemy.types import Integer, Unicode, UnicodeText, Boolean, Float, \ String @@ -179,7 +179,10 @@ class History(Entity): """History of actions that are connected to a certain release, such as, renamed to, downloaded, deleted, download subtitles etc""" + added = Field(Integer) message = Field(UnicodeText()) + type = Field(Unicode(50)) + release = ManyToOne('Release') diff --git a/couchpotato/environment.py b/couchpotato/environment.py index cb789a34..f444dc7c 100644 --- a/couchpotato/environment.py +++ b/couchpotato/environment.py @@ -4,6 +4,7 @@ from couchpotato.core.settings import Settings class Env: ''' Environment variables ''' + _uses_git = False _debug = False _settings = Settings() _loader = Loader() diff --git a/couchpotato/static/scripts/page/wanted.js b/couchpotato/static/scripts/page/wanted.js index 3ba0f2bf..48d860fe 100644 --- a/couchpotato/static/scripts/page/wanted.js +++ b/couchpotato/static/scripts/page/wanted.js @@ -17,14 +17,6 @@ Page.Wanted = new Class({ }); $(self.wanted).inject(self.el); App.addEvent('library.update', self.wanted.update.bind(self.wanted)); - - // Snatched movies - self.snatched = new MovieList({ - 'status': 'snatched', - 'actions': SnatchedActions - }); - $(self.snatched).inject(self.el); - App.addEvent('library.update', self.snatched.update.bind(self.snatched)); } } diff --git a/libs/git/__init__.py b/libs/git/__init__.py new file mode 100644 index 00000000..636608a4 --- /dev/null +++ b/libs/git/__init__.py @@ -0,0 +1,28 @@ +# Copyright (c) 2009, Rotem Yaari +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of organization nor the +# names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY Rotem Yaari ''AS IS'' AND ANY +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL Rotem Yaari BE LIABLE FOR ANY +# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +from repository import RemoteRepository +from repository import LocalRepository +from repository import clone +from repository import find_repository diff --git a/libs/git/branch.py b/libs/git/branch.py new file mode 100644 index 00000000..ef863dc0 --- /dev/null +++ b/libs/git/branch.py @@ -0,0 +1,76 @@ +# Copyright (c) 2009, Rotem Yaari +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of organization nor the +# names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY Rotem Yaari ''AS IS'' AND ANY +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL Rotem Yaari BE LIABLE FOR ANY +# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +import re +from ref import Ref + +class Branch(Ref): + def delete(self): + raise NotImplementedError() + def __repr__(self): + return "" % (self.name,) +class LocalBranch(Branch): + def delete(self, force=True): + self.repo._executeGitCommandAssertSuccess("git branch -%s %s" % ("D" if force else "d", self.name,)) + def setRemoteBranch(self, branch): + if branch is None: + self.repo.config.unsetParameter('branch.%s.remote' % self.name) + self.repo.config.unsetParameter('branch.%s.merge' % self.name) + return + elif not isinstance(branch, RegisteredRemoteBranch): + raise ValueError("Remote branch must be a remote branch object (got %r)" % (branch,)) + self.repo.config.setParameter('branch.%s.remote' % self.name, branch.remote.name) + self.repo.config.setParameter('branch.%s.merge' % self.name, 'refs/heads/%s' % branch.name) + def getRemoteBranch(self): + remote = self.repo.config.getParameter('branch.%s.remote' % self.name) + if remote is None: + return None + remote = self.repo.getRemoteByName(remote) + merge = self.repo.config.getParameter('branch.%s.merge' % self.name) + merge = re.sub('^refs/heads/', '', merge) + remote_branch = remote.getBranchByName(merge) + return remote_branch + +class LocalBranchAlias(LocalBranch): + def __init__(self, repository, name, dest): + super(LocalBranchAlias, self).__init__(repository, name) + self.dest = dest + +class RemoteBranch(Branch): + pass +class RegisteredRemoteBranch(RemoteBranch): + def __init__(self, repo, remote, name): + super(RegisteredRemoteBranch, self).__init__(repo, name) + self.remote = remote + def getHead(self): + return self.repo._getCommitByRefName("%s/%s" % (self.remote.name, self.name)) + def delete(self): + """ + Deletes the actual branch on the remote repository! + """ + self.repo.push(self.remote, fromBranch="", toBranch=self, force=True) + def getNormalizedName(self): + return "%s/%s" % (self.remote.name, self.name) + def __repr__(self): + return "" % (self.name, self.remote.name) diff --git a/libs/git/commit.py b/libs/git/commit.py new file mode 100644 index 00000000..35258f44 --- /dev/null +++ b/libs/git/commit.py @@ -0,0 +1,75 @@ +# Copyright (c) 2009, Rotem Yaari +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of organization nor the +# names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY Rotem Yaari ''AS IS'' AND ANY +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL Rotem Yaari BE LIABLE FOR ANY +# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +from .ref import Ref +from .files import ModifiedFile + +SHA1_LENGTH = 40 + +class Commit(Ref): + def __init__(self, repo, sha): + sha = str(sha).lower() + if len(sha) < SHA1_LENGTH: + sha = repo._getCommitByPartialHash(sha).hash + super(Commit, self).__init__(repo, sha) + self.hash = sha + def __repr__(self): + return self.hash + def __eq__(self, other): + if not isinstance(other, Commit): + if isinstance(other, Ref): + other = other.getHead().hash + else: + other = other.hash + if other is None: + return False + if not isinstance(other, basestring): + raise TypeError("Comparing %s and %s" % (type(self), type(other))) + return (self.hash == other.lower()) + def getParents(self): + output = self.repo._getOutputAssertSuccess("git rev-list %s --parents -1" % self) + return [Commit(self.repo, sha.strip()) for sha in output.split()[1:]] + def getChange(self): + returned = [] + for line in self.repo._getOutputAssertSuccess("git show --pretty=format: --raw %s" % self).splitlines(): + line = line.strip() + if not line: + continue + filename = line.split()[-1] + returned.append(ModifiedFile(filename)) + return returned + getChangedFiles = getChange + ############################ Misc. Commit attributes ########################### + def _getCommitField(self, field): + return self.repo._executeGitCommandAssertSuccess("git log -1 --pretty=format:%s %s" % (field, self)).stdout.read().strip() + def getAuthorName(self): + return self._getCommitField("%an") + def getAuthorEmail(self): + return self._getCommitField("%ae") + def getDate(self): + return int(self._getCommitField("%at")) + def getSubject(self): + return self._getCommitField("%s") + def getMessageBody(self): + return self._getCommitField("%b") diff --git a/libs/git/config.py b/libs/git/config.py new file mode 100644 index 00000000..18872b06 --- /dev/null +++ b/libs/git/config.py @@ -0,0 +1,43 @@ +# Copyright (c) 2009, Rotem Yaari +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of organization nor the +# names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY Rotem Yaari ''AS IS'' AND ANY +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL Rotem Yaari BE LIABLE FOR ANY +# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +from .exceptions import GitCommandFailedException + +class GitConfiguration(object): + def __init__(self, repo): + super(GitConfiguration, self).__init__() + self.repo = repo + def setParameter(self, path, value, local=True): + self.repo._executeGitCommandAssertSuccess("git config %s \"%s\" \"%s\"" % ("" if local else "--global", path, value)) + def unsetParameter(self, path, local=True): + try: + self.repo._executeGitCommandAssertSuccess("git config --unset %s \"%s\"" % ("" if local else "--global", path)) + except GitCommandFailedException: + if self.getParameter(path) is not None: + raise + def getParameter(self, path): + return self.getDict().get(path, None) + def getDict(self): + return dict(line.strip().split("=",1) + for line in self.repo._getOutputAssertSuccess("git config -l").splitlines()) diff --git a/libs/git/exceptions.py b/libs/git/exceptions.py new file mode 100644 index 00000000..4c4185cb --- /dev/null +++ b/libs/git/exceptions.py @@ -0,0 +1,53 @@ +# Copyright (c) 2009, Rotem Yaari +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of organization nor the +# names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY Rotem Yaari ''AS IS'' AND ANY +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL Rotem Yaari BE LIABLE FOR ANY +# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +import os + +class GitException(Exception): + def __init__(self, msg): + super(GitException, self).__init__() + self.msg = msg + def __repr__(self): + return "%s: %s" % (type(self).__name__, self.msg) + __str__ = __repr__ + +class CannotFindRepository(GitException): + pass + +class MergeConflict(GitException): + def __init__(self, msg='Merge Conflict'): + super(MergeConflict, self).__init__(msg=msg) + +class GitCommandFailedException(GitException): + def __init__(self, directory, command, popen): + super(GitCommandFailedException, self).__init__(None) + self.command = command + self.directory = os.path.abspath(directory) + self.stderr = popen.stderr.read() + self.stdout = popen.stdout.read() + self.popen = popen + self.msg = "Command %r failed in %s (%s):\n%s\n%s" % (command, self.directory, popen.returncode, + self.stderr, self.stdout) +class NonexistentRefException(GitException): + pass diff --git a/libs/git/files.py b/libs/git/files.py new file mode 100644 index 00000000..854dde69 --- /dev/null +++ b/libs/git/files.py @@ -0,0 +1,32 @@ +# Copyright (c) 2009, Rotem Yaari +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of organization nor the +# names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY Rotem Yaari ''AS IS'' AND ANY +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL Rotem Yaari BE LIABLE FOR ANY +# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +class ModifiedFile(object): + def __init__(self, filename): + super(ModifiedFile, self).__init__() + self.filename = filename + def __repr__(self): + return self.filename + def __eq__(self, other): + return isinstance(other, ModifiedFile) and other.filename == self.filename diff --git a/libs/git/ref.py b/libs/git/ref.py new file mode 100644 index 00000000..ee16af1d --- /dev/null +++ b/libs/git/ref.py @@ -0,0 +1,59 @@ +# Copyright (c) 2009, Rotem Yaari +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of organization nor the +# names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY Rotem Yaari ''AS IS'' AND ANY +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL Rotem Yaari BE LIABLE FOR ANY +# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +class Ref(object): + def __init__(self, repo, name): + super(Ref, self).__init__() + self.repo = repo + self.name = name + def getHead(self): + return self.repo._getCommitByRefName(self.name) + def getNormalizedName(self): + return self.name + def getNewCommits(self, comparedTo, limit=""): + returned = [] + command = "git cherry %s %s %s" % (self.repo._normalizeRefName(comparedTo), + self.getNormalizedName(), + self.repo._normalizeRefName(limit)) + for line in self.repo._getOutputAssertSuccess(command).splitlines(): + symbol, sha = line.split() + if symbol == '-': + #already has an equivalent commit + continue + returned.append(self.repo._getCommitByHash(sha.strip())) + return returned + def __eq__(self, ref): + return (type(ref) is type(self) and ref.name == self.name) + def __ne__(self, ref): + return not (self == ref) + def __repr__(self): + return "<%s %s>" % (type(self).__name__, self.getNormalizedName()) + ################################## Containment ################################# + def getMergeBase(self, other): + return self.repo.getMergeBase(self, other) + __and__ = getMergeBase + def contains(self, other): + return self.getMergeBase(other) == other + __contains__ = contains + diff --git a/libs/git/ref_container.py b/libs/git/ref_container.py new file mode 100644 index 00000000..250079d9 --- /dev/null +++ b/libs/git/ref_container.py @@ -0,0 +1,45 @@ +# Copyright (c) 2009, Rotem Yaari +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of organization nor the +# names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY Rotem Yaari ''AS IS'' AND ANY +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL Rotem Yaari BE LIABLE FOR ANY +# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +from . import exceptions + +class RefContainer(object): + def getBranches(self): + raise NotImplementedError() + def getTags(self): + raise NotImplementedError() + ########################### Looking for specific refs ########################## + def _getByName(self, func, name): + for ref in func(): + if ref.name == name: + return ref + raise exceptions.NonexistentRefException(name) + def getBranchByName(self, name): + return self._getByName(self.getBranches, name) + def hasBranch(self, name): + try: + self.getBranchByName(name) + return True + except exceptions.NonexistentRefException: + return False diff --git a/libs/git/remotes.py b/libs/git/remotes.py new file mode 100644 index 00000000..8bc28615 --- /dev/null +++ b/libs/git/remotes.py @@ -0,0 +1,51 @@ +# Copyright (c) 2009, Rotem Yaari +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of organization nor the +# names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY Rotem Yaari ''AS IS'' AND ANY +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL Rotem Yaari BE LIABLE FOR ANY +# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +from . import branch +from . import ref_container + +class Remote(ref_container.RefContainer): + def __init__(self, repo, name, url): + super(Remote, self).__init__() + self.repo = repo + self.name = name + self.url = url + def fetch(self): + self.repo._executeGitCommandAssertSuccess("git fetch %s" % self.name) + def prune(self): + self.repo._executeGitCommandAssertSuccess("git remote prune %s" % self.name) + def __eq__(self, other): + return (type(self) is type(other)) and (self.name == other.name) + ###################### For compatibility with RefContainer ##################### + def getBranches(self): + prefix = "%s/" % self.name + returned = [] + for line in self.repo._getOutputAssertSuccess("git branch -r").splitlines(): + if self.repo.getGitVersion() >= '1.6.3' and ' -> ' in line: + continue + line = line.strip() + if line.startswith(prefix): + returned.append(branch.RegisteredRemoteBranch(self.repo, self, line[len(prefix):])) + return returned + diff --git a/libs/git/repository.py b/libs/git/repository.py new file mode 100644 index 00000000..84779fd8 --- /dev/null +++ b/libs/git/repository.py @@ -0,0 +1,399 @@ +# Copyright (c) 2009, Rotem Yaari +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of organization nor the +# names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY Rotem Yaari ''AS IS'' AND ANY +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL Rotem Yaari BE LIABLE FOR ANY +# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +import re +import os +import subprocess +import sys + +from . import branch +from . import commit +from . import config +from .files import ModifiedFile +from . import ref +from . import ref_container +from . import remotes +from .utils import quote_for_shell +from .utils import CommandString as CMD + +#exceptions +from .exceptions import CannotFindRepository +from .exceptions import GitException +from .exceptions import GitCommandFailedException +from .exceptions import MergeConflict + +BRANCH_ALIAS_MARKER = ' -> ' + +class Repository(ref_container.RefContainer): + ############################# internal methods ############################# + _loggingEnabled = False + def _getWorkingDirectory(self): + return '.' + def _logGitCommand(self, command, cwd): + if self._loggingEnabled: + print >> sys.stderr, ">>", command + def enableLogging(self): + self._loggingEnabled = True + def disableLogging(self): + self._loggingEnabled = False + def _executeGitCommand(self, command, cwd=None): + if cwd is None: + cwd = self._getWorkingDirectory() + command = str(command) + self._logGitCommand(command, cwd) + returned = subprocess.Popen(command, + shell=True, + cwd=cwd, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE) + returned.wait() + return returned + def _executeGitCommandAssertSuccess(self, command, **kwargs): + returned = self._executeGitCommand(command, **kwargs) + assert returned.returncode is not None + if returned.returncode != 0: + raise GitCommandFailedException(kwargs.get('cwd', self._getWorkingDirectory()), command, returned) + return returned + def _getOutputAssertSuccess(self, command, **kwargs): + return self._executeGitCommandAssertSuccess(command, **kwargs).stdout.read() + def _getMergeBase(self, a, b): + raise NotImplementedError() + def getMergeBase(self, a, b): + repo = self + if isinstance(b, commit.Commit) and isinstance(b.repo, LocalRepository): + repo = b.repo + elif isinstance(a, commit.Commit) and isinstance(a.repo, LocalRepository): + repo = a.repo + return repo._getMergeBase(a, b) + + +############################## remote repositories ############################# +class RemoteRepository(Repository): + def __init__(self, url): + super(RemoteRepository, self).__init__() + self.url = url + def _getRefs(self, prefix): + output = self._executeGitCommandAssertSuccess("git ls-remote %s" % (self.url,)) + for output_line in output.stdout: + commit, refname = output_line.split() + if refname.startswith(prefix): + yield refname[len(prefix):] + def _getRefsAsClass(self, prefix, cls): + return [cls(self, ref) for ref in self._getRefs(prefix)] + def getBranches(self): + return self._getRefsAsClass('refs/heads/', branch.RemoteBranch) +############################## local repositories ############################## +class LocalRepository(Repository): + def __init__(self, path): + super(LocalRepository, self).__init__() + self.path = path + self.config = config.GitConfiguration(self) + self._version = None + def __repr__(self): + return "" % (self.path,) + def _getWorkingDirectory(self): + return self.path + def _getCommitByHash(self, sha): + return commit.Commit(self, sha) + def _getCommitByRefName(self, name): + return commit.Commit(self, self._getOutputAssertSuccess("git rev-parse %s" % name).strip()) + def _getCommitByPartialHash(self, sha): + return self._getCommitByRefName(sha) + def getGitVersion(self): + if self._version is None: + version_output = self._getOutputAssertSuccess("git version") + version_match = re.match(r"git\s+version\s+(\S+)$", version_output, re.I) + if version_match is None: + raise GitException("Cannot extract git version (unfamiliar output format %r?)" % version_output) + self._version = version_match.group(1) + return self._version + ########################### Initializing a repository ########################## + def init(self, bare=False): + if not os.path.exists(self.path): + os.mkdir(self.path) + if not os.path.isdir(self.path): + raise GitException("Cannot create repository in %s - " + "not a directory" % self.path) + self._executeGitCommandAssertSuccess("git init %s" % ("--bare" if bare else "")) + def _asURL(self, repo): + if isinstance(repo, LocalRepository): + repo = repo.path + elif isinstance(repo, RemoteRepository): + repo = repo.url + elif not isinstance(repo, basestring): + raise TypeError("Cannot clone from %r" % (repo,)) + return repo + def clone(self, repo): + self._executeGitCommandAssertSuccess("git clone %s %s" % (self._asURL(repo), self.path), cwd=".") + ########################### Querying repository refs ########################### + def getBranches(self): + returned = [] + for git_branch_line in self._executeGitCommandAssertSuccess("git branch").stdout: + if git_branch_line.startswith("*"): + git_branch_line = git_branch_line[1:] + git_branch_line = git_branch_line.strip() + if BRANCH_ALIAS_MARKER in git_branch_line: + alias_name, aliased = git_branch_line.split(BRANCH_ALIAS_MARKER) + returned.append(branch.LocalBranchAlias(self, alias_name, aliased)) + else: + returned.append(branch.LocalBranch(self, git_branch_line)) + return returned + def _getCommits(self, specs, includeMerges): + command = "git log --pretty=format:%%H %s" % specs + if not includeMerges: + command += " --no-merges" + for c in self._executeGitCommandAssertSuccess(command).stdout: + yield commit.Commit(self, c.strip()) + def getCommits(self, start=None, end="HEAD", includeMerges=True): + spec = self._normalizeRefName(start or "") + spec += ".." + spec += self._normalizeRefName(end) + return list(self._getCommits(spec, includeMerges=includeMerges)) + def getCurrentBranch(self): + #todo: improve this method of obtaining current branch + for branch_name in self._executeGitCommandAssertSuccess("git branch").stdout: + branch_name = branch_name.strip() + if not branch_name.startswith("*"): + continue + branch_name = branch_name[1:].strip() + if branch_name == '(no branch)': + return None + return self.getBranchByName(branch_name) + def getRemotes(self): + config_dict = self.config.getDict() + returned = [] + for line in self._getOutputAssertSuccess("git remote show -n").splitlines(): + line = line.strip() + returned.append(remotes.Remote(self, line, config_dict.get('remote.%s.url' % line.strip()))) + return returned + def getRemoteByName(self, name): + return self._getByName(self.getRemotes, name) + def _getMergeBase(self, a, b): + if isinstance(a, ref.Ref): + a = a.getHead() + if isinstance(b, ref.Ref): + b = b.getHead() + returned = self._executeGitCommand("git merge-base %s %s" % (a, b)) + if returned.returncode == 0: + return commit.Commit(self, returned.stdout.read().strip()) + # make sure this is not a misc. error with git + unused = self.getHead() + return None + ################################ Querying Status ############################### + def containsCommit(self, commit): + try: + self._executeGitCommandAssertSuccess("git log -1 %s" % (commit,)) + except GitException: + return False + return True + def getHead(self): + return self._getCommitByRefName("HEAD") + def _getFiles(self, *flags): + flags = ["--exclude-standard"] + list(flags) + return [f.strip() + for f in self._getOutputAssertSuccess("git ls-files %s" % (" ".join(flags))).splitlines()] + def _getRawDiff(self, *flags): + flags = " ".join(str(f) for f in flags) + return [ModifiedFile(line.split()[-1]) for line in + self._getOutputAssertSuccess("git diff --raw %s" % flags).splitlines()] + def getStagedFiles(self): + if self.isInitialized(): + return self._getRawDiff('--cached') + return self._getFiles() + def getUnchangedFiles(self): + return self._getFiles() + def getChangedFiles(self): + return self._getRawDiff() + def getUntrackedFiles(self): + return self._getFiles("--others") + def isInitialized(self): + try: + self.getHead() + return True + except GitException: + return False + def isValid(self): + return os.path.isdir(os.path.join(self.path, ".git")) or \ + (os.path.isfile(os.path.join(self.path, "HEAD")) and os.path.isdir(os.path.join(self.path, "objects"))) + def isWorkingDirectoryClean(self): + return not (self.getUntrackedFiles() or self.getChangedFiles() or self.getStagedFiles()) + def __contains__(self, thing): + if isinstance(thing, basestring) or isinstance(thing, commit.Commit): + return self.containsCommit(thing) + raise NotImplementedError() + ################################ Staging content ############################### + def add(self, path): + self._executeGitCommandAssertSuccess("git add %s" % quote_for_shell(path)) + def addAll(self): + return self.add('.') + ################################## Committing ################################## + def _normalizeRefName(self, thing): + if isinstance(thing, ref.Ref): + thing = thing.getNormalizedName() + return str(thing) + def _deduceNewCommitFromCommitOutput(self, output): + for pattern in [ + # new-style commit pattern + r"^\[\S+\s+(?:\(root-commit\)\s+)?(\S+)\]", + ]: + match = re.search(pattern, output) + if match: + return commit.Commit(self, match.group(1)) + return None + def commit(self, message, allowEmpty=False): + command = "git commit -m %s" % quote_for_shell(message) + if allowEmpty: + command += " --allow-empty" + output = self._getOutputAssertSuccess(command) + return self._deduceNewCommitFromCommitOutput(output) + ################################ Changing state ################################ + def createBranch(self, name, startingPoint=None): + command = "git branch %s " % name + if startingPoint is not None: + command += self._normalizeRefName(startingPoint) + self._executeGitCommandAssertSuccess(command) + return branch.LocalBranch(self, name) + def checkout(self, thing=None, targetBranch=None, files=()): + if thing is None: + thing = "" + command = "git checkout %s" % (self._normalizeRefName(thing),) + if targetBranch is not None: + command += " -b %s" % (targetBranch,) + if files: + command += " -- %s" % " ".join(files) + self._executeGitCommandAssertSuccess(command) + def mergeMultiple(self, srcs, allowFastForward=True, log=False, message=None): + try: + self._executeGitCommandAssertSuccess(CMD("git merge", + " ".join(self._normalizeRefName(src) for src in srcs), + "--no-ff" if not allowFastForward else None, + "--log" if log else None, + ("-m \"%s\"" % message) if message is not None else None)) + except GitCommandFailedException, e: + # git-merge tends to ignore the stderr rule... + output = e.stdout + e.stderr + if 'conflict' in output.lower(): + raise MergeConflict() + raise + def merge(self, src, *args, **kwargs): + return self.mergeMultiple([src], *args, **kwargs) + def _reset(self, flag, thing): + command = "git reset %s %s" % ( + flag, + self._normalizeRefName(thing)) + self._executeGitCommandAssertSuccess(command) + def resetSoft(self, thing="HEAD"): + return self._reset("--soft", thing) + def resetHard(self, thing="HEAD"): + return self._reset("--hard", thing) + def resetMixed(self, thing="HEAD"): + return self._reset("--mixed", thing) + def _clean(self, flags): + self._executeGitCommandAssertSuccess("git clean -q " + flags) + def cleanIgnoredFiles(self): + """Cleans files that match the patterns in .gitignore""" + return self._clean("-f -X") + def cleanUntrackedFiles(self): + return self._clean("-f -d") + ################################# collaboration ################################ + def addRemote(self, name, url): + self._executeGitCommandAssertSuccess("git remote add %s %s" % (name, url)) + return remotes.Remote(self, name, url) + def fetch(self, repo=None): + command = "git fetch" + if repo is not None: + command += " " + command += self._asURL(repo) + self._executeGitCommandAssertSuccess(command) + def pull(self, repo=None): + command = "git pull" + if repo is not None: + command += " " + command += self._asURL(repo) + self._executeGitCommandAssertSuccess(command) + def _getRefspec(self, fromBranch=None, toBranch=None, force=False): + returned = "" + if fromBranch is not None: + returned += self._normalizeRefName(fromBranch) + if returned or toBranch is not None: + returned += ":" + if toBranch is not None: + if isinstance(toBranch, branch.RegisteredRemoteBranch): + toBranch = toBranch.name + returned += self._normalizeRefName(toBranch) + if returned and force: + returned = "+%s" % returned + return returned + def push(self, remote=None, fromBranch=None, toBranch=None, force=False): + command = "git push" + #build push arguments + refspec = self._getRefspec(toBranch=toBranch, fromBranch=fromBranch, force=force) + + if refspec and not remote: + remote = "origin" + if isinstance(remote, remotes.Remote): + remote = remote.name + elif isinstance(remote, RemoteRepository): + remote = remote.url + elif isinstance(remote, LocalRepository): + remote = remote.path + if remote is not None and not isinstance(remote, basestring): + raise TypeError("Invalid type for 'remote' parameter: %s" % (type(remote),)) + command = "git push %s %s" % (remote if remote is not None else "", refspec) + self._executeGitCommandAssertSuccess(command) + def rebase(self, src): + self._executeGitCommandAssertSuccess("git rebase %s" % self._normalizeRefName(src)) + #################################### Stashes ################################### + def saveStash(self, name=None): + command = "git stash save" + if name is not None: + command += " %s" % name + self._executeGitCommandAssertSuccess(command) + def popStash(self, arg=None): + command = "git stash pop" + if arg is not None: + command += " %s" % arg + self._executeGitCommandAssertSuccess(command) + ################################# Configuration ################################ + def getConfig(self): + return dict(s.split("=",1) for s in self._getOutputAssertSuccess("git config -l")) + +################################### Shortcuts ################################## +def clone(source, location): + returned = LocalRepository(location) + returned.clone(source) + return returned + +def find_repository(): + orig_path = path = os.path.realpath('.') + drive, path = os.path.splitdrive(path) + while path: + current_path = os.path.join(drive, path) + current_repo = LocalRepository(current_path) + if current_repo.isValid(): + return current_repo + path, path_tail = os.path.split(current_path) + if not path_tail: + raise CannotFindRepository("Cannot find repository for %s" % (orig_path,)) + diff --git a/libs/git/utils.py b/libs/git/utils.py new file mode 100644 index 00000000..5da81805 --- /dev/null +++ b/libs/git/utils.py @@ -0,0 +1,59 @@ +# Copyright (c) 2009, Rotem Yaari +# All rights reserved. +# +# Redistribution and use in source and binary forms, with or without +# modification, are permitted provided that the following conditions are met: +# * Redistributions of source code must retain the above copyright +# notice, this list of conditions and the following disclaimer. +# * Redistributions in binary form must reproduce the above copyright +# notice, this list of conditions and the following disclaimer in the +# documentation and/or other materials provided with the distribution. +# * Neither the name of organization nor the +# names of its contributors may be used to endorse or promote products +# derived from this software without specific prior written permission. +# +# THIS SOFTWARE IS PROVIDED BY Rotem Yaari ''AS IS'' AND ANY +# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +# DISCLAIMED. IN NO EVENT SHALL Rotem Yaari BE LIABLE FOR ANY +# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +class CommandString(object): + """ + >>> CommandString('a', 'b', 'c') + a b c + >>> CommandString('a', 'b', None, 'c') + a b c + """ + def __init__(self, *args): + self.command = "" + for arg in args: + if not arg: + continue + if self.command: + self.command += " " + self.command += str(arg) + def __repr__(self): + return self.command + +def quote_for_shell(s): + """ + >>> print quote_for_shell('this is a " string') + "this is a \\" string" + >>> print quote_for_shell('this is a $shell variable') + "this is a \\$shell variable" + >>> print quote_for_shell(r'an escaped \\$') + "an escaped \\\\\\$" + """ + returned = s.replace("\\", "\\\\").replace('"', '\\"').replace("$", "\\$") + if " " in returned: + returned = '"%s"' % returned + return returned + +if __name__ == '__main__': + import doctest + doctest.testmod()