├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── git-sync-changes └── tests └── test-git-sync-changes.sh /.gitignore: -------------------------------------------------------------------------------- 1 | *~ -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | We'd love to accept your patches and contributions to this project. There are 4 | just a few small guidelines you need to follow. 5 | 6 | ## Contributor License Agreement 7 | 8 | Contributions to this project must be accompanied by a Contributor License 9 | Agreement. You (or your employer) retain the copyright to your contribution, 10 | this simply gives us permission to use and redistribute your contributions as 11 | part of the project. Head over to to see 12 | your current agreements on file or to sign a new one. 13 | 14 | You generally only need to submit a CLA once, so if you've already submitted one 15 | (even if it was for a different project), you probably don't need to do it 16 | again. 17 | 18 | ## Code reviews 19 | 20 | All submissions, including submissions by project members, require review. We 21 | use GitHub pull requests for this purpose. Consult 22 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more 23 | information on using pull requests. 24 | -------------------------------------------------------------------------------- /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 | 204 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Collaborative editing for git repositories 2 | 3 | This repository implements shared worktrees for git. 4 | 5 | This is done by storing the worktree state in the repository itself. 6 | 7 | The result is that anyone with push permissions on the repository 8 | can sync their local worktree with that shared worktree, and then 9 | view and edit pending changes prior to committing them. 10 | 11 | ## Disclaimer 12 | 13 | This is not an official Google product. 14 | 15 | ## Installation 16 | 17 | To install, simply copy the `git-sync-changes` script to somewhere 18 | in your PATH. 19 | 20 | ## Usage 21 | 22 | The shared worktree functionality is implemented with a new git 23 | command called `git-sync-changes`. Running that command will sync 24 | pending changes between your local worktree and the shared worktree, 25 | leaving the two in the same state. 26 | 27 | The command takes two optional parameters, the remote repository 28 | storing the shared worktree, and the name of that tree. 29 | 30 | For example: 31 | 32 | ```sh 33 | git sync-changes origin shared-work 34 | ``` 35 | 36 | will sync your local worktree with the worktree named `shared-work` 37 | in the remote named `origin`. 38 | 39 | If the worktree name is not specified, the tool will default to 40 | a worktree named after the current user and the current checked-out 41 | branch. 42 | 43 | If the remote is not specified, then it defaults to `origin`. 44 | 45 | Each invocation of the command only performs a single sync, so to 46 | keep your worktree continuously updated, run it periodically. 47 | 48 | For instance, you could run the command in a loop with something 49 | like: 50 | 51 | ```sh 52 | ( while [ 1 ]; do 53 | git sync-changes 54 | sleep 30 55 | done) 56 | ``` 57 | -------------------------------------------------------------------------------- /git-sync-changes: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Copyright 2017 Google Inc. All rights reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 6 | # in compliance with the License. You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software distributed under the License 11 | # is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express 12 | # or implied. See the License for the specific language governing permissions and limitations under 13 | # the License. 14 | 15 | current_branch() { 16 | git symbolic-ref HEAD 17 | } 18 | 19 | current_head() { 20 | git show-ref --heads $(current_branch) | cut -d ' ' -f 1 21 | } 22 | 23 | current_user() { 24 | user_email=$(git config --get user.email) 25 | echo "${user_email:-${USER}}" 26 | } 27 | 28 | remote="${1:-origin}" 29 | client_name="${2:-$(current_user)/$(current_branch)}" 30 | 31 | check_remote() { 32 | git remote get-url "${remote}" 2>/dev/null >&2 && return 0 33 | echo "Remote ${remote} does not exist... nothing to sync" >&2 ; return 1 34 | } 35 | 36 | if [ -z "$(current_head)" ]; then 37 | echo "Empty repository; nothing to do" >&2 38 | exit 0 39 | fi 40 | check_remote || exit 0 41 | 42 | local_commits_ref() { 43 | echo "refs/synced_client/${client_name}" 44 | } 45 | 46 | remote_commits_ref() { 47 | echo "refs/synced_remote_client/${remote}/${client_name}" 48 | } 49 | 50 | local_changes_ref() { 51 | echo "refs/synced_changes/${client_name}" 52 | } 53 | 54 | remote_changes_ref() { 55 | echo "refs/synced_remote_changes/${remote}/${client_name}" 56 | } 57 | 58 | pull_remote_commit() { 59 | git fetch "${remote}" "+$(local_commits_ref):$(remote_commits_ref)" 2>/dev/null >&2 60 | git fetch "${remote}" "+$(local_changes_ref):$(remote_changes_ref)" 2>/dev/null >&2 61 | 62 | remote_commit="$(git show-ref $(remote_commits_ref) | cut -d ' ' -f 1)" 63 | if [ -z "${remote_commit}" ]; then 64 | # There is no remote commit to merge 65 | return 0 66 | fi 67 | 68 | local_commit="$(git show-ref $(local_commits_ref) | cut -d ' ' -f 1)" 69 | if [ -z "${local_commit}" ]; then 70 | if [ -n "$(git status --porcelain)" ]; then 71 | # There are local changes that we do not know how to sync 72 | echo "Changes to the local client conflict with the remote client" >&2 73 | return 1 74 | fi 75 | git update-ref "$(local_commits_ref)" "$(remote_commits_ref)" 2>/dev/null >&2 76 | git update-ref "$(current_branch)" "${remote_commit}^1" 2>/dev/null >&2 77 | git checkout HEAD 2>/dev/null >&2 78 | return 0 79 | fi 80 | 81 | git merge-base --is-ancestor "${remote_commit}" "${local_commit}" 2>/dev/null >&2 82 | if [ "$?" == "0" ]; then 83 | # We have already merged the remote commits 84 | return 0 85 | fi 86 | 87 | git merge-base --is-ancestor "${local_commit}" "${remote_commit}" 2>/dev/null >&2 88 | if [ "$?" == "0" ]; then 89 | git update-ref "$(local_commits_ref)" "$(remote_commits_ref)" 2>/dev/null >&2 90 | git update-ref "$(current_branch)" "${remote_commit}^1" 2>/dev/null >&2 91 | git checkout HEAD 2>/dev/null >&2 92 | if [ -n "$(git status --porcelain)" ]; then 93 | # There are obsolete local changes that we do need to clear out 94 | git reset HEAD ./ 2>/dev/null >&2 95 | git checkout -- ./ 2>/dev/null >&2 96 | for file in `git status --porcelain | grep '??' | cut -d ' ' -f 2`; do 97 | rm "${file}" 98 | done 99 | fi 100 | return 0 101 | fi 102 | 103 | # We have conflicting updates to the client, and the user must manually fix them 104 | echo "Conflicting local and remote clients" >&2 105 | return 1 106 | } 107 | 108 | # Save the current, committed state of the local client. 109 | # 110 | # The resulting commit forms a history of every commit (including rebases) 111 | # that the local client has made to the current branch since that local 112 | # branch was created. 113 | save_current_commit() { 114 | previous_commits="$(git show-ref $(local_commits_ref) | cut -d ' ' -f 1)" 115 | committed_tree=$(git cat-file -p "$(current_branch)" | head -n 1 | cut -d ' ' -f 2) 116 | if [ -z "${previous_commits}" ]; then 117 | merged_commit=$(git commit-tree -p "$(current_branch)" -m "Save local commits" "${committed_tree}") 118 | git update-ref "$(local_commits_ref)" "${merged_commit}" 2>/dev/null >&2 119 | fi 120 | git merge-base --is-ancestor "$(current_branch)" "$(local_commits_ref)" 2>/dev/null >&2 121 | if [ "$?" != "0" ]; then 122 | # The local branch has been updated since we last saved the committed state. 123 | # We need to update the commits ref to include this change in our history. 124 | merged_commit=$(git commit-tree -p "$(current_branch)" -p "$(local_commits_ref)" -m "Save local commits" "${committed_tree}") 125 | git update-ref "$(local_commits_ref)" "${merged_commit}" 2>/dev/null >&2 126 | fi 127 | } 128 | 129 | push_current_commit() { 130 | remote_commit="$(git show-ref $(remote_commits_ref) | cut -d ' ' -f 1)" 131 | result="1" 132 | if [ -n "${remote_commit}" ]; then 133 | git push "${remote}" --force-with-lease="$(local_commits_ref):${remote_commit}" "$(local_commits_ref):$(local_commits_ref)" 2>/dev/null >&2 134 | result="$?" 135 | else 136 | git push "${remote}" "$(local_commits_ref):$(local_commits_ref)" 2>/dev/null >&2 137 | result="$?" 138 | fi 139 | if [ "${result}" != "0" ]; then 140 | echo "Failed to push the committed local client state to the remote" >&2 141 | return 1 142 | fi 143 | } 144 | 145 | # Make the local file system match the file tree in the commit at $(local_changes_ref). 146 | # 147 | # This is the logical inverse of `save_changes` 148 | replay_changes() { 149 | maindir=$(pwd) 150 | tempdir=$(mktemp -d 2>/dev/null || mktemp -d -t 'sync-changes') 151 | git worktree add --no-checkout "${tempdir}" 2>/dev/null >&2 152 | cd "${tempdir}" 153 | tempbranch="$(current_branch)" 154 | 155 | git checkout "$(local_changes_ref)" 2>/dev/null >&2 156 | find . -not -path './.git/*' -and -not -name '.git' -and -type d -exec mkdir -p "${maindir}/{}" \; 157 | find . -not -path './.git/*' -and -not -name '.git' -and -not -type d -exec cp "${tempdir}/{}" "${maindir}/{}" \; 158 | 159 | cd "${maindir}" 160 | find . -not -path './.git/*' -and -not -name '.git' -and -not -type d -exec bash -c "if [ ! -e '${tempdir}/{}' ] && [ -z \"$(git check-ignore {})\" ]; then rm '{}'; fi" \; 161 | rm -rf "${tempdir}" 162 | git update-ref -d "${tempbranch}" 2>/dev/null >&2 163 | git worktree prune 2>/dev/null >&2 164 | } 165 | 166 | # Merge saved changes from the remote to our local changes. 167 | # 168 | # This method enforces the following constraints; after the method returns. 169 | # 1. The commit at $(local_changes_ref) (if it exists) contains all 170 | # changes that were made either locally or remotely after the branch was 171 | # changed to its current value. 172 | # 2. The local client's files (except for ignored files) match the tree 173 | # in the commit at $(local_changes_ref) (if it exists). 174 | merge_remote_changes() { 175 | local_ref="$(local_changes_ref)" 176 | remote_ref="$(remote_changes_ref)" 177 | local_commit="$(git show-ref ${local_ref} | cut -d ' ' -f 1)" 178 | remote_commit="$(git show-ref ${remote_ref} | cut -d ' ' -f 1)" 179 | 180 | if [ -z "${remote_commit}" ]; then 181 | # There are no remote changes to merge. 182 | return 0 183 | fi 184 | 185 | git merge-base --is-ancestor "$(local_commits_ref)" "${remote_commit}" 2>/dev/null >&2 186 | if [ "$?" != "0" ]; then 187 | # The remote changes are out of date, so do not pull them down. 188 | # (But still allow our local, up to date changes to be pushed back) 189 | return 0 190 | fi 191 | 192 | if [ -z "${local_commit}" ]; then 193 | # We have no local modifications, so copy the remote ones as-is 194 | git update-ref "${local_ref}" "${remote_commit}" 2>/dev/null >&2 195 | diff="$(git diff $(local_commits_ref)..${remote_commit})" 196 | if [ -n "${diff}" ]; then 197 | echo "${diff}" | git apply -- 198 | fi 199 | return 1 200 | elif [ "${local_commit}" == "${remote_commit}" ]; then 201 | # Everything is already in sync. 202 | return 1 203 | fi 204 | 205 | if [ -n "${local_commit}" ]; then 206 | merge_base="$(git merge-base ${local_ref} ${remote_ref})" 207 | if [ "${remote_commit}" == "${merge_base}" ]; then 208 | # The remote changes have already been included in our local changes. 209 | # All that is left is for us to potentially push the local changes. 210 | return 0 211 | fi 212 | fi 213 | 214 | # Create a temporary directory in which to perform the merge 215 | maindir=$(pwd) 216 | tempdir=$(mktemp -d 2>/dev/null || mktemp -d -t 'sync-changes') 217 | git worktree add "${tempdir}" 2>/dev/null >&2 218 | cd "${tempdir}" 219 | 220 | # Perform the merge, favoring our changes in the case of conflicts, and 221 | # update the local ref. 222 | if [ -n "${local_commit}" ]; then 223 | git merge --ff -s recursive -X theirs "${local_ref}" 2>/dev/null >&2 224 | fi 225 | git merge --ff -s recursive -X ours "${remote_ref}" 2>/dev/null >&2 226 | git add ./ 227 | git commit -a -m "Merge remote changes" 2>/dev/null >&2 228 | tempbranch="$(current_branch)" 229 | git update-ref "${local_ref}" "${tempbranch}" 2>/dev/null >&2 230 | 231 | # Cleanup post merge 232 | cd "${maindir}" 233 | rm -rf "${tempdir}" 234 | git update-ref -d "${tempbranch}" 235 | git worktree prune 236 | 237 | # Copy any remote changes to our working dir 238 | replay_changes 239 | return 0 240 | } 241 | 242 | push_local_changes() { 243 | local_ref="$(local_changes_ref)" 244 | remote_ref="$(remote_changes_ref)" 245 | remote_commit="$(git show-ref ${remote_ref} | cut -d ' ' -f 1)" 246 | 247 | if [ -z "$(git show-ref ${local_ref})" ]; then 248 | # We have reset our history locally and not retrieved any up-to-date history from 249 | # the remote, so reset the change history on the remote 250 | git push "${remote}" --force-with-lease="${local_ref}:${remote_commit}" --delete "${local_ref}" 2>/dev/null >&2 251 | git update-ref -d "${remote_ref}" 252 | return 0 253 | fi 254 | 255 | git push "${remote}" --force-with-lease="${local_ref}:${remote_commit}" "${local_ref}:${local_ref}" 2>/dev/null >&2 || return 0 256 | } 257 | 258 | # Create an undo-buffer-like commit of the local changes. 259 | # 260 | # This differs from `git stash` in that multiple changes can 261 | # be chained together. 262 | # 263 | # The resulting commit is stored in $(local_changes_ref) 264 | # 265 | # This method enforces two constraints; after the method returns: 266 | # 1. The contents of the local client's files (other than ignored files) 267 | # matches the tree of the commit stored at $(local_changes_ref), 268 | # if it exists. 269 | # 2. The history of the commit stored at $(local_changes_ref), 270 | # if it exists, includes every change that was saved since HEAD 271 | # was changed to its current value. 272 | save_changes() { 273 | saved_changes_commit="$(git show-ref $(local_changes_ref) | cut -d ' ' -f 1)" 274 | if [ -n "${saved_changes_commit}" ]; then 275 | git merge-base --is-ancestor "$(local_commits_ref)" "$(local_changes_ref)" 2>/dev/null >&2 276 | if [ "$?" != "0" ]; then 277 | # The local branch has been updated since our last save. We need 278 | # to clear out the (now obsolete) saved changes. 279 | git update-ref -d "$(local_changes_ref)" 280 | saved_changes_commit="" 281 | fi 282 | fi 283 | 284 | current_changes="$(git status --porcelain)" 285 | if [ -z "${saved_changes_commit}" ] && [ -z "${current_changes}" ]; then 286 | # We have neither local modifications nor previously saved changes 287 | return 0 288 | fi 289 | if [ -z "${current_changes}" ]; then 290 | # We undid previous changes, so we need to create a commit to record that 291 | changes_tree=$(git cat-file -p "$(local_commits_ref)" | head -n 1 | cut -d ' ' -f 2) 292 | changes_commit=$(git commit-tree -p "${saved_changes_commit}" -m "Save local changes" "${changes_tree}") 293 | git update-ref "$(local_changes_ref)" "${changes_commit}" 2>/dev/null >&2 294 | return 0 295 | fi 296 | 297 | if [ -z "${saved_changes_commit}" ]; then 298 | saved_changes_commit="$(local_commits_ref)" 299 | fi 300 | 301 | # Create a temporary directory in which to create the changes commit 302 | maindir=$(pwd) 303 | tempdir=$(mktemp -d 2>/dev/null || mktemp -d -t 'sync-changes') 304 | git worktree add --no-checkout "${tempdir}" 2>/dev/null >&2 305 | find . -not -path './.git/*' -and -not -name '.git' -and -type d -exec mkdir -p "${tempdir}/{}" \; 306 | find . -not -path './.git/*' -and -not -name '.git' -and -not -type d -exec cp "{}" "${tempdir}/{}" \; 307 | cd "${tempdir}" 308 | tempbranch="$(current_branch)" 309 | git add ./ 310 | 311 | if [ -n "$(git diff ${saved_changes_commit} -- ./)" ]; then 312 | # We have changes since the last time we saved. 313 | git commit -a -m "Save local changes" 2>/dev/null >&2 314 | changes_tree=$(git cat-file -p "${tempbranch}" | head -n 1 | cut -d ' ' -f 2) 315 | changes_commit=$(git commit-tree -p "${saved_changes_commit}" -m "Save local changes" "${changes_tree}") 316 | git update-ref "$(local_changes_ref)" "${changes_commit}" 2>/dev/null >&2 317 | fi 318 | 319 | # Cleanup post merge 320 | cd "${maindir}" 321 | rm -rf "${tempdir}" 322 | git update-ref -d "${tempbranch}" 323 | git worktree prune 324 | } 325 | 326 | pull_remote_commit || exit 1 327 | save_current_commit 328 | push_current_commit || exit 1 329 | save_changes 330 | merge_remote_changes || exit 0 331 | push_local_changes 332 | -------------------------------------------------------------------------------- /tests/test-git-sync-changes.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # 3 | # Copyright 2018 Google Inc. All rights reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except 6 | # in compliance with the License. You may obtain a copy of the License at 7 | # 8 | # http://www.apache.org/licenses/LICENSE-2.0 9 | # 10 | # Unless required by applicable law or agreed to in writing, software distributed under the License 11 | # is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express 12 | # or implied. See the License for the specific language governing permissions and limitations under 13 | # the License. 14 | 15 | # Set up our test directories and automatically cleanup on exit 16 | maindir=$(pwd) 17 | test_remote=$(mktemp -d 2>/dev/null || mktemp -d -t 'sync-changes') 18 | test_client_1=$(mktemp -d 2>/dev/null || mktemp -d -t 'sync-changes') 19 | test_client_2=$(mktemp -d 2>/dev/null || mktemp -d -t 'sync-changes') 20 | trap "{ cd ${maindir}; rm -rf ${test_remote}; rm -rf ${test_client_1}; rm -rf ${test_client_2}; }" EXIT 21 | 22 | sync_cmd="${maindir}/git-sync-changes" 23 | 24 | setup_repo() { 25 | echo "Setting up remote repository..." 26 | cd "${test_remote}" 27 | git init 2>/dev/null >&2 28 | echo "# Example README" >> README.md 29 | git add ./ 2>/dev/null >&2 30 | git commit -a -m 'Initial commit with a README file' 2>/dev/null >&2 31 | 32 | echo "Setting up the first test client..." 33 | cd "${test_client_1}" 34 | git init 2>/dev/null >&2 35 | git remote add origin "${test_remote}" 2>/dev/null >&2 36 | git fetch origin 2>/dev/null >&2 37 | git checkout -t "origin/master" 2>/dev/null >&2 38 | 39 | echo "Setting up the second test client..." 40 | cd "${test_client_2}" 41 | git init 2>/dev/null >&2 42 | git remote add origin "${test_remote}" 2>/dev/null >&2 43 | git fetch origin 2>/dev/null >&2 44 | git checkout -t "origin/master" 2>/dev/null >&2 45 | } 46 | 47 | test_initial_sync() { 48 | echo "Testing syncing a new repository..." 49 | cd "${test_client_1}" 50 | if [ "$(git ls-remote origin | grep 'refs/synced_client')" != "" ]; then 51 | echo $'\tinitial sync test run against an already initialized remote' 52 | return 1 53 | fi 54 | ${sync_cmd} || return 1 55 | 56 | cd "${test_client_2}" 57 | if [ "$(git ls-remote origin | grep 'refs/synced_client')" == "" ]; then 58 | echo $'\tinitial sync test failed to initialize the remote' 59 | return 1 60 | fi 61 | ${sync_cmd} || return 1 62 | 63 | user_email="$(git config --get user.email)" 64 | if [ "$(git show-ref refs/synced_remote_client/origin/${user_email}/refs/heads/master)" == "" ]; then 65 | echo $'\tinitial sync test failed to sync the remote commit' 66 | return 1 67 | else 68 | echo $'\tinitial sync test passed' 69 | fi 70 | } 71 | 72 | test_modified_file() { 73 | echo "Testing syncing a file modification..." 74 | cd "${test_client_1}" 75 | readme_txt="Additional text added to the README" 76 | echo "${readme_txt}" >> README.md 77 | ${sync_cmd} || return 1 78 | 79 | cd "${test_client_2}" 80 | ${sync_cmd} || return 1 81 | if [ "$(tail -n 1 README.md)" != "${readme_txt}" ]; then 82 | return 1 83 | else 84 | echo $'\tfile modification sync test passed' 85 | fi 86 | } 87 | 88 | test_new_file() { 89 | echo "Testing syncing a new file..." 90 | cd "${test_client_1}" 91 | second_readme_txt="Additional README" 92 | echo "${second_readme_txt}" >> README_2.md 93 | ${sync_cmd} || return 1 94 | 95 | cd "${test_client_2}" 96 | ${sync_cmd} || return 1 97 | if [ "$(cat README_2.md)" != "${second_readme_txt}" ]; then 98 | return 1 99 | else 100 | echo $'\tnew file sync test passed' 101 | fi 102 | } 103 | 104 | test_rollback_changes() { 105 | echo "Testing syncing a client with reverted changes..." 106 | cd "${test_client_1}" 107 | git reset HEAD ./ 2>/dev/null >&2 108 | git checkout -- ./ 2>/dev/null >&2 109 | for file in `git status --porcelain=1 | grep '??' | cut -d ' ' -f 2`; do 110 | rm "${file}" 111 | done 112 | ${sync_cmd} || return 1 113 | 114 | cd "${test_client_2}" 115 | ${sync_cmd} || return 1 116 | if [ -n "$(git status --porcelain=1)" ]; then 117 | git status 118 | ls -al 119 | return 1 120 | else 121 | echo $'\treverted changes sync test passed' 122 | fi 123 | } 124 | 125 | test_sync_then_commit() { 126 | echo "Testing syncing a file modification..." 127 | cd "${test_client_1}" 128 | readme_txt="Additional text added to the README" 129 | echo "${readme_txt}" >> README.md 130 | second_readme_txt="Additional README" 131 | echo "${second_readme_txt}" >> README_2.md 132 | ${sync_cmd} || return 1 133 | 134 | cd "${test_client_2}" 135 | ${sync_cmd} || return 1 136 | if [ "$(cat README_2.md)" != "${second_readme_txt}" ]; then 137 | return 1 138 | elif [ "$(tail -n 1 README.md)" != "${readme_txt}" ]; then 139 | return 1 140 | fi 141 | 142 | cd "${test_client_1}" 143 | git add ./ 144 | git commit -a -m 'Second commit' 2>/dev/null >&2 145 | ${sync_cmd} || return 1 146 | log=`git log` 147 | status=`git status` 148 | 149 | cd "${test_client_2}" 150 | ${sync_cmd} || return 1 151 | 152 | if [ "$(git log)" != "${log}" ]; then 153 | echo $'\t'"Log mismatch: '$(git log)' vs '${log}'" >&2 154 | return 1 155 | elif [ "$(git status)" != "${status}" ]; then 156 | echo $'\t'"Status mismatch: '$(git status)' vs '${status}'" >&2 157 | return 1 158 | else 159 | echo $'\tfile sync and then commit test passed' 160 | fi 161 | } 162 | 163 | test_filename_with_space() { 164 | echo "Testing syncing a file with space in its name" 165 | cd "${test_client_1}" 166 | third_readme_txt="Additional README with space in its name" 167 | echo "${third_readme_txt}" >> README\ 3.md 168 | ${sync_cmd} || return 1 169 | 170 | cd "${test_client_2}" 171 | ${sync_cmd} || return 1 172 | if [ "$(cat README\ 3.md)" != "${third_readme_txt}" ]; then 173 | return 1 174 | fi 175 | 176 | cd "${test_client_1}" 177 | git add ./ 178 | git commit -a -m 'Third commit' 2>/dev/null >&2 179 | ${sync_cmd} || return 1 180 | log=`git log` 181 | status=`git status` 182 | 183 | cd "${test_client_2}" 184 | ${sync_cmd} || return 1 185 | 186 | if [ "$(git log)" != "${log}" ]; then 187 | echo $'\t'"Log mismatch: '$(git log)' vs '${log}'" >&2 188 | return 1 189 | elif [ "$(git status)" != "${status}" ]; then 190 | echo $'\t'"Status mismatch: '$(git status)' vs '${status}'" >&2 191 | return 1 192 | else 193 | echo $'\tfilename with space sync test passed' 194 | fi 195 | } 196 | 197 | exit_with_message() { 198 | echo $'\t'"$1" 199 | exit 1 200 | } 201 | 202 | setup_repo 203 | test_initial_sync || exit_with_message "testing the initial sync failed" 204 | test_modified_file || exit_with_message "testing a modified file failed" 205 | test_new_file || exit_with_message "testing a new file failed" 206 | test_rollback_changes || exit_with_message "testing a rollback failed" 207 | test_sync_then_commit || exit_with_message "testing a sync and commit failed" 208 | test_filename_with_space || exit_with_message "testing a sync with a space in a file name failed" 209 | --------------------------------------------------------------------------------