├── setup.cfg ├── .gitignore ├── LICENSE.txt ├── setup.py ├── README.rst └── dropblame └── __init__.py /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .*.swp 2 | build/ 3 | dist/ 4 | *.egg-info/ 5 | *.egg 6 | *.py[cod] 7 | __pycache__/ 8 | *.so 9 | *~ 10 | .tox 11 | .cache 12 | MANIFEST 13 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Copyright (c) 2016 Jaiden Mispy 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | sOFTWARE. 20 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | # Always prefer setuptools over distutils 2 | from setuptools import setup 3 | # To use a consistent encoding 4 | from codecs import open 5 | from os import path 6 | 7 | here = path.abspath(path.dirname(__file__)) 8 | 9 | # Get the long description from the README file 10 | with open(path.join(here, 'README.rst'), encoding='utf-8') as f: 11 | long_description = f.read() 12 | 13 | setup( 14 | name='dropblame', 15 | packages=['dropblame'], 16 | version='0.0.1', 17 | description='"git blame" for Dropbox files', 18 | long_description=long_description, 19 | author='Jaiden Mispy', 20 | author_email='jaiden@mispy.me', 21 | url='https://github.com/mispy/dropblame', 22 | keywords='git dropbox command-line', 23 | classifiers=[ 24 | 'License :: OSI Approved :: MIT License', 25 | 'Programming Language :: Python :: 2', 26 | 'Programming Language :: Python :: 3', 27 | 'Topic :: Utilities' 28 | ], 29 | install_requires=['dropbox', 'pyyaml', 'ndg-httpsclient'], 30 | entry_points={ 31 | 'console_scripts': [ 32 | 'drop=dropblame:main', 33 | ], 34 | }, 35 | ) 36 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | dropblame 2 | ========= 3 | 4 | .. image:: https://badge.fury.io/py/dropblame.svg 5 | :target: https://badge.fury.io/py/dropblame 6 | 7 | | 8 | 9 | A Python command line tool which will convert the revision history of a 10 | Dropbox file into a git repository, so you can run ``git blame`` or 11 | ``git diff``. Suggested by `@cgranade `_ since lots of physics people use Dropbox as a sort of low-barrier version control. 12 | 13 | Installation 14 | ------------ 15 | 16 | ``pip install dropblame`` 17 | 18 | Usage 19 | ----- 20 | 21 | ``drop blame /path/to/Dropbox/file`` 22 | 23 | Syncs Dropbox revisions to a git repo and runs git blame. Any additional 24 | arguments will be passed to git blame. 25 | 26 | ``drop cd /path/to/Dropbox/file`` 27 | 28 | Syncs Dropbox revisions to a git repo and then opens a shell there, if 29 | you want to run diff or other operations. The commit messages contain 30 | Dropbox revision ids. 31 | 32 | Notes 33 | ----- 34 | 35 | The first time you run ``drop`` you will be asked for configuration 36 | details to connect to Dropbox, which will be stored in 37 | ~/.dropblame/config.yml. 38 | 39 | Note that this tool can only go back as far as the Dropbox API will 40 | allow, which is currently 100 revisions. 41 | 42 | I've only tested this on Linux so far. 43 | -------------------------------------------------------------------------------- /dropblame/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import sys 4 | import os 5 | import dropbox 6 | import subprocess 7 | import requests 8 | import json 9 | from dropbox.exceptions import AuthError 10 | import yaml 11 | import pipes 12 | 13 | 14 | class Config(object): 15 | def __init__(self): 16 | self.homedir = os.path.expanduser("~") 17 | self.our_dir = os.path.join(self.homedir, ".dropblame") 18 | self.config_path = os.path.join(self.our_dir, "config.yml") 19 | self.storage_dir = os.path.join(self.our_dir, "storage") 20 | self.dropbox_dir = None 21 | self.token = None 22 | 23 | if not os.path.exists(self.config_path): 24 | print("Creating new config file at ~/.dropblame/config.yml") 25 | if not os.path.exists(self.our_dir): 26 | os.makedirs(self.our_dir) 27 | self.save_config() 28 | 29 | self.load_config() 30 | 31 | def read_dropbox_dir(self): 32 | while self.dropbox_dir is None: 33 | path = raw_input("Please enter the path to your Dropbox " + 34 | "directory (default: ~/Dropbox): ").strip() 35 | if path == '': 36 | path = "~/Dropbox" 37 | 38 | if not os.path.exists(os.path.expanduser(path)): 39 | print("No directory could be found at {0}".format(path)) 40 | continue 41 | 42 | self.dropbox_dir = os.path.expanduser(path) 43 | 44 | def read_token(self): 45 | print("\nTo link this to Dropbox, you will first need to generate " + 46 | "an access token: https://blogs.dropbox.com/developers/2014/" 47 | "05/generate-an-access-token-for-your-own-account/") 48 | while self.token is None: 49 | token = raw_input("Enter your token here: ").strip() 50 | if token == '': 51 | continue 52 | print("Testing your token now...") 53 | 54 | dbx = dropbox.Dropbox(token) 55 | try: 56 | dbx.users_get_current_account() 57 | except AuthError: 58 | print("ERROR: Invalid access token. Please try again!") 59 | continue 60 | 61 | print("Token looks good, thanks!") 62 | self.token = token 63 | 64 | def load_config(self): 65 | data = {} 66 | with open(self.config_path, 'r') as f: 67 | data = yaml.load(f.read()) 68 | 69 | if 'dropbox_dir' in data: 70 | self.dropbox_dir = data['dropbox_dir'] 71 | else: 72 | self.read_dropbox_dir() 73 | 74 | if 'token' in data: 75 | self.token = data['token'] 76 | else: 77 | self.read_token() 78 | 79 | self.save_config() 80 | 81 | def save_config(self): 82 | data = {} 83 | if self.dropbox_dir is not None: 84 | data['dropbox_dir'] = self.dropbox_dir 85 | if self.token is not None: 86 | data['token'] = self.token 87 | 88 | yaml_text = yaml.dump(data, default_flow_style=False) 89 | with open(self.config_path, 'w') as f: 90 | f.write(yaml_text) 91 | 92 | 93 | def cmd(line, cwd=None): 94 | p = subprocess.Popen(line, shell=True, cwd=cwd, stdout=subprocess.PIPE) 95 | return p.communicate()[0] 96 | 97 | 98 | # Convert the revision history of a given file into a git repository 99 | def sync_repo(filepath): 100 | basename = os.path.basename(filepath) 101 | relpath = os.path.relpath(os.path.realpath(filepath), 102 | os.path.realpath(config.dropbox_dir)) 103 | gitdir = os.path.join(config.storage_dir, relpath) 104 | if not os.path.exists(gitdir): 105 | os.makedirs(gitdir) 106 | 107 | revs = [entry.rev for entry in 108 | dbx.files_list_revisions("/"+relpath, limit=100).entries] 109 | revs.reverse() 110 | 111 | current_revs = [] 112 | if os.path.exists(os.path.join(gitdir, ".git")): 113 | current_revs = cmd("git log --format=%B", gitdir).split() 114 | else: 115 | cmd("git init", gitdir) 116 | 117 | # As we find more user ids who contributed to the file, we 118 | # request and cache their info here 119 | userinfo = {} 120 | 121 | missing_revs = [rev for rev in revs if rev not in current_revs] 122 | 123 | if len(missing_revs) > 0: 124 | print("Found {0} new revisions to download for {1}". 125 | format(len(missing_revs), relpath)) 126 | 127 | i = 0 128 | for rev in missing_revs: 129 | i += 1 130 | localpath = os.path.join(gitdir, basename) 131 | revpath = "rev:{0}".format(rev) 132 | print("{0}/{1} Fetching revision {2}". 133 | format(i, len(missing_revs), rev)) 134 | # Bypass dropbox python package due to missing sharing_info 135 | # https://github.com/dropbox/dropbox-sdk-python/issues/40 136 | r = requests.post( 137 | "https://api.dropboxapi.com/2/files/get_metadata", 138 | headers={'Authorization': "Bearer {0}".format(config.token), 139 | 'Content-Type': "application/json"}, 140 | data=json.dumps({'path': revpath})) 141 | meta = json.loads(r.text) 142 | 143 | author_name = "You" 144 | if 'sharing_info' in meta: 145 | author_id = meta['sharing_info']['modified_by'] 146 | if author_id not in userinfo: 147 | userinfo[author_id] = dbx.users_get_account(author_id) 148 | author_name = userinfo[author_id].name.display_name 149 | 150 | dbx.files_download_to_file(localpath, revpath) 151 | cmd(("git add -A . && git commit -m {0} --author=\"{1} " + 152 | "\" --date=\"{2}\""). 153 | format(rev, pipes.quote(author_name), meta['client_modified']), 154 | gitdir) 155 | 156 | return gitdir 157 | 158 | 159 | def print_usage(): 160 | usage = """ 161 | USAGE 162 | 163 | {0} blame /path/to/Dropbox/file 164 | 165 | Syncs Dropbox revisions to a git repo and runs git blame. Any additional 166 | arguments will be passed to git blame. 167 | 168 | {1} cd /path/to/Dropbox/file 169 | 170 | Syncs Dropbox revisions to a git repo and then opens a shell there, if 171 | you want to run diff or other operations. Note that the repo is readonly. 172 | 173 | --- 174 | 175 | The first time you run drop you will be asked for configuration details to 176 | connect to Dropbox, which will be stored in ~/.dropblame/config.yml. 177 | 178 | Note that this tool can only go back as far as the Dropbox API will allow, 179 | which is currently 100 revisions. 180 | """.format(os.path.basename(sys.argv[0]), 181 | os.path.basename(sys.argv[0])).strip() 182 | 183 | print(usage) 184 | 185 | 186 | def main(): 187 | global config, dbx 188 | config = Config() 189 | dbx = dropbox.Dropbox(config.token) 190 | 191 | if len(sys.argv) < 3: 192 | print_usage() 193 | sys.exit(1) 194 | 195 | if sys.argv[1] == "help": 196 | print_usage() 197 | sys.exit(0) 198 | 199 | if sys.argv[1] not in ["blame", "cd"]: 200 | print_usage() 201 | sys.exit(1) 202 | 203 | path = os.path.expanduser(sys.argv[2]) 204 | 205 | if not os.path.exists(path): 206 | print("cannot access {0}: No such file or directory". 207 | format(sys.argv[2])) 208 | sys.exit(1) 209 | 210 | gitdir = sync_repo(path) 211 | 212 | if sys.argv[1] == "cd": 213 | p = subprocess.Popen("$SHELL", shell=True, cwd=gitdir) 214 | p.wait() 215 | else: 216 | cmd = ['git', 'blame', os.path.basename(path)] + sys.argv[3:] 217 | p = subprocess.Popen(cmd, cwd=gitdir) 218 | p.wait() 219 | 220 | if __name__ == '__main__': 221 | main() 222 | --------------------------------------------------------------------------------