├── .gitignore ├── .saplings ├── LICENSE ├── MANIFEST.in ├── README.txt ├── saplib ├── __init__.py ├── config.py ├── lib.py └── split.py ├── sapling.py ├── sapversion ├── __init__.py └── version.txt ├── setup.py ├── test ├── __init__.py ├── fixtures.py ├── test-config.py ├── test-lib.py └── test-split.py └── upload.sh /.gitignore: -------------------------------------------------------------------------------- 1 | MANIFEST 2 | .idea/ 3 | build/ 4 | dist/ 5 | *.iml 6 | *.pyc 7 | *.pyo 8 | sapling.egg-info/* 9 | -------------------------------------------------------------------------------- /.saplings: -------------------------------------------------------------------------------- 1 | tests = { 2 | 'name': 'tests', 3 | 'paths': [ 'test' ] 4 | } 5 | 6 | splits = [ tests ] 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include sapversion/version.txt 3 | -------------------------------------------------------------------------------- /README.txt: -------------------------------------------------------------------------------- 1 | == Installation: 2 | 3 | To install the python library you can do one of the following: 4 | 1.) use easy_install to install from the PyPI registry: http://pypi.python.org/pypi/sapling 5 | $ sudo easy_install sapling 6 | 2.) use pip to install from PyPI 7 | $ sudo pip install sapling 8 | 3.) Or you can get the source distribution and run the following from its root: 9 | $ sudo python setup.py install 10 | 11 | Then to link the porcelain into git-core, do: 12 | $ sudo sapling.py --install 13 | 14 | Get help 15 | $ git sap -h 16 | 17 | == Configuration 18 | 19 | Sapling (git sap) is configured with a .saplings file at the root of your git repository. Here's 20 | an example .saplings configuration for a scala project with common component and a hack component 21 | that uses it: 22 | 23 | common = { 24 | # The logical name of the split - listed by git sap. 25 | 'name': 'common', 26 | 27 | # The paths that comprise this split. These paths will form the saplings split from and merged 28 | # into your repository when using git sap --split and git sap --merge respectively 29 | 'paths': [ 30 | 'project', 31 | 'src/main/scala/com/twitter/common', 32 | 'src/main/resources/com/twitter/common', 33 | 'src/test/scala/com/twitter/common', 34 | ] 35 | } 36 | 37 | hack = { 38 | 'name': 'hack', 39 | 'paths': [ 40 | 'project', 41 | 'src/main/scala/com/twitter/hack', 42 | ] 43 | } 44 | 45 | # This is all git sap looks for - a list of splits named 'splits'. Each split in the list must be 46 | # a dict with the keys shown/described above. 47 | splits = [ 48 | common, 49 | hack 50 | ] 51 | 52 | == Use 53 | 54 | To verify your .saplings is correct, you can view the current splits (assuming .saplings above) 55 | with: 56 | $ git sap 57 | common 58 | hack 59 | 60 | You could split out the common split to a new repo like so: 61 | $ git sap --split common 62 | $ git push git@github.com:jsirois/common.git sapling_split_common:master 63 | 64 | You can merge changes back in from a split branch or repo using standard git tools: 65 | $ git pull git@github.com:jsirois/common.git master 66 | 67 | This will maintain a parallel history of changes to the split which can make for confusing looking 68 | "double commits". An alternative that allows for more controlled imports is to apply patches from 69 | the split onto the mainline using a combination of: 70 | 1.) (split branch)$ git format-patch ... \ 71 | --ignore-if-in-upstream ..[remote split tracking branch] > /tmp/mbox 72 | 2.) (master)$ git am -k ... < /tmp/mbox 73 | 74 | == Development: 75 | 76 | To run all tests, you can use something like: 77 | $ PYTHONPATH=$PYTHONPATH:. py.test test/*.py -v 78 | 79 | == Known Issues: 80 | 81 | There is no way currently to do differential splits. Although a split for fixed branch/split config 82 | will always produce the same split branch (identical shas), it will always re-run over the entire 83 | source branch which can take a long time for big branches. 84 | 85 | == Roadmap: 86 | 87 | 0.1.x 88 | + support differential splitting 89 | + built in support for patch merging strategy 90 | 91 | -------------------------------------------------------------------------------- /saplib/__init__.py: -------------------------------------------------------------------------------- 1 | from config import Config, ConfigError 2 | from lib import find, with_line_numbers 3 | from split import Split 4 | 5 | __all__ = ( 6 | 'Config', 7 | 'ConfigError', 8 | 'Split', 9 | 'version' 10 | ) 11 | -------------------------------------------------------------------------------- /saplib/config.py: -------------------------------------------------------------------------------- 1 | from lib import with_line_numbers 2 | from split import Split 3 | import traceback 4 | 5 | class ConfigError(Exception): 6 | """Thrown when a Config cannot parse.""" 7 | 8 | def __init__(self, msg, *args): 9 | self.msg = msg % args 10 | 11 | def __str__(self): 12 | return self.msg 13 | 14 | 15 | class Config(object): 16 | """Represents a sapling split configuration. Configurations can contain any valid python code 17 | but need only define a splits list containing the splits in a git repository. Each split is a 18 | dict that must have the following entries: 19 | 'name': a logical name for the split 20 | 'paths': the paths this split is comprised of relative to the root of the git repository 21 | """ 22 | 23 | __slots__ = ('_splits') 24 | 25 | def __init__(self, repo, data = None): 26 | if data is None or len(data) == 0 or data.isspace(): 27 | self._splits = {} 28 | else: 29 | self._splits = Config._parse(repo, data.strip()) 30 | 31 | @classmethod 32 | def _parse(cls, repo, config): 33 | local_config = {} 34 | try: 35 | exec(config, {}, local_config) 36 | except StandardError: 37 | raise ConfigError("Problem parsing config:\n%s\n\n%s", with_line_numbers(config), 38 | traceback.format_exc()) 39 | 40 | Config._validate(local_config) 41 | 42 | splits = {} 43 | for splitmap in local_config['splits']: 44 | split = Config._parse_split(repo, splitmap) 45 | splits[split.name] = split 46 | return splits 47 | 48 | @classmethod 49 | def _parse_split(cls, repo, splitmap): 50 | name = splitmap.pop('name') 51 | patterns = splitmap.pop('paths') 52 | try: 53 | return Split(repo, name, patterns) 54 | except KeyError: 55 | raise ConfigError("Problem creating split: %s\n%s\n\n%s", name, splitmap, 56 | traceback.format_exc()) 57 | 58 | @classmethod 59 | def _validate(cls, config): 60 | if 'splits' in config: 61 | for split in config['splits']: 62 | Config._validate_split(split) 63 | 64 | @classmethod 65 | def _validate_split(cls, split): 66 | problems = [] 67 | if 'name' not in split: 68 | problems.append("split must define a 'name'") 69 | if 'paths' not in split: 70 | problems.append("split must define 'paths'") 71 | if len(problems) > 0: 72 | raise ConfigError("Invalid split %s has the following problems:\n\t%s", split, 73 | '\n\t'.join(problems)) 74 | 75 | @property 76 | def splits(self): 77 | """A dict of the configured Splits keyed by their names.""" 78 | return self._splits 79 | 80 | def __str__(self): 81 | return "Config(%s)" % ", ".join('%s => %s' % (x, y) for (x, y) in self.splits.items()) 82 | -------------------------------------------------------------------------------- /saplib/lib.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | def find(iterable, predicate, default = None): 4 | """Finds and returns the first item in iterable that passes the supplied predicate. If not item 5 | matches and a default was not specified then a KeyError is raised; otherwise, the default is 6 | returned""" 7 | 8 | for item in iterable: 9 | if (predicate(item)): 10 | return item 11 | 12 | if default is None: 13 | raise KeyError 14 | 15 | if callable(default): 16 | return default() 17 | else: 18 | return default 19 | 20 | def with_line_numbers(string): 21 | """Adds line numbers to the given string in a right-justified left hand column. For example: 22 | A two line 23 | string 24 | 25 | would be transformed into: 26 | 1 A two line 27 | 2 string 28 | """ 29 | 30 | lines = string.splitlines() 31 | if len(lines) == 0: 32 | lines.append(string) 33 | width = len(str(len(lines))) 34 | return os.linesep.join([str(i + 1).rjust(width) + ' ' + line for (i, line) in enumerate(lines)]) 35 | 36 | -------------------------------------------------------------------------------- /saplib/split.py: -------------------------------------------------------------------------------- 1 | import git 2 | import gitdb 3 | import lib 4 | import os 5 | import re 6 | import StringIO 7 | 8 | class Split(object): 9 | """Represents a split of a git repository off to a remote repository. A Split maps one or more 10 | subtrees of a containing git repository as a logical unit that can be pushed to or pulled from its 11 | remote.""" 12 | 13 | __slots__ = ('_repo', '_name', '_paths', '_excludes') 14 | 15 | def __init__(self, repo, name, patterns): 16 | """Creates a new Split over the given repo with the specified logical name. The patterns 17 | specify paths to include in the split and regular expressions to prune out sub-paths. Paths to 18 | include are taken as relative to the root of the repo and can either be directory paths, in 19 | which case the full directory tree is retained in the split, or an individual file path. 20 | Excludes are distinguished with a leading ! character with the rest of the pattern forming a 21 | path regular expression to match files gathered from the path patterns that should be pruned. 22 | 23 | For example, the following patterns would specify a split that grabs a top-level README, and the 24 | src/ tree except for any OWNERS files contained within: 25 | [ 'README', 'src', '!.+/OWNERS$""" 26 | 27 | self._repo = repo 28 | self._name = name 29 | 30 | paths = [] 31 | excludes = set() 32 | for pattern in patterns: 33 | if pattern.startswith('!'): 34 | excludes.add(re.compile(pattern[1:])) 35 | else: 36 | paths.append(os.path.normpath(pattern)) 37 | self.paths = paths 38 | self._excludes = excludes 39 | 40 | @property 41 | def name(self): 42 | """The logical name of this Split.""" 43 | return self._name 44 | 45 | @property 46 | def paths(self): 47 | "The paths this split is comprised of." 48 | return self._paths 49 | 50 | @paths.setter 51 | def paths(self, value): 52 | self._paths = self._validate_paths(value) 53 | 54 | def _validate_paths(self, paths): 55 | tree = self._current_tree() 56 | for path in paths: 57 | try: 58 | tree / path 59 | except KeyError: 60 | raise KeyError("Invalid path: %s" % path) 61 | return paths 62 | 63 | def commits(self, reverse = True): 64 | """Returns an iterator over the commits in the current head that instersect this split. By 65 | default commits are returned oldest first, but this can be overridden by specifying 66 | 'reverse' = False""" 67 | 68 | refspec = self._current_head() 69 | return git.Commit.iter_items(self._repo, refspec, self.paths, reverse = reverse) 70 | 71 | class ApplyListener(object): 72 | def on_start(self, commit_count): 73 | pass 74 | def on_commit(self, original_commit, new_commit): 75 | pass 76 | def on_finish(self): 77 | pass 78 | 79 | def apply(self, branch_name, apply_listener = ApplyListener()): 80 | """Applies this split over the commits to the named branch and returns the tip commit. An 81 | ApplyListener callback can be passed to track progress of the split; otherwise, a no-op 82 | ApplyListener is used. If there are no (new) commits to split None is returned.""" 83 | 84 | commits = list(self.commits()) 85 | if not commits: 86 | return None 87 | 88 | commit_count = len(commits) 89 | 90 | apply_listener.on_start(commit_count) 91 | try: 92 | if not commits: 93 | return None 94 | 95 | parent = None 96 | branch = lib.find(self._repo.branches, 97 | lambda branch: branch.name == branch_name, 98 | lambda: self._repo.create_head(branch_name)) 99 | 100 | for commit in commits: 101 | index_path = '/tmp/%s.index' % branch_name 102 | if os.path.exists(index_path): 103 | os.remove(index_path) 104 | 105 | index = git.IndexFile(self._repo, index_path) 106 | for item in self._subtrees(commit): 107 | if self._is_included(item): 108 | index.add([item]) 109 | else: 110 | index.add(item.traverse(lambda item, depth: self._is_included(item))) 111 | synthetic_tree = index.write_tree() 112 | 113 | parents = [] if parent is None else [ parent ] 114 | parent = self._copy_commit(commit, synthetic_tree, parents) 115 | apply_listener.on_commit(commit, parent) 116 | 117 | branch.commit = parent 118 | return parent 119 | 120 | finally: 121 | apply_listener.on_finish() 122 | 123 | def _copy_commit(self, orig_commit, tree, parents): 124 | new_commit = git.Commit(self._repo, git.Commit.NULL_BIN_SHA, tree, orig_commit.author, 125 | orig_commit.authored_date, orig_commit.author_tz_offset, 126 | orig_commit.committer, orig_commit.committed_date, 127 | orig_commit.committer_tz_offset, 128 | "%s\n(sapling split of %s)" % (orig_commit.message, orig_commit.hexsha), 129 | parents, orig_commit.encoding) 130 | 131 | return self._write_commit(new_commit) 132 | 133 | def _write_commit(self, commit): 134 | stream = StringIO.StringIO() 135 | commit._serialize(stream) 136 | 137 | stream_len = stream.tell() 138 | stream.seek(0) 139 | 140 | istream = self._repo.odb.store(gitdb.IStream(git.Commit.type, stream_len, stream)) 141 | commit.binsha = istream.binsha 142 | return commit 143 | 144 | def _subtrees(self, commit = None, ignore_not_found = True): 145 | if commit is None: 146 | commit = self._current_head_commit() 147 | 148 | for path in self.paths: 149 | try: 150 | yield commit.tree / path 151 | except KeyError as e: 152 | if not ignore_not_found: 153 | raise e 154 | 155 | def _is_included(self, item): 156 | return item.type is "blob" and not self._is_excluded(item) 157 | 158 | def _is_excluded(self, item): 159 | for exclude in self._excludes: 160 | if exclude.match(item.path): 161 | return True 162 | return False 163 | 164 | def _current_tree(self): 165 | return self._current_head_commit().tree 166 | 167 | def _current_head_commit(self): 168 | return self._current_head().commit 169 | 170 | def _current_head(self): 171 | return self._repo.head 172 | 173 | def __str__(self): 174 | return "Split(name=%s, paths=%s, excludes=%s)" % ( 175 | self._name, 176 | self.paths, 177 | [ exclude.pattern for exclude in self._excludes ] 178 | ) 179 | -------------------------------------------------------------------------------- /sapling.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | from __future__ import print_function 3 | from __builtin__ import list as pylist 4 | 5 | from sapversion import version 6 | 7 | import git 8 | import optparse 9 | import os 10 | import saplib 11 | import subprocess 12 | import sys 13 | 14 | def log(message, *args, **kwargs): 15 | print(message % args, file = sys.stderr, **kwargs) 16 | 17 | def usage(message, *args): 18 | print(message % args) 19 | exit(1) 20 | 21 | def open_repo(native = True): 22 | try: 23 | return git.Repo(odbt = git.db.GitCmdObjectDB if native else git.db.GitDB) 24 | except git.exc.InvalidGitRepositoryError: 25 | usage("Must be inside a git repository") 26 | 27 | def open_config(repo): 28 | config_path = os.path.join(repo.working_tree_dir, '.saplings') 29 | if os.path.exists(config_path): 30 | with open(config_path, 'r') as config: 31 | try: 32 | return saplib.Config(repo, config.read()) 33 | except saplib.ConfigError as e: 34 | usage("Problem loading .saplings config: %s", e) 35 | else: 36 | return saplib.Config(repo) 37 | 38 | def install(show = False, force = False): 39 | git_exec_path = subprocess.Popen(["git", "--exec-path"], 40 | stdout = subprocess.PIPE).communicate()[0].strip() 41 | installed_link_path = os.path.join(git_exec_path, 'git-sap') 42 | 43 | if show: 44 | print(os.path.realpath(installed_link_path)) 45 | return 46 | 47 | recreate = force and os.path.exists(installed_link_path) 48 | if recreate: 49 | try: 50 | os.remove(installed_link_path) 51 | except OSError as e: 52 | usage("failed to remove old symlink: %s", e) 53 | 54 | if not os.path.exists(installed_link_path): 55 | try: 56 | os.symlink(os.path.abspath(sys.argv[0]), installed_link_path) 57 | print("symlink %s at: %s" % ("re-installed" if recreate else "installed", 58 | installed_link_path)) 59 | except OSError as e: 60 | usage("failed to install symlink: %s", e) 61 | 62 | else: 63 | print("symlink exists: %s" % installed_link_path) 64 | 65 | def list(repo, split_config, verbose): 66 | for split in split_config.splits.values(): 67 | print(split.name) 68 | if verbose: 69 | paths = ( 70 | "%s/" % os.path.relpath(os.path.join(repo.working_tree_dir, path)) for path in split.paths 71 | ) 72 | log("paths (%d):\n\t%s", len(split.paths), "\n\t".join(paths)) 73 | 74 | def split(splits, verbose, dry_run): 75 | for split in splits: 76 | if (verbose): 77 | log("Operating on split: %s", split) 78 | 79 | # TODO(jsirois): allow customization of branch, consider special names: 80 | # name1:branch1 name2 ... nameN:branchN 81 | branch_name = '_sapling_split_%s_' % split.name 82 | 83 | if dry_run: 84 | commits = pylist(split.commits()) 85 | print("Would split %d new commits to branch: %s" % (len(commits), branch_name)) 86 | print("\n".join(commit.hexsha for commit in commits)) 87 | return 88 | 89 | class ProgressTracker(saplib.Split.ApplyListener): 90 | def __init__(self): 91 | self._commit_index = 0 92 | self._width = 80.0 93 | self._pct = 0 94 | self._pct_complete = 0 95 | 96 | def on_start(self, commit_count): 97 | self._commit_count = commit_count 98 | message = "[split = %s, branch = %s] Processing %d commits" % (split.name, 99 | branch_name, 100 | self._commit_count) 101 | if verbose: 102 | log(message) 103 | else: 104 | self._width = max(len(message) + 2.0, float(self._width)) 105 | self._quantum = self._commit_count / self._width 106 | log(message + (" " * (int(self._width) - len(message) - 1)) + "|") 107 | 108 | def on_commit(self, original_commit, new_commit): 109 | self._commit_index += 1 110 | 111 | if verbose: 112 | log("%s -> %s (%d of %d)", original_commit.hexsha, new_commit.hexsha, self._commit_index, 113 | self._commit_count) 114 | else: 115 | self._pct_complete = int(self._commit_index / self._quantum % self._commit_count) 116 | if self._pct_complete > self._pct: 117 | log("." * (self._pct_complete - self._pct), end = "") 118 | self._pct = self._pct_complete 119 | sys.__stdout__.flush() 120 | 121 | def on_finish(self): 122 | if not verbose: 123 | log("." * (int(self._width) - self._pct_complete)) 124 | 125 | tip = split.apply(branch_name, apply_listener = ProgressTracker()) 126 | 127 | if (tip): 128 | print(tip.hexsha) 129 | else: 130 | log("No new commits to split.") 131 | 132 | def parse_args(): 133 | versionMessage = "%prog {0} (http://pypi.python.org/pypi/sapling/{0})".format(version()) 134 | 135 | usage = """ 136 | %prog (-dv --python-git-db) --list 137 | %prog (-dv --python-git-db) --split [splitname...]""" 138 | 139 | epilog = "Happy splitting!" 140 | 141 | parser = optparse.OptionParser(usage = usage, version = versionMessage, epilog = epilog) 142 | 143 | parser.add_option("-d", "--debug", dest = "debug", action = "store_true", default = False, 144 | help = "Prints extra debugging information.") 145 | parser.add_option("-v", "--verbose", dest = "verbose", action = "store_true", default = False, 146 | help = "Prints extra information.") 147 | parser.add_option("--python-git-db", dest = "native", action = "store_false", default = True, 148 | help = "Specifies the python implementation of the git object database should " 149 | "be used instead of the native one - can speed operations when repository has " 150 | "few large files.") 151 | 152 | # TODO(jsirois): enforce mutual exclusivity of these option groups 153 | 154 | install = optparse.OptionGroup(parser, "Install sap as a git subcommand") 155 | install.add_option("--install", 156 | dest = "subcommand", 157 | action = "store_const", 158 | const = "install", 159 | help = "Installs the git sap command if not installed already.") 160 | install.add_option("-f", "--force", 161 | dest = "force", 162 | action = "store_true", 163 | default = False, 164 | help = "Forces a re-install of the git sap command.") 165 | install.add_option("-s", "--show", 166 | dest = "show", 167 | action = "store_true", 168 | default = False, 169 | help = "Does not perform an install, instead shows the path of the binary " 170 | "git sap' calls into.") 171 | parser.add_option_group(install) 172 | 173 | list = optparse.OptionGroup(parser, "List configured splits for the current git repo") 174 | list.add_option("--list", 175 | dest = "subcommand", 176 | default = "list", 177 | action = "store_const", 178 | const = "list", 179 | help = "Lists splits defined in .saplings if any.") 180 | parser.add_option_group(list) 181 | 182 | split = optparse.OptionGroup(parser, "Split new commits out that affect one or more splits") 183 | split.add_option("--split", 184 | dest = "subcommand", 185 | action = "store_const", 186 | const = "split", 187 | help = "Populates branches with commits intersecting the specified splits. " 188 | "If a --branch is not specified, arguments are treated as split names " 189 | "definied in the .saplings config.") 190 | split.add_option("-b", "--branch", 191 | dest = "branch", 192 | help = "Specifies a branch to split to, arguments are treated as the patterns " 193 | "to split.") 194 | split.add_option("-n", "--dry-run", 195 | dest = "dry_run", 196 | action = "store_true", 197 | default = False, 198 | help = "Does not perform a split, instead just lists the commits that would be " 199 | "split.") 200 | parser.add_option_group(split) 201 | 202 | (options, args) = parser.parse_args() 203 | return (options, args, parser.error) 204 | 205 | def main(): 206 | (options, args, ferror) = parse_args() 207 | 208 | if options.subcommand is "install": 209 | if len(args) != 0: 210 | ferror("list takes no arguments") 211 | install(options.show, options.force) 212 | return 213 | 214 | # Fail fast if we're not in a repo 215 | repo = open_repo(options.native) 216 | 217 | if options.debug: 218 | print("repo\t[%s]\t%s" % (repo.active_branch, repo.working_tree_dir)) 219 | 220 | if options.subcommand is "list": 221 | # Fail fast if we don't have an invalid .saplings config 222 | split_config = open_config(repo) 223 | 224 | if len(args) != 0: 225 | ferror("list takes no arguments") 226 | 227 | list(repo, split_config, options.verbose) 228 | 229 | elif options.subcommand is "split": 230 | if options.branch: 231 | if len(args) == 0: 232 | ferror("At least 1 split path must be specified") 233 | 234 | try: 235 | splits = [ saplib.Split(repo, options.branch, args) ] 236 | except KeyError as e: 237 | ferror(e) 238 | else: 239 | if len(args) == 0: 240 | ferror("At least 1 split must be specified") 241 | 242 | splits_by_name = open_config(repo).splits 243 | try: 244 | splits = [ splits_by_name[name] for name in args ] 245 | except KeyError as e: 246 | ferror("Split not defined: %s" % e) 247 | 248 | split(splits, options.verbose, options.dry_run) 249 | 250 | try: 251 | main() 252 | exit(0) 253 | except object as e: 254 | usage(e) 255 | -------------------------------------------------------------------------------- /sapversion/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | def version(): 4 | """Returns the current version of sapling""" 5 | 6 | with open(os.path.join(os.path.dirname(__file__), 'version.txt'), 'r') as version: 7 | return version.read().strip() 8 | -------------------------------------------------------------------------------- /sapversion/version.txt: -------------------------------------------------------------------------------- 1 | 0.0.11 2 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup 2 | 3 | from sapversion import version 4 | 5 | setup( 6 | name = 'sapling', 7 | version = version(), 8 | 9 | author = 'John Sirois', 10 | author_email = 'john.sirois@gmail.com', 11 | description = 'A git porcelain to manage bidirectional subtree syncing with foreign git ' 12 | 'repositories', 13 | license = 'Apache License Version 2.0', 14 | url = 'http://github.com/jsirois/sapling', 15 | 16 | provides = 'sapling', 17 | install_requires = ( 18 | 'gitdb >= 0.5.1', 19 | 'GitPython > 0.2, < 0.4', 20 | ), 21 | 22 | packages = [ 'saplib', 'sapversion' ], 23 | package_data = { 'sapversion': [ 'version.txt' ] }, 24 | scripts = [ 'sapling.py' ], 25 | 26 | classifiers = [ 27 | 'Programming Language :: Python', 28 | 'Programming Language :: Python :: 2.6', 29 | 'Development Status :: 2 - Pre-Alpha', 30 | 'Environment :: Console', 31 | 'Intended Audience :: Developers', 32 | 'License :: OSI Approved :: Apache Software License', 33 | 34 | # TODO(jsirois): the sapling.py --install action is actually unix/symlink dependant - 35 | # perhaps detect windows and just copy the sapling.py script to git-core/git-sap ? 36 | 'Operating System :: OS Independent', 37 | 38 | 'Topic :: Software Development :: Version Control' 39 | ], 40 | ) 41 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- 1 | from fixtures import RepoFixture 2 | 3 | __all__ = ('RepoFixture') 4 | -------------------------------------------------------------------------------- /test/fixtures.py: -------------------------------------------------------------------------------- 1 | import git 2 | 3 | class RepoFixture(object): 4 | 5 | # TODO(jsirois): Provide a way to easily build up a temporary repo to use for tests instead of 6 | # assuming this project is housed in a git repo. 7 | 8 | def repo(self): 9 | return git.Repo() 10 | -------------------------------------------------------------------------------- /test/test-config.py: -------------------------------------------------------------------------------- 1 | import fixtures 2 | import saplib 3 | import unittest 4 | 5 | class TestConfig(unittest.TestCase, fixtures.RepoFixture): 6 | def test_empty_config(self): 7 | self.assertEquals(0, len(self._create_config("splits = []").splits)) 8 | self.assertEquals(0, len(self._create_config("").splits)) 9 | self.assertEquals(0, len(self._create_config().splits)) 10 | 11 | def test_invalid_split(self): 12 | self._assert_config_error("splits = [{}]") 13 | self._assert_config_error("splits = [{'paths': []}]") 14 | 15 | def test_simple_split(self): 16 | config = self._create_config(""" 17 | test = { 18 | 'name': 'test', 19 | 'paths': [ 20 | 'test', 21 | ] 22 | } 23 | splits = [ test ]""") 24 | 25 | self.assertTrue('test' in config.splits) 26 | split = config.splits['test'] 27 | self.assertEquals('test', split.name) 28 | self.assertEquals(['test'], split.paths) 29 | 30 | def _assert_config_error(self, config): 31 | self.assertRaises(saplib.ConfigError, saplib.Config, self.repo(), config) 32 | 33 | def _create_config(self, config = None): 34 | return saplib.Config(self.repo(), config) 35 | -------------------------------------------------------------------------------- /test/test-lib.py: -------------------------------------------------------------------------------- 1 | import saplib 2 | import unittest 3 | 4 | class LibTest(unittest.TestCase): 5 | def test_find(self): 6 | self.assertEqual(2, saplib.find([1, 2, 3], lambda x: x > 1)) 7 | self.assertEqual(2, saplib.find([1, 2, 3], lambda x: x == 4, 2)) 8 | 9 | self.assertEqual(2, saplib.find([1, 2, 3], lambda x: x == 2, 10 | lambda: self.fail("unexpected application of callable"))) 11 | self.assertEqual("delayed", saplib.find([1, 2, 3], lambda x: x == 4, lambda: "delayed")) 12 | self.assertRaises(KeyError, saplib.find, [1, 2, 3], lambda x: x == 4) 13 | 14 | def test_with_line_numbers(self): 15 | self.assertEqual("1 ", saplib.with_line_numbers("")) 16 | self.assertEqual("1 a", saplib.with_line_numbers("a")) 17 | self.assertEqual(""" 1 a 18 | 2 b 19 | 3 c 20 | 4 d 21 | 5 e 22 | 6 f 23 | 7 g 24 | 8 h 25 | 9 i 26 | 10 j 27 | 11 k""", saplib.with_line_numbers("""a 28 | b 29 | c 30 | d 31 | e 32 | f 33 | g 34 | h 35 | i 36 | j 37 | k""".strip())) 38 | -------------------------------------------------------------------------------- /test/test-split.py: -------------------------------------------------------------------------------- 1 | import fixtures 2 | import saplib 3 | import unittest 4 | 5 | class SplitTest(unittest.TestCase, fixtures.RepoFixture): 6 | def test_name_only(self): 7 | split = saplib.Split(self.repo(), 'jake', []) 8 | self.assertEquals('jake', split.name) 9 | self.assertEquals([], split.paths) 10 | 11 | def test_invalid(self): 12 | self.assertRaises(KeyError, saplib.Split, self.repo(), 'jake', [ 'baz/' ]) 13 | self.assertRaises(KeyError, saplib.Split, self.repo(), 'jake', [ 'test', 'baz' ]) 14 | 15 | def test_simple(self): 16 | split = saplib.Split(self.repo(), 'jake', [ 'test', 'saplib' ]) 17 | self.assertEquals('jake', split.name) 18 | self.assertEquals([ 'test', 'saplib' ], split.paths) 19 | -------------------------------------------------------------------------------- /upload.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | python setup.py sdist upload --sign 4 | --------------------------------------------------------------------------------