├── .travis.yml ├── LICENSE ├── README.md ├── git-rbr ├── git-rbr-core └── t └── test_rbr.py /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "2.7" 4 | 5 | dist: trusty 6 | 7 | before_install: 8 | # 9 | # The tests use `git for-each-ref --contains`, which was new in 10 | # Git 2.7 (commit 4a71109aa). So we need something that new. 11 | # 12 | # The Travis `trusty` environment... 13 | # * is documented to have "A Git 2.x release": 14 | # https://docs.travis-ci.com/user/trusty-ci-environment ; 15 | # * though Trusty upstream has 1.9.1; 16 | # * empirically sometimes has 2.11.0: 17 | # https://travis-ci.org/dropbox/git-rbr/builds/224543377 ; 18 | # * but other times 1.9.1: 19 | # https://travis-ci.org/dropbox/git-rbr/builds/224544754 . 20 | # (The difference is likely connected to the worker "version" 21 | # of v2.7.0 vs v2.5.0.) 22 | # 23 | # So, install our own to be safe. Maybe someday in the future 24 | # we can take this out. 25 | - sudo add-apt-repository -y ppa:git-core/ppa 26 | - sudo apt-get -qq update 27 | - sudo apt-get install -y git 28 | 29 | install: 30 | # Bit of potentially-debugging output 31 | - git --version 32 | - git config -l 33 | # Travis pre-installs pytest, but let's be explicit 34 | - pip install pytest 35 | 36 | script: 37 | - py.test 38 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # git-rbr: "recursive rebase" 2 | 3 | **NOTE**: This repository is no longer maintained and may not be up-to-date. 4 | 5 | ## Running tests 6 | 7 | [![Build Status](https://travis-ci.org/dropbox/git-rbr.svg?branch=master)](https://travis-ci.org/dropbox/git-rbr) 8 | 9 | From the root of the repo, run 10 | 11 | $ py.test 12 | 13 | Dependencies for the test suite: 14 | 15 | * **pytest**: `pip install pytest` 16 | 17 | * **Git v2.7 or later**: if your Git version is too old, install a 18 | current one from https://git-scm.com/downloads or 19 | https://launchpad.net/~git-core/+archive/ubuntu/ppa . 20 | (`git-rbr` itself should work with much older versions of Git... 21 | though we don't currently test that automatically.) 22 | 23 | ## License 24 | 25 | Copyright 2017 Dropbox, Inc. 26 | 27 | Licensed under the Apache License, Version 2.0 (the "License"); 28 | you may not use this file except in compliance with the License. 29 | You may obtain a copy of the License at 30 | 31 | http://www.apache.org/licenses/LICENSE-2.0 32 | 33 | Unless required by applicable law or agreed to in writing, software 34 | distributed under the License is distributed on an "AS IS" BASIS, 35 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 36 | See the License for the specific language governing permissions and 37 | limitations under the License. 38 | -------------------------------------------------------------------------------- /git-rbr: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | SUBDIRECTORY_OK=Yes 4 | OPTIONS_KEEPDASHDASH= 5 | OPTIONS_STUCKLONG=t 6 | OPTIONS_SPEC="\ 7 | git rbr [options] 8 | git rbr --continue | --abort | --skip 9 | 10 | Rebase, recursively, this branch and all its dependents. 11 | 12 | Each branch is rebased on top of its own respective upstream. 13 | -- 14 | Available options are 15 | v,verbose! print more information 16 | dry-run! only simulate, don't actually rebase 17 | orphan! force proceed even if some branches will be left behind 18 | Actions: 19 | continue! continue 20 | abort! abort 21 | skip! do \`git rebase --skip\` and continue 22 | " 23 | 24 | . git-sh-setup 25 | require_work_tree_exists 26 | cd_to_toplevel 27 | 28 | state_dir="$GIT_DIR"/rebase-recursive 29 | 30 | # 31 | # Parse args 32 | # 33 | 34 | action= 35 | verbose= 36 | dry_run= 37 | orphan= 38 | 39 | total_argc=$# 40 | while test $# != 0 41 | do 42 | case "$1" in 43 | --continue|--abort|--skip) 44 | test $total_argc -eq 2 || usage 45 | action=${1#--} 46 | ;; 47 | --verbose) 48 | verbose=t 49 | GIT_QUIET= 50 | ;; 51 | --dry-run) 52 | dry_run=t 53 | ;; 54 | --orphan) 55 | orphan=t 56 | ;; 57 | --) 58 | shift 59 | break 60 | ;; 61 | esac 62 | shift 63 | done 64 | test $# -le 0 || usage 65 | 66 | in_progress= 67 | if test -d "$state_dir" 68 | then 69 | in_progress=t 70 | fi 71 | 72 | if test -z "$in_progress" && 73 | { test -d "$GIT_DIR"/rebase-apply || test -d "$GIT_DIR"/rebase-merge; } 74 | then 75 | die "\ 76 | A non-recursive rebase ('git rebase') is already in progress, 77 | without a recursive rebase ('git rbr'). 78 | 79 | Finish or abort that one first. 80 | " 81 | fi 82 | 83 | if test -n "$action" && test -z "$in_progress" 84 | then 85 | coda=" 86 | Try starting a new one? 87 | " 88 | test "$action" = "abort" && coda= 89 | die "\ 90 | No recursive rebase ('git rbr') is in progress -- nothing to $action. 91 | $coda" 92 | fi 93 | 94 | if test -z "$action" && test -n "$in_progress" 95 | then 96 | state_dir_shortname=.git/${state_dir##*/}/ 97 | die "\ 98 | There's already a '$state_dir_shortname' directory; it looks like 99 | a recursive rebase ('git rbr') is already in progress. Try 100 | git rebase (--continue | --skip | --abort) 101 | to proceed with that or to abort it. 102 | 103 | Alternatively, you can remove the '$state_dir_shortname' directory 104 | to wipe the recursive-rebase state, and start 'git rbr' over. 105 | But if you need to do that, it's probably a bug and please report it; 106 | first try 'git rebase --abort' (or '--continue', etc.) instead. 107 | " 108 | fi 109 | 110 | # 111 | # Main work 112 | # 113 | 114 | get_upstream () { 115 | git rev-parse --abbrev-ref "$1"@{u} 116 | } 117 | 118 | write_basic_state () { 119 | mkdir -p "$state_dir" 120 | echo "$branch" > "$state_dir"/branch 121 | echo "$(get_upstream "$branch")" > "$state_dir"/upstream 122 | echo "$verbose" > "$state_dir"/verbose 123 | echo "$dry_run" > "$state_dir"/dry-run 124 | echo "$orphan" > "$state_dir"/orphan 125 | } 126 | 127 | cleanup_state () { 128 | rm -rf "$state_dir" 129 | } 130 | 131 | run_core () { 132 | GIT_DIR="$GIT_DIR" git rbr-core "$1" 133 | core_status=$? 134 | if test "$core_status" -eq 0 || 135 | test "$core_status" -eq 2 # special "error but clean up" status 136 | then 137 | cleanup_state 138 | fi 139 | exit "$core_status" 140 | } 141 | 142 | # 143 | # Handle in-progress rebase, and abort/continue/etc 144 | # 145 | 146 | case "$action" in 147 | continue) 148 | run_core continue 149 | exit 0 150 | ;; 151 | skip) 152 | run_core skip 153 | exit 0 154 | ;; 155 | abort) 156 | run_core abort 157 | exit 0 158 | ;; 159 | esac 160 | 161 | # 162 | # Handle initial case 163 | # 164 | 165 | require_clean_work_tree "rebase-recursive" "$(gettext "Please commit or stash them.")" 166 | 167 | head_ref=$(git symbolic-ref -q HEAD) 168 | if test -z "$head_ref" 169 | then 170 | die "\ 171 | You are on a detached HEAD. 172 | Check out the branch you want to rebase." 173 | fi 174 | branch=${head_ref#refs/heads/} 175 | if test "$branch" = "$head_ref" 176 | then 177 | die "\ 178 | HEAD is not under refs/heads/: $head_ref 179 | 180 | The 'upstream' feature of Git only operates on refs/heads/. 181 | " 182 | fi 183 | 184 | write_basic_state 185 | 186 | run_core init 187 | 188 | 189 | # These follow the style in Git upstream. 190 | # 191 | # Local Variables: 192 | # sh-indentation: 8 193 | # sh-basic-offset: 8 194 | # sh-indent-for-case-label: 0 195 | # sh-indent-for-case-alt: + 196 | # indent-tabs-mode: t 197 | # End: 198 | -------------------------------------------------------------------------------- /git-rbr-core: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | 3 | from collections import defaultdict 4 | import json 5 | import os 6 | import os.path 7 | import re 8 | import subprocess 9 | import sys 10 | 11 | 12 | def exit_usage(): 13 | sys.stderr.write('bad arguments: ' + ' '.join(sys.argv) + '\n') 14 | sys.exit(2) 15 | 16 | 17 | def cmd_output(cmd): 18 | try: 19 | return subprocess.check_output(cmd, stderr=subprocess.STDOUT) 20 | except subprocess.CalledProcessError as e: 21 | print 'Error in command: %s' % (' '.join(cmd),) 22 | sys.stdout.write(e.output) 23 | sys.exit(e.returncode) 24 | 25 | 26 | def unchecked_output(cmd): 27 | try: 28 | return subprocess.check_output(cmd) 29 | except subprocess.CalledProcessError as e: 30 | return e.output 31 | 32 | 33 | def child_branches(branch): 34 | # type: (str) -> List[str] 35 | config_name_text = unchecked_output( 36 | ['git', 'config', '--name-only', 37 | '--get-regexp', r'branch\..*\.merge', 38 | '^refs/heads/%s$' % (re.escape(branch),)]) 39 | return re.findall('^branch\.(.*)\.merge$', config_name_text, re.M) 40 | 41 | 42 | def is_atop(upstream, branch): 43 | # type: (str) -> bool 44 | '''True just if `branch` is on top of `upstream`.''' 45 | return '0' == cmd_output( 46 | ['git', 'rev-list', '--count', '--max-count=1', 47 | upstream, '--not', branch, 48 | '--']).strip() 49 | 50 | 51 | class Options(object): 52 | state_dir = None # type: str 53 | 54 | verbose = False 55 | dry_run = False 56 | orphan = False 57 | 58 | def __init__(self, git_dir): 59 | # type: (str) -> None 60 | self.state_dir = os.path.join(git_dir, 'rebase-recursive') 61 | 62 | self.verbose = bool(self.read_state_file('verbose')) 63 | self.dry_run = bool(self.read_state_file('dry-run')) 64 | self.orphan = bool(self.read_state_file('orphan')) 65 | 66 | def state_filename(self, basename): 67 | # type: (str) -> str 68 | return os.path.join(self.state_dir, basename) 69 | 70 | def read_state_file(self, name): 71 | # type: (str) -> str 72 | with file(self.state_filename(name)) as f: 73 | return f.read().strip() 74 | 75 | def try_read_state_file(self, name): 76 | # type: (str) -> Optional[str] 77 | '''Read the given file, or return None if missing.''' 78 | try: 79 | return self.read_state_file(name) 80 | except IOError: 81 | return None 82 | 83 | def v_out(self, message): 84 | # type: (str) -> None 85 | if self.verbose: 86 | sys.stderr.write(message + '\n') 87 | 88 | 89 | class RebaseGraph(object): 90 | opts = None # type: Options 91 | 92 | orig_branch = None # type: str 93 | orig_upstream = None # type: str 94 | 95 | def __init__(self, opts): 96 | # type: Options -> None 97 | self.opts = opts 98 | self.orig_branch = self.opts.read_state_file('branch') 99 | self.orig_upstream = self.opts.read_state_file('upstream') 100 | 101 | def return_to_original_branch(self): 102 | # type: () -> None 103 | cmd_output( 104 | ['git', 'checkout', '--quiet', '--force', self.orig_branch]) 105 | 106 | # TODO this function's code is a bit messy. 107 | # TODO store the resulting graph so we don't have to re-look things up later. 108 | def check_graph(self): 109 | # type: () -> bool 110 | '''True just if the check passes.''' 111 | if self.opts.orphan: 112 | return True 113 | 114 | try: 115 | subprocess.check_output(['git', 'for-each-ref', '--count=1', 116 | '--contains=HEAD']) 117 | except subprocess.CalledProcessError: 118 | sys.stderr.write('''\ 119 | Warning: You appear to have an ancient Git version that leaves us unable 120 | to check whether some branches on top of this one lack "upstream" pointers 121 | and will be left behind atop the old version of the commits we rewrite. 122 | 123 | Please install a recent version of Git (v2.4.3, which is from June 2015, 124 | or later) and retry. 125 | 126 | Alternatively, pass '--orphan' to 'git rbr' to skip this check -- perhaps 127 | after confirming with 'git branch -vv' that you have set all branch's 128 | upstreams as intended! 129 | ''') 130 | return False 131 | 132 | orig_ref = cmd_output( 133 | ['git', 'rev-parse', '--symbolic-full-name', self.orig_branch]).strip() 134 | 135 | this_branch_commits = cmd_output( 136 | ['git', 'rev-list', self.orig_branch, '--not', self.orig_upstream, '--'] 137 | ).strip().split('\n') 138 | 139 | containing_refs_data = cmd_output( 140 | ['git', 'for-each-ref', '--format', '%(refname) %(upstream)'] 141 | + ['--contains=' + commit for commit in this_branch_commits] 142 | ) 143 | 144 | head_prefix = 'refs/heads/' 145 | containing_refs = set() 146 | non_heads = set() 147 | upstreams = {} 148 | children = defaultdict(list) 149 | for line in containing_refs_data.rstrip('\n').split('\n'): 150 | ref, upstream = line.split(' ') 151 | containing_refs.add(ref) 152 | if not ref.startswith(head_prefix): 153 | non_heads.add(ref) 154 | continue 155 | if upstream: 156 | upstreams[ref] = upstream 157 | children[upstream].append(ref) 158 | 159 | maybe_good_refs = containing_refs - non_heads 160 | 161 | missing_upstream = set( 162 | ref for ref in maybe_good_refs if ref not in upstreams) 163 | maybe_good_refs -= missing_upstream 164 | 165 | wild_upstream = set(ref for ref in maybe_good_refs 166 | if ref != orig_ref 167 | and upstreams[ref] not in containing_refs) 168 | maybe_good_refs -= wild_upstream 169 | 170 | cycles = set() 171 | fingers = set(maybe_good_refs) 172 | while fingers: 173 | new_fingers = set(upstreams[ref] for ref in fingers 174 | if upstreams[ref] in maybe_good_refs) 175 | if new_fingers == fingers: 176 | cycles = fingers 177 | break 178 | fingers = new_fingers 179 | 180 | if non_heads or missing_upstream or wild_upstream or cycles: 181 | message = '''\ 182 | Warning: Some refs based on branch '%s' are not its descendants in the 183 | Git "upstream" metadata! This means that a recursive rebase won't touch them, 184 | and will leave them behind atop the old version of the commits we rewrite. 185 | 186 | Specifically:\ 187 | ''' % (self.orig_branch,) 188 | if non_heads: 189 | message += '\n These refs are not branches:\n' 190 | message += ''.join(' %s\n' % (ref,) for ref in non_heads) 191 | if missing_upstream: 192 | message += '\n These branches have no upstream set:\n' 193 | message += ''.join(' %s\n' % (ref[len(head_prefix):],) 194 | for ref in missing_upstream) 195 | if wild_upstream: 196 | # TODO Really just follow the actual upstream-graph. 197 | message += '\n These branches have an upstream pointing outside the relevant branches:\n' 198 | message += ''.join(' %s -> %s\n' 199 | % (ref[len(head_prefix):], upstreams[ref]) 200 | for ref in wild_upstream) 201 | if cycles: 202 | message += '\n These branches are in a cycle (!) of upstreams:\n' 203 | message += ''.join(' %s\n' % (ref[len(head_prefix):],) 204 | for ref in cycles) 205 | message += ''' 206 | Fix with 'git branch -u' aka '--set-upstream-to', and examine the situation 207 | with 'git branch -vv'. 208 | 209 | Or to proceed anyway, pass '--orphan' to 'git rbr' to skip this check. 210 | 211 | ''' 212 | sys.stderr.write(message) 213 | return False 214 | 215 | return True 216 | 217 | 218 | def munge_resolve_message(text): 219 | '''If the `git rebase` how-to-resolve message appears in `text`, correct it.''' 220 | return re.sub( 221 | '(?<=")git rebase(?= --(?:continue|skip|abort)")', 222 | 'git rbr', 223 | text) 224 | 225 | 226 | def git_rev_parse(commitish): 227 | return cmd_output(['git', 'rev-parse', '--verify', commitish]).strip() 228 | 229 | 230 | def one_rebase(onto, upstream, branch): 231 | # type: (str, str, str) -> None 232 | # More complex than `cmd_output`, in order to munge the merge-conflict 233 | # instructions. Which go to stdout in a plain (`type=am`) or 234 | # `type=merge` rebase, but to stderr in a `type=interactive` rebase, 235 | # so cover both. 236 | proc = subprocess.Popen( 237 | ['git', 'rebase', '--onto', onto, upstream, branch], 238 | stdout=subprocess.PIPE, stderr=subprocess.PIPE) 239 | out, err = proc.communicate() 240 | retcode = proc.poll() 241 | if retcode: 242 | sys.stdout.write(munge_resolve_message(out)) 243 | sys.stderr.write(munge_resolve_message(err)) 244 | sys.exit(retcode) 245 | 246 | 247 | # Logically what we're doing here is 248 | # 249 | # def rbr_simple(onto, upstream, branch): 250 | # old_id = git_rev_parse(branch) 251 | # git_rebase_onto(onto, upstream, branch) 252 | # for child in child_branches(branch): 253 | # rbr_simple(branch, old_id, child) 254 | # 255 | # def rbr_full(onto, upstream, branch): 256 | # for child in child_branches(branch): 257 | # rbr_full(branch, branch, child) 258 | # if not is_atop(upstream, branch) or onto != upstream: 259 | # rbr_simple(onto, upstream, branch) 260 | # 261 | # It gets more complicated than that in order to support --continue 262 | # and friends. Effectively we maintain the stack of recursive calls 263 | # to these two logical functions, but in "$state_dir" rather than just 264 | # in the running shell, so that we can pick it back up smoothly after 265 | # a rebase hits a conflict and the user re-enters here with --continue 266 | # etc. 267 | # 268 | # In the logical code above, the one line where we can exit and invite 269 | # the user to resume is the `git rebase` call. So the stack we need 270 | # to represent on disk consists of frames 271 | # full_0 full_1 ... full_n simple_0 ... simple_m 272 | # where the innermost frame simple_m is at the rebase; the innermost 273 | # rbr_full frame full_n is at the rbr_simple call; and the other frames 274 | # are each at the recursion inside their respective loops. 275 | # 276 | # Internally, it's convenient to overload the stack data structures 277 | # slightly to represent two variant forms of stack: 278 | # 1. at the top of the `rbr_simple` loop, and 279 | # 2. at the top of the `rbr_full` loop. 280 | # These don't appear in stacks stored on disk. 281 | # 282 | class RebaseRecursiveMachine(object): 283 | opts = None # type: Options 284 | graph = None # type: RebaseGraph 285 | 286 | # The logical stack (see above) is represented by current_rebase, 287 | # stack_simple, and stack_full. Three variants: 288 | # 289 | # Variant 0: inside a rebase 290 | # current_rebase not None. 291 | # simple_m described by current_rebase, full_n omitted as a tail call. 292 | # 293 | # Variant 1: top of `rbr_simple` loop. 294 | # current_rebase None, stack_simple nonempty. 295 | # simple_m included in stack_simple, full_n omitted as a tail call. 296 | # 297 | # Variant 2: top of `rbr_full` loop. 298 | # current_rebase None, stack_simple empty. 299 | # full_n included in stack_full. 300 | 301 | # For Variant 0, the inmost logical frame simple_m, consisting of 302 | # onto, upstream, and branch for the current rebase; else None. 303 | current_rebase = None # type: Optional[Tuple[str, str, str]] 304 | 305 | # The stack of logical frames simple_0 ... simple_(m-1) (or 306 | # simple_m), each with branch, old_id, and a list of remaining child 307 | # branches. 308 | stack_simple = [] # type: List[Tuple[str, str, List[str]]] 309 | 310 | # The stack of logical frames full_0 ... full_(n-1) (or full_n), 311 | # each with onto, upstream, branch, and a list of remaining child 312 | # branches. 313 | stack_full = [] # type: List[Tuple[str, str, str, List[str]]] 314 | 315 | @staticmethod 316 | def create_initial(opts, graph): 317 | # type: (Options, RebaseGraph) -> RebaseRecursiveMachine 318 | self = RebaseRecursiveMachine() 319 | self.opts = opts 320 | self.graph = graph 321 | 322 | # Stack variant 2. 323 | self.current_rebase = None 324 | self.stack_simple = [] 325 | self.stack_full = [(graph.orig_upstream, 326 | graph.orig_upstream, 327 | graph.orig_branch, 328 | child_branches(graph.orig_branch))] 329 | return self 330 | 331 | @staticmethod 332 | def create_resume(opts, graph): 333 | # type: (Options, RebaseGraph) -> RebaseRecursiveMachine 334 | self = RebaseRecursiveMachine() 335 | self.opts = opts 336 | self.graph = graph 337 | 338 | with file(self.opts.state_filename('stack')) as f: 339 | stack_data = json.load(f) 340 | self.current_rebase = stack_data['current_rebase'] 341 | self.stack_simple = stack_data['stack_simple'] 342 | self.stack_full = stack_data['stack_full'] 343 | return self 344 | 345 | def write_stack(self): 346 | # type: () -> None 347 | stack_data = { 348 | 'stack_full': self.stack_full, 349 | 'stack_simple': self.stack_simple, 350 | 'current_rebase': self.current_rebase, 351 | } 352 | with file(self.opts.state_filename('stack'), 'w') as f: 353 | json.dump(stack_data, f) 354 | 355 | def write_rebase_log(self, branch): 356 | # type: (str) -> None 357 | ref = cmd_output( 358 | ['git', 'rev-parse', '--symbolic-full-name', branch]).strip() 359 | current_commit_id = cmd_output( 360 | ['git', 'rev-parse', '--verify', branch]).strip() 361 | with file(self.opts.state_filename('rebase-log'), 'a') as f: 362 | f.write('%s %s\n' % (ref, current_commit_id)) 363 | 364 | def run(self): 365 | # type: () -> None 366 | while True: 367 | #self.opts.v_out(repr( 368 | # (self.current_rebase, self.stack_simple, self.stack_full))) 369 | 370 | if self.current_rebase is not None: 371 | # Variant 0. Top of an `rbr_simple` call, at the rebase. 372 | onto, upstream, branch = self.current_rebase 373 | 374 | # Attempt rebase. 375 | self.opts.v_out('Rebasing: %s <- %s' % (onto, branch)) 376 | old_id = git_rev_parse(branch) 377 | if not self.opts.dry_run: 378 | self.write_stack() 379 | self.write_rebase_log(branch) 380 | with file(self.opts.state_filename('rebase-oldid'), 'w') as f: 381 | f.write('%s\n' % (old_id,)) 382 | one_rebase(onto, upstream, branch) 383 | 384 | # If we made it here, proceed to the loop. 385 | self.current_rebase = None 386 | self.stack_simple.append((branch, old_id, child_branches(branch))) 387 | 388 | elif self.stack_simple: 389 | # Variant 1. Top of the `rbr_simple` loop. 390 | branch, old_id, children = self.stack_simple[-1] 391 | if children: 392 | # Loop in `rbr_simple` not done yet. Recurse. 393 | child = children.pop() 394 | self.current_rebase = (branch, old_id, child) 395 | else: 396 | # Loop in `rbr_simple` done; frame returns. 397 | self.stack_simple.pop() 398 | 399 | elif self.stack_full: 400 | # Variant 2. Top of the `rbr_full` loop. 401 | onto, upstream, branch, children = self.stack_full[-1] 402 | if children: 403 | # Loop not done yet. Recurse. 404 | child = children.pop() 405 | self.stack_full.append((branch, branch, child, 406 | child_branches(child))) 407 | else: 408 | # Loop done. 409 | if not is_atop(onto, branch) or onto != upstream: 410 | # Enter an `rbr_simple` frame; Variant 0. 411 | self.stack_full.pop() 412 | self.current_rebase = (onto, upstream, branch) 413 | else: 414 | # Whole frame done. 415 | self.stack_full.pop() 416 | 417 | else: 418 | # Like Variant 2, but our whole outermost frame is done. 419 | break 420 | 421 | self.graph.return_to_original_branch() 422 | 423 | def start(self): 424 | # type: () -> None 425 | self.run() 426 | 427 | def resume(self, cmd): 428 | # type: (str) -> None 429 | assert cmd in ('continue', 'skip') 430 | 431 | assert self.current_rebase # TODO better error message 432 | # Variant 0 stack. 433 | 434 | # Resume the rebase. 435 | cmd_output(['git', 'rebase', '--'+cmd]) # TODO handle errors here 436 | 437 | # If that worked, proceed to the loop with a Variant 1 stack, 438 | # just like in run(). 439 | onto, upstream, branch = self.current_rebase 440 | old_id = self.opts.read_state_file('rebase-oldid') 441 | self.current_rebase = None 442 | self.stack_simple.append((branch, old_id, child_branches(branch))) 443 | self.run() 444 | 445 | 446 | def abort(opts, graph): 447 | # type: (Options, RebaseGraph) -> None 448 | stack_data = opts.try_read_state_file('stack') or '{}' 449 | if json.loads(stack_data).get('current_rebase'): 450 | opts.v_out('git rebase --abort') 451 | unchecked_output(['git', 'rebase', '--abort']) 452 | 453 | log = opts.try_read_state_file('rebase-log') 454 | for line in reversed(log.split('\n')) if log else []: 455 | ref, commit_id = line.rsplit(' ', 1) 456 | opts.v_out('Resetting: %s %s' % (ref, commit_id)) 457 | cmd_output( 458 | ['git', 'update-ref', '-m', 'rebase-recursive: aborting', ref, commit_id]) 459 | 460 | graph.return_to_original_branch() 461 | 462 | 463 | def main(): 464 | if len(sys.argv) != 2: 465 | exit_usage() 466 | cmd = sys.argv[1] 467 | 468 | opts = Options(os.environ['GIT_DIR']) 469 | 470 | graph = RebaseGraph(opts) 471 | 472 | if cmd == 'init': 473 | if not graph.check_graph(): 474 | sys.exit(2) 475 | machine = RebaseRecursiveMachine.create_initial(opts, graph) 476 | machine.start() 477 | elif cmd in ('continue', 'skip'): 478 | machine = RebaseRecursiveMachine.create_resume(opts, graph) 479 | machine.resume(cmd) 480 | elif cmd == 'abort': 481 | abort(opts, graph) 482 | else: 483 | exit_usage() 484 | 485 | 486 | if __name__ == '__main__': 487 | main() 488 | -------------------------------------------------------------------------------- /t/test_rbr.py: -------------------------------------------------------------------------------- 1 | # Usage: 2 | # $ py.test 3 | 4 | import os.path 5 | import pytest 6 | import re 7 | import shutil 8 | import subprocess 9 | from subprocess import check_output, check_call 10 | import tempfile 11 | 12 | 13 | @pytest.fixture 14 | def repo(): 15 | # type: () -> str 16 | d = tempfile.mkdtemp(prefix='tmp.test-git-rbr.') 17 | git_dir = os.environ.get('GIT_DIR') 18 | os.environ['GIT_DIR'] = os.path.join(d, '.git') 19 | cwd = os.getcwd() 20 | os.chdir(d) 21 | check_call(['git', 'init', d]) 22 | 23 | this_dir = os.path.dirname(os.path.realpath(__file__)) 24 | rbr_root = os.path.dirname(this_dir) 25 | os.environ['PATH'] = os.pathsep.join([rbr_root, os.environ['PATH']]) 26 | 27 | return d 28 | 29 | # Pytest 2.6.1 which I have handy doesn't support this. Forget it for now. 30 | # yield d 31 | 32 | os.chdir(cwd) 33 | if git_dir is None: 34 | del os.environ['GIT_DIR'] 35 | else: 36 | os.environ['GIT_DIR'] = git_dir 37 | shutil.rmtree(d) 38 | 39 | 40 | def shell(cmds): 41 | # type: (str) -> None 42 | try: 43 | check_output(['sh', '-ec', cmds]) 44 | except subprocess.CalledProcessError as e: 45 | cmds_fmtd = re.sub('^', ' ', cmds.strip('\n'), flags=re.M) + '\n' 46 | raise RuntimeError('Shell commands exited with code %s:\n%s' 47 | % (e.returncode, cmds_fmtd)) 48 | 49 | 50 | def expect_error(desc, signature, cmd): 51 | # type: (str, str, str) -> None 52 | '''Expect `cmd` to fail with a matching message.''' 53 | try: 54 | out = check_output(cmd, stderr=subprocess.STDOUT) 55 | print out 56 | raise RuntimeError('Expected %s; none happened' % (desc,)) 57 | except subprocess.CalledProcessError as e: 58 | if signature not in e.output: 59 | print e.output 60 | raise RuntimeError('Expected %s; got different message' % (desc,)) 61 | 62 | 63 | def expect_conflict(cmd): 64 | # type: (str) -> None 65 | expect_error('conflict', 'git rbr --continue', cmd) 66 | 67 | 68 | def setup_shell(cmds): 69 | # type: (str) -> None 70 | preamble = ''' 71 | testci () { 72 | # args: message [filename [contents]] 73 | # optional args default to message 74 | mkdir -p "$(dirname "${2:-$1}")" && 75 | echo "${3:-$1}" >"${2:-$1}" && 76 | git add "${2:-$1}" && 77 | git commit -m "$1" 78 | } 79 | ''' 80 | shell(preamble + cmds) 81 | 82 | 83 | def describe_for_error(commitish): 84 | return check_output( 85 | ['git', 'log', '-n1', '--pretty=format:%h \'%s\'%d', commitish]) 86 | 87 | 88 | def show_for_error(revlist_args): 89 | return check_output( 90 | ['git', 'log', '--oneline', '--graph', '--decorate', '--boundary'] 91 | + revlist_args) 92 | 93 | 94 | def show_repo_for_error(): 95 | return show_for_error(['--all', 'HEAD']) 96 | 97 | 98 | def show_range_for_error(revrange): 99 | return show_for_error([revrange]) 100 | 101 | 102 | def range_subjects(revrange): 103 | # type: (str) -> None 104 | return check_output(['git', 'log', '--pretty=format:%s', '--reverse', 105 | revrange]).strip('\n').split('\n') 106 | # '%s..%s' % (upstream, branch) 107 | 108 | 109 | def all_branches(): 110 | # type: () -> Set[str] 111 | return set(check_output( 112 | ['git', 'for-each-ref', '--format=%(refname:short)', 'refs/heads/'] 113 | ).strip().split('\n')) 114 | 115 | 116 | class RepoError(RuntimeError): 117 | def __init__(self, msg): 118 | super(RepoError, self).__init__( 119 | msg.rstrip('\n') + '\n' + show_repo_for_error() 120 | ) 121 | 122 | 123 | def assert_range_subjects(revrange, subjects): 124 | # type: (str, str) -> None 125 | assert ' '.join(range_subjects(revrange)) == subjects 126 | 127 | 128 | def assert_atop(upstream, branch): 129 | if '0' != check_output( 130 | ['git', 'rev-list', '--count', '--max-count=1', 131 | upstream, '--not', branch, '--']).strip(): 132 | raise RepoError('Commit %s not atop %s:' % (branch, upstream,)) 133 | 134 | 135 | def assert_updated(branches=None): 136 | '''Assert each branch is atop its upstream. If None, all branches but master.''' 137 | if branches is None: 138 | branches = all_branches() - set(['master']) 139 | 140 | for branch in branches: 141 | assert_atop(branch+'@{u}', branch) 142 | 143 | 144 | def assert_linear(upstream, branch, subjects): 145 | # type: (str, str, str) -> None 146 | '''Assert upstream..branch is a linear history with the given subjects. 147 | 148 | `subjects` is a space-separated list. 149 | ''' 150 | 151 | revrange = '%s..%s' % (upstream, branch) 152 | if any(len(line.split(' ')) != 2 153 | for line in check_output(['git', 'log', '--pretty=format:%h %p', 154 | revrange]).strip('\n').split('\n')): 155 | raise RepoError('Range %s not linear: has merge commit:' 156 | % (revrange,)) 157 | 158 | assert_atop(upstream, branch) 159 | 160 | assert_range_subjects(revrange, subjects) 161 | 162 | 163 | def branch_values(branches=None): 164 | # type: (Optional[List[str]]) -> Dict[str, str] 165 | '''Returns the commit ID of each branch. If None, all branches.''' 166 | data = check_output(['git', 'for-each-ref', 167 | '--format=%(refname:short) %(objectname)', 'refs/heads/']) 168 | all_values = { 169 | branch: value 170 | for line in data.strip('\n').split('\n') 171 | for branch, value in (line.split(' '),) 172 | } 173 | if branches is None: 174 | return all_values 175 | return {branch: all_values[branch] for branch in branches} 176 | 177 | 178 | @pytest.fixture 179 | def repo_tree(repo): 180 | # master <- a <- b <- c 181 | # <- d 182 | # master, a, b advanced 183 | # no conflicts 184 | setup_shell(''' 185 | testci master 186 | git checkout -qtb a 187 | testci a 188 | git checkout -qtb b 189 | testci b 190 | git checkout -qtb c 191 | testci c 192 | git checkout a -qtb d 193 | testci d 194 | git checkout -q master 195 | testci master2 196 | git checkout -q a 197 | testci a2 198 | git checkout -q b 199 | testci b2 200 | git checkout -q a 201 | ''') 202 | 203 | 204 | def test_tree(repo_tree): 205 | setup_shell('git checkout a') 206 | check_call(['git', 'rbr', '-v']) 207 | assert_updated() 208 | assert_linear('master^', 'c', 'master2 a a2 b b2 c') 209 | assert_linear('a^', 'd', 'a2 d') 210 | 211 | 212 | def test_safety_checks(repo_tree): 213 | setup_shell('git checkout a') 214 | setup_shell('git tag t b') 215 | expect_error('non-branch error', 'are not branches', ['git', 'rbr', '-v']) 216 | setup_shell('git tag -d t') 217 | 218 | setup_shell('git branch b --unset-upstream') 219 | expect_error('unset-upstream error', 'have no upstream set', ['git', 'rbr', '-v']) 220 | setup_shell('git branch b -u master') 221 | expect_error('wild-upstream error', 'upstream pointing outside', 222 | ['git', 'rbr', '-v']) 223 | setup_shell('git branch b -u c') 224 | expect_error('upstream-cycle error', 'are in a cycle', ['git', 'rbr', '-v']) 225 | setup_shell('git branch b -u a') 226 | 227 | check_call(['git', 'rbr', '-v']) 228 | assert_updated() 229 | 230 | 231 | @pytest.fixture 232 | def repo_conflicted(repo): 233 | # master <- a <- b <- ab <- c 234 | # master, a advanced 235 | # a, ab conflict 236 | setup_shell(''' 237 | testci master 238 | git checkout -qtb a 239 | testci a 240 | git checkout -qtb b 241 | testci b 242 | git checkout -qtb ab 243 | testci ab a 244 | git checkout -qtb c 245 | testci c 246 | git checkout -q master 247 | testci master2 248 | git checkout -q a 249 | testci aa a 250 | ''') 251 | 252 | 253 | def test_continue(repo_conflicted): 254 | setup_shell('git checkout a') 255 | expect_conflict(['git', 'rbr', '-v']) 256 | check_call(['git', 'add', '-u']) 257 | check_call(['git', 'rbr', '--continue']) 258 | assert_updated() 259 | assert_linear('master^', 'c', 'master2 a aa b ab c') 260 | 261 | 262 | def test_skip(repo_conflicted): 263 | setup_shell('git checkout a') 264 | expect_conflict(['git', 'rbr', '-v']) 265 | check_call(['git', 'rbr', '--skip']) 266 | assert_updated() 267 | assert_linear('master^', 'c', 'master2 a aa b c') 268 | 269 | 270 | def test_abort(repo_conflicted): 271 | setup_shell('git checkout a') 272 | before = branch_values() 273 | expect_conflict(['git', 'rbr', '-v']) 274 | check_call(['git', 'rbr', '--abort']) 275 | assert before == branch_values() 276 | --------------------------------------------------------------------------------