├── .gitignore ├── README.md ├── codest ├── __init__.py ├── main.py ├── repository.py └── sync.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | *.pyc 3 | *.swp 4 | .installed.cfg 5 | tags 6 | dist 7 | *.egg-info 8 | lib 9 | lib64 10 | .idea 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # codest 2 | 3 | Coding at local like you do it on remote. 4 | 5 | ## Features 6 | 7 | * Automatically: monitor changes. 8 | * Instantly: Sync just after change. 9 | * Incrementally: only sync changed parts. 10 | * Efficiently: aggregate all changes within a specifield interval and respect your git-ignore rules, only sync needed files. 11 | 12 | ## Installation 13 | 14 | Please make sure these programs installed and available in your `PATH`: 15 | 16 | * rsync >= 3.1.0 17 | * git >= 2.3.0 18 | 19 | ``` 20 | $ pip install codest 21 | ``` 22 | 23 | ## Usage 24 | 25 | Monitor changes and sync diff: 26 | 27 | ``` 28 | $ codest path/to/local/foo [user[:password]@]host:/path/on/remote/bar 29 | ``` 30 | 31 | Then, all the code in `/path/on/remote/bar` on the **remote** host will keep syncing with local `foo` directory. 32 | 33 | If you just want to sync your code manually, you can put a `-s` argument after the command: 34 | 35 | ``` 36 | $ codest -s path/to/local/foo [user[:password]@]host:/path/on/remote/bar 37 | ``` 38 | 39 | This will cause a sync, and codest will exit. 40 | 41 | And you can specifiy sync interval by setting `-i` option: 42 | 43 | ``` 44 | $ codest -i 2 path/to/local/foo [user[:password]@]host:/path/on/remote/bar 45 | ``` 46 | 47 | Then, codest will sync to remote every 2 seconds if changes happened. 48 | 49 | To show help message: 50 | 51 | ``` 52 | $ codest -h 53 | ``` 54 | -------------------------------------------------------------------------------- /codest/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | -------------------------------------------------------------------------------- /codest/main.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import sys 5 | import argparse 6 | import logging 7 | import subprocess 8 | import os 9 | import time 10 | 11 | from watchdog.observers import Observer 12 | from watchdog.events import FileSystemEventHandler 13 | 14 | from codest.repository import Repository, GitRepository 15 | from codest.sync import Sync 16 | 17 | 18 | logger = logging.getLogger(__name__) 19 | 20 | 21 | class CodestEventHandler(FileSystemEventHandler): 22 | 23 | def __init__(self, sync, repos): 24 | super(CodestEventHandler, self).__init__() 25 | 26 | self._sync = sync 27 | self._repos = repos 28 | 29 | self._sync.start() 30 | 31 | def on_any_event(self, event): 32 | repo = match_repo(event.src_path, self._repos) 33 | file_path = repo.filter_path(event.src_path) 34 | if file_path: 35 | self._sync.add_path(file_path) 36 | 37 | def stop_sync(self): 38 | self._sync.stop() 39 | 40 | 41 | def load_repos(path): 42 | p1 = subprocess.Popen(('find %s -name .git -type d -prune' % path).split(' '), stdout=subprocess.PIPE) 43 | p2 = subprocess.Popen('xargs -n 1 dirname'.split(' '), stdin=p1.stdout, stdout=subprocess.PIPE) 44 | p1.stdout.close() 45 | git_paths = p2.communicate()[0].split(os.linesep)[:-1] 46 | git_paths.sort(key=lambda x: (x, -1 * len(x.split(os.path.sep)))) 47 | repos = [GitRepository(p) for p in git_paths] 48 | if path not in git_paths: 49 | repos.append(Repository(path)) 50 | return repos 51 | 52 | 53 | def match_repo(path, candidates): 54 | for repo in candidates: 55 | if path.startswith(os.path.abspath(repo.path)): 56 | return repo 57 | return None 58 | 59 | 60 | def parse_args(): 61 | argparser = argparse.ArgumentParser() 62 | argparser.add_argument('-s', '--sync-only', dest='sync_only', 63 | action='store_true', default=False, help='Do a sync, then exit.') 64 | argparser.add_argument('-i', '--interval', dest='interval', type=int, default=3, 65 | help='Sync interval in seconds, defaults to 3.') 66 | argparser.add_argument('path', nargs='?', default=os.getcwd(), help='Local path to be synced') 67 | argparser.add_argument('remote_path', type=str, help='Remote path.') 68 | args = argparser.parse_args() 69 | return args 70 | 71 | 72 | def config_logger(): 73 | root = logging.getLogger() 74 | root.setLevel(logging.INFO) 75 | 76 | ch = logging.StreamHandler(sys.stdout) 77 | ch.setLevel(logging.DEBUG) 78 | formatter = logging.Formatter('[%(levelname)s %(asctime)s] %(message)s') 79 | ch.setFormatter(formatter) 80 | root.addHandler(ch) 81 | 82 | 83 | def main(): 84 | config_logger() 85 | args = parse_args() 86 | 87 | repos = load_repos(args.path) 88 | sync = Sync(args.path, args.remote_path, 89 | interval=args.interval) 90 | 91 | logging.info('Syncing recursively: %s', args.path) 92 | sync.sync_root() 93 | 94 | if args.sync_only: 95 | return 96 | 97 | # set up monitor 98 | event_handler = CodestEventHandler(sync, repos) 99 | observer = Observer() 100 | observer.schedule(event_handler, os.path.abspath(args.path), recursive=True) 101 | observer.start() 102 | try: 103 | while True: 104 | time.sleep(1) 105 | except KeyboardInterrupt: 106 | observer.stop() 107 | logging.info('codest will exit after the current sync.') 108 | event_handler.stop_sync() 109 | observer.join() 110 | 111 | 112 | if __name__ == '__main__': 113 | main() 114 | -------------------------------------------------------------------------------- /codest/repository.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | from subprocess import Popen 6 | 7 | 8 | class Repository(object): 9 | 10 | def __init__(self, path): 11 | self.path = path 12 | 13 | def filter_path(self, path): 14 | return path 15 | 16 | 17 | class GitRepository(Repository): 18 | 19 | def __init__(self, path): 20 | super(GitRepository, self).__init__(path) 21 | self._ignored_files = set() 22 | 23 | def is_ignored(self, relpath): 24 | if relpath.endswith('.git') or '.git/' in relpath: 25 | return True 26 | if relpath.endswith('.gitignore'): 27 | self._ignored_files = set([]) 28 | if relpath in self._ignored_files: 29 | return True 30 | if self._check_ignore(relpath): 31 | self._ignored_files.add(relpath) 32 | return True 33 | return False 34 | 35 | def _check_ignore(self, relpath): 36 | cwd = os.getcwd() 37 | try: 38 | os.chdir(self.path) 39 | params = ['git', 'check-ignore', '-q', relpath] 40 | ret = Popen(params).wait() 41 | return ret == 0 42 | finally: 43 | os.chdir(cwd) 44 | 45 | def filter_path(self, path): 46 | relpath = os.path.relpath(path, os.path.abspath(self.path)) 47 | if not self.is_ignored(relpath): 48 | return path 49 | -------------------------------------------------------------------------------- /codest/sync.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import time 6 | import subprocess 7 | import logging 8 | from threading import RLock, Thread 9 | 10 | 11 | class SyncRunner(Thread): 12 | 13 | def __init__(self, sync, **kwargs): 14 | super(SyncRunner, self).__init__(**kwargs) 15 | self._sync = sync 16 | self._stopped = False 17 | 18 | def run(self): 19 | while not self._stopped: 20 | time.sleep(self._sync.interval) 21 | self._sync.sync() 22 | 23 | def stop(self): 24 | self._stopped = True 25 | 26 | 27 | class Sync(object): 28 | 29 | def __init__(self, path, remote_path, interval=3): 30 | self._path = path 31 | self._remote_path = remote_path 32 | self.interval = interval 33 | 34 | self._abspath = os.path.abspath(self._path) 35 | self._paths_to_sync = set([]) 36 | self._lock = RLock() 37 | self._runner = SyncRunner(self) 38 | 39 | def sync_root(self): 40 | params = ['rsync', '-aqzR', '--delete', '--exclude=.git', '--filter=:- /.gitignore'] 41 | params.append(self._cal_path_with_implied_dirs(self._path)) 42 | params.append(self._remote_path) 43 | subprocess.Popen(params).wait() 44 | 45 | def sync(self): 46 | paths_to_sync = None 47 | with self._lock: 48 | paths_to_sync = self._paths_to_sync 49 | self._paths_to_sync = set([]) 50 | if paths_to_sync: 51 | logging.info('Syncing:%s%s', os.linesep, os.linesep.join( 52 | [os.path.relpath(p) for p in paths_to_sync] 53 | )) 54 | self._rsync_paths(paths_to_sync) 55 | 56 | def _rsync_paths(self, paths): 57 | params = ['rsync', '-dqRl', '--delete', '--delete-missing-args', '--filter=:- /.gitignore'] 58 | params.extend([self._cal_path_with_implied_dirs(p) for p in paths]) 59 | params.append(self._remote_path) 60 | subprocess.Popen(params).wait() 61 | 62 | def _cal_path_with_implied_dirs(self, file_path): 63 | relpath = os.path.relpath(file_path, self._abspath) 64 | return os.path.join(self._path, '.', relpath) 65 | 66 | def start(self): 67 | self._runner.start() 68 | 69 | def stop(self): 70 | self._runner.stop() 71 | self._runner.join() 72 | 73 | def add_path(self, path): 74 | with self._lock: 75 | self._paths_to_sync.add(os.path.abspath(path)) 76 | 77 | def remove_path(self, path): 78 | with self._lock: 79 | abs_path = os.path.abspath(path) 80 | if abs_path in self._paths_to_sync: 81 | self._paths_to_sync.remove(abs_path) 82 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # -*- coding: utf-8 -*- 3 | 4 | from setuptools import setup 5 | 6 | install_requires = [ 7 | "watchdog" 8 | ] 9 | 10 | entry_points = """ 11 | [console_scripts] 12 | codest=codest.main:main 13 | """ 14 | 15 | setup( 16 | name="codest", 17 | version="0.1.3", 18 | url='https://github.com/psjay/codest', 19 | license='MIT', 20 | description="Coding at local like you do it on remote", 21 | author='psjay', 22 | author_email='psjay.peng@gmail.com', 23 | packages=['codest'], 24 | install_requires=install_requires, 25 | entry_points=entry_points, 26 | classifiers=[ 27 | "Programming Language :: Python", 28 | "Programming Language :: Python :: 2", 29 | "Programming Language :: Python :: 2.7", 30 | "Operating System :: POSIX", 31 | "License :: OSI Approved :: MIT License", 32 | "Topic :: Software Development", 33 | "Topic :: Utilities", 34 | ], 35 | ) 36 | --------------------------------------------------------------------------------