├── requirements.txt ├── furo2 ├── __init__.py └── furo2.py ├── .gitignore ├── t ├── Dockerfile └── 01-furo2 ├── .travis.yml ├── Makefile ├── setup.py └── README.md /requirements.txt: -------------------------------------------------------------------------------- 1 | PyYAML 2 | setuptools 3 | -------------------------------------------------------------------------------- /furo2/__init__.py: -------------------------------------------------------------------------------- 1 | version = '2.0.0-alpha' 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /*.egg-info/ 2 | /dist/ 3 | /.cache/ 4 | -------------------------------------------------------------------------------- /t/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:8 2 | 3 | RUN apt-get update && apt-get install -y --no-install-recommends git python3 python3-pip 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - 3.4 4 | - 3.5 5 | - 3.6 6 | install: 7 | - pip install -r requirements.txt 8 | script: 9 | - make test-local 10 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | test: test-local test-docker 2 | 3 | test-local: 4 | ./t/01-furo2 5 | 6 | test-docker: 7 | docker build t 8 | docker run --rm -v "$$PWD:/src" -w /src "$$(docker build -q t)" sh -c 'pip3 install . && FURO2=$$(which furo2) ./t/01-furo2' 9 | 10 | sdist: 11 | python3 setup.py sdist 12 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | 3 | import furo2 4 | 5 | setup( 6 | name='furo2', 7 | version=furo2.version, 8 | install_requires=['PyYAML>=3.12'], 9 | packages=find_packages(), 10 | entry_points={ 11 | 'console_scripts': [ 12 | 'furo2 = furo2.furo2:run', 13 | ], 14 | }, 15 | author='motemen', 16 | author_email='motemen@hatena.ne.jp', 17 | url='https://github.com/motemen/furoshiki2', 18 | ) 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # furoshiki2 2 | 3 | furoshiki is a thin wrapper around CUI operation that records command execution results. 4 | 5 | ## Usage 6 | 7 | % furo2 8 | furo2 exec COMMAND [ARGS...] 9 | furo2 history [pull | show COMMIT | fix] 10 | furo2 version 11 | 12 | ## Installation 13 | 14 | With homebrew: 15 | 16 | brew install --HEAD motemen/furoshiki2/furoshiki2 17 | 18 | Or with pip: 19 | 20 | pip3 install git+https://github.com/motemen/furoshiki2 21 | 22 | Or: 23 | 24 | git clone https://github.com/motemen/furoshiki2 25 | cd furoshiki2 26 | pip3 install . 27 | 28 | ## Configuration 29 | 30 | Create a repository and set `FURO_LOGS_REPOSITORY` environment variable to the repo's pushable URL eg: 31 | 32 | export FURO_LOGS_REPOSITORY=git@github.com:motemen/logs 33 | -------------------------------------------------------------------------------- /t/01-furo2: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | export EMAIL=test@example.com 6 | 7 | root=$(cd "$(dirname "$0")" && cd .. && pwd) 8 | export PYTHONPATH="$root" 9 | 10 | FURO2=${FURO2-"python3 -m furo2.furo2"} 11 | 12 | main() { 13 | python3 --version 14 | git version 15 | $FURO2 version 16 | 17 | logsrepo=$(mktemp -d) 18 | logsdir=$(mktemp -d) 19 | logsdir2=$(mktemp -d) 20 | workdir=$(mktemp -d) 21 | 22 | git init --quiet --bare "$logsrepo" 23 | 24 | export FURO_LOGS_REPOSITORY="$logsrepo" 25 | export FURO_LOGS_DIR="$logsdir" 26 | 27 | cd "$workdir" 28 | git init --quiet 29 | git commit --quiet --allow-empty -m 'init' 30 | git config remote.origin.url https://git.example.com/u/repo 31 | 32 | $FURO2 exec echo 'Hello furoshiki!' 33 | $FURO2 history 34 | 35 | $FURO2 exec echo 'f u' 'r o' 36 | $FURO2 history 37 | $FURO2 history show HEAD | grep 'f u r o' 38 | test "$($FURO2 history -1 | wc -l)" -eq 1 39 | 40 | git config remote.origin.url ssh://git@git.example.com/u/repo 41 | test "$($FURO2 history -1 | wc -l)" -eq 1 42 | 43 | git config remote.origin.url git://git.example.com/u/repo 44 | test "$($FURO2 history -1 | wc -l)" -eq 1 45 | 46 | git config remote.origin.url https://user:pass@git.example.com/u/repo 47 | test "$($FURO2 history -1 | wc -l)" -eq 1 48 | 49 | cat > "$workdir"/project.yml < /dev/null | grep 'this must not be executed'; then 61 | die 'exec should not succeed when FURO_LOGS_REPOSITORY is empty' 62 | fi 63 | } 64 | 65 | die() { 66 | echo "$*" >&2 67 | exit 1 68 | } 69 | 70 | main 71 | -------------------------------------------------------------------------------- /furo2/furo2.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | # coding: utf-8 3 | 4 | import getpass 5 | import json 6 | import os 7 | import os.path 8 | import re 9 | import shlex 10 | import shutil 11 | import subprocess 12 | import sys 13 | import tempfile 14 | from datetime import datetime 15 | from pathlib import Path 16 | from . import version 17 | 18 | logs_dir = os.getenv('FURO_LOGS_DIR') or '~/.furo2/logs' 19 | 20 | 21 | def get_logs_repo(): 22 | logs_repo = os.getenv('FURO_LOGS_REPOSITORY') 23 | if not logs_repo: 24 | raise Exception('FURO_LOGS_REPOSITORY not set') 25 | return logs_repo 26 | 27 | 28 | def script_command(out_file, command): 29 | if sys.platform == 'darwin': 30 | return script_command_darwin(out_file, command) 31 | elif sys.platform == 'linux': 32 | return script_command_linux(out_file, command) 33 | else: 34 | raise 'platform not supported: %s' % sys.platform 35 | 36 | 37 | def script_command_linux(out_file, command): 38 | escaped_command = [shlex.quote(c) for c in command] 39 | return ['script', '--quiet', '--command', 40 | 'sh -c "%s"' % ' '.join(escaped_command), out_file] 41 | 42 | 43 | def script_command_darwin(out_file, command): 44 | return ['script', '-q', out_file] + command 45 | 46 | 47 | def git(args, **kwargs): 48 | if os.getenv('FURO_DEBUG'): 49 | sys.stderr.write('>>> RUN %s\n' % (['git'] + args)) 50 | subprocess.check_call(['git'] + args, **kwargs) 51 | 52 | 53 | def git_output(args, **kwargs): 54 | if os.getenv('FURO_DEBUG'): 55 | sys.stderr.write('>>> RUN %s\n' % (['git'] + args)) 56 | return subprocess.check_output(['git'] + args, **kwargs).decode() 57 | 58 | 59 | def _init_project(): 60 | root_dir = git_output(['rev-parse', '--show-toplevel']).strip() 61 | project_file = Path(root_dir) / 'project.yml' 62 | 63 | repository = git_output(['config', 'remote.origin.url']).strip() 64 | repo_path = re.sub(r'^https?://(?:[^@]+@)?|^ssh://(?:[^@]+@)?|^git://|\.git$', '', repository) 65 | repo_path = re.sub( 66 | r'^[a-zA-Z0-9_]+@([a-zA-Z0-9._-]+):(.*)$', r'\1/\2', repo_path) 67 | 68 | project_path = None 69 | if project_file.exists(): 70 | import yaml 71 | with project_file.open('r') as f: 72 | project_config = yaml.safe_load(f) 73 | project_path = project_config.get('project') 74 | 75 | if not project_path: 76 | project_path = repo_path 77 | 78 | project_logs_dir = Path(os.path.expanduser(logs_dir)) / project_path 79 | 80 | return repo_path, project_path, project_logs_dir 81 | 82 | 83 | def command_exec(command): 84 | if len(command) == 0: 85 | raise UsageError() 86 | 87 | repo_path, project_path, project_logs_dir = _init_project() 88 | logs_repo = get_logs_repo() 89 | 90 | log_file = project_logs_dir / \ 91 | datetime.now().strftime('%Y/%m/%d/%H%M%S.%f.log') 92 | 93 | git_revision = git_output(['rev-parse', 'HEAD']).strip() 94 | 95 | temp_file = Path(tempfile.NamedTemporaryFile( 96 | prefix='furo2', delete=False).name) 97 | 98 | os.environ['FURO'] = '1' 99 | if os.getenv('FURO_DEBUG'): 100 | sys.stderr.write('>>> RUN %s\n' % command) 101 | return_code = subprocess.call( 102 | script_command(str(temp_file), command)) 103 | 104 | if not log_file.parent.exists(): 105 | log_file.parent.mkdir(parents=True) 106 | 107 | with log_file.open('w') as f: 108 | f.write('command: %s\n' % json.dumps(command)) 109 | f.write('user: %s\n' % getpass.getuser()) 110 | f.write('repoPath: %s\n' % repo_path) 111 | f.write('projectPath: %s\n' % project_path) 112 | f.write('gitRevision: %s\n' % git_revision) 113 | f.write('furoVersion: %s\n' % version) 114 | f.write('exitCode: %d\n' % return_code) 115 | f.write('---\n') 116 | with log_file.open('ab') as f: 117 | f.write(temp_file.open('rb').read(None)) 118 | 119 | temp_file.unlink() 120 | 121 | os.chdir(str(project_logs_dir)) 122 | 123 | # Upload execution log to logs repository 124 | 125 | current_remote = '' 126 | try: 127 | current_remote = git_output(['config', 'remote.origin.url']).strip() 128 | except subprocess.CalledProcessError: 129 | pass 130 | 131 | # XXX: what if current_remote differs from logs_repo? 132 | if not current_remote: 133 | git(['init', '--quiet']) 134 | git(['remote', 'add', 'origin', logs_repo]) 135 | 136 | headline = (return_code != 0 and '[failed] ' or '') + ' '.join(command) 137 | 138 | git(['checkout', '--quiet', '-B', project_path]) 139 | 140 | has_branch = False 141 | try: 142 | git(['ls-remote', '--exit-code', 'origin', 143 | project_path], stdout=subprocess.DEVNULL) 144 | has_branch = True 145 | except subprocess.CalledProcessError as e: 146 | if e.returncode != 2: 147 | raise e 148 | 149 | git(['add', '--force', str(log_file)]) 150 | git(['commit', '--quiet', '--message', headline]) 151 | 152 | if has_branch: 153 | git(['pull', '--quiet', '--rebase', 'origin', project_path]) 154 | 155 | git(['push', '--quiet', 'origin', project_path]) 156 | 157 | # TODO: post-execution hook like posting to Slack? 158 | # TODO: displaying log URL? 159 | 160 | sys.exit(return_code) 161 | 162 | 163 | def command_history(args): 164 | repo_path, project_path, project_logs_dir = _init_project() 165 | 166 | if len(args) == 0: 167 | cmd = None 168 | else: 169 | cmd, *args = args 170 | 171 | if cmd == 'show': 172 | os.chdir(str(project_logs_dir)) 173 | os.environ['GIT_EXTERNAL_DIFF'] = 'sh -c "cat $5"' 174 | git(['show', '--pretty=format:', '--ext-diff'] + args) 175 | elif cmd == 'pull': 176 | if not project_logs_dir.exists(): 177 | try: 178 | project_logs_dir.parent.mkdir(parents=True) 179 | except FileExistsError: 180 | pass 181 | logs_repo = get_logs_repo() 182 | git(['clone', logs_repo, 183 | '-b', project_path, str(project_logs_dir)]) 184 | else: 185 | os.chdir(str(project_logs_dir)) 186 | git(['pull', 'origin', project_path]) 187 | elif cmd == 'fix': 188 | logs_repo = get_logs_repo() 189 | os.chdir(str(project_logs_dir.parent)) 190 | if input('rm -rf %s [y/N]: ' % project_logs_dir) == 'y': 191 | shutil.rmtree(str(project_logs_dir)) 192 | try: 193 | git(['clone', logs_repo, 194 | '-b', project_path, str(project_logs_dir)]) 195 | except subprocess.CalledProcessError: 196 | pass 197 | elif cmd == 'git': 198 | os.chdir(str(project_logs_dir)) 199 | git(args) 200 | else: 201 | if cmd is not None: 202 | args = [cmd] + args 203 | os.chdir(str(project_logs_dir)) 204 | git(['log', '--no-decorate', '--pretty=%h [%ad] (%an) %s'] + args) 205 | 206 | 207 | def command_version(args): 208 | print('furoshiki2 version %s' % version) 209 | 210 | 211 | def command_help(): 212 | print(""" 213 | furo2 exec COMMAND [ARGS...] 214 | furo2 history [pull | show COMMIT | fix] 215 | furo2 version 216 | """.strip(), file=sys.stderr) 217 | sys.exit(129) 218 | 219 | 220 | class UsageError(BaseException): 221 | pass 222 | 223 | 224 | def run(): 225 | if len(sys.argv) == 1: 226 | command_help() 227 | 228 | try: 229 | cmd, *args = sys.argv[1:] 230 | if cmd == 'exec': 231 | command_exec(args) 232 | elif cmd == 'history': 233 | command_history(args) 234 | elif cmd == 'version': 235 | command_version(args) 236 | else: 237 | raise UsageError() 238 | except UsageError: 239 | command_help() 240 | 241 | 242 | if __name__ == '__main__': 243 | run() 244 | --------------------------------------------------------------------------------