├── bin └── git-trac ├── git_trac ├── __init__.py ├── test │ ├── __init__.py │ ├── test_simple.py │ ├── test_cmdline.py │ ├── test_builder.py │ ├── test_builder_git.py │ ├── builder.py │ └── test_doctests.py ├── releasemgr │ ├── __init__.py │ ├── patchbot.py │ ├── bootstrap.py │ ├── sagedev_org.py │ ├── sagepad_org.py │ ├── google_compute_engine.py │ ├── commit_message.py │ ├── make_release.py │ ├── www_sagemath_org.py │ ├── fileserver_sagemath_org.py │ ├── version_string.py │ ├── cmdline.py │ └── app.py ├── people.py ├── ticket_or_branch.py ├── cached_property.py ├── token_transport.py ├── trac_error.py ├── logger.py ├── git_commit.py ├── config.py ├── py26_compat.py ├── git_error.py ├── digest_transport.py ├── pretty_ticket.py ├── digest_transport_py2.py ├── trac_server.py ├── trac_ticket.py ├── cmdline.py ├── git_interface.py ├── git_repository.py └── app.py ├── setup.cfg ├── doc ├── git-cheat-sheet.pdf └── git-cheat-sheet.tex ├── tox.ini ├── MANIFEST.in ├── .gitignore ├── setup.py ├── .travis.yml ├── git-trac ├── git-releasemgr ├── enable.sh └── README.md /bin/git-trac: -------------------------------------------------------------------------------- 1 | ../git-trac -------------------------------------------------------------------------------- /git_trac/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /git_trac/test/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /git_trac/releasemgr/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [options.extras_require] 2 | releasemgr = 3 | fabric 4 | requests 5 | -------------------------------------------------------------------------------- /git_trac/people.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | RELEASE_MANAGER = 'Release Manager ' 4 | -------------------------------------------------------------------------------- /doc/git-cheat-sheet.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sagemath/git-trac-command/master/doc/git-cheat-sheet.pdf -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = py27, py37, py310 3 | 4 | [testenv] 5 | commands=python -m unittest discover 6 | extras = releasemgr 7 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include README* LICENSE* 2 | include git-trac *.py 3 | recursive-include git_trac *.py 4 | recursive-exclude git_trac /#*.py .\#*.py -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | *.pyc 3 | .\#* 4 | \#*\# 5 | build 6 | dist 7 | MANIFEST 8 | .tox 9 | *.aux 10 | *.log 11 | *.out 12 | *.synctex.gz 13 | -------------------------------------------------------------------------------- /git_trac/releasemgr/patchbot.py: -------------------------------------------------------------------------------- 1 | import requests 2 | 3 | def patchbot_status(ticket_number): 4 | r = requests.get('https://patchbot.sagemath.org/ticket/{}/status'.format(ticket_number)) 5 | return r.text 6 | -------------------------------------------------------------------------------- /git_trac/test/test_simple.py: -------------------------------------------------------------------------------- 1 | """ 2 | Easy doctests 3 | """ 4 | 5 | import unittest 6 | 7 | 8 | class SimpleTests(unittest.TestCase): 9 | 10 | def testTrue(self): 11 | """ 12 | Make sure there is at least one passing doctest 13 | """ 14 | self.assertTrue(True) 15 | 16 | 17 | 18 | 19 | if __name__ == '__main__': 20 | unittest.main() 21 | 22 | -------------------------------------------------------------------------------- /git_trac/releasemgr/bootstrap.py: -------------------------------------------------------------------------------- 1 | """ 2 | Run bootstrap to create a new confball 3 | """ 4 | 5 | import os 6 | from subprocess import Popen, check_call 7 | 8 | 9 | def run_bootstrap(sha1): 10 | confball = 'upstream/configure-{}.tar.gz'.format(sha1) 11 | check_call(['./bootstrap', '-s']) 12 | if not os.path.exists(confball): 13 | raise RuntimeError('bootstrap failed to generate {}'.format(confball)) 14 | return confball 15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | from distutils.core import setup 4 | 5 | setup( 6 | name='git_trac', 7 | description='A "git trac" subcommand for git', 8 | author='Volker Braun', 9 | author_email='vbraun.name@gmail.com', 10 | packages=['git_trac'], 11 | data_files=[('share/git-trac-command', ['doc/git-cheat-sheet.pdf'])], 12 | scripts=['git-trac'], 13 | version='1.0', 14 | url='https://github.com/sagemath/git-trac-command', 15 | ) 16 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | matrix: 4 | include: 5 | - python: 2.7 6 | env: 7 | - TOXENV=py27 8 | - PIP_DEPS="tox" 9 | - python: 3.4 10 | env: 11 | - TOXENV=py34 12 | - PIP_DEPS="tox" 13 | - python: 3.6 14 | env: 15 | - TOXENV=py36 16 | - PIP_DEPS="tox" 17 | - python: 3.7 18 | env: 19 | - TOXENV=py37 20 | - PIP_DEPS="tox" 21 | 22 | install: 23 | - pip install $PIP_DEPS 24 | 25 | script: 26 | - tox 27 | -------------------------------------------------------------------------------- /git-trac: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | 3 | # Easiest way to use this is to create a symlink 4 | # /dir/in/search/path/git-trac -> git-trac 5 | 6 | 7 | import sys 8 | 9 | try: 10 | from git_trac import cmdline 11 | except ImportError: 12 | sys.path.append('') 13 | from git_trac import cmdline 14 | 15 | if __name__ == '__main__': 16 | try: 17 | cmdline.launch() 18 | except ValueError as error: 19 | print('Error: {0}'.format(error)) 20 | sys.exit(1) 21 | except SystemExit as msg: 22 | if msg.code != 0: 23 | print('{0}\nExiting.'.format(msg)) 24 | -------------------------------------------------------------------------------- /git_trac/test/test_cmdline.py: -------------------------------------------------------------------------------- 1 | """ 2 | Test the command line argument parser 3 | """ 4 | 5 | import unittest 6 | from git_trac.cmdline import make_parser 7 | 8 | 9 | class ParserTests(unittest.TestCase): 10 | 11 | def testHelp(self): 12 | parser = make_parser() 13 | args = parser.parse_args(['help']) 14 | self.assertEqual(args.subcommand, 'help') 15 | 16 | def testDashHelp(self): 17 | parser = make_parser() 18 | args = parser.parse_args(['-h']) 19 | self.assertEqual(args.subcommand, 'help') 20 | 21 | 22 | 23 | 24 | 25 | if __name__ == '__main__': 26 | unittest.main() 27 | 28 | -------------------------------------------------------------------------------- /git-releasemgr: -------------------------------------------------------------------------------- 1 | #! /usr/bin/env python3 2 | 3 | # Easiest way to use this is to create a symlink 4 | # /dir/in/search/path/git-releasemgr -> git-releasemgr 5 | 6 | import sys 7 | 8 | try: 9 | from git_trac.releasemgr import cmdline 10 | except ImportError: 11 | sys.path.append('') 12 | from git_trac.releasemgr import cmdline 13 | 14 | if __name__ == '__main__': 15 | try: 16 | cmdline.launch() 17 | except ValueError as error: 18 | print(u'Error: {0}'.format(error)) 19 | sys.exit(1) 20 | except SystemExit as msg: 21 | if msg.code != 0: 22 | print(u'{0}\nExiting.'.format(msg)) 23 | 24 | -------------------------------------------------------------------------------- /git_trac/releasemgr/sagedev_org.py: -------------------------------------------------------------------------------- 1 | """ 2 | Fab file for interaction with the sagemath.org server 3 | """ 4 | import os 5 | 6 | try: 7 | import fabric 8 | from fabric.api import env, run, sudo, put, settings, cd 9 | from fabric.contrib.files import exists 10 | except ImportError: 11 | # Fabric shoud be py3-compatible any time now, but not yet 12 | # Evil hack to make importable in py3 tests 13 | class AttrDict(dict): 14 | def __init__(self, *args, **kwargs): 15 | super(AttrDict, self).__init__(*args, **kwargs) 16 | self.__dict__ = self 17 | env = AttrDict() 18 | 19 | env.use_ssh_config = True 20 | env.user = 'vbraun' 21 | env.hosts = ['sagedev_org'] 22 | 23 | 24 | 25 | def upload_dist_tarball(tarball): 26 | """ 27 | Add tarball to http://sage.sagedev.org/home/release/ 28 | """ 29 | basename = os.path.basename(tarball) 30 | put(tarball, os.path.join('~/release', basename)) 31 | run('ln -f ~/release/{0} ~/release/pub/{0}'.format(basename)) 32 | run('sudo -H -u sagemath /home/sagemath/mirror') 33 | -------------------------------------------------------------------------------- /git_trac/ticket_or_branch.py: -------------------------------------------------------------------------------- 1 | """ 2 | Ticket Number or Remote Branch Name 3 | """ 4 | 5 | 6 | class TicketOrBranch(object): 7 | 8 | def __init__(self, name): 9 | self._name = name 10 | self._validate() 11 | 12 | def __int__(self): 13 | try: 14 | return int(self._name) 15 | except ValueError: 16 | pass 17 | 18 | def __str__(self): 19 | return self._name 20 | 21 | def is_number(self): 22 | try: 23 | int(self._name) 24 | return True 25 | except ValueError: 26 | return False 27 | 28 | def is_branch(self): 29 | for prefix in ['u/', 'public/']: 30 | if self._name.startswith(prefix): 31 | return True 32 | if self._name in ['master', 'develop']: 33 | return True 34 | return False 35 | 36 | def _validate(self): 37 | if self.is_number() or self.is_branch(): 38 | return 39 | raise ValueError('Not a valid ticket number or remote branch name') 40 | 41 | -------------------------------------------------------------------------------- /git_trac/test/test_builder.py: -------------------------------------------------------------------------------- 1 | """ 2 | Build Stuff for Doctests 3 | """ 4 | 5 | ############################################################################## 6 | # Copyright (C) 2013 Volker Braun 7 | # 8 | # This program is free software: you can redistribute it and/or modify 9 | # it under the terms of the GNU General Public License as published by 10 | # the Free Software Foundation, either version 3 of the License, or 11 | # (at your option) any later version. 12 | # 13 | # This program is distributed in the hope that it will be useful, 14 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | # GNU General Public License for more details. 17 | # 18 | # You should have received a copy of the GNU General Public License 19 | # along with this program. If not, see . 20 | ############################################################################## 21 | 22 | 23 | from .test_builder_git import TestBuilderGit 24 | 25 | 26 | class TestBuilder(TestBuilderGit): 27 | 28 | def make_app(self): 29 | from .app import Application 30 | return Application() 31 | 32 | -------------------------------------------------------------------------------- /git_trac/releasemgr/sagepad_org.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | """ 3 | Fab file for interaction sagepad.org 4 | """ 5 | 6 | from __future__ import (absolute_import, division, print_function, unicode_literals) 7 | 8 | import os 9 | 10 | try: 11 | import fabric 12 | from fabric.api import env, run, sudo, put, settings, cd, hosts 13 | from fabric.contrib.files import exists 14 | except ImportError: 15 | # Fabric shoud be py3-compatible any time now, but not yet 16 | pass 17 | 18 | 19 | env_sagepad = dict( 20 | use_ssh_config=True, 21 | user='files', 22 | host_string='sagepad_org' 23 | ) 24 | 25 | 26 | def rsync_upstream_packages(): 27 | """ 28 | pull upstream packages via rsync 29 | """ 30 | with settings(**env_sagepad): 31 | run('rsync --archive --recursive rsync.sagemath.org::spkgs/upstream /var/www/sage-upstream') 32 | 33 | 34 | def upload_temp_confball(confball): 35 | """ 36 | Add temporary tarball to ​http://sagepad.org/spkg/ 37 | 38 | These are for testing only and not send out to the mirror network 39 | """ 40 | destination = '/var/www/sage-upstream/upstream/configure' 41 | with settings(**env_sagepad): 42 | basename = os.path.basename(confball) 43 | put(confball, os.path.join(destination, basename)) 44 | -------------------------------------------------------------------------------- /git_trac/cached_property.py: -------------------------------------------------------------------------------- 1 | """ 2 | A read-only cached version of @property 3 | """ 4 | 5 | ############################################################################## 6 | # Copyright (C) 2013 Volker Braun 7 | # 8 | # This program is free software: you can redistribute it and/or modify 9 | # it under the terms of the GNU General Public License as published by 10 | # the Free Software Foundation, either version 3 of the License, or 11 | # (at your option) any later version. 12 | # 13 | # This program is distributed in the hope that it will be useful, 14 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | # GNU General Public License for more details. 17 | # 18 | # You should have received a copy of the GNU General Public License 19 | # along with this program. If not, see . 20 | ############################################################################## 21 | 22 | 23 | 24 | class cached_property(object): 25 | 26 | def __init__(self, method, name=None): 27 | self.method = method 28 | self.name = name or method.__name__ 29 | self.__doc__ = method.__doc__ 30 | 31 | def __get__(self, instance, cls): 32 | if instance is None: 33 | return self 34 | result = self.method(instance) 35 | setattr(instance, self.name, result) 36 | return result 37 | -------------------------------------------------------------------------------- /enable.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # As yet another way to use the git-trac command, this script adds it 4 | # to the path manually. The only way to use it is to source it into 5 | # your current shell: 6 | # 7 | # [user@localhost]$ source enable.sh 8 | # 9 | 10 | if [ -z $BASH_VERSION ] 11 | then 12 | echo "This script only works if you use bash, aborting." 13 | exit 1 14 | fi 15 | 16 | if [ ${BASH_VERSINFO[0]} -le 2 ] 17 | then 18 | echo 'Your bash version is too old.' 19 | exit 1 20 | fi 21 | 22 | if [ "${BASH_SOURCE[0]}" == "${0}" ] 23 | then 24 | echo "You are trying to call this script directly, which is not" 25 | echo "possible. You must source this script instead:" 26 | echo "" 27 | echo " [user@localhost]$ source git-trac-command/enable.sh" 28 | echo "" 29 | exit 1 30 | fi 31 | 32 | GIT_TRAC_DIR=`cd $(dirname -- $BASH_SOURCE)/bin && pwd -P` 33 | GIT_TRAC_CMD="$GIT_TRAC_DIR/git-trac" 34 | 35 | if [ "$(command -v git-trac)" == "$GIT_TRAC_CMD" ] 36 | then 37 | echo "The git-trac command is already in your search PATH" 38 | else 39 | echo "Prepending the git-trac command to your search PATH" 40 | export PATH="$GIT_TRAC_DIR":$PATH 41 | fi 42 | 43 | if [ ! -d "$HOME/.ssh" ]; then 44 | mkdir "$HOME/.ssh" 45 | fi 46 | 47 | ssh-keygen -F trac.sagemath.org > /dev/null || echo "trac.sagemath.org ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBG+/AO490umZWuczUgClP4BgFm5XR9I43z4kf9f+pu8Uj6UvH/7Pz1oBkJ71xET+xTmecBHB2c9OwlgPjB70AB8= This was added by git-trac-command/enable.sh" >> "$HOME/.ssh/known_hosts" 48 | -------------------------------------------------------------------------------- /git_trac/token_transport.py: -------------------------------------------------------------------------------- 1 | """ 2 | HTTP transport to the Trac server using token-based authentication on each 3 | request. 4 | """ 5 | 6 | try: 7 | from xmlrpclib import SafeTransport, Fault 8 | except ImportError: 9 | from xmlrpc.client import SafeTransport, Fault 10 | 11 | from .trac_error import ( 12 | TracInternalError, TracAuthenticationError, TracConnectionError) 13 | 14 | 15 | 16 | class TokenAuthenticatedTransport(SafeTransport, object): 17 | def __init__(self, token): 18 | super(TokenAuthenticatedTransport, self).__init__() 19 | self._token = token 20 | 21 | def single_request(self, host, handler, request_body, verbose): 22 | """ 23 | Issue an XML-RPC request. 24 | """ 25 | 26 | try: 27 | return super(TokenAuthenticatedTransport, self).single_request( 28 | host, handler, request_body, verbose) 29 | except Fault as e: 30 | raise TracInternalError(e) 31 | except IOError as e: 32 | if hasattr(e, 'code') and e.code == 401: 33 | raise TracAuthenticationError() 34 | else: 35 | raise TracConnectionError(e.reason) 36 | 37 | def get_host_info(self, host): 38 | host, extra_headers, x509 = super(TokenAuthenticatedTransport, 39 | self).get_host_info(host) 40 | 41 | if extra_headers: 42 | headers = dict(extra_headers) 43 | else: 44 | headers = {} 45 | extra_headers = [] 46 | 47 | if 'Authorization' not in headers: 48 | auth = 'Bearer ' + self._token 49 | extra_headers.append(('Authorization', auth.strip())) 50 | 51 | return host, extra_headers, x509 52 | -------------------------------------------------------------------------------- /git_trac/trac_error.py: -------------------------------------------------------------------------------- 1 | """ 2 | Exception Classes for Trac 3 | """ 4 | 5 | ############################################################################## 6 | # The "git trac ..." command extension for git 7 | # Copyright (C) 2013 Volker Braun 8 | # 9 | # This program is free software: you can redistribute it and/or modify 10 | # it under the terms of the GNU General Public License as published by 11 | # the Free Software Foundation, either version 3 of the License, or 12 | # (at your option) any later version. 13 | # 14 | # This program is distributed in the hope that it will be useful, 15 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | # GNU General Public License for more details. 18 | # 19 | # You should have received a copy of the GNU General Public License 20 | # along with this program. If not, see . 21 | ############################################################################## 22 | 23 | class TracError(RuntimeError): 24 | pass 25 | 26 | class TracConnectionError(TracError): 27 | def __init__(self, msg=None): 28 | if msg is None: 29 | TracError.__init__(self, 'Connection to trac server failed.') 30 | else: 31 | TracError.__init__(self, msg) 32 | 33 | 34 | class TracInternalError(TracError): 35 | def __init__(self, fault): 36 | self._fault = fault 37 | self.faultCode = fault.faultCode 38 | 39 | def __str__(self): 40 | return str(self._fault) 41 | 42 | 43 | class TracAuthenticationError(TracError): 44 | def __init__(self): 45 | TracError.__init__(self, 'Authentication with trac server failed.') 46 | 47 | -------------------------------------------------------------------------------- /git_trac/logger.py: -------------------------------------------------------------------------------- 1 | """ 2 | Customized Python logger 3 | """ 4 | ############################################################################## 5 | # Copyright (C) 2014 Volker Braun 6 | # 7 | # This program is free software: you can redistribute it and/or modify 8 | # it under the terms of the GNU General Public License as published by 9 | # the Free Software Foundation, either version 3 of the License, or 10 | # (at your option) any later version. 11 | # 12 | # This program is distributed in the hope that it will be useful, 13 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 14 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 15 | # GNU General Public License for more details. 16 | # 17 | # You should have received a copy of the GNU General Public License 18 | # along with this program. If not, see . 19 | ############################################################################## 20 | 21 | 22 | import logging 23 | 24 | 25 | def make_logger(name, doctest_mode=False): 26 | logger = logging.getLogger(name) 27 | if len(logger.handlers) == 0: 28 | if doctest_mode: 29 | formatter = logging.Formatter('[%(name)s] %(levelname)s: %(message)s') 30 | else: 31 | formatter = logging.Formatter('%(asctime)s [%(name)s] %(levelname)s: %(message)s', 32 | datefmt='%H:%M:%S') 33 | handler = logging.StreamHandler() 34 | handler.setFormatter(formatter) 35 | logger.addHandler(handler) 36 | logger.setLevel(logging.WARNING) 37 | 38 | return logger 39 | 40 | 41 | def enable_doctest_mode(): 42 | global logger 43 | logger = make_logger('git-trac', True) 44 | 45 | 46 | logger = make_logger('git-trac') 47 | 48 | 49 | 50 | 51 | -------------------------------------------------------------------------------- /git_trac/releasemgr/google_compute_engine.py: -------------------------------------------------------------------------------- 1 | """ 2 | Fab file for interaction with our GCE instance 3 | """ 4 | import os 5 | 6 | try: 7 | import fabric 8 | import fabric.tasks 9 | from fabric.api import env, run, sudo, put, settings, cd 10 | from fabric.contrib.files import exists 11 | except ImportError: 12 | # Fabric shoud be py3-compatible any time now, but not yet 13 | pass 14 | 15 | 16 | env_gce = dict( 17 | use_ssh_config=True, 18 | user='sagemath', 19 | host_string='google_compute_engine', 20 | ) 21 | 22 | 23 | def package_name(url_or_path): 24 | tarball_name = os.path.basename(url_or_path) 25 | return tarball_name.split('-', 1)[0] 26 | 27 | 28 | def upload_tarball(url_or_path): 29 | """ 30 | Add tarball to http://sagemath.org/packages/upstream 31 | """ 32 | with settings(**env_gce): 33 | package = package_name(url_or_path) 34 | destination = os.path.join('/home/sagemath/files/spkg/upstream', package) 35 | run('mkdir -p {0}'.format(destination)) 36 | run('touch {0}'.format(os.path.join(destination, 'index.html'))) 37 | if os.path.exists(url_or_path): # is local file 38 | put(url_or_path, os.path.join(destination, os.path.basename(url_or_path))) 39 | else: # should be a url 40 | with cd(destination): 41 | run('wget --no-directories -p -N ' + url_or_path) 42 | run('/home/sagemath/publish-files.sh') 43 | 44 | 45 | def upload_dist_tarball(tarball): 46 | """ 47 | Add tarball to http://sagemath.org/packages/upstream 48 | """ 49 | with settings(**env_gce): 50 | basename = os.path.basename(tarball) 51 | put(tarball, os.path.join('/home/sagemath/files/devel', basename)) 52 | run('/home/sagemath/publish-files.sh') 53 | 54 | -------------------------------------------------------------------------------- /git_trac/releasemgr/commit_message.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | u""" 3 | Pretty-Print Ticket as Git Commit Message 4 | 5 | EXAMPLES:: 6 | 7 | sage: class Ticket(object): 8 | ....: number = 1234 9 | ....: title = 'Title' 10 | ....: description = u'description äöü' 11 | ....: reporter = 'Reporter' 12 | ....: author = u'Ingólfur Eðvarðsson' 13 | ....: reviewer = 'Reviewer' 14 | ....: branch = 'Branch' 15 | ....: keywords = 'Keywords' 16 | ....: dependencies = 'Dependencies' 17 | ....: ctime_str = 'creation time string' 18 | ....: mtime_str = 'modification time string' 19 | ....: owner = 'Owner' 20 | ....: upstream = 'Upstream' 21 | ....: status = 'Status' 22 | ....: component = 'Component' 23 | ....: def grouped_comment_iter(self): 24 | ....: return () 25 | sage: ticket = Ticket() 26 | sage: from git_trac.releasemgr.commit_message import format_ticket 27 | sage: print(format_ticket(ticket)) 28 | Trac #1234: Title 29 | 30 | description äöü 31 | 32 | URL: https://trac.sagemath.org/1234 33 | Reported by: Reporter 34 | Ticket author(s): Ingólfur Eðvarðsson 35 | Reviewer(s): Reviewer 36 | """ 37 | import textwrap 38 | 39 | 40 | 41 | #123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 42 | 43 | SUMMARY_TEMPLATE = u""" 44 | Trac #{ticket.number}: {ticket.title} 45 | """ 46 | 47 | DESCRIPTION_TEMPLATE = u""" 48 | {ticket.description} 49 | 50 | URL: https://trac.sagemath.org/{ticket.number} 51 | Reported by: {ticket.reporter} 52 | Ticket author(s): {ticket.author} 53 | Reviewer(s): {ticket.reviewer} 54 | """ 55 | 56 | 57 | def wrap_lines(text): 58 | text = text.strip() 59 | accumulator = [] 60 | for line in text.splitlines(): 61 | line = '\n'.join(textwrap.wrap(line, 72)) 62 | accumulator.append(line) 63 | return '\n'.join(accumulator) 64 | 65 | 66 | def format_ticket(ticket): 67 | summary = SUMMARY_TEMPLATE.format(ticket=ticket).strip() 68 | if len(summary) > 72: 69 | print('Warning: Overlong summary at {0} characters.'.format(len(summary))) 70 | description = DESCRIPTION_TEMPLATE.format(ticket=ticket) 71 | description = wrap_lines(description) 72 | return summary + '\n\n' + description 73 | -------------------------------------------------------------------------------- /git_trac/releasemgr/make_release.py: -------------------------------------------------------------------------------- 1 | """ 2 | Create a new beta/rc/stable release. 3 | """ 4 | 5 | import os 6 | import tempfile 7 | import shutil 8 | from subprocess import Popen, check_call 9 | 10 | 11 | def update_version(version): 12 | check_call(['sage', '-sh', '-c', 'sage-update-version {0}'.format(version)]) 13 | 14 | def create_tarball(): 15 | check_call(['sage', '-sdist']) 16 | 17 | def sage_build(clean=None, internet=True, cwd=None, test_long=False): 18 | env = dict(os.environ) 19 | env['SAGE_PARALLEL_SPKG_BUILD'] = 'yes' 20 | env['SAGE_ATLAS_ARCH'] = 'Corei2,SSE3,SSE2,SSE1' 21 | env['SAGE_ATLAS_LIB'] = '/usr/lib64/atlas' 22 | env['MAKE'] = 'make -j10' 23 | if not internet: 24 | poison = 'http://192.0.2.0:5187/' 25 | env['http_proxy'] = poison 26 | env['https_proxy'] = poison 27 | env['ftp_proxy'] = poison 28 | env['rsync_proxy'] = poison 29 | def run(*args): 30 | proc = Popen(args, env=env, cwd=cwd) 31 | rc = proc.wait() 32 | if rc != 0: 33 | raise RuntimeError('command returned non-zero exit code: ' + ' '.join(args)) 34 | if clean is None: 35 | run('make', 'doc-clean') 36 | elif clean: 37 | run('make', 'distclean') 38 | run('make') 39 | run('make', 'doc-pdf') 40 | run('make', 'ptestlong' if test_long else 'ptest') 41 | 42 | def check_tarball(tarball): 43 | print('-' * 78) 44 | print('Checking tarball: ' + tarball) 45 | tarball = os.path.abspath(tarball) 46 | if not os.path.exists(tarball): 47 | raise ValueError('tarball file does not exist') 48 | cwd = os.getcwd() 49 | tmp = tempfile.mkdtemp(dir='/var/tmp') 50 | try: 51 | os.chdir(tmp) 52 | check_call(['tar', 'xf', tarball]) 53 | sage_root = os.path.join(tmp, os.listdir(tmp)[0]) 54 | sage_build(clean=False, cwd=sage_root, internet=False, test_long=True) 55 | finally: 56 | os.chdir(cwd) 57 | shutil.rmtree(tmp) 58 | 59 | def check_upgrade(git, from_version, to_version): 60 | print('-' * 78) 61 | print('Checking upgrade {0} -> {1}'.format(from_version, to_version)) 62 | sage_root = git.rev_parse(show_toplevel=True).strip() 63 | git.checkout(from_version) 64 | sage_build(clean=True, cwd=sage_root) 65 | git.checkout(to_version) 66 | sage_build(cwd=sage_root, test_long=True) 67 | git.checkout('develop') 68 | -------------------------------------------------------------------------------- /git_trac/releasemgr/www_sagemath_org.py: -------------------------------------------------------------------------------- 1 | """ 2 | Fab file for interaction with the sagemath.org server 3 | """ 4 | import os 5 | 6 | try: 7 | import fabric 8 | from fabric.api import env, run, sudo, put, settings, cd 9 | from fabric.contrib.files import exists 10 | except ImportError: 11 | # Fabric shoud be py3-compatible any time now, but not yet 12 | pass 13 | 14 | 15 | env_sagemath = dict( 16 | use_ssh_config=True, 17 | user='sagemath', 18 | host_string='boxen', 19 | ) 20 | 21 | 22 | def package_name(url_or_path): 23 | tarball_name = os.path.basename(url_or_path) 24 | return tarball_name.split('-', 1)[0] 25 | 26 | 27 | def upload_tarball(url_or_path): 28 | """ 29 | Add tarball to http://sagemath.org/packages/upstream 30 | """ 31 | with settings(**env_sagemath): 32 | package = package_name(url_or_path) 33 | destination = os.path.join('/home/sagemath/files/spkg/upstream', package) 34 | run('mkdir -p {0}'.format(destination)) 35 | run('touch {0}'.format(os.path.join(destination, 'index.html'))) 36 | if os.path.exists(url_or_path): # is local file 37 | put(url_or_path, os.path.join(destination, os.path.basename(url_or_path))) 38 | else: # should be a url 39 | with cd(destination): 40 | run('wget --no-directories -p -N ' + url_or_path) 41 | run('/home/sagemath/publish-files.sh') 42 | 43 | 44 | def upload_dist_tarball(tarball, devel=True): 45 | """ 46 | Add sdist tarball to http://sagemath.org/packages/ 47 | 48 | INPUT: 49 | 50 | - ``devel`` -- boolean. Whether this is a beta/rc release. 51 | """ 52 | if devel: 53 | destination = '/home/sagemath/files/devel' 54 | else: 55 | destination = '/home/sagemath/files/src' 56 | with settings(**env_sagemath): 57 | basename = os.path.basename(tarball) 58 | put(tarball, os.path.join(destination, basename)) 59 | run('/home/sagemath/publish-files.sh') 60 | 61 | 62 | 63 | 64 | # def upload_tarball(url): 65 | # """ 66 | # Add tarball to http://sagemath.org/packages/upstream 67 | # """ 68 | # if os.path.exists(url): # is local file 69 | # put(url, os.path.join('/www-data/tmp/upstream', os.path.basename(url))) 70 | # else: # should be a url 71 | # with cd('/www-data/tmp/upstream'): 72 | # run('wget --no-directories -p -N ' + url) 73 | # with cd('/www-data/sagemath-org/scripts'): 74 | # run('./mirror_upstream.py /www-data/tmp/upstream') 75 | # run('./mirror-index.py') 76 | # run('./fix_permissions.sh') 77 | # run('/www-data/sagemath-org/go_live.sh') 78 | -------------------------------------------------------------------------------- /git_trac/releasemgr/fileserver_sagemath_org.py: -------------------------------------------------------------------------------- 1 | 2 | # -*- coding: utf-8 -*- 3 | """ 4 | Fab file for interaction with the sagemath.org server 5 | """ 6 | 7 | from __future__ import (absolute_import, division, print_function, unicode_literals) 8 | 9 | import os 10 | 11 | try: 12 | import fabric 13 | from fabric.api import env, run, sudo, put, settings, cd 14 | from fabric.contrib.files import exists 15 | except ImportError: 16 | # Fabric shoud be py3-compatible any time now, but not yet 17 | pass 18 | 19 | 20 | env_sagemath = dict( 21 | use_ssh_config=True, 22 | user='files', 23 | host_string='fileserver', 24 | ) 25 | 26 | 27 | def package_name(url_or_path): 28 | tarball_name = os.path.basename(url_or_path) 29 | return tarball_name.split('-', 1)[0] 30 | 31 | 32 | def upload_tarball(url_or_path): 33 | """ 34 | Add tarball to http://sagemath.org/packages/upstream 35 | """ 36 | with settings(**env_sagemath): 37 | package = package_name(url_or_path) 38 | destination = os.path.join('/home/files/files/spkg/upstream', package) 39 | run('mkdir -p {0}'.format(destination)) 40 | run('touch {0}'.format(os.path.join(destination, 'index.html'))) 41 | if os.path.exists(url_or_path): # is local file 42 | put(url_or_path, os.path.join(destination, os.path.basename(url_or_path))) 43 | else: # should be a url 44 | with cd(destination): 45 | run('wget --no-directories -p -N ' + url_or_path) 46 | run('/home/files/publish-files.sh') 47 | 48 | 49 | def upload_dist_tarball(tarball, devel=True): 50 | """ 51 | Add sdist tarball to http://sagemath.org/packages/ 52 | 53 | INPUT: 54 | 55 | - ``devel`` -- boolean. Whether this is a beta/rc release. 56 | """ 57 | if devel: 58 | destination = '/home/files/files/devel' 59 | else: 60 | destination = '/home/files/files/src' 61 | with settings(**env_sagemath): 62 | basename = os.path.basename(tarball) 63 | put(tarball, os.path.join(destination, basename)) 64 | run('/home/files/publish-files.sh') 65 | 66 | 67 | def upload_temp_confball(confball): 68 | """ 69 | Add temporary tarball to ​http://old.files.sagemath.org/configure/ 70 | 71 | These are for testing only and not send out to the mirror network 72 | """ 73 | destination = '/home/files/files-old/configure' 74 | with settings(**env_sagemath): 75 | basename = os.path.basename(confball) 76 | put(confball, os.path.join(destination, basename)) 77 | 78 | 79 | 80 | 81 | # def upload_tarball(url): 82 | # """ 83 | # Add tarball to http://sagemath.org/packages/upstream 84 | # """ 85 | # if os.path.exists(url): # is local file 86 | # put(url, os.path.join('/www-data/tmp/upstream', os.path.basename(url))) 87 | # else: # should be a url 88 | # with cd('/www-data/tmp/upstream'): 89 | # run('wget --no-directories -p -N ' + url) 90 | # with cd('/www-data/sagemath-org/scripts'): 91 | # run('./mirror_upstream.py /www-data/tmp/upstream') 92 | # run('./mirror-index.py') 93 | # run('./fix_permissions.sh') 94 | # run('/www-data/sagemath-org/go_live.sh') 95 | -------------------------------------------------------------------------------- /git_trac/releasemgr/version_string.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | import re 4 | 5 | 6 | class VersionString(object): 7 | 8 | V_BETA = re.compile(r'^[0-9]+\.[0-9]+\.beta[0-9]+$') 9 | V_RC = re.compile(r'^[0-9]+\.[0-9]+\.rc[0-9]+$') 10 | V_STABLE = re.compile(r'^[0-9]+\.[0-9]+$') 11 | V_STABLE2 = re.compile(r'^[0-9]+\.[0-9]+\.[0-9]+$') 12 | 13 | def __init__(self, version): 14 | self.version = version 15 | self.validate() 16 | 17 | def is_beta(self): 18 | """ 19 | Return whether the version is a beta version 20 | 21 | EXAMPLES:: 22 | 23 | sage: from git_trac.releasemgr.version_string import VersionString 24 | sage: VersionString('6.7.beta0').is_beta() 25 | True 26 | sage: VersionString('6.7.rc0').is_beta() 27 | False 28 | sage: VersionString('6.8').is_beta() 29 | False 30 | """ 31 | return bool(self.V_BETA.match(self.version)) 32 | 33 | def is_rc(self): 34 | """ 35 | Return whether the version is a release candidate 36 | 37 | EXAMPLES:: 38 | 39 | sage: from git_trac.releasemgr.version_string import VersionString 40 | sage: VersionString('6.7.beta0').is_rc() 41 | False 42 | sage: VersionString('6.7.rc0').is_rc() 43 | True 44 | sage: VersionString('6.8').is_rc() 45 | False 46 | """ 47 | return bool(self.V_RC.match(self.version)) 48 | 49 | def is_stable(self): 50 | """ 51 | Return whether the version is a development version 52 | 53 | EXAMPLES:: 54 | 55 | sage: from git_trac.releasemgr.version_string import VersionString 56 | sage: VersionString('6.7.beta0').is_stable() 57 | False 58 | sage: VersionString('6.7.rc0').is_stable() 59 | False 60 | sage: VersionString('6.8').is_stable() 61 | True 62 | """ 63 | return bool(self.V_STABLE.match(self.version) or 64 | self.V_STABLE2.match(self.version)) 65 | 66 | def is_devel(self): 67 | """ 68 | Return whether the version is a development version 69 | 70 | EXAMPLES:: 71 | 72 | sage: from git_trac.releasemgr.version_string import VersionString 73 | sage: VersionString('6.7.beta0').is_devel() 74 | True 75 | sage: VersionString('6.7.rc0').is_devel() 76 | True 77 | sage: VersionString('6.8').is_devel() 78 | False 79 | """ 80 | return not self.is_stable() 81 | 82 | def validate(self): 83 | """ 84 | Raise a ``ValueError`` if the version is not formatted correctly 85 | 86 | EXAMPLES:: 87 | 88 | sage: from git_trac.releasemgr.version_string import VersionString 89 | sage: VersionString('6.7.beta0') 90 | 91 | sage: VersionString('6.7.gamma0') 92 | Traceback (most recent call last): 93 | ... 94 | ValueError: version string 6.7.gamma0 is not valid 95 | """ 96 | if not any([self.is_beta(), self.is_rc(), self.is_stable()]): 97 | raise ValueError('version string {0} is not valid'.format(self.version)) 98 | -------------------------------------------------------------------------------- /git_trac/git_commit.py: -------------------------------------------------------------------------------- 1 | """ 2 | Git Commit 3 | """ 4 | 5 | ############################################################################## 6 | # The "git trac ..." command extension for git 7 | # Copyright (C) 2013 Volker Braun 8 | # 9 | # This program is free software: you can redistribute it and/or modify 10 | # it under the terms of the GNU General Public License as published by 11 | # the Free Software Foundation, either version 3 of the License, or 12 | # (at your option) any later version. 13 | # 14 | # This program is distributed in the hope that it will be useful, 15 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | # GNU General Public License for more details. 18 | # 19 | # You should have received a copy of the GNU General Public License 20 | # along with this program. If not, see . 21 | ############################################################################## 22 | 23 | try: 24 | from functools import total_ordering 25 | except ImportError: 26 | from py26_compat import total_ordering 27 | 28 | @total_ordering 29 | class GitCommit(object): 30 | 31 | def __init__(self, repository, commit_sha1, title=None): 32 | self.repository = repository 33 | self._sha1 = commit_sha1.strip() 34 | assert len(self._sha1) == 40 35 | self._title = title 36 | 37 | def __repr__(self): 38 | return 'Commit '+self.short_sha1 39 | 40 | @property 41 | def sha1(self): 42 | return self._sha1 43 | 44 | @property 45 | def short_sha1(self): 46 | return self._sha1[0:6] 47 | 48 | @property 49 | def title(self): 50 | return self._title 51 | 52 | def __str__(self): 53 | return self._sha1 54 | 55 | def __hash__(self): 56 | return hash(self._sha1) 57 | 58 | def __eq__(self, other): 59 | """ 60 | EXAMPLES:: 61 | 62 | sage: repo.head.__eq__(repo.head) 63 | True 64 | """ 65 | return (self._sha1 == other._sha1) 66 | 67 | def __lt__(self, other): 68 | """ 69 | EXAMPLES:: 70 | 71 | sage: repo.head.__lt__(repo.head) 72 | False 73 | """ 74 | return (self._sha1 < other._sha1) 75 | 76 | def get_history(self, limit=20): 77 | """ 78 | Return the list of (direct and indirect) parent commits 79 | """ 80 | master = self.repository.master 81 | result = [] 82 | rev_list = self.repository.git.rev_list( 83 | self.sha1, '^'+master.sha1, format='oneline', max_count=limit) 84 | for line in rev_list.splitlines(): 85 | sha1 = line[0:40] 86 | title = line[40:].strip() 87 | result.append(GitCommit(self.repository, sha1, title)) 88 | result.append(master) 89 | return result 90 | 91 | def get_message(self, format='fuller'): 92 | """ 93 | Return the log entry for the commit 94 | 95 | EXAMPLES:: 96 | 97 | sage: commit = repo.head.get_history()[-1] 98 | sage: print(commit.get_message()) 99 | commit ... 100 | Author: ... 101 | AuthorDate: ... 102 | Commit: ... 103 | CommitDate: ... 104 | 105 | sixth commit 106 | 107 | """ 108 | return self.repository.git.log(self.sha1, format=format, max_count=1) 109 | 110 | def get_parents(self): 111 | parents = self.repository.git.show('--format=%P', '--no-patch', self.sha1) 112 | return [GitCommit(self.repository, sha1) for sha1 in parents.split()] 113 | 114 | -------------------------------------------------------------------------------- /git_trac/test/test_builder_git.py: -------------------------------------------------------------------------------- 1 | ## -*- encoding: utf-8 -*- 2 | """ 3 | Build a new git repo for doctests 4 | """ 5 | 6 | ############################################################################## 7 | # The "git trac ..." command extension for git 8 | # Copyright (C) 2013 Volker Braun 9 | # 10 | # This program is free software: you can redistribute it and/or modify 11 | # it under the terms of the GNU General Public License as published by 12 | # the Free Software Foundation, either version 3 of the License, or 13 | # (at your option) any later version. 14 | # 15 | # This program is distributed in the hope that it will be useful, 16 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 17 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 | # GNU General Public License for more details. 19 | # 20 | # You should have received a copy of the GNU General Public License 21 | # along with this program. If not, see . 22 | ############################################################################## 23 | 24 | 25 | POPULATE_GIT_REPO = """ 26 | git init . 27 | git config --local --add trac.username trac_user 28 | git config --local --add trac.password trac_pass 29 | 30 | # create conflicting branches 31 | echo 'version 0' > file.txt 32 | git add file.txt 33 | git commit -m 'initial commit' 34 | git checkout -q -b branch1 35 | echo 'version branch 1' > file.txt 36 | git add file.txt 37 | git commit -m 'branch 2 is here' 38 | git checkout -q master 39 | git checkout -q -b branch2 40 | echo 'version branch 2' > file.txt 41 | git add file.txt 42 | git commit -m 'branch 2 conflicts branch 1' 43 | git checkout -q master 44 | 45 | # a bunch of branches 46 | echo '123' > foo1.txt && git add . && git commit -m 'initial commit' 47 | git checkout -q -b 'my_branch' 48 | echo '234' > foo2.txt && git add . && git commit -m 'second commit' 49 | git checkout -q -b 'u/user/description' 50 | echo '345' > foo3.txt 51 | mv foo2.txt foo2_moved.txt 52 | git add --all 53 | git commit -m 'third commit' 54 | git checkout -q -b 'u/user/1000/description' 55 | echo '456' > foo4.txt && git add . && git commit -m 'fourth commit' 56 | git checkout -q -b 'u/bob/1001/work' 57 | echo '567' > foo5.txt && git add . && git commit -m 'fifth commit' 58 | git checkout -q -b 'u/alice/1001/work' 59 | mkdir 'bar' && echo '678' > bar/foo6.txt && git add bar && git commit -m 'sixth commit' 60 | 61 | # finally, some changes to the working tree 62 | git checkout -q -b 'public/1002/anything' 63 | touch staged_file && git add staged_file 64 | echo 'another line' >> foo4.txt 65 | touch untracked_file 66 | """ 67 | 68 | import os 69 | import tempfile 70 | import shutil 71 | import atexit 72 | import subprocess 73 | 74 | temp_dirs = [] 75 | 76 | @atexit.register 77 | def delete_temp_dirs(): 78 | global temp_dirs 79 | for temp_dir in temp_dirs: 80 | # print 'deleting '+temp_dir 81 | shutil.rmtree(temp_dir) 82 | 83 | 84 | class TestBuilderGit(object): 85 | 86 | def __init__(self): 87 | temp_dir = tempfile.mkdtemp() 88 | global temp_dirs 89 | temp_dirs.append(temp_dir) 90 | self.repo_path = os.path.abspath(os.path.join(temp_dir, 'git_repo')) 91 | self.reset_repo() 92 | 93 | def make_repo(self, verbose, user_email_set): 94 | from .git_repository import GitRepository 95 | repo = GitRepository(verbose=verbose) 96 | repo.git._user_email_set = user_email_set 97 | return repo 98 | 99 | def reset_repo(self): 100 | """ 101 | Return a newly populated git repository 102 | """ 103 | try: 104 | cwd = os.getcwd() 105 | shutil.rmtree(self.repo_path, ignore_errors=True) 106 | os.mkdir(self.repo_path) 107 | os.chdir(self.repo_path) 108 | for line in POPULATE_GIT_REPO.splitlines(): 109 | subprocess.check_output(line, shell=True) 110 | finally: 111 | os.chdir(cwd) 112 | 113 | -------------------------------------------------------------------------------- /git_trac/config.py: -------------------------------------------------------------------------------- 1 | """ 2 | Container for Configuration Data 3 | """ 4 | 5 | ############################################################################## 6 | # The "git trac ..." command extension for git 7 | # Copyright (C) 2013 Volker Braun 8 | # 9 | # This program is free software: you can redistribute it and/or modify 10 | # it under the terms of the GNU General Public License as published by 11 | # the Free Software Foundation, either version 3 of the License, or 12 | # (at your option) any later version. 13 | # 14 | # This program is distributed in the hope that it will be useful, 15 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | # GNU General Public License for more details. 18 | # 19 | # You should have received a copy of the GNU General Public License 20 | # along with this program. If not, see . 21 | ############################################################################## 22 | 23 | import os 24 | 25 | 26 | from .git_error import GitError 27 | 28 | 29 | class Config(object): 30 | 31 | def __init__(self, git): 32 | self._git = git 33 | self._debug = False 34 | 35 | def _save(self, config_option, value): 36 | try: 37 | self._git.config('--local', '--unset-all', config_option) 38 | except GitError: 39 | pass 40 | if len(value.strip()) != 0: 41 | self._git.config('--local', '--add', config_option, value) 42 | 43 | def _load(self, config_option): 44 | return self._git.config('--get', config_option).strip() 45 | 46 | @property 47 | def debug(self): 48 | return self._debug 49 | 50 | @debug.setter 51 | def debug(self, value): 52 | self._debug = bool(value) 53 | 54 | @property 55 | def version(self): 56 | return 1 57 | 58 | @property 59 | def server_hostname(self): 60 | return 'https://trac.sagemath.org' 61 | 62 | @property 63 | def server_realm(self): 64 | return 'sage.math.washington.edu' 65 | 66 | @property 67 | def server_anonymous_xmlrpc(self): 68 | return 'xmlrpc' 69 | 70 | @property 71 | def server_authenticated_xmlrpc(self): 72 | return 'login/xmlrpc' 73 | 74 | @property 75 | def username(self): 76 | try: 77 | return os.environ['TRAC_USERNAME'] 78 | except KeyError: 79 | pass 80 | try: 81 | return self._load('trac.username') 82 | except GitError: 83 | raise AuthenticationError('Use "git trac config --user="' 84 | ' to set your trac username') 85 | 86 | @username.setter 87 | def username(self, value): 88 | self._save('trac.username', value) 89 | 90 | @property 91 | def password(self): 92 | try: 93 | return os.environ['TRAC_PASSWORD'] 94 | except KeyError: 95 | pass 96 | try: 97 | return self._load('trac.password') 98 | except GitError: 99 | raise AuthenticationError('Use "git trac config --pass="' 100 | ' to set your trac password') 101 | 102 | @password.setter 103 | def password(self, value): 104 | self._save('trac.password', value) 105 | 106 | @property 107 | def token(self): 108 | try: 109 | return os.environ['TRAC_TOKEN'] 110 | except KeyError: 111 | pass 112 | try: 113 | return self._load('trac.token') 114 | except GitError: 115 | raise AuthenticationError('Use "git trac config --token="' 116 | ' to set your trac authentication token') 117 | 118 | @token.setter 119 | def token(self, value): 120 | self._save('trac.token', value) 121 | 122 | 123 | class AuthenticationError(Exception): 124 | pass 125 | -------------------------------------------------------------------------------- /git_trac/py26_compat.py: -------------------------------------------------------------------------------- 1 | """ 2 | Python 2.6 hacks 3 | """ 4 | 5 | 6 | import sys 7 | import subprocess 8 | 9 | 10 | ######################################################################################## 11 | 12 | def check_output(*popenargs, **kwargs): 13 | """ 14 | Emulation of check_output 15 | """ 16 | if 'stdout' in kwargs: 17 | raise ValueError('stdout argument not allowed, it will be overridden.') 18 | process = subprocess.Popen(stdout=subprocess.PIPE, *popenargs, **kwargs) 19 | output, unused_err = process.communicate() 20 | retcode = process.poll() 21 | if retcode: 22 | cmd = kwargs.get("args") 23 | if cmd is None: 24 | cmd = popenargs[0] 25 | raise subprocess.CalledProcessError(retcode, cmd) 26 | return output 27 | 28 | 29 | ######################################################################################## 30 | # Backport of importlib.import_module from 3.x. 31 | # 32 | # Code from: http://code.activestate.com/recipes/576685/ 33 | 34 | def total_ordering(cls): 35 | """ 36 | Backport to work with Python 2.6 37 | 38 | Class decorator that fills in missing ordering methods 39 | """ 40 | convert = { 41 | '__lt__': [ 42 | ( 43 | '__gt__', 44 | lambda self, other: not (self < other or self == other) 45 | ), 46 | ( 47 | '__le__', 48 | lambda self, other: self < other or self == other 49 | ), 50 | ( 51 | '__ge__', 52 | lambda self, other: not self < other 53 | )], 54 | '__le__': [ 55 | ( 56 | '__ge__', 57 | lambda self, other: not self <= other or self == other 58 | ), 59 | ( 60 | '__lt__', 61 | lambda self, other: self <= other and not self == other 62 | ), 63 | ( 64 | '__gt__', 65 | lambda self, other: not self <= other 66 | )], 67 | '__gt__': [ 68 | ( 69 | '__lt__', 70 | lambda self, other: not (self > other or self == other) 71 | ), 72 | ( 73 | '__ge__', 74 | lambda self, other: self > other or self == other 75 | ), 76 | ( 77 | '__le__', 78 | lambda self, other: not self > other 79 | )], 80 | '__ge__': [ 81 | ( 82 | '__le__', 83 | lambda self, other: (not self >= other) or self == other 84 | ), 85 | ( 86 | '__gt__', 87 | lambda self, other: self >= other and not self == other 88 | ), 89 | ( 90 | '__lt__', 91 | lambda self, other: not self >= other 92 | )] 93 | } 94 | roots = set(dir(cls)) & set(convert) 95 | if not roots: 96 | raise ValueError( 97 | 'must define at least one ordering operation: < > <= >=' 98 | ) 99 | root = max(roots) # prefer __lt__ to __le__ to __gt__ to __ge__ 100 | for opname, opfunc in convert[root]: 101 | if opname not in roots: 102 | opfunc.__name__ = opname 103 | opfunc.__doc__ = getattr(int, opname).__doc__ 104 | setattr(cls, opname, opfunc) 105 | return cls 106 | 107 | 108 | 109 | ######################################################################################## 110 | # Backport of importlib.import_module from 3.x. 111 | # 112 | # Taken from https://pypi.python.org/pypi/importlib 113 | 114 | def _resolve_name(name, package, level): 115 | """Return the absolute name of the module to be imported.""" 116 | if not hasattr(package, 'rindex'): 117 | raise ValueError("'package' not set to a string") 118 | dot = len(package) 119 | for x in range(level, 1, -1): 120 | try: 121 | dot = package.rindex('.', 0, dot) 122 | except ValueError: 123 | raise ValueError("attempted relative import beyond top-level " 124 | "package") 125 | return "%s.%s" % (package[:dot], name) 126 | 127 | 128 | def import_module(name, package=None): 129 | """Import a module. 130 | 131 | The 'package' argument is required when performing a relative import. It 132 | specifies the package to use as the anchor point from which to resolve the 133 | relative import to an absolute import. 134 | 135 | """ 136 | if name.startswith('.'): 137 | if not package: 138 | raise TypeError("relative imports require the 'package' argument") 139 | level = 0 140 | for character in name: 141 | if character != '.': 142 | break 143 | level += 1 144 | name = _resolve_name(name[level:], package, level) 145 | __import__(name) 146 | return sys.modules[name] 147 | -------------------------------------------------------------------------------- /git_trac/test/builder.py: -------------------------------------------------------------------------------- 1 | ## -*- encoding: utf-8 -*- 2 | """ 3 | Build Stuff for Doctests 4 | """ 5 | 6 | ############################################################################## 7 | # The "git trac ..." command extension for git 8 | # Copyright (C) 2013 Volker Braun 9 | # 10 | # This program is free software: you can redistribute it and/or modify 11 | # it under the terms of the GNU General Public License as published by 12 | # the Free Software Foundation, either version 3 of the License, or 13 | # (at your option) any later version. 14 | # 15 | # This program is distributed in the hope that it will be useful, 16 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 17 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 | # GNU General Public License for more details. 19 | # 20 | # You should have received a copy of the GNU General Public License 21 | # along with this program. If not, see . 22 | ############################################################################## 23 | 24 | 25 | 26 | 27 | POPULATE_GIT_REPO = u""" 28 | git init . 29 | git config --local --add user.email "committer@example.com" 30 | git config --local --add user.name "Jäne Developer (弄火)" 31 | git config --local --add trac.username trac_user 32 | git config --local --add trac.password trac_pass 33 | 34 | # create conflicting branches 35 | echo 'version 0' > file.txt 36 | git add file.txt 37 | git commit -m 'initial commit' 38 | git checkout -q -b branch1 39 | echo 'version branch 1' > file.txt 40 | git add file.txt 41 | git commit -m 'branch 2 is here' 42 | git checkout -q master 43 | git checkout -q -b branch2 44 | echo 'version branch 2' > file.txt 45 | git add file.txt 46 | git commit -m 'branch 2 conflicts branch 1' 47 | git checkout -q master 48 | 49 | # a bunch of branches 50 | echo '123' > foo1.txt && git add . && git commit -m 'initial commit' 51 | git checkout -q -b 'my_branch' 52 | echo '234' > foo2.txt && git add . && git commit -m 'secønd commit' 53 | git checkout -q -b 'u/user/description' 54 | echo '345' > foo3.txt 55 | mv foo2.txt foo2_moved.txt 56 | git add --all 57 | git commit -m 'third commit' 58 | git checkout -q -b 'u/user/1000/description' 59 | echo '456' > foo4.txt && git add . && git commit -m 'føurth commit' 60 | git checkout -q -b 'u/bob/1001/work' 61 | echo '567' > foo5.txt && git add . && git commit -m 'fifth commit' 62 | git checkout -q -b 'u/alice/1001/work' 63 | mkdir 'bar' && echo '678' > bar/foo6.txt && git add bar && git commit -m 'sixth commit' 64 | 65 | # finally, some changes to the working tree 66 | git checkout -q -b 'public/1002/anything' 67 | touch staged_file && git add staged_file 68 | echo 'another line' >> foo4.txt 69 | touch untracked_file 70 | """ 71 | 72 | import os 73 | import tempfile 74 | import shutil 75 | import atexit 76 | 77 | try: 78 | from subprocess import check_output # new in Python 2.7 79 | except ImportError: 80 | from git_trac.py26_compat import check_output 81 | 82 | temp_dirs = [] 83 | 84 | @atexit.register 85 | def delete_temp_dirs(): 86 | global temp_dirs 87 | for temp_dir in temp_dirs: 88 | # print 'deleting '+temp_dir 89 | shutil.rmtree(temp_dir) 90 | 91 | 92 | 93 | class GitRepoBuilder(object): 94 | 95 | def make_repo(self, verbose, user_email_set): 96 | from git_trac.git_repository import GitRepository 97 | repo = GitRepository(verbose=verbose) 98 | repo.git._user_email_set = user_email_set 99 | return repo 100 | 101 | def make_trac(self): 102 | from git_trac.app import Application 103 | return Application().trac 104 | 105 | def _make_fake_remote(self, temp_dir): 106 | self.trac_remote = os.path.abspath(os.path.join(temp_dir, 'trac_remote')) 107 | try: 108 | cwd = os.getcwd() 109 | os.mkdir(self.trac_remote) 110 | os.chdir(self.trac_remote) 111 | for line in POPULATE_GIT_REPO.splitlines(): 112 | check_output(line.encode('utf-8'), shell=True) 113 | finally: 114 | os.chdir(cwd) 115 | 116 | def reset_repo(self): 117 | """ 118 | Return a newly populated git repository 119 | """ 120 | try: 121 | cwd = os.getcwd() 122 | shutil.rmtree(self.repo_path, ignore_errors=True) 123 | os.mkdir(self.repo_path) 124 | os.chdir(self.repo_path) 125 | for line in POPULATE_GIT_REPO.splitlines(): 126 | check_output(line.encode('utf-8'), shell=True) 127 | check_output('git remote add trac file://' + self.trac_remote, shell=True) 128 | finally: 129 | os.chdir(cwd) 130 | 131 | def setUp(self): 132 | temp_dir = tempfile.mkdtemp() 133 | global temp_dirs 134 | temp_dirs.append(temp_dir) 135 | self._make_fake_remote(temp_dir) 136 | self.repo_path = os.path.abspath(os.path.join(temp_dir, 'git_repo')) 137 | self.reset_repo() 138 | self.old_cwd = os.getcwd() 139 | os.chdir(self.repo_path) 140 | # print('set up test repo ' + self.repo_path) 141 | 142 | def tearDown(self): 143 | os.chdir(self.old_cwd) 144 | -------------------------------------------------------------------------------- /git_trac/git_error.py: -------------------------------------------------------------------------------- 1 | """ 2 | Exception classes for Git 3 | """ 4 | 5 | ############################################################################## 6 | # Copyright (C) 2013 Volker Braun 7 | # 8 | # This program is free software: you can redistribute it and/or modify 9 | # it under the terms of the GNU General Public License as published by 10 | # the Free Software Foundation, either version 3 of the License, or 11 | # (at your option) any later version. 12 | # 13 | # This program is distributed in the hope that it will be useful, 14 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 15 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 16 | # GNU General Public License for more details. 17 | # 18 | # You should have received a copy of the GNU General Public License 19 | # along with this program. If not, see . 20 | ############################################################################## 21 | 22 | 23 | class GitError(RuntimeError): 24 | r""" 25 | Error raised when git exits with a non-zero exit code. 26 | 27 | EXAMPLES:: 28 | 29 | sage: from git_trac.git_error import GitError 30 | sage: raise GitError({'exit_code':128, 'stdout':'', 'stderr':'', 'cmd':'command'}) 31 | Traceback (most recent call last): 32 | ... 33 | GitError: git returned with 34 | non-zero exit code (128) when executing "command" 35 | """ 36 | def __init__(self, result, explain=None, advice=None): 37 | r""" 38 | Initialization. 39 | 40 | TESTS:: 41 | 42 | sage: from git_trac.git_error import GitError 43 | sage: type(GitError({'exit_code':128, 'stdout':'', 'stderr':'', 'cmd':'command'})) 44 | 45 | """ 46 | self.exit_code = result['exit_code'] 47 | self.cmd = result['cmd'].strip() 48 | def prefix(string, prefix): 49 | return '\n'.join([prefix + ': ' + line.rstrip() for line in string.splitlines()]) 50 | self.stdout = prefix(result['stdout'], ' STDOUT') 51 | self.stderr = prefix(result['stderr'], ' STDERR') 52 | self.explain = explain 53 | self.advice = advice 54 | template = 'git returned with non-zero exit code ({0}) when executing "{1}"' 55 | msg = template.format(self.exit_code, self.cmd) 56 | if len(self.stdout) != 0: 57 | msg += '\n' + self.stdout 58 | if len(self.stderr) != 0: 59 | msg += '\n' + self.stderr 60 | RuntimeError.__init__(self, msg) 61 | 62 | 63 | class DetachedHeadException(RuntimeError): 64 | r""" 65 | Error raised when a git command can not be executed because the repository 66 | is in a detached HEAD state. 67 | 68 | EXAMPLES:: 69 | 70 | sage: from git_trac.git_error import DetachedHeadException 71 | sage: raise DetachedHeadException() 72 | Traceback (most recent call last): 73 | ... 74 | DetachedHeadException: unexpectedly, 75 | git is in a detached HEAD state 76 | """ 77 | def __init__(self): 78 | r""" 79 | Initialization. 80 | 81 | TESTS:: 82 | 83 | sage: from git_trac.git_error import DetachedHeadException 84 | sage: type(DetachedHeadException()) 85 | 86 | """ 87 | RuntimeError.__init__(self, "unexpectedly, git is in a detached HEAD state") 88 | 89 | 90 | class InvalidStateError(RuntimeError): 91 | r""" 92 | Error raised when a git command can not be executed because the repository 93 | is not in a clean state. 94 | 95 | EXAMPLES:: 96 | 97 | sage: from git_trac.git_error import InvalidStateError 98 | sage: raise InvalidStateError() 99 | Traceback (most recent call last): 100 | ... 101 | InvalidStateError: unexpectedly, 102 | git is in an unclean state 103 | """ 104 | def __init__(self): 105 | r""" 106 | Initialization. 107 | 108 | TESTS:: 109 | 110 | sage: from git_trac.git_error import InvalidStateError 111 | sage: type(InvalidStateError()) 112 | 113 | """ 114 | RuntimeError.__init__(self, "unexpectedly, git is in an unclean state") 115 | 116 | 117 | class UserEmailException(RuntimeError): 118 | r""" 119 | Error raised if user/email is not set. 120 | 121 | This means that it is not advisable to make commits to the repository. 122 | 123 | EXAMPLES:: 124 | 125 | sage: from git_trac.git_error import UserEmailException 126 | sage: raise UserEmailException() 127 | Traceback (most recent call last): 128 | ... 129 | UserEmailException: user/email 130 | is not configured, cannot make commits 131 | """ 132 | def __init__(self): 133 | r""" 134 | Initialization. 135 | 136 | TESTS:: 137 | 138 | sage: from git_trac.git_error import UserEmailException 139 | sage: type(UserEmailException()) 140 | 141 | """ 142 | RuntimeError.__init__(self, "user/email is not configured, cannot make commits") 143 | 144 | -------------------------------------------------------------------------------- /git_trac/digest_transport.py: -------------------------------------------------------------------------------- 1 | r""" 2 | HTTP transport to the trac server 3 | 4 | AUTHORS: 5 | 6 | - David Roe, Julian Rueth, Robert Bradshaw: initial version 7 | 8 | """ 9 | #***************************************************************************** 10 | # Copyright (C) 2013 David Roe 11 | # Julian Rueth 12 | # Robert Bradshaw 13 | # 14 | # Distributed under the terms of the GNU General Public License (GPL) 15 | # as published by the Free Software Foundation; either version 2 of 16 | # the License, or (at your option) any later version. 17 | #***************************************************************************** 18 | 19 | import urllib.request 20 | import urllib.parse 21 | 22 | from xmlrpc.client import SafeTransport, Fault 23 | 24 | from .trac_error import \ 25 | TracInternalError, TracAuthenticationError, TracConnectionError 26 | from .cached_property import cached_property 27 | 28 | 29 | class DigestTransport(SafeTransport): 30 | """ 31 | Handles an HTTP transaction to an XML-RPC server. 32 | 33 | EXAMPLES:: 34 | 35 | sage: from sage.dev.digest_transport import DigestTransport 36 | sage: DigestTransport() 37 | 38 | """ 39 | def __init__(self): 40 | """ 41 | Initialization. 42 | 43 | EXAMPLES:: 44 | 45 | sage: from sage.dev.digest_transport import DigestTransport 46 | sage: type(DigestTransport()) 47 | 48 | """ 49 | super().__init__() 50 | 51 | @cached_property 52 | def opener(self): 53 | """ 54 | Create an opener object. 55 | 56 | By calling :meth:`add_authentication` before calling this property for 57 | the first time, authentication credentials can be set. 58 | 59 | EXAMPLES:: 60 | 61 | sage: from sage.dev.digest_transport import DigestTransport 62 | sage: DigestTransport().opener 63 | 64 | """ 65 | authhandler = urllib.request.HTTPDigestAuthHandler() 66 | return urllib.request.build_opener(authhandler) 67 | 68 | def single_request(self, host, handler, request_body, verbose): 69 | """ 70 | Issue an XML-RPC request. 71 | 72 | EXAMPLES:: 73 | 74 | sage: from sage.dev.digest_transport import DigestTransport 75 | sage: from sage.env import TRAC_SERVER_URI 76 | sage: import urllib.parse 77 | sage: url = urllib.parse.urlparse(TRAC_SERVER_URI).netloc 78 | sage: d = DigestTransport() 79 | sage: d.single_request(url, 'xmlrpc', "ticket.get1000", 0) # optional: internet 80 | ([1000, 81 | , 82 | , 83 | {'status': 'closed', 84 | 'changetime': , 85 | 'description': '', 86 | 'reporter': 'was', 87 | 'cc': '', 88 | 'type': 'defect', 89 | 'milestone': 'sage-2.10', 90 | '_ts': '1199953720000000', 91 | 'component': 'distribution', 92 | 'summary': 'Sage does not have 10000 users yet.', 93 | 'priority': 'major', 94 | 'owner': 'was', 95 | 'time': , 96 | 'keywords': '', 97 | 'resolution': 'fixed'}],) 98 | """ 99 | url = urllib.parse.urlunparse(('https', host, handler, '', '', '')) 100 | try: 101 | req = urllib.request.Request( 102 | url, request_body, 103 | {'Content-Type': 'text/xml', 'User-Agent': self.user_agent}) 104 | response = self.opener.open(req) 105 | self.verbose = verbose 106 | return self.parse_response(response) 107 | except Fault as e: 108 | raise TracInternalError(e) 109 | except IOError as e: 110 | if hasattr(e, 'code') and e.code == 401: 111 | raise TracAuthenticationError() 112 | else: 113 | raise TracConnectionError(e.reason) 114 | 115 | 116 | 117 | class AuthenticatedDigestTransport(DigestTransport): 118 | 119 | def __init__(self, realm, url, username, password): 120 | """ 121 | Set authentication credentials for the opener returned by 122 | :meth:`opener`. 123 | 124 | EXAMPLES:: 125 | 126 | sage: from sage.dev.digest_transport import DigestTransport 127 | sage: dt = DigestTransport() 128 | sage: dt.add_authentication("realm", "url", "username", "password") 129 | sage: dt.opener 130 | """ 131 | super().__init__() 132 | self._realm = realm 133 | self._url = url 134 | self._username = username 135 | self._password = password 136 | 137 | @cached_property 138 | def opener(self): 139 | authhandler = urllib.request.HTTPDigestAuthHandler() 140 | authhandler.add_password( 141 | self._realm, self._url, self._username, self._password) 142 | return urllib.request.build_opener(authhandler) 143 | -------------------------------------------------------------------------------- /git_trac/pretty_ticket.py: -------------------------------------------------------------------------------- 1 | """ 2 | Pretty-Print Tickets 3 | 4 | EXAMPLES: 5 | 6 | sage: class Ticket(object): 7 | ....: number = 1234 8 | ....: title = 'Title' 9 | ....: description = 'description' 10 | ....: reporter = 'Reporter' 11 | ....: author = 'Author' 12 | ....: reviewer = 'Reviewer' 13 | ....: branch = 'Branch' 14 | ....: keywords = 'Keywords' 15 | ....: dependencies = 'Dependencies' 16 | ....: ctime_str = 'creation time string' 17 | ....: mtime_str = 'modification time string' 18 | ....: owner = 'Owner' 19 | ....: upstream = 'Upstream' 20 | ....: status = 'Status' 21 | ....: component = 'Component' 22 | ....: def grouped_comment_iter(self): 23 | ....: return () 24 | sage: ticket = Ticket() 25 | sage: from git_trac.pretty_ticket import format_ticket 26 | sage: print(format_ticket(ticket)) 27 | ============================================================================== 28 | Trac #1234: Title 29 | 30 | description 31 | Status: Status Component: Component 32 | Last modified: modification time string Created: creation time string UTC 33 | Report upstream: Upstream 34 | Authors: Author 35 | Reviewers: Reviewer 36 | Branch: Branch 37 | Keywords: Keywords 38 | Dependencies: Dependencies 39 | ------------------------------------------------------------------------------ 40 | URL: https://trac.sagemath.org/1234 41 | ============================================================================== 42 | """ 43 | 44 | import textwrap 45 | 46 | 47 | SEPARATOR_TEMPLATE = '\n' + '-' * 78 + '\n' 48 | 49 | #123456789 123456789 123456789 123456789 123456789 123456789 123456789 123456789 50 | 51 | DESCRIPTION_TEMPLATE = u'=' * 78 + u""" 52 | Trac #{ticket.number}: {ticket.title} 53 | 54 | {ticket.description} 55 | Status: {ticket.status: <25} Component: {ticket.component: <25} 56 | Last modified: {ticket.mtime_str: <25}Created: {ticket.ctime_str} UTC 57 | Report upstream: {ticket.upstream} 58 | Authors: {ticket.author} 59 | Reviewers: {ticket.reviewer} 60 | Branch: {ticket.branch} 61 | Keywords: {ticket.keywords} 62 | Dependencies: {ticket.dependencies} 63 | """ 64 | 65 | u""" 66 | Reported by: {ticket.reporter: <25} Owner: {ticket.owner: <25} 67 | """ 68 | 69 | 70 | 71 | GENERIC_CHANGE_TEMPLATE = u""" 72 | [{change.change_capitalized}] {change.change_action} 73 | """ 74 | 75 | CHANGE_TEMPLATES = { 76 | 'comment_set': 77 | u""" 78 | Comment #{change.number} by {change.author} at {change.ctime} UTC: 79 | {change.comment} 80 | """, 81 | 82 | 83 | 'author_set': 84 | u""" 85 | [Authors] set to {change.new} 86 | """, 87 | 'author_del': 88 | u""" 89 | [Authors] {change.old} deleted 90 | """, 91 | 'author_mod': 92 | u""" 93 | [Authors] changed from {change.old} to {change.new} 94 | """, 95 | 96 | 97 | 'reviewer_set': 98 | u""" 99 | [Reviewers] set to {change.new} 100 | """, 101 | 'reviewer_mod': 102 | u""" 103 | [Reviewers] changed from {change.old} to {change.new} 104 | """, 105 | 'reviewer_del': 106 | u""" 107 | [Reviewers] {change.old} deleted 108 | """, 109 | 110 | 111 | 'description_set': "[Description] modified", 112 | 'description_mod': "[Description] modified", 113 | 'description_del': "[Description] modified", 114 | 115 | 'attachment_set': 116 | u""" 117 | [Attachment] "{change.new}" added 118 | """, 119 | 'attachment_mod': 120 | u""" 121 | [Attachment] "{change.new}" updated 122 | """, 123 | 'attachment_del': 124 | u""" 125 | [Attachment] "{change.old}" deleted 126 | """, 127 | 128 | 'summary_set': 129 | u""" 130 | [Summary] set to {change.new} 131 | """, 132 | 'summary_mod': 133 | u""" 134 | [Summary] changed to {change.new} 135 | """, 136 | 'summary_del': 137 | u""" 138 | [Summary] {change.old} deleted 139 | """, 140 | 141 | 'upstream_set': 142 | u""" 143 | [Report Upstream] set to {change.new} 144 | """, 145 | 'upstream_mod': 146 | u""" 147 | [Report Upstream] changed to {change.new} 148 | """, 149 | 'upstream_del': 150 | u""" 151 | [Report Upstream] {change.old} deleted 152 | """, 153 | } 154 | 155 | 156 | COMMENT_TEMPLATE = u""" 157 | Comment #{change.number} by {change.author} at {change.ctime} UTC: 158 | {change.comment} 159 | """ 160 | 161 | FOOTER_TEMPLATE = u""" 162 | URL: https://trac.sagemath.org/{ticket.number} 163 | """ + '=' * 78 164 | 165 | 166 | def wrap_lines(text): 167 | text = text.strip() 168 | accumulator = [] 169 | for line in text.splitlines(): 170 | line = '\n'.join(textwrap.wrap(line, 78)) 171 | accumulator.append(line) 172 | return '\n'.join(accumulator) 173 | 174 | 175 | def format_ticket(ticket): 176 | result = [] 177 | result.append(DESCRIPTION_TEMPLATE.format(ticket=ticket)) 178 | for change_set in ticket.grouped_comment_iter(): 179 | result.append(SEPARATOR_TEMPLATE.format(ticket=ticket)) 180 | for change in change_set: 181 | if change.change.startswith('_'): 182 | # changed comments are returned like this 183 | continue 184 | if change.old == '' and change.new == '' and change.change != 'comment': 185 | continue 186 | if change.old == '': 187 | change_type = change.change + '_set' 188 | elif change.new == '': 189 | change_type = change.change + '_del' 190 | else: 191 | change_type = change.change + '_mod' 192 | template = CHANGE_TEMPLATES.get( 193 | change_type, GENERIC_CHANGE_TEMPLATE) 194 | result.append(template.format( 195 | ticket=ticket, change=change).strip()) 196 | #result.append('DEBUG ' + str(change.get_data())) 197 | result.append(SEPARATOR_TEMPLATE.format(ticket=ticket)) 198 | result.append(FOOTER_TEMPLATE.format(ticket=ticket)) 199 | result = '\n'.join(r.strip() for r in result) 200 | return wrap_lines(result) 201 | -------------------------------------------------------------------------------- /git_trac/digest_transport_py2.py: -------------------------------------------------------------------------------- 1 | r""" 2 | Python 2.7 version of the HTTP transport to the trac server 3 | 4 | AUTHORS: 5 | 6 | - David Roe, Julian Rueth, Robert Bradshaw: initial version 7 | 8 | """ 9 | #***************************************************************************** 10 | # Copyright (C) 2013 David Roe 11 | # Julian Rueth 12 | # Robert Bradshaw 13 | # 14 | # Distributed under the terms of the GNU General Public License (GPL) 15 | # as published by the Free Software Foundation; either version 2 of 16 | # the License, or (at your option) any later version. 17 | # http://www.gnu.org/licenses/ 18 | #***************************************************************************** 19 | 20 | import sys 21 | from xmlrpclib import SafeTransport, Fault 22 | import urllib2 23 | 24 | # Monkey patch http://bugs.python.org/issue8194 25 | if (sys.version_info[0] == 2 and 26 | sys.version_info[1] == 7 and 27 | sys.version_info[2] <= 1): 28 | 29 | import httplib 30 | class patched_addinfourl(urllib2.addinfourl): 31 | 32 | def getheader(self, name, default=None): 33 | if self.headers is None: 34 | raise httplib.ResponseNotReady() 35 | return self.headers.getheader(name, default) 36 | 37 | def getheaders(self): 38 | if self.headers is None: 39 | raise httplib.ResponseNotReady() 40 | return self.headers.items() 41 | 42 | urllib2.addinfourl = patched_addinfourl 43 | 44 | 45 | 46 | from .trac_error import \ 47 | TracInternalError, TracAuthenticationError, TracConnectionError 48 | from .cached_property import cached_property 49 | 50 | 51 | class DigestTransport(object, SafeTransport): 52 | """ 53 | Handles an HTTP transaction to an XML-RPC server. 54 | 55 | EXAMPLES:: 56 | 57 | sage: from sage.dev.digest_transport import DigestTransport 58 | sage: DigestTransport() 59 | 60 | """ 61 | def __init__(self): 62 | """ 63 | Initialization. 64 | 65 | EXAMPLES:: 66 | 67 | sage: from sage.dev.digest_transport import DigestTransport 68 | sage: type(DigestTransport()) 69 | 70 | """ 71 | super(DigestTransport, self).__init__() 72 | self._use_datetime = 0 73 | 74 | @cached_property 75 | def opener(self): 76 | """ 77 | Create an opener object. 78 | 79 | By calling :meth:`add_authentication` before calling this property for 80 | the first time, authentication credentials can be set. 81 | 82 | EXAMPLES:: 83 | 84 | sage: from sage.dev.digest_transport import DigestTransport 85 | sage: DigestTransport().opener 86 | 87 | """ 88 | return urllib2.build_opener(urllib2.HTTPDigestAuthHandler()) 89 | 90 | def single_request(self, host, handler, request_body, verbose): 91 | """ 92 | Issue an XML-RPC request. 93 | 94 | EXAMPLES:: 95 | 96 | sage: from sage.dev.digest_transport import DigestTransport 97 | sage: from sage.env import TRAC_SERVER_URI 98 | sage: import urlparse 99 | sage: url = urlparse.urlparse(TRAC_SERVER_URI).netloc 100 | sage: d = DigestTransport() 101 | sage: d.single_request(url, 'xmlrpc', "ticket.get1000", 0) # optional: internet 102 | ([1000, 103 | , 104 | , 105 | {'status': 'closed', 106 | 'changetime': , 107 | 'description': '...', 108 | 'reporter': 'was', 109 | 'cc': '', 110 | 'type': 'defect', 111 | 'milestone': 'sage-2.10', 112 | '_ts': '...', 113 | 'component': 'distribution', 114 | 'summary': 'Sage does not have 10000 users yet.', 115 | 'priority': 'major', 116 | 'owner': 'was', 117 | 'time': , 118 | 'keywords': '', 119 | 'resolution': 'fixed'}],) 120 | """ 121 | try: 122 | import urlparse 123 | req = urllib2.Request( 124 | urlparse.urlunparse(('https', host, handler, '', '', '')), 125 | request_body, {'Content-Type': 'text/xml', 126 | 'User-Agent': self.user_agent}) 127 | response = self.opener.open(req) 128 | self.verbose = verbose 129 | return self.parse_response(response) 130 | except Fault as e: 131 | raise TracInternalError(e) 132 | except urllib2.HTTPError as e: 133 | if e.code == 401: 134 | raise TracAuthenticationError() 135 | else: 136 | raise TracConnectionError(e.reason) 137 | 138 | 139 | 140 | class AuthenticatedDigestTransport(DigestTransport): 141 | 142 | def __init__(self, realm, url, username, password): 143 | """ 144 | Set authentication credentials for the opener returned by 145 | :meth:`opener`. 146 | 147 | EXAMPLES:: 148 | 149 | sage: from sage.dev.digest_transport import DigestTransport 150 | sage: dt = DigestTransport() 151 | sage: dt.add_authentication("realm", "url", "username", "password") 152 | sage: dt.opener 153 | """ 154 | super(AuthenticatedDigestTransport, self).__init__() 155 | self._realm = realm 156 | self._url = url 157 | self._username = username 158 | self._password = password 159 | 160 | @cached_property 161 | def opener(self): 162 | authhandler = urllib2.HTTPDigestAuthHandler() 163 | authhandler.add_password( 164 | self._realm, self._url, self._username, self._password) 165 | return urllib2.build_opener(authhandler) 166 | -------------------------------------------------------------------------------- /git_trac/test/test_doctests.py: -------------------------------------------------------------------------------- 1 | """ 2 | Run doctests as part of the unittests 3 | """ 4 | 5 | ############################################################################## 6 | # The "git trac ..." command extension for git 7 | # Copyright (C) 2013 Volker Braun 8 | # 9 | # This program is free software: you can redistribute it and/or modify 10 | # it under the terms of the GNU General Public License as published by 11 | # the Free Software Foundation, either version 3 of the License, or 12 | # (at your option) any later version. 13 | # 14 | # This program is distributed in the hope that it will be useful, 15 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 16 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 17 | # GNU General Public License for more details. 18 | # 19 | # You should have received a copy of the GNU General Public License 20 | # along with this program. If not, see . 21 | ############################################################################## 22 | 23 | 24 | import doctest 25 | import sys 26 | import os 27 | import re 28 | 29 | try: 30 | from importlib import import_module 31 | except ImportError: 32 | from git_trac.py26_compat import import_module 33 | 34 | try: 35 | import unittest 36 | except ImportError: 37 | import unittest2 as unittest 38 | 39 | 40 | from git_trac.test.doctest_parser import SageDocTestParser, SageOutputChecker 41 | from git_trac.test.builder import GitRepoBuilder 42 | 43 | 44 | def sage_testmod(module, verbose=False, globs={}): 45 | """ 46 | Run doctest with sage prompts 47 | """ 48 | if isinstance(module, str): 49 | module = import_module(module) 50 | parser = SageDocTestParser(long=True, optional_tags=('sage',)) 51 | finder = doctest.DocTestFinder(parser=parser) 52 | checker = SageOutputChecker() 53 | opts = doctest.NORMALIZE_WHITESPACE|doctest.ELLIPSIS|doctest.IGNORE_EXCEPTION_DETAIL 54 | runner = doctest.DocTestRunner(checker=checker, optionflags=opts, verbose=verbose) 55 | for test in finder.find(module): 56 | test.globs.update(globs) 57 | rc = runner.run(test) 58 | if rc.failed: 59 | return False 60 | return True 61 | 62 | 63 | class RemainingDoctests(GitRepoBuilder, unittest.TestCase): 64 | 65 | MODULE_FILENAME_RE = re.compile('^.*/[a-zA-Z0-9][a-zA-Z0-9_]*.py$') 66 | 67 | already_handled = ( 68 | 'git_trac.git_interface', 69 | 'git_trac.git_commit', 70 | 'git_trac.git_repository', 71 | 'git_trac.trac_server', 72 | 'git_trac.app', 73 | ) 74 | 75 | notest = ( 76 | 'git_trac.digest_transport', 77 | 'git_trac.digest_transport_py2', 78 | 'git_trac.test.doctest_parser', 79 | ) 80 | 81 | @property 82 | def root_path(self): 83 | GIT_TRAC_DIR = os.path.dirname(os.path.dirname(os.path.dirname(__file__))) 84 | return os.path.abspath(GIT_TRAC_DIR) 85 | 86 | def file_to_module(self, filename): 87 | """ 88 | Convert filename to module name 89 | """ 90 | cwd = self.root_path 91 | fqn = os.path.abspath(filename) 92 | assert fqn.startswith(cwd) 93 | fqn = fqn[len(cwd) : -len('.py')].lstrip(os.path.sep) 94 | module = fqn.replace(os.path.sep, '.') 95 | if module.endswith('.__init__'): 96 | module = module[:-len('.__init__')] 97 | return module 98 | 99 | def relative_path(self, filename): 100 | cwd = self.root_path 101 | fqn = os.path.abspath(filename) 102 | assert fqn.startswith(cwd) 103 | fqn = fqn[len(cwd):].lstrip(os.path.sep) 104 | return fqn 105 | 106 | def add_file(self, filename): 107 | return self.file_to_module(filename) 108 | 109 | def add_dir(self, *test_paths): 110 | modules = [] 111 | for test_path in test_paths: 112 | for name in os.listdir(test_path): 113 | name = os.path.join(test_path, name) 114 | if os.path.isdir(name): 115 | modules.extend(self.add_dir(name)) 116 | elif self.MODULE_FILENAME_RE.match(name): 117 | modules.append(self.add_file(name)) 118 | return modules 119 | 120 | def is_py3(self): 121 | return sys.version_info[0] >= 3 122 | 123 | def find_modules(self): 124 | modules = self.add_dir(os.path.join(self.root_path, 'git_trac')) 125 | modules = set(modules).difference(self.already_handled + self.notest) 126 | return modules 127 | 128 | def test_finder(self): 129 | modules = self.find_modules() 130 | required = ( 131 | 'git_trac.trac_error', 132 | 'git_trac.git_error', 133 | 'git_trac.pretty_ticket', 134 | 'git_trac.releasemgr.commit_message', 135 | ) 136 | not_found = set(required).difference(modules) 137 | self.assertTrue(not_found == set()) 138 | self.assertFalse(any(name in modules for name in self.already_handled)) 139 | 140 | def test_remaining_doctests(self): 141 | for module in self.find_modules(): 142 | rc = sage_testmod(module) 143 | self.assertTrue(rc) 144 | 145 | 146 | class GitDebugDoctests(GitRepoBuilder, unittest.TestCase): 147 | 148 | def setUp(self): 149 | super(GitDoctests, self).setUp() 150 | import git_trac.git_interface 151 | git_trac.git_interface.DEBUG_PRINT = True 152 | 153 | def tearDown(self): 154 | import git_trac.git_interface 155 | git_trac.git_interface.DEBUG_PRINT = False 156 | super(GitDoctests, self).tearDown() 157 | 158 | 159 | class GitDoctests(GitRepoBuilder, unittest.TestCase): 160 | 161 | def test_utils(self): 162 | repo = self.make_repo(verbose=False, user_email_set=True) 163 | globs = {'reset_repo': self.reset_repo, 'repo':repo, 'git':repo.git} 164 | rc = sage_testmod('git_trac.git_commit', globs=globs) 165 | self.assertTrue(rc) 166 | rc = sage_testmod('git_trac.git_repository', globs=globs) 167 | self.assertTrue(rc) 168 | 169 | 170 | class TracDoctests(GitRepoBuilder, unittest.TestCase): 171 | 172 | def test_trac_model(self): 173 | globs = {'trac':self.make_trac()} 174 | rc = sage_testmod('git_trac.trac_server', globs=globs) 175 | self.assertTrue(rc) 176 | 177 | 178 | class AppDoctests(GitRepoBuilder, unittest.TestCase): 179 | 180 | def test_app(self): 181 | from git_trac.app import Application 182 | app = Application() 183 | globs = {'app':app} 184 | rc = sage_testmod('git_trac.app', globs=globs) 185 | self.assertTrue(rc) 186 | 187 | 188 | 189 | if __name__ == '__main__': 190 | unittest.main() 191 | 192 | 193 | 194 | -------------------------------------------------------------------------------- /git_trac/trac_server.py: -------------------------------------------------------------------------------- 1 | """ 2 | Interface to the Sage Trac server 3 | 4 | Uses XML-RPC to talk to the trac server. 5 | 6 | EXAMPLES:: 7 | 8 | """ 9 | ############################################################################## 10 | # The "git trac ..." command extension for git 11 | # Copyright (C) 2013 Volker Braun 12 | # 13 | # This program is free software: you can redistribute it and/or modify 14 | # it under the terms of the GNU General Public License as published by 15 | # the Free Software Foundation, either version 3 of the License, or 16 | # (at your option) any later version. 17 | # 18 | # This program is distributed in the hope that it will be useful, 19 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 20 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 21 | # GNU General Public License for more details. 22 | # 23 | # You should have received a copy of the GNU General Public License 24 | # along with this program. If not, see . 25 | ############################################################################## 26 | 27 | import os 28 | 29 | try: 30 | # Python 3.3+ 31 | from xmlrpc.client import ServerProxy 32 | from .digest_transport import DigestTransport, AuthenticatedDigestTransport 33 | from urllib import parse as url_parse 34 | except ImportError: 35 | # Python 2.7 36 | from xmlrpclib import ServerProxy 37 | from .digest_transport_py2 import DigestTransport, AuthenticatedDigestTransport 38 | from urllib2 import urlparse as url_parse 39 | 40 | from .token_transport import TokenAuthenticatedTransport 41 | 42 | 43 | from .config import AuthenticationError 44 | from .logger import logger 45 | from .trac_ticket import TracTicket 46 | from .cached_property import cached_property 47 | 48 | 49 | class TracServer(object): 50 | 51 | def __init__(self, config): 52 | self.config = config 53 | self._current_ticket_number = None 54 | 55 | @cached_property 56 | def url_anonymous(self): 57 | return url_parse.urljoin(self.config.server_hostname, 58 | self.config.server_anonymous_xmlrpc) 59 | 60 | @cached_property 61 | def url_authenticated(self): 62 | return url_parse.urljoin(self.config.server_hostname, 63 | self.config.server_authenticated_xmlrpc) 64 | 65 | @cached_property 66 | def anonymous_proxy(self): 67 | transport = DigestTransport() 68 | return ServerProxy( 69 | self.url_anonymous, 70 | transport=transport, 71 | verbose=self.config.debug 72 | ) 73 | 74 | @cached_property 75 | def authenticated_proxy(self): 76 | try: 77 | # First try to use token authentication, if provided 78 | token = self.config.token 79 | except AuthenticationError: 80 | # This implies that a token was not configured; fall back 81 | # to original HTTP digest authenticated transport 82 | transport = AuthenticatedDigestTransport( 83 | realm=self.config.server_realm, 84 | url=self.config.server_hostname, 85 | username=self.config.username, 86 | password=self.config.password) 87 | endpoint_url = self.url_authenticated 88 | else: 89 | transport = TokenAuthenticatedTransport(token=token) 90 | # Ironically, the way Sage's Trac is currently configured, 91 | # token-based authentication must go through the "anonymous" 92 | # URL, since the authenticated URL forces HTTP Digest 93 | # authentication only 94 | endpoint_url = self.url_anonymous 95 | 96 | return ServerProxy( 97 | endpoint_url, 98 | transport=transport, 99 | verbose=self.config.debug 100 | ) 101 | 102 | def get_ssh_keys(self): 103 | return self.authenticated_proxy.sshkeys.getkeys() 104 | 105 | def get_ssh_fingerprints(self): 106 | import tempfile 107 | import subprocess 108 | try: 109 | fd, tmp = tempfile.mkstemp() 110 | os.close(fd) 111 | for key in self.get_ssh_keys(): 112 | key = key.strip() 113 | if not key: 114 | logger.debug('Skipping empty ssh key line') 115 | continue 116 | with open(tmp, 'w') as f: 117 | f.write(key) 118 | try: 119 | out = subprocess.check_output(['ssh-keygen', '-lf', tmp]) 120 | except subprocess.CalledProcessError as error: 121 | logger.error(error) 122 | logger.error('The SSH key "{0}" is probably invaild.'.format(key)) 123 | raise error 124 | yield out.decode('utf-8').strip() 125 | finally: 126 | os.remove(tmp) 127 | 128 | def __repr__(self): 129 | return "Trac server at " + self.config.server_hostname 130 | 131 | def load(self, ticket_number): 132 | ticket_number = int(ticket_number) 133 | ticket = TracTicket(ticket_number, self.anonymous_proxy) 134 | return ticket 135 | 136 | def remote_branch(self, ticket_number): 137 | ticket = self.load(ticket_number) 138 | branch = ticket.branch 139 | if branch == '': 140 | raise ValueError('"Branch:" field is not set on ticket #' 141 | + str(ticket_number)) 142 | return branch 143 | 144 | def set_remote_branch(self, ticket, new_branch): 145 | """ 146 | Replace the trac "Branch:" field with ``new_branch`` 147 | 148 | INPUT: 149 | 150 | - ``ticket`` -- a :class:`TracTicket`. The output of 151 | :meth:`load`, for example. 152 | 153 | - ``new_branch`` -- string. 154 | """ 155 | attributes = {'_ts': ticket._data['_ts'], 156 | 'branch': new_branch} 157 | comment = '' 158 | self.authenticated_proxy.ticket.update( 159 | ticket.number, comment, attributes, True) 160 | 161 | def create(self, summary, description): 162 | """ 163 | Create a new trac ticket 164 | 165 | INPUT: 166 | 167 | - ``summary`` -- string. The summary (title) of the ticket 168 | 169 | - ``description`` -- string. The ticket description. 170 | 171 | OUTPUT: 172 | 173 | Integer. The newly-created trac ticket number. 174 | """ 175 | return self.authenticated_proxy.ticket.create(summary, description) 176 | 177 | def search_branch(self, branch_name): 178 | """ 179 | Return the trac ticket using the given (remote) branch 180 | 181 | INPUT: 182 | 183 | - ``branch_name`` -- string. The name of a remote branch on 184 | the trac git repo. 185 | 186 | OUTPUT: 187 | 188 | The ticket number as an integer. A ``ValueError`` is raised if 189 | no such ticket exists currently. 190 | 191 | EXAMPLES:: 192 | 193 | sage: trac.search_branch('u/ohanar/build_system') 194 | 14480 195 | sage: isinstance(_, int) 196 | True 197 | """ 198 | branch = self.anonymous_proxy.search.branch(branch_name) 199 | if len(branch) == 0: 200 | raise ValueError('no such branch on a trac ticket') 201 | return branch[0][0] 202 | -------------------------------------------------------------------------------- /git_trac/releasemgr/cmdline.py: -------------------------------------------------------------------------------- 1 | # -*- encoding: utf-8 -*- 2 | """ 3 | Handle Command Line Options 4 | """ 5 | 6 | ############################################################################## 7 | # The "git releasemgr ..." command extension for git 8 | # Copyright (C) 2013 Volker Braun 9 | # 10 | # This program is free software: you can redistribute it and/or modify 11 | # it under the terms of the GNU General Public License as published by 12 | # the Free Software Foundation, either version 3 of the License, or 13 | # (at your option) any later version. 14 | # 15 | # This program is distributed in the hope that it will be useful, 16 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 17 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 | # GNU General Public License for more details. 19 | # 20 | # You should have received a copy of the GNU General Public License 21 | # along with this program. If not, see . 22 | ############################################################################## 23 | 24 | import importlib 25 | 26 | from ..logger import logger 27 | 28 | 29 | 30 | def debug_shell(app): 31 | from IPython.terminal.ipapp import TerminalIPythonApp 32 | ip = TerminalIPythonApp.instance() 33 | ip.initialize(argv=[]) 34 | ip.shell.user_global_ns['app'] = app 35 | ip.shell.user_global_ns['repo'] = app.repo 36 | ip.shell.user_global_ns['git'] = app.git 37 | ip.shell.user_global_ns['trac'] = app.trac 38 | def ipy_import(module_name, identifier): 39 | module = importlib.import_module(module_name) 40 | ip.shell.user_global_ns[identifier] = getattr(module, identifier) 41 | ipy_import('git_trac.git_interface', 'GitInterface') 42 | ipy_import('git_trac.trac_server', 'TracServer') 43 | ip.start() 44 | 45 | 46 | 47 | description = \ 48 | """ 49 | The Sage release management command extension for git 50 | """ 51 | 52 | 53 | 54 | def launch(): 55 | from argparse import ArgumentParser 56 | parser = ArgumentParser(description=description) 57 | parser.add_argument('--debug', dest='debug', action='store_true', 58 | default=False, 59 | help='debug') 60 | parser.add_argument('--log', dest='log', default=None, 61 | help='one of [DEBUG, INFO, ERROR, WARNING, CRITICAL]') 62 | parser.add_argument('--trac-context', dest='trac_context', type=str, default='', 63 | help='Text to add to comments on Trac tickets') 64 | 65 | subparsers = parser.add_subparsers(dest='subcommand') 66 | 67 | # git releasemgr print 68 | parser_print = subparsers.add_parser('print', help='Print as commit message') 69 | parser_print.add_argument('ticket', type=int, help='Ticket number') 70 | 71 | # git releasemgr merge 72 | parser_merge = subparsers.add_parser('merge', help='Merge branch from ticket') 73 | parser_merge.add_argument('--close', dest='close', action='store_true', 74 | help='Close ticket', default=False) 75 | parser_merge.add_argument('--allow-empty', dest='allow_empty', 76 | action='store_true', 77 | help='Allow empty commits', default=False) 78 | parser_merge.add_argument('--ignore-dependencies', dest='ignore_dependencies', 79 | action='store_true', 80 | help='Do not check whether dependencies are merged', default=False) 81 | parser_merge.add_argument('--ignore-name', dest='ignore_name', 82 | action='store_true', 83 | help='Do not sanity-check names', default=False) 84 | parser_merge.add_argument('tickets', type=int, nargs='+', help='Ticket number(s)') 85 | 86 | # git releasemgr merge-all 87 | parser_merge_all = subparsers.add_parser('merge-all', help='Merge all tickets that are ready') 88 | parser_merge_all.add_argument('--limit', dest='limit', type=int, 89 | help='Merge this many tickets', default=0) 90 | def add_filter_args(parser): 91 | milestone_help = ('Only tickets in this milestone ' 92 | '(default: any milestone other than sage-duplicate/invalid/wontfix, ' 93 | 'sage-feature, sage-pending, sage-wishlist); ' 94 | 'use "current" for best guess of current milestone') 95 | parser.add_argument('--milestone', dest='milestone', 96 | help=milestone_help, default=None) 97 | status_help = ('Only tickets with this status ' 98 | '(default: positive_review)') 99 | parser.add_argument('--status', type=str, nargs='*', 100 | help=status_help) 101 | patchbot_status_help = ('Only tickets with this patchbot status ' 102 | '(TestsPassed, TestsPassedOnRetry, PluginOnlyFailed, Pending, ...) ' 103 | '(default: any status)') 104 | parser.add_argument('--patchbot-status', type=str, nargs='*', 105 | help=patchbot_status_help) 106 | add_filter_args(parser_merge_all) 107 | 108 | # git releasemgr print 109 | parser_confball = subparsers.add_parser('confball', help='Create new confball') 110 | 111 | # git releasemgr test 112 | parser_test = subparsers.add_parser('test', help='Test merge unreviewed ticket') 113 | parser_test.add_argument('ticket', type=int, help='Ticket number') 114 | 115 | # git releasemgr unmerge 116 | parser_unmerge = subparsers.add_parser('unmerge', help='Unmerge branch from ticket') 117 | parser_unmerge.add_argument('ticket', type=int, help='Ticket number') 118 | 119 | # git releasemgr close 120 | parser_close = subparsers.add_parser('close', help='Close merged tickets') 121 | parser_close.add_argument('--head', dest='head', default='HEAD', 122 | help='Head commit') 123 | parser_close.add_argument('--exclude', dest='exclude', default='trac/develop', 124 | help='Exclude commit') 125 | 126 | # git releasemgr todo 127 | parser_todo = subparsers.add_parser('todo', help='Print list of tickets ready to merge') 128 | add_filter_args(parser_todo) 129 | 130 | # git releasemgr upstream 131 | parser_upstream = subparsers.add_parser('upstream', help='Upload upstream tarball') 132 | parser_upstream.add_argument('url', type=str, help='Tarball URL') 133 | 134 | # git releasemgr dist 135 | parser_dist = subparsers.add_parser('dist', help='Upload Sage source tarball') 136 | parser_dist.add_argument('tarball', type=str, help='Tarball filename') 137 | 138 | # git releasemgr release 139 | parser_release = subparsers.add_parser('release', help='Create new release') 140 | parser_release.add_argument('--check', action='store_true', 141 | default=False, help='Extra checks') 142 | parser_release.add_argument('version', type=str, help='New version string') 143 | 144 | # git releasemgr publish 145 | parser_publish = subparsers.add_parser('publish', help='Publish version') 146 | 147 | args = parser.parse_args() 148 | print(args) 149 | 150 | if args.log is not None: 151 | import logging 152 | level = getattr(logging, args.log) 153 | logger.setLevel(level=level) 154 | 155 | if args.subcommand is None: 156 | return parser.print_help() 157 | 158 | from .app import ReleaseApplication 159 | app = ReleaseApplication(trac_context=args.trac_context) 160 | 161 | if args.debug: 162 | debug_shell(app) 163 | elif args.subcommand == 'print': 164 | app.print_ticket(args.ticket) 165 | elif args.subcommand == 'merge': 166 | app.merge_multiple(args.tickets, close=args.close, 167 | allow_empty=args.allow_empty, 168 | ignore_dependencies=args.ignore_dependencies, 169 | ignore_name=args.ignore_name) 170 | elif args.subcommand == 'test': 171 | app.test_merge(args.ticket) 172 | elif args.subcommand == 'unmerge': 173 | app.unmerge(args.ticket) 174 | elif args.subcommand == 'close': 175 | app.close_tickets(args.head, args.exclude) 176 | elif args.subcommand == 'publish': 177 | app.publish() 178 | elif args.subcommand == 'todo': 179 | app.todo(milestone=args.milestone, statuses=args.status, patchbot_statuses=args.patchbot_status) 180 | elif args.subcommand == 'merge-all': 181 | app.merge_all(args.limit, milestone=args.milestone, statuses=args.status, patchbot_statuses=args.patchbot_status) 182 | elif args.subcommand == 'confball': 183 | app.confball() 184 | elif args.subcommand == 'upstream': 185 | app.upstream(args.url) 186 | elif args.subcommand == 'dist': 187 | app.dist(args.tarball) 188 | elif args.subcommand == 'release': 189 | app.release(args.version, check=args.check) 190 | else: 191 | print('Unknown subcommand "{0}"'.format(args.subcommand)) 192 | parser.print_help() 193 | -------------------------------------------------------------------------------- /git_trac/trac_ticket.py: -------------------------------------------------------------------------------- 1 | """ 2 | A Trac Ticket 3 | 4 | EXAMPLES:: 5 | 6 | sage: from datetime import datetime 7 | sage: create_time = datetime.utcfromtimestamp(1376149000) 8 | sage: modify_time = datetime.utcfromtimestamp(1376150000) 9 | sage: from git_trac.trac_ticket import TracTicket_class 10 | sage: t = TracTicket_class(123, create_time, modify_time, {}) 11 | sage: t 12 | 13 | sage: t.number 14 | 123 15 | sage: t.title 16 | '' 17 | sage: t.ctime 18 | datetime.datetime(2013, 8, 10, 15, 36, 40) 19 | sage: t.mtime 20 | datetime.datetime(2013, 8, 10, 15, 53, 20) 21 | """ 22 | 23 | ############################################################################## 24 | # The "git trac ..." command extension for git 25 | # Copyright (C) 2013 Volker Braun 26 | # 27 | # This program is free software: you can redistribute it and/or modify 28 | # it under the terms of the GNU General Public License as published by 29 | # the Free Software Foundation, either version 3 of the License, or 30 | # (at your option) any later version. 31 | # 32 | # This program is distributed in the hope that it will be useful, 33 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 34 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 35 | # GNU General Public License for more details. 36 | # 37 | # You should have received a copy of the GNU General Public License 38 | # along with this program. If not, see . 39 | ############################################################################## 40 | 41 | import textwrap 42 | from datetime import datetime 43 | 44 | def format_trac(text): 45 | text = text.strip() 46 | accumulator = [] 47 | for line in text.splitlines(): 48 | line = '\n'.join(textwrap.wrap(line, 78)) 49 | accumulator.append(line) 50 | return '\n'.join(accumulator) 51 | 52 | def make_time(time): 53 | """ 54 | Convert xmlrpc DateTime objects to datetime.datetime 55 | """ 56 | if isinstance(time, datetime): 57 | return time 58 | return datetime.strptime(time.value, "%Y%m%dT%H:%M:%S") 59 | 60 | 61 | def TicketChange(changelog_entry): 62 | time, author, change, data1, data2, data3 = changelog_entry 63 | # print(time, author, change, data1, data2, data3) 64 | if change == 'comment': 65 | return TicketComment_class(time, author, change, data1, data2, data3) 66 | return TicketChange_class(time, author, change, data=(data1, data2, data3)) 67 | 68 | 69 | class TicketChange_class(object): 70 | 71 | def __init__(self, time, author, change, data=None): 72 | self._time = make_time(time) 73 | self._author = author 74 | self._change = change 75 | if data: 76 | self._data = data 77 | else: 78 | self._data = ('', '', 1) 79 | 80 | def get_data(self): 81 | try: 82 | return ' ['+str(self._data)+']' 83 | except AttributeError: 84 | return '' 85 | 86 | @property 87 | def ctime(self): 88 | return self._time 89 | 90 | @property 91 | def ctime_str(self): 92 | return str(self.ctime) 93 | 94 | @property 95 | def author(self): 96 | return self._author 97 | 98 | @property 99 | def change(self): 100 | return self._change 101 | 102 | @property 103 | def change_capitalized(self): 104 | return self._change.capitalize() 105 | 106 | @property 107 | def old(self): 108 | return self._data[0] 109 | 110 | @property 111 | def new(self): 112 | return self._data[1] 113 | 114 | @property 115 | def change_action(self): 116 | if self.old == '': 117 | return u'set to {change.new}'.format(change=self) 118 | elif self.new == '': 119 | return u'{change.old} deleted'.format(change=self) 120 | else: 121 | return u'changed from {change.old} to {change.new}'.format(change=self) 122 | 123 | def __repr__(self): 124 | return self.get_author() + u' changed ' + self.get_change() + self.get_data() 125 | 126 | 127 | class TicketComment_class(TicketChange_class): 128 | 129 | def __init__(self, time, author, change, data1, data2, data3): 130 | TicketChange_class.__init__(self, time, author, change) 131 | self._number = data1 132 | self._comment = data2 133 | 134 | @property 135 | def number(self): 136 | return self._number 137 | 138 | @property 139 | def comment(self): 140 | return self._comment 141 | 142 | @property 143 | def comment_formatted(self): 144 | return format_trac(self.comment) 145 | 146 | def __repr__(self): 147 | return self.author + ' commented "' + \ 148 | self.comment + '" [' + self.number + ']' 149 | 150 | 151 | def TracTicket(ticket_number, server_proxy): 152 | from xml.parsers.expat import ExpatError 153 | ticket_number = int(ticket_number) 154 | try: 155 | change_log = server_proxy.ticket.changeLog(ticket_number) 156 | except ExpatError: 157 | print('Failed to parse the trac changelog, malformed XML!') 158 | change_log = [] 159 | data = server_proxy.ticket.get(ticket_number) 160 | ticket_changes = [TicketChange(entry) for entry in change_log] 161 | return TracTicket_class(data[0], data[1], data[2], data[3], ticket_changes) 162 | 163 | 164 | class TracTicket_class(object): 165 | 166 | def __init__(self, number, ctime, mtime, data, change_log=None): 167 | self._number = number 168 | self._ctime = make_time(ctime) 169 | self._mtime = make_time(mtime) 170 | self._last_viewed = None 171 | self._download_time = None 172 | self._data = data 173 | self._change_log = change_log 174 | 175 | @property 176 | def timestamp(self): 177 | """ 178 | Timestamp for XML-RPC calls 179 | 180 | The timestamp is an integer that must be set in subsequent 181 | ticket.update() XMLRPC calls to trac. 182 | """ 183 | return self._data['_ts'] 184 | 185 | @property 186 | def number(self): 187 | return self._number 188 | 189 | __int__ = number 190 | 191 | @property 192 | def title(self): 193 | return self._data.get('summary', '') 194 | 195 | @property 196 | def ctime(self): 197 | return self._ctime 198 | 199 | @property 200 | def mtime(self): 201 | return self._mtime 202 | 203 | @property 204 | def ctime_str(self): 205 | return str(self.ctime) 206 | 207 | @property 208 | def mtime_str(self): 209 | return str(self.mtime) 210 | 211 | @property 212 | def branch(self): 213 | return self._data.get('branch', '').strip() 214 | 215 | @property 216 | def dependencies(self): 217 | return self._data.get('dependencies', '') 218 | 219 | @property 220 | def description(self): 221 | default = '+++ no description +++' 222 | return self._data.get('description', default) 223 | 224 | @property 225 | def description_formatted(self): 226 | return format_trac(self.description) 227 | 228 | def change_iter(self): 229 | for change in self._change_log: 230 | yield change 231 | 232 | def comment_iter(self): 233 | for change in self._change_log: 234 | if isinstance(change, TicketComment_class): 235 | yield change 236 | 237 | def grouped_comment_iter(self): 238 | change_iter = iter(self._change_log) 239 | change = next(change_iter) 240 | def sort_key(c): 241 | return (-int(c.change == 'comment'), c.change) 242 | while True: 243 | stop = False 244 | time = change.ctime 245 | accumulator = [(sort_key(change), change)] 246 | while True: 247 | try: 248 | change = next(change_iter) 249 | except StopIteration: 250 | stop = True 251 | break 252 | if change.ctime == time: 253 | accumulator.append((sort_key(change), change)) 254 | else: 255 | break 256 | yield tuple(c[1] for c in sorted(accumulator)) 257 | if stop: 258 | break 259 | 260 | @property 261 | def author(self): 262 | return self._data.get('author', '') 263 | 264 | @property 265 | def cc(self): 266 | return self._data.get('cc', '') 267 | 268 | @property 269 | def component(self): 270 | return self._data.get('component', '') 271 | 272 | @property 273 | def reviewer(self): 274 | return self._data.get('reviewer', '') 275 | 276 | @property 277 | def reporter(self): 278 | return self._data.get('reporter', '') 279 | 280 | @property 281 | def milestone(self): 282 | return self._data.get('milestone', '') 283 | 284 | @property 285 | def owner(self): 286 | return self._data.get('owner', '') 287 | 288 | @property 289 | def priority(self): 290 | return self._data.get('priority', '') 291 | 292 | @property 293 | def commit(self): 294 | return self._data.get('commit', '') 295 | 296 | @property 297 | def keywords(self): 298 | return self._data.get('keywords', '') 299 | 300 | @property 301 | def ticket_type(self): 302 | return self._data.get('type', '') 303 | 304 | @property 305 | def upstream(self): 306 | return self._data.get('upstream', '') 307 | 308 | @property 309 | def status(self): 310 | return self._data.get('status', '') 311 | 312 | @property 313 | def work_issues(self): 314 | return self._data.get('work_issues', '') 315 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Git-Trac Integration 2 | ==================== 3 | 4 | About 5 | ----- 6 | 7 | This module implements a "git trac" subcommand of the git suite that 8 | interfaces with trac over XMLRPC. 9 | 10 | Included is a one-page git cheat sheet for Sage/Git/Trac: 11 | http://github.com/sagemath/git-trac-command/raw/master/doc/git-cheat-sheet.pdf 12 | 13 | Installation 14 | ------------ 15 | 16 | The easiest way to just try out the code in this repo is to source the 17 | ``enable.sh`` script, which will prepend it to your PATH. This enables 18 | the git trac subcommand until you close that shell, so no permanent 19 | change is made: 20 | 21 | $ git clone https://github.com/sagemath/git-trac-command.git 22 | $ source git-trac-command/enable.sh 23 | Prepending the git-trac command to your search PATH 24 | 25 | **Note for `zsh` users:** `enable.sh` requires bash. Use one of the other options described below. 26 | 27 | To permanently install the code from this repo, clone it and run 28 | ``setup.py``: 29 | 30 | $ git clone https://github.com/sagemath/git-trac-command.git 31 | $ cd git-trac-command 32 | $ python setup.py install --user 33 | 34 | Alternatively you can just symlink ``git-trac`` to anywhere in your path: 35 | 36 | $ git clone https://github.com/sagemath/git-trac-command.git 37 | $ cd git-trac-command 38 | $ ln -s `pwd`/git-trac ~/bin/ 39 | 40 | On a Mac, which may not have a default in the home directory for commands, 41 | it may be easier to add this to your ``.profile``: 42 | 43 | $ git clone https://github.com/sagemath/git-trac-command.git 44 | $ cd git-trac-command 45 | $ pico/vim/emacs $HOME/.profile 46 | 47 | 48 | Usage 49 | ----- 50 | 51 | * Print the trac ticket information using ``git trac print 52 | ``. 53 | 54 | $ git trac print 12345 55 | ============================================================================== 56 | Trac #12345: Title of ticket 12345 57 | ... 58 | ============================================================================== 59 | 60 | Alternatively, you can pass a remote branch name, in which case trac 61 | is searched for a ticket whose (remote) "Branch:" field equals the 62 | branch name. If that fails, the ticket number will be deduced from 63 | the branch name by scanning for a number. If you neither specify a 64 | ticket number or branch name, the local git branch name is used: 65 | 66 | $ git branch 67 | /u/user/description 68 | $ git trac print 69 | ============================================================================== 70 | Trac #nnnnn: Title 71 | 72 | Description 73 | Status: Status Component: Component 74 | ... 75 | Branch: u/user/description 76 | ============================================================================== 77 | 78 | 79 | * Checkout 80 | a remote branch: 81 | 82 | $ git trac checkout 12345 83 | 84 | Will automatically pick a local branch name ``t/12345/description`` 85 | based on the remote branch name. If you want a particular local 86 | branch name, you can specify it manually: 87 | 88 | $ git trac checkout -b my_branch 12345 89 | 90 | 91 | * Create a new ticket on trac, and a new local branch 92 | corresponding to it: 93 | 94 | $ git trac create "This is the summary" 95 | 96 | This will automatically create a local branch name 97 | ``t/12345/this_is_the_summary``. You can specify it manually if you 98 | prefer with: 99 | 100 | $ git trac create -b my_branch "This is the summary" 101 | 102 | 103 | * Pull (= fetch + merge) from the branch 104 | on a ticket: 105 | 106 | $ git trac pull 12345 107 | 108 | You can omit the ticket number, in which case the script will try to 109 | search for the ticket having the local branch name attached. If that 110 | fails, an attempt is made to deduce the ticket number from the local 111 | branch name. 112 | 113 | 114 | * Push (upload) to the branch 115 | on a ticket, and set the trac "Branch:" field accordingly: 116 | 117 | $ git trac push 12345 118 | 119 | You can omit the ticket number, in which case the script will try to 120 | search for the ticket having the local branch name attached. If that 121 | fails, an attempt is made to deduce the ticket number from the local 122 | branch name. 123 | 124 | 125 | * Log of the commits for a 126 | ticket: 127 | 128 | $ git trac log 12345 129 | 130 | 131 | * Find the trac ticket for a 132 | commit, either identified by its SHA1 or branch/tag name. 133 | 134 | $ git log --oneline -1 ee5e39e 135 | ee5e39e Allow default arguments in closures 136 | $ git trac find ee5e39e 137 | Commit has been merged by the release manager into your current branch. 138 | commit 44efa774c5f991ea5f160646515cfe8d3f738479 139 | Merge: 5fd5442 679310b 140 | Author: Release Manager 141 | Date: Sat Dec 21 01:16:56 2013 +0000 142 | 143 | Trac #15447: implement evaluation of PARI closures 144 | 145 | * Review tickets with minimal recompiling. This assumes that you are 146 | currently on the "develop" branch, that is, the latest beta. Just 147 | checking out an older ticket would most likely reset the Sage tree 148 | to an older version, so you would have to compile older versions of 149 | packages to make it work. Instead, you can create an anonymous 150 | ("detached HEAD") merge of the ticket and the develop branch:: 151 | 152 | $ git trac try 12345 153 | 154 | This will only touch files that are really modified by the 155 | ticket. In particular, if only Python files are changed by the 156 | ticket (which is true for most tickets) then you just have to run 157 | `sage -b` to rebuild the Sage library. When you are finished 158 | reviewing, just checkout a named branch. For example:: 159 | 160 | $ git checkout develop 161 | 162 | If you want to edit the ticket branch (that is, add additional 163 | commits) you cannot use `git trac try`. You must use `git trac 164 | checkout` to get the actual ticket branch as a starting point. 165 | 166 | 167 | Too Long, Didn't Read 168 | --------------------- 169 | 170 | To fix a bug, start with 171 | 172 | $ git trac create "Fix foo" 173 | 174 | This will open the ticket and create a new local branch 175 | ``t//fix_foo``. Then edit Sage, followed by 176 | 177 | $ git add 178 | $ git commit 179 | 180 | Repeat edit/commit as necessary. When you are finished, run 181 | 182 | $ git trac push 183 | 184 | It will take the ticket number out of the branch name, so you don't 185 | have to specify it. 186 | 187 | 188 | Configuration 189 | ------------- 190 | 191 | The scripts assume that the trac remote repository is set up as the 192 | remote ``trac`` in the local repo. That is, you should have the 193 | following for the Sage git server: 194 | 195 | $ git remote add trac https://trac.sagemath.org/sage.git # read-only 196 | $ git remote add trac ssh://git@trac.sagemath.org/sage.git # read-write 197 | $ git remote -v 198 | trac ssh://git@trac.sagemath.org/sage.git (fetch) 199 | trac ssh://git@trac.sagemath.org/sage.git (push) 200 | 201 | Trac username and password are stored in the local repo (the 202 | DOT_GIT/config file): 203 | 204 | $ git trac config --user=Myself --pass=s3kr1t 205 | Trac xmlrpc URL: 206 | https://trac.sagemath.org/xmlrpc (anonymous) 207 | https://trac.sagemath.org/login/xmlrpc (authenticated) 208 | Username: Myself 209 | Password: ****** 210 | 211 | Instead of a username and password you may also configure authentication via 212 | a generated token by passing `--token=` instead of `--pass`: 213 | 214 | $ git trac config --user= --token= 215 | 216 | **This is required if you authenticate to Trac with your GitHub account, as 217 | you do not have a Trac password.**. Logged in users can find their token 218 | under https://trac.sagemath.org/prefs/token . Technically, token 219 | authentication does not require configuring a username. However, explicitly 220 | providing your username to the configuration is still required for many 221 | features to work correctly. If you log into Trac via GitHub, make sure this 222 | is your full username, including the `gh-` prefix. 223 | 224 | If both a token and a username/password are configured, the token-based 225 | authentication takes precedence. 226 | 227 | If you do not want to store your trac username/password/token on disk you 228 | can temporarily override it with the environment variables 229 | ``TRAC_USERNAME``, ``TRAC_PASSWORD``, and ``TRAC_TOKEN`` respectively. 230 | These take precedence over any other configuration. 231 | 232 | 233 | Sage-Trac Specifics 234 | ------------------- 235 | 236 | Some of the functionality depends on the special trac plugins (see 237 | https://github.com/sagemath/sage_trac), namely: 238 | 239 | * Searching for a trac ticket by branch name requires the 240 | ``trac_plugin_search_branch.py`` installed in trac and a custom trac 241 | field named "Branch:": 242 | 243 | $ git trac search --branch=u/vbraun/toric_bundle 244 | 15328 245 | 246 | * SSH public key management requires the ``sshkeys.py`` trac 247 | plugin: 248 | 249 | $ git trac ssh-keys 250 | $ git trac ssh-keys --add=~/.ssh/id_rsa.pub 251 | This is not implemented yet 252 | 253 | 254 | Release Management 255 | ------------------ 256 | 257 | The Sage release management scripts are in the `git-trac.releasemgr` 258 | subdirectory. They are probably only useful to the Sage release 259 | manager. 260 | 261 | 262 | Testing and Python Compatibility 263 | -------------------------------- 264 | 265 | * The git-trac command supports Python 2.7, 3.3, 3.4 and 3.7. 266 | * Most recent [Travis CI](https://travis-ci.org/sagemath/git-trac-command) test: 267 | [![Build Status](https://travis-ci.org/sagemath/git-trac-command.svg?branch=master)](https://travis-ci.org/sagemath/git-trac-command) 268 | -------------------------------------------------------------------------------- /doc/git-cheat-sheet.tex: -------------------------------------------------------------------------------- 1 | \documentclass[10pt,landscape,a4paper]{article} 2 | 3 | %***************************************************************************** 4 | % Copyright (C) 2014 Volker Braun 5 | % Creative Commons Attribution-Share Alike 3.0 License. 6 | %***************************************************************************** 7 | 8 | \usepackage{multicol} 9 | \usepackage{calc} 10 | \usepackage{ifthen} 11 | \usepackage[landscape]{geometry} 12 | \usepackage{amsmath,amsthm,amsfonts,amssymb} 13 | \usepackage{color,graphicx,overpic} 14 | \usepackage{hyperref} 15 | \usepackage{inconsolata} 16 | \usepackage[T1]{fontenc} 17 | \usepackage{fancyvrb} 18 | \usepackage[usenames,dvipsnames,svgnames,table]{xcolor} 19 | 20 | \pdfinfo{ 21 | /Title (git-cheat-sheet.pdf) 22 | /Creator (TeX) 23 | /Producer (pdfTeX 1.40.0) 24 | /Author (Volker Braun) 25 | /Subject (Example) 26 | /Keywords (pdflatex, latex,pdftex,tex)} 27 | 28 | % This sets page margins to .5 inch if using letter paper, and to 1cm 29 | % if using A4 paper. (This probably isn't strictly necessary.) 30 | % If using another size paper, use default 1cm margins. 31 | \ifthenelse{\lengthtest { \paperwidth = 11in}} 32 | { \geometry{top=.5in,left=.5in,right=.5in,bottom=.5in} } 33 | {\ifthenelse{ \lengthtest{ \paperwidth = 297mm}} 34 | {\geometry{top=1cm,left=1cm,right=1cm,bottom=1cm} } 35 | {\geometry{top=1cm,left=1cm,right=1cm,bottom=1cm} } 36 | } 37 | 38 | % Turn off header and footer 39 | \pagestyle{empty} 40 | 41 | % Redefine section commands to use less space 42 | \makeatletter 43 | \renewcommand{\section}{\@startsection{section}{1}{0mm}% 44 | {-1ex plus -.5ex minus -.2ex}% 45 | {0.5ex plus .2ex}%x 46 | {\normalfont\Large\bfseries}} 47 | \renewcommand{\subsection}{\@startsection{subsection}{2}{0mm}% 48 | {-1explus -.5ex minus -.2ex}% 49 | {0.5ex plus .2ex}% 50 | {\normalfont\normalsize\bfseries}} 51 | \renewcommand{\subsubsection}{\@startsection{subsubsection}{3}{0mm}% 52 | {-1ex plus -.5ex minus -.2ex}% 53 | {1ex plus .2ex}% 54 | {\normalfont\small\bfseries}} 55 | \makeatother 56 | 57 | % Define BibTeX command 58 | \def\BibTeX{{\rm B\kern-.05em{\sc i\kern-.025em b}\kern-.08em 59 | T\kern-.1667em\lower.7ex\hbox{E}\kern-.125emX}} 60 | 61 | % Don't print section numbers 62 | \setcounter{secnumdepth}{0} 63 | 64 | 65 | \setlength{\parindent}{0pt} 66 | \setlength{\parskip}{0pt plus 0.5ex} 67 | 68 | %My Environments 69 | \newtheorem{example}[section]{Example} 70 | % ----------------------------------------------------------------------- 71 | 72 | \begin{document} 73 | \raggedright 74 | \footnotesize 75 | \begin{multicols}{3} 76 | 77 | % multicol parameters 78 | % These lengths are set only within the two main columns 79 | %\setlength{\columnseprule}{0.25pt} 80 | \setlength{\premulticols}{1pt} 81 | \setlength{\postmulticols}{1pt} 82 | \setlength{\multicolsep}{1pt} 83 | \setlength{\columnsep}{2pt} 84 | 85 | \newcommand{\note}[1]{\hfill\textrm{\textcolor{gray}{#1}}} 86 | \newcommand{\args}[1]{\textit{\textcolor{blue}{#1}}} 87 | \newcommand{\stdout}[1]{\textcolor{Sepia}{#1}} 88 | 89 | % \fvset{frame=none,framesep=1mm,fontfamily=courier,fontsize=\scriptsize,numbers=left,framerule=.3mm,numbersep=1mm,commandchars=\\\{\}} 90 | 91 | \fvset{gobble=2,framesep=1mm,commandchars=\\\{\},xleftmargin=2mm,xrightmargin=4mm} 92 | 93 | \begin{center} 94 | \Huge\textbf{Sage, Git, \& Trac} 95 | \end{center} 96 | 97 | 98 | \section{Quickstart} 99 | 100 | 101 | \subsection{Configuration} 102 | 103 | You only need to do this once: 104 | \begin{Verbatim} 105 | git config --global user.name "Your Name" 106 | git config --global user.email you@yourdomain.example.com 107 | \end{Verbatim} 108 | This data ends up in commits, so do it now before you forget! 109 | 110 | 111 | \subsection{Get the Sage Source Code} 112 | 113 | \begin{Verbatim} 114 | git clone https://github.com/sagemath/sage.git 115 | \end{Verbatim} 116 | 117 | 118 | \subsection{Branch Often} 119 | 120 | A new branch is like an independent copy of the source code. Always 121 | switch to a new branch \emph{before} editing anything: 122 | \begin{Verbatim} 123 | git checkout \args{develop}\note{switch to the starting point} 124 | git branch \args{new\_branch\_name}\note{create new branch} 125 | git checkout \args{new\_branch\_name}\note{switch to new branch} 126 | \end{Verbatim} 127 | Without an argument, the list of branches is displayed: 128 | \begin{Verbatim} 129 | git branch 130 | \stdout{ master} 131 | \stdout{* new_branch_name}\note{* marks the current branch} 132 | \end{Verbatim} 133 | When you are finished, delete unused branches: 134 | \begin{Verbatim} 135 | git branch -d \args{branch\_to\_delete} 136 | \end{Verbatim} 137 | 138 | 139 | \subsection{Where Am I?} 140 | 141 | Each change recorded by git is called a ``commit''. Examine history: 142 | \begin{Verbatim} 143 | git show\note{show the most recent commit} 144 | git log\note{list in reverse chronological order} 145 | \end{Verbatim} 146 | 147 | 148 | \subsection{What Did I Do?} 149 | 150 | This is probably the most important command. Example output: 151 | \begin{Verbatim} 152 | git status 153 | \stdout{ On branch new_branch_name}\note{= current branch name} 154 | \stdout{Changes not staged for commit:} 155 | \stdout{ (use "git add ..." to update what will be committed)} 156 | \stdout{ (use "git checkout -- ..." to discard changes in} 157 | \stdout{ working directory)} 158 | \stdout{} 159 | \stdout{ modified: modified_file.py}\note{= file you just edited} 160 | \stdout{} 161 | \stdout{Untracked files:} 162 | \stdout{ (use "git add ..." to include in what will be} 163 | \stdout{ committed)} 164 | \stdout{} 165 | \stdout{ new_file.py}\note{= file you just added} 166 | \stdout{} 167 | \stdout{no changes added to commit} 168 | \stdout{(use "git add" and/or "git commit -a")} 169 | \end{Verbatim} 170 | 171 | 172 | \subsection{Prepare to Commit} 173 | 174 | When you are finished, tell git which changes you want to commit: 175 | \begin{Verbatim} 176 | git add \args{filename}\note{add particular file} 177 | git add .\note{add all modified \& new} 178 | \end{Verbatim} 179 | The status command then lists the staged changes: 180 | \begin{Verbatim} 181 | git status 182 | \stdout{On branch new_branch_name} 183 | \stdout{Changes to be committed:} 184 | \stdout{ (use "git reset HEAD ..." to unstage)} 185 | \stdout{} 186 | \stdout{ modified: modified_file.txt} 187 | \stdout{ new file: new_file.txt} 188 | \end{Verbatim} 189 | 190 | 191 | \subsection{Commit} 192 | 193 | The commit command permanently records the staged changes. The new 194 | commit becomes the new branch head: 195 | \begin{Verbatim} 196 | git commit\note{opens editor for commit message} 197 | git commit -m "My Commit Message" 198 | \end{Verbatim} 199 | Commits cannot be changed, but they can be discarded and re-done with 200 | the \texttt{--amend} switch. \emph{Never} amend commits that you have 201 | already shared with somebody. 202 | 203 | 204 | \section{Summary} 205 | 206 | \textbf{workspace} is the file system: files that you can edit 207 | \begin{Verbatim} 208 | git add \args{filename}\note{copy file to staging} 209 | git reset HEAD \args{filename}\note{copy staged file back} 210 | \end{Verbatim} 211 | \textbf{staging} is a special area inside the git repository 212 | \begin{Verbatim} 213 | git commit\note{commit all staged files} 214 | \end{Verbatim} 215 | \textbf{commits} are the permanently recorded history 216 | \begin{Verbatim} 217 | git checkout -- \args{filename}\note{copy file from repo to workspace} 218 | \end{Verbatim} 219 | 220 | 221 | \section{Merging} 222 | 223 | A commit with more than one parent is a merge commit: 224 | \begin{Verbatim} 225 | git merge \args{other\_branch}\note{incorporate other branch/commit} 226 | \end{Verbatim} 227 | If there is no conflict this automatically creates a new merge 228 | commit. Otherwise, the conflicting regions are marked like this: 229 | \begin{Verbatim} 230 | \stdout{Here are lines that are either unchanged from the common} 231 | \stdout{ancestor, or cleanly resolved because only one side changed.} 232 | \stdout{<<<<<<< yours:source_file.py} 233 | \stdout{Conflict resolution is hard;} 234 | \stdout{let's go shopping.} 235 | \stdout{=======} 236 | \stdout{Git makes conflict resolution easy.} 237 | \stdout{>>>>>>> theirs:source_file.py} 238 | \stdout{And here is another line that is cleanly resolved or unmodified.} 239 | \end{Verbatim} 240 | Edit as needed; To finish, run one of: 241 | \begin{Verbatim} 242 | git commit\note{commit your merge conflict resolution} 243 | git merge --abort\note{discard merge attempt} 244 | \end{Verbatim} 245 | 246 | 247 | \section{Branch Heads} 248 | 249 | A git branch is just a pointer to a commit. This commit is called the 250 | branch \texttt{HEAD}. You can point it elsewhere with 251 | (\texttt{--hard}) or without (\texttt{--soft}, less common) resetting 252 | the actual files. That is, the following discards content of the 253 | current branch and makes it indistinguishable from a new branch that 254 | started at \verb!new_head_commit!: 255 | \begin{Verbatim} 256 | git reset --hard \args{new_head_commit} 257 | \end{Verbatim} 258 | 259 | There are various ways to specify a commit to reset to: 260 | \begin{Verbatim} 261 | 3472a854df051b57d1cb7e4934913f17f1fef820\note{40-digit SHA1} 262 | 3472a85\note{the first few digits of the SHA1} 263 | branch_name\note{the name of another branch pointing to it} 264 | 6.2.beta6\note{a tag in the Sage git repo; Every version is tagged} 265 | origin/develop\note{the \texttt{develop} branch in the remote \texttt{origin}} 266 | HEAD~\note{first parent of the current head} 267 | HEAD~2\note{first parent of the first parent of the current head} 268 | HEAD^2\note{second parent of the current head} 269 | FETCH_HEAD\note{commit downloaded with the \texttt{git fetch} command} 270 | \end{Verbatim} 271 | 272 | 273 | \section{Trac and the Sage Git Repo} 274 | 275 | At \url{http://git.sagemath.org} you can browse our own git 276 | repository. On trac tickets, you can click on the links under 277 | \textbf{Branch:} 278 | 279 | 280 | \subsection{Git Trac Subcommand} 281 | 282 | We have added a \texttt{git trac} command to interact with our git and 283 | trac server. You can download and temporarily enable it via 284 | \begin{Verbatim} 285 | git clone git@github.com:sagemath/git-trac-command.git 286 | source git-trac-command/enable.sh 287 | \end{Verbatim} 288 | See the developer guide for how to install it on your system. 289 | 290 | 291 | \subsection{Configure Git Trac} 292 | 293 | To make changes to trac you need to have an account: 294 | \begin{Verbatim} 295 | git trac config --user \textcolor{blue}{USER} --pass \textcolor{blue}{PASS} 296 | \end{Verbatim} 297 | Furthermore, our git repository uses your SSH keys for 298 | authentication. Log in on \url{https://trac.sagemath.org} and go to 299 | Preferences $\to$ SSH keys. 300 | 301 | 302 | \subsection{Downloading / Creating a Branch} 303 | 304 | \begin{Verbatim} 305 | git trac checkout \args{ticket_number}\note{branch for existing ticket} 306 | git trac create \args{"Ticket Title"}\note{create new ticket} 307 | \end{Verbatim} 308 | This will get the branch from trac, or create a new one if there is 309 | none yet attached to the ticket. 310 | 311 | 312 | \subsection{Pull Changes from Trac} 313 | 314 | \begin{Verbatim} 315 | git trac pull \args{optional_ticket_number} 316 | \end{Verbatim} 317 | The trac ticket number will be guessed from a number embedded in the 318 | current branch name, or if there is a branch of the same name on a 319 | ticket already. 320 | 321 | 322 | \subsection{Push your Changes to Trac} 323 | 324 | \begin{Verbatim} 325 | git trac push \args{optional_ticket_number} 326 | \end{Verbatim} 327 | 328 | 329 | \section{Getting Help} 330 | 331 | \begin{Verbatim} 332 | git help \args{command}\note{show help for (optional) command} 333 | git trac create -h\note{help for subcommand} 334 | \end{Verbatim} 335 | Sage developer guide: \url{https://doc.sagemath.org/html/en/developer/} 336 | 337 | % % You can even have references 338 | % \rule{0.3\linewidth}{0.25pt} 339 | % \scriptsize 340 | % \bibliographystyle{abstract} 341 | % \bibliography{refFile} 342 | 343 | \end{multicols} 344 | \end{document} 345 | 346 | 347 | 348 | 349 | 350 | -------------------------------------------------------------------------------- /git_trac/cmdline.py: -------------------------------------------------------------------------------- 1 | ## -*- encoding: utf-8 -*- 2 | """ 3 | Handle Command Line Options 4 | """ 5 | 6 | ############################################################################## 7 | # The "git trac ..." command extension for git 8 | # Copyright (C) 2013 Volker Braun 9 | # 10 | # This program is free software: you can redistribute it and/or modify 11 | # it under the terms of the GNU General Public License as published by 12 | # the Free Software Foundation, either version 3 of the License, or 13 | # (at your option) any later version. 14 | # 15 | # This program is distributed in the hope that it will be useful, 16 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 17 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 18 | # GNU General Public License for more details. 19 | # 20 | # You should have received a copy of the GNU General Public License 21 | # along with this program. If not, see . 22 | ############################################################################## 23 | 24 | 25 | import sys 26 | import os 27 | import warnings 28 | import argparse 29 | 30 | from .logger import logger 31 | from .ticket_or_branch import TicketOrBranch 32 | 33 | 34 | def xdg_open(uri): 35 | import subprocess 36 | if sys.platform == 'darwin': 37 | rc = subprocess.call(['open', uri]) 38 | error = 'Failed to run "open", please open {0}' 39 | else: 40 | rc = subprocess.call(['xdg-open', uri]) 41 | error = 'Failed to run "xdg-open", please open {0}' 42 | if rc != 0: 43 | print(error.format(uri)) 44 | 45 | 46 | def show_cheat_sheet(): 47 | # case where `git-trac` was just symbolically linked 48 | root_dir = os.path.dirname(os.path.dirname(__file__)) 49 | cheat_sheet = os.path.join(root_dir, 'doc', 'git-cheat-sheet.pdf') 50 | # case of `python setup.py install --user` 51 | if not os.path.exists(cheat_sheet): 52 | root_dir = __import__('site').USER_BASE 53 | cheat_sheet = os.path.join(root_dir, 54 | 'share', 55 | 'git-trac-command', 56 | 'git-cheat-sheet.pdf') 57 | # case of `python setup.py install` 58 | if not os.path.exists(cheat_sheet): 59 | root_dir = sys.prefix 60 | cheat_sheet = os.path.join(root_dir, 61 | 'share', 62 | 'git-trac-command', 63 | 'git-cheat-sheet.pdf') 64 | # go to internet if not found 65 | if not os.path.exists(cheat_sheet): 66 | cheat_sheet = "http://github.com/sagemath/git-trac-command/raw/master/doc/git-cheat-sheet.pdf" 67 | print('Cheat sheet not found locally. Trying the internet.') 68 | xdg_open(cheat_sheet) 69 | 70 | 71 | def debug_shell(app, parser): 72 | from IPython.terminal.ipapp import TerminalIPythonApp 73 | ip = TerminalIPythonApp.instance() 74 | ip.initialize(argv=[]) 75 | ip.shell.user_global_ns['app'] = app 76 | ip.shell.user_global_ns['logger'] = logger 77 | ip.shell.user_global_ns['repo'] = app.repo 78 | ip.shell.user_global_ns['git'] = app.git 79 | ip.shell.user_global_ns['trac'] = app.trac 80 | ip.shell.user_global_ns['parser'] = parser 81 | def ipy_import(module_name, identifier): 82 | import importlib 83 | module = importlib.import_module(module_name) 84 | ip.shell.user_global_ns[identifier] = getattr(module, identifier) 85 | ipy_import('git_trac.git_interface', 'GitInterface') 86 | ipy_import('git_trac.trac_server', 'TracServer') 87 | ip.start() 88 | 89 | 90 | 91 | description = \ 92 | """ 93 | The trac command extension for git 94 | """ 95 | 96 | def monkey_patch(): 97 | """ 98 | Monkey patch ArgumentParser 99 | """ 100 | old_parse_args = argparse.ArgumentParser.parse_args 101 | 102 | def parse_args_override(self, args=None): 103 | """ 104 | http://bugs.python.org/issue9253 prevents us from just redefining -h 105 | Workaround by monkey-patching parse_args 106 | """ 107 | if args is None: 108 | args = list(sys.argv)[1:] 109 | if len(args) == 1 and args[-1] == '-h': 110 | # Convert "git-trac -h" to "git-trac help" 111 | args[-1] = 'help' 112 | return old_parse_args(self, args) 113 | 114 | setattr(argparse.ArgumentParser, 'parse_args', parse_args_override) 115 | 116 | 117 | 118 | def make_parser(): 119 | monkey_patch() 120 | parser = argparse.ArgumentParser(description=description, add_help=False) 121 | # We cannot handle "git trac --help", this is outside of our control and purely within git 122 | # redefine to not print '--help' in the online help 123 | parser.add_argument('-h', dest='option_help', action='store_true', 124 | default=False, 125 | help='show this help message and exit') 126 | 127 | parser.add_argument('--debug', dest='debug', action='store_true', 128 | default=False, 129 | help='debug') 130 | parser.add_argument('--log', dest='log', default=None, 131 | help='one of [DEBUG, INFO, ERROR, WARNING, CRITICAL]') 132 | subparsers = parser.add_subparsers(dest='subcommand') 133 | 134 | parser_create = subparsers.add_parser('create', help='Create new ticket') 135 | parser_create.add_argument('-b', '--branch', dest='branch_name', 136 | help='Branch name', 137 | default=None) 138 | parser_create.add_argument('summary', type=str, help='Ticket summary') 139 | 140 | parser_checkout = subparsers.add_parser('checkout', help='Download branch') 141 | parser_checkout.add_argument('-b', '--branch', dest='branch_name', 142 | help='Local branch name', 143 | default=None) 144 | parser_checkout.add_argument('ticket_or_branch', type=TicketOrBranch, 145 | help='Ticket number or remote branch name') 146 | 147 | parser_search = subparsers.add_parser('search', help='Search trac') 148 | parser_search.add_argument('--branch', dest='branch_name', 149 | help='Remote git branch name (default: local branch)', 150 | default=None) 151 | 152 | parser_fetch = subparsers.add_parser('fetch', help='Fetch branch from trac ticket') 153 | parser_fetch.add_argument('ticket_or_branch', nargs='?', type=TicketOrBranch, 154 | help='Ticket number or remote branch name', default=None) 155 | 156 | parser_pull = subparsers.add_parser('pull', help='Get updates') 157 | parser_pull.add_argument('ticket_or_branch', nargs='?', type=TicketOrBranch, 158 | help='Ticket number or remote branch name', default=None) 159 | 160 | parser_push = subparsers.add_parser('push', help='Upload changes') 161 | parser_push.add_argument('--force', dest='force', action='store_true', 162 | default=False, help='Force push') 163 | parser_push.add_argument('--branch', dest='remote', 164 | default=None, help='Remote branch name') 165 | parser_push.add_argument('ticket', nargs='?', type=int, 166 | help='Ticket number', default=None) 167 | 168 | parser_get = subparsers.add_parser('get', help='Print trac page') 169 | parser_get.add_argument('ticket', nargs='?', type=int, 170 | help='Ticket number', default=None) 171 | 172 | parser_depends = subparsers.add_parser('depends', help='Print trac dependencies') 173 | parser_depends.add_argument('ticket', nargs='?', type=int, 174 | help='Ticket number', default=None) 175 | 176 | parser_print = subparsers.add_parser('print', help='Print trac page') 177 | parser_print.add_argument('ticket', nargs='?', type=int, 178 | help='Ticket number', default=None) 179 | 180 | parser_browse = subparsers.add_parser('browse', help='Open trac page in browser') 181 | parser_browse.add_argument('ticket', nargs='?', type=int, 182 | help='Ticket number', default=None) 183 | 184 | parser_review = subparsers.add_parser('review', help='Show code to review') 185 | parser_review.add_argument('ticket', nargs='?', type=int, 186 | help='Ticket number', default=None) 187 | 188 | parser_find = subparsers.add_parser('find', help='Find trac ticket from SHA1') 189 | parser_find.add_argument('commit', type=str, help='Commit SHA1') 190 | 191 | parser_try = subparsers.add_parser('try', help='Try out trac ticket in "detached HEAD"') 192 | parser_try.add_argument('ticket_or_branch', type=TicketOrBranch, 193 | help='Ticket number or remote branch name') 194 | 195 | parser_log = subparsers.add_parser('log', help='Commit log for ticket') 196 | parser_log.add_argument('ticket', type=int, help='Ticket number') 197 | parser_log.add_argument('--oneline', dest='oneline', action='store_true', 198 | default=False, help='One line per commit') 199 | 200 | parser_config = subparsers.add_parser('config', help='Configure git-trac') 201 | parser_config.add_argument('--user', dest='trac_user', 202 | help='Trac username', default=None) 203 | parser_config.add_argument('--pass', dest='trac_pass', 204 | help='Trac password', default=None) 205 | parser_config.add_argument('--token', dest='trac_token', 206 | help="Trac authentication token (this can " 207 | "be used in lieu of username/password " 208 | "and must be used if you authenticate " 209 | "with Trac via GitHub)") 210 | 211 | parser_cheatsheet = subparsers.add_parser('cheat-sheet', help='Show the git trac cheat sheet') 212 | 213 | parser_help = subparsers.add_parser('help', help='Show the git trac help') 214 | 215 | return parser 216 | 217 | 218 | 219 | def launch(): 220 | parser = make_parser() 221 | args = parser.parse_args(sys.argv[1:]) 222 | if args.log is not None: 223 | import logging 224 | level = getattr(logging, args.log) 225 | logger.setLevel(level=level) 226 | 227 | from .app import Application 228 | app = Application() 229 | 230 | if args.debug: 231 | print(args) 232 | app.config.debug = True 233 | debug_shell(app, parser) 234 | elif args.option_help: 235 | parser.print_help() 236 | elif args.subcommand == 'create': 237 | app.create(args.summary, args.branch_name) 238 | elif args.subcommand == 'checkout': 239 | app.checkout(args.ticket_or_branch, args.branch_name) 240 | elif args.subcommand == 'fetch': 241 | app.fetch(args.ticket_or_branch) 242 | elif args.subcommand == 'pull': 243 | app.pull(args.ticket_or_branch) 244 | elif args.subcommand == 'push': 245 | ticket_number = app.guess_ticket_number(args.ticket) 246 | print('Pushing to Trac #{0}...'.format(ticket_number)) 247 | app.push(ticket_number, remote=args.remote, force=args.force) 248 | elif args.subcommand == 'review': 249 | ticket_number = app.guess_ticket_number(args.ticket) 250 | app.review_diff(ticket_number) 251 | elif args.subcommand == 'try': 252 | app.tryout(args.ticket_or_branch) 253 | elif args.subcommand == 'get': 254 | warnings.warn('deprecated; use "git trac print" instead') 255 | ticket_number = app.guess_ticket_number(args.ticket) 256 | app.print_ticket(ticket_number) 257 | elif args.subcommand == 'print': 258 | ticket_number = app.guess_ticket_number(args.ticket) 259 | app.print_ticket(ticket_number) 260 | elif args.subcommand == 'depends': 261 | ticket_number = app.guess_ticket_number(args.ticket) 262 | app.print_dependencies(ticket_number) 263 | elif args.subcommand == 'browse': 264 | ticket_number = app.guess_ticket_number(args.ticket) 265 | xdg_open('https://trac.sagemath.org/{0}'.format(ticket_number)) 266 | elif args.subcommand == 'log': 267 | app.log(args.ticket, oneline=args.oneline) 268 | elif args.subcommand == 'find': 269 | app.find(args.commit) 270 | elif args.subcommand == 'search': 271 | try: 272 | app.search(branch=args.branch_name) 273 | except ValueError: 274 | parser_search.print_help() 275 | raise 276 | elif args.subcommand == 'config': 277 | app.add_remote() 278 | if args.trac_user is not None: 279 | app.save_trac_username(args.trac_user) 280 | if args.trac_pass is not None: 281 | app.save_trac_password(args.trac_pass) 282 | if args.trac_token is not None: 283 | app.save_trac_token(args.trac_token) 284 | app.print_config() 285 | elif args.subcommand == 'cheat-sheet': 286 | show_cheat_sheet() 287 | elif args.subcommand == 'help': 288 | parser.print_help() 289 | else: 290 | print('Unknown subcommand "{0}"'.format(args.subcommand)) 291 | parser.print_help() 292 | -------------------------------------------------------------------------------- /git_trac/git_interface.py: -------------------------------------------------------------------------------- 1 | ## -*- encoding: utf-8 -*- 2 | r""" 3 | Git Interface 4 | 5 | This module provides a python interface to git. Essentially, it is 6 | a raw wrapper around calls to git and retuns the output as strings. 7 | 8 | EXAMPLES:: 9 | 10 | sage: git.execute('status', porcelain=True) 11 | DEBUG cmd: git status --porcelain 12 | DEBUG stdout: M foo4.txt 13 | DEBUG stdout: A staged_file 14 | DEBUG stdout: ?? untracked_file 15 | ' M foo4.txt\nA staged_file\n?? untracked_file\n' 16 | 17 | sage: git.status(porcelain=True) 18 | DEBUG cmd: git status --porcelain 19 | DEBUG stdout: M foo4.txt 20 | DEBUG stdout: A staged_file 21 | DEBUG stdout: ?? untracked_file 22 | ' M foo4.txt\nA staged_file\n?? untracked_file\n' 23 | """ 24 | 25 | ############################################################################## 26 | # The "git trac ..." command extension for git 27 | # Copyright (C) 2013 Volker Braun 28 | # David Roe 29 | # Julian Rueth 30 | # Keshav Kini 31 | # Nicolas M. Thiery 32 | # Robert Bradshaw 33 | # 34 | # This program is free software: you can redistribute it and/or modify 35 | # it under the terms of the GNU General Public License as published by 36 | # the Free Software Foundation, either version 3 of the License, or 37 | # (at your option) any later version. 38 | # 39 | # This program is distributed in the hope that it will be useful, 40 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 41 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 42 | # GNU General Public License for more details. 43 | # 44 | # You should have received a copy of the GNU General Public License 45 | # along with this program. If not, see . 46 | ############################################################################## 47 | 48 | 49 | import os 50 | import subprocess 51 | 52 | from .git_error import GitError 53 | from .logger import logger 54 | 55 | 56 | # Modified for doctesting 57 | DEBUG_PRINT = False 58 | 59 | 60 | class GitInterfaceSilentProxy(object): 61 | """ 62 | Execute a git command silently, discarding the output. 63 | """ 64 | def __init__(self, actual_interface): 65 | self._interface = actual_interface 66 | 67 | def execute(self, *args, **kwds): 68 | self._interface.execute(*args, **kwds) 69 | return None # the "silent" part 70 | 71 | 72 | class GitInterfaceExitCodeProxy(object): 73 | """ 74 | Execute a git command silently, return only the exit code. 75 | """ 76 | def __init__(self, actual_interface): 77 | self._interface = actual_interface 78 | 79 | def execute(self, cmd, *args, **kwds): 80 | result = self._interface._run(cmd, args, kwds, 81 | popen_stdout=subprocess.PIPE, 82 | popen_stderr=subprocess.PIPE, 83 | exit_code_to_exception=False) 84 | return result['exit_code'] 85 | 86 | 87 | class GitInterfacePrintProxy(object): 88 | """ 89 | Execute a git command and print to stdout like the commandline client. 90 | """ 91 | def __init__(self, actual_interface): 92 | self._interface = actual_interface 93 | 94 | def execute(self, cmd, *args, **kwds): 95 | result = self._interface._run(cmd, args, kwds, 96 | popen_stdout=subprocess.PIPE, 97 | popen_stderr=subprocess.PIPE) 98 | print(result['stdout']) 99 | if result['stderr']: 100 | WARNING = '\033[93m' 101 | RESET = '\033[0m' 102 | print(WARNING+result['stderr']+RESET) 103 | return None 104 | 105 | 106 | 107 | 108 | class GitInterface(object): 109 | r""" 110 | A wrapper around the ``git`` command line tool. 111 | 112 | Most methods of this class correspond to actual git commands. Some add 113 | functionality which is not directly available in git. However, all of the 114 | methods should be non-interactive. If interaction is required the method 115 | should live in :class:`saged.dev.sagedev.SageDev`. 116 | 117 | EXAMPLES:: 118 | 119 | sage: git 120 | Interface to git repo 121 | """ 122 | 123 | # commands that cannot change the repository even with 124 | # some crazy flags set - these commands should be safe 125 | _safe_commands = ( 126 | 'config', 'diff', 'grep', 'log', 127 | 'ls_remote', 'remote', 'reset', 'show', 128 | 'show_ref', 'status', 'symbolic_ref', 129 | 'rev_parse', 130 | ) 131 | 132 | _unsafe_commands = ( 133 | 'add', 'am', 'apply', 'bisect', 134 | 'branch', 'checkout', 'cherry_pick', 'clean', 135 | 'clone', 'commit', 'fetch', 'for_each_ref', 136 | 'format_patch', 'init', 'ls_files', 'merge', 137 | 'mv', 'pull', 'push', 'rebase', 138 | 'rev_list', 'rm', 'stash', 'tag' 139 | ) 140 | 141 | def __init__(self, verbose=False, git_cmd=None): 142 | self._verbose = verbose 143 | self._git_cmd = 'git' if git_cmd is None else git_cmd 144 | self._user_email_set = False 145 | self.silent = GitInterfaceSilentProxy(self) 146 | self.exit_code = GitInterfaceExitCodeProxy(self) 147 | self.echo = GitInterfacePrintProxy(self) 148 | 149 | @property 150 | def git_cmd(self): 151 | """ 152 | The git executable 153 | 154 | EXAMPLES:: 155 | 156 | sage: git.git_cmd 157 | 'git' 158 | """ 159 | return self._git_cmd 160 | 161 | def __repr__(self): 162 | r""" 163 | Return a printable representation of this object. 164 | 165 | TESTS:: 166 | 167 | sage: repr(git) 168 | 'Interface to git repo' 169 | """ 170 | return 'Interface to git repo' 171 | 172 | def _log(self, prefix, log): 173 | for line in log.splitlines(): 174 | logger.debug('%s = %s', prefix, line) 175 | if DEBUG_PRINT: 176 | print('DEBUG {0}: {1}'.format(prefix, line)) 177 | 178 | def _run_unsafe(self, cmd, args, kwds={}, popen_stdout=None, popen_stderr=None): 179 | r""" 180 | Run git 181 | 182 | INPUT: 183 | 184 | - ``cmd`` -- git command run 185 | 186 | - ``args`` -- extra arguments for git 187 | 188 | - ``kwds`` -- extra keywords for git 189 | 190 | - ``popen_stdout`` -- Popen-like keywords. 191 | 192 | - ``popen_stderr`` -- Popen-like keywords. 193 | 194 | OUTPUT: 195 | 196 | A dictionary with keys ``exit_code``, ``stdout``, ``stderr``, ``cmd``. 197 | 198 | .. WARNING:: 199 | 200 | This method does not raise an exception if the git call returns a 201 | non-zero exit code. 202 | 203 | EXAMPLES:: 204 | 205 | sage: import subprocess 206 | sage: result = git._run('status', (), {}, popen_stdout=subprocess.PIPE) 207 | DEBUG cmd: git status 208 | DEBUG stdout: # On branch public/1002/anything 209 | DEBUG stdout: # Changes to be committed: 210 | DEBUG stdout: # (use "git reset HEAD ..." to unstage) 211 | DEBUG stdout: # 212 | DEBUG stdout: # new file: staged_file 213 | DEBUG stdout: # 214 | DEBUG stdout: # Changes not staged for commit: 215 | DEBUG stdout: # (use "git add ..." to update what will be committed) 216 | DEBUG stdout: # (use "git checkout -- ..." to discard changes in working directory) 217 | DEBUG stdout: # 218 | DEBUG stdout: # modified: foo4.txt 219 | DEBUG stdout: # 220 | DEBUG stdout: # Untracked files: 221 | DEBUG stdout: # (use "git add ..." to include in what will be committed) 222 | DEBUG stdout: # 223 | DEBUG stdout: # untracked_file 224 | sage: result == \ 225 | ....: {'exit_code': 0, 'stdout': '# On branch public/1002/anything\n# Changes to be committed:\n# (use "git reset HEAD ..." to unstage)\n#\n#\tnew file: staged_file\n#\n# Changes not staged for commit:\n# (use "git add ..." to update what will be committed)\n# (use "git checkout -- ..." to discard changes in working directory)\n#\n#\tmodified: foo4.txt\n#\n# Untracked files:\n# (use "git add ..." to include in what will be committed)\n#\n#\tuntracked_file\n', 'cmd': 'git status', 'stderr': None} 226 | True 227 | """ 228 | env = kwds.pop('env', {}) 229 | s = [self.git_cmd, cmd] 230 | for k, v in kwds.items(): 231 | if len(k) == 1: 232 | k = '-' + k 233 | else: 234 | k = '--' + k.replace('_', '-') 235 | if v is True: 236 | s.append(k) 237 | elif v is not False: 238 | s.append(k+'='+str(v)) 239 | if args: 240 | s.extend(a for a in args if a is not None) 241 | s = [str(arg) for arg in s] 242 | complete_cmd = ' '.join(s) 243 | self._log('cmd', complete_cmd) 244 | 245 | env.update(os.environ) 246 | process = subprocess.Popen(s, stdout=popen_stdout, stderr=popen_stderr, env=env) 247 | stdout, stderr = process.communicate() 248 | retcode = process.poll() 249 | if stdout is not None and popen_stdout is subprocess.PIPE: 250 | stdout = stdout.decode('utf-8') 251 | self._log('stdout', stdout) 252 | if stderr is not None and popen_stderr is subprocess.PIPE: 253 | stderr = stderr.decode('utf-8') 254 | self._log('stderr', stderr) 255 | return {'exit_code':retcode, 'stdout':stdout, 'stderr':stderr, 'cmd':complete_cmd} 256 | 257 | def _run(self, cmd, args, kwds={}, popen_stdout=None, popen_stderr=None, exit_code_to_exception=True): 258 | result = self._run_unsafe(cmd, args, kwds, 259 | popen_stdout=popen_stdout, 260 | popen_stderr=popen_stderr) 261 | if exit_code_to_exception and result['exit_code']: 262 | raise GitError(result) 263 | return result 264 | 265 | def execute(self, cmd, *args, **kwds): 266 | r""" 267 | Run git on a command given by a string. 268 | 269 | Raises an exception if git has non-zero exit code. 270 | 271 | INPUT: 272 | 273 | - ``cmd`` -- string. The git command to run 274 | 275 | - ``*args`` -- list of strings. Extra arguments for git. 276 | 277 | - ``**kwds`` -- keyword arguments. Extra keywords for git. Will be rewritten 278 | such that ``foo='bar'`` becomes the git commandline argument ``--foo='bar'``. 279 | As a special case, ``foo=True`` becomes just ``--foo``. 280 | 281 | EXAMPLES:: 282 | 283 | sage: git.execute('status') 284 | DEBUG cmd: git status 285 | DEBUG stdout: # On branch public/1002/anything 286 | DEBUG stdout: # Changes to be committed: 287 | DEBUG stdout: # (use "git reset HEAD ..." to unstage) 288 | DEBUG stdout: # 289 | DEBUG stdout: # new file: staged_file 290 | DEBUG stdout: # 291 | DEBUG stdout: # Changes not staged for commit: 292 | DEBUG stdout: # (use "git add ..." to update what will be committed) 293 | DEBUG stdout: # (use "git checkout -- ..." to discard changes in working directory) 294 | DEBUG stdout: # 295 | DEBUG stdout: # modified: foo4.txt 296 | DEBUG stdout: # 297 | DEBUG stdout: # Untracked files: 298 | DEBUG stdout: # (use "git add ..." to include in what will be committed) 299 | DEBUG stdout: # 300 | DEBUG stdout: # untracked_file 301 | '# On branch public/1002/anything\n# Changes to be committed:\n# (use "git reset HEAD ..." to unstage)\n#\n#\tnew file: staged_file\n#\n# Changes not staged for commit:\n# (use "git add ..." to update what will be committed)\n# (use "git checkout -- ..." to discard changes in working directory)\n#\n#\tmodified: foo4.txt\n#\n# Untracked files:\n# (use "git add ..." to include in what will be committed)\n#\n#\tuntracked_file\n' 302 | 303 | sage: git.execute('status', foo=True) # --foo is not a valid parameter 304 | Traceback (most recent call last): 305 | ... 306 | git_trac.git_error.GitError: git returned with non-zero exit code (129) 307 | when executing "git status --foo" 308 | STDERR: error: unknown option `foo' 309 | STDERR: usage: git status [options] [--] ... 310 | STDERR: 311 | STDERR: -v, --verbose be verbose 312 | STDERR: -s, --short show status concisely 313 | STDERR: -b, --branch show branch information 314 | STDERR: --porcelain machine-readable output 315 | STDERR: --long show status in long format (default) 316 | STDERR: -z, --null terminate entries with NUL 317 | STDERR: -u, --untracked-files[=] 318 | STDERR: show untracked files, optional modes: all, normal, no. (Default: all) 319 | STDERR: --ignored show ignored files 320 | STDERR: --ignore-submodules[=] 321 | STDERR: ignore changes to submodules, optional when: all, dirty, untracked. (Default: all) 322 | STDERR: --column[=