├── .babelrc ├── .eslintrc ├── .gitignore ├── .npmignore ├── .travis.yml ├── HISTORY.md ├── LICENSE ├── README.md ├── package.json ├── scripts ├── download-uhub.sh ├── test-github.sh ├── test-no-api.sh └── test-uhub.sh ├── src ├── constants │ ├── changeType.js │ ├── errors.js │ └── filetype.js ├── drivers │ ├── driver.js │ └── github.js ├── index.js ├── models │ ├── author.js │ ├── blob.js │ ├── branch.js │ ├── cache.js │ ├── change.js │ ├── commit.js │ ├── commitBuilder.js │ ├── comparison.js │ ├── conflict.js │ ├── file.js │ ├── fileDiff.js │ ├── localFile.js │ ├── reference.js │ ├── repositoryState.js │ ├── treeConflict.js │ ├── treeEntry.js │ └── workingState.js └── utils │ ├── arraybuffer.js │ ├── base64.js │ ├── blob.js │ ├── branches.js │ ├── cache.js │ ├── change.js │ ├── commit.js │ ├── conflict.js │ ├── directory.js │ ├── error.js │ ├── file.js │ ├── filestree.js │ ├── gravatar.js │ ├── localFile.js │ ├── normalize.js │ ├── path.js │ ├── remote.js │ ├── repo.js │ ├── treeNode.js │ └── working.js ├── test ├── api.js ├── api │ ├── branch.js │ ├── commit.js │ ├── driver.js │ └── local.js ├── blob.js ├── changes.js ├── conflict.js ├── decoding.js ├── dir.js ├── file.js ├── filestree.js ├── mock.js ├── remote.js ├── repoUtils.js ├── repository.js └── workingState.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "es2015", "stage-2" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "gitbook", 3 | "env": { 4 | "mocha": true 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | 5 | # Runtime data 6 | pids 7 | *.pid 8 | *.seed 9 | 10 | # Directory for instrumented libs generated by jscoverage/JSCover 11 | lib-cov 12 | 13 | # Coverage directory used by tools like istanbul 14 | coverage 15 | 16 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 17 | .grunt 18 | 19 | # Compiled binary addons (http://nodejs.org/api/addons.html) 20 | build/Release 21 | 22 | # Dependency directory 23 | # Commenting this out is preferred by some people, see 24 | # https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git- 25 | node_modules 26 | 27 | # Users Environment Variables 28 | .lock-wscript 29 | 30 | test/_local 31 | .tmp 32 | uhub 33 | lib 34 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | !lib 2 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | language: node_js 3 | node_js: 4 | - "stable" 5 | before_install: 6 | - git config --global user.email "contact@gitbook.com" 7 | - git config --global user.name "GitBook Team" 8 | script: 9 | - npm run test 10 | - npm run lint 11 | -------------------------------------------------------------------------------- /HISTORY.md: -------------------------------------------------------------------------------- 1 | # Release notes 2 | All notable changes to this project will be documented in this file. 3 | This project adheres to [Semantic Versioning](http://semver.org/). 4 | 5 | ### 8.1.2 6 | 7 | - Fix branch forwarding when branch object has a remote. 8 | 9 | ### 8.1.1 10 | 11 | - Fix `RemoteUtils.pull` for a branch that doesn't exist locally 12 | 13 | ### 8.1.0 14 | 15 | - Add options `clean` and `cleanBase` on `BranchUtils.create` to transfer changes to the new branch 16 | 17 | ### 8.0.1 18 | 19 | - Fix corruption of files when using `FileUtils.move` on a modified/created file 20 | 21 | ### 8.0.0 22 | 23 | - **Breaking Change:** Signature of `Author.create` changed from `Author.create(name, email)` to `Author.create({ name, email })` 24 | - Branches have a `commit` property 25 | 26 | ### 7.9.0 27 | 28 | - Added `CommitUtils.fetchComparison` method 29 | - Added models for `FileDiff` and `Comparison` 30 | 31 | ### 7.8.0 32 | 33 | - Added `RepoUtils.isClean` method 34 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "{}" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright 2014 FriendCode Inc. 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # repofs 2 | 3 | [![NPM version](https://badge.fury.io/js/repofs.svg)](http://badge.fury.io/js/repofs) 4 | [![Build Status](https://travis-ci.org/GitbookIO/repofs.png?branch=master)](https://travis-ci.org/GitbookIO/repofs) 5 | 6 | This module provides a simple and unified API to manipulate Git repositories on GitHub. This module can be use in Node.JS and in the browser. 7 | 8 | It allows more complex operations than the [Contents API](https://developer.github.com/v3/repos/contents/) using the [Git Data API](https://developer.github.com/v3/git/). 9 | 10 | It is powered by an immutable model. Async operations are Promise-based. 11 | 12 | ## Installation 13 | 14 | ``` 15 | $ npm install repofs 16 | ``` 17 | 18 | ## How to use it? 19 | 20 | To use `repofs` in the browser, include it using browserify/webpack. 21 | 22 | ```js 23 | var repofs = require('repofs'); 24 | ``` 25 | 26 | Initialize a driver instance, a driver represents the communication layer between repofs and the real git repository. 27 | 28 | ```js 29 | var driver = repofs.GitHubDriver({ 30 | repository: 'MyUsername/myrepository', 31 | username: 'MyUsername', 32 | token: 'MyPasswordOrMyApiToken' 33 | }); 34 | ``` 35 | 36 | ### Start with an empty RepositoryState 37 | 38 | The first step is to create an instance of `RepositoryState`: 39 | 40 | ```js 41 | var repoState = repofs.RepositoryState.createEmpty(); 42 | ``` 43 | 44 | ### Fetch the list of branches 45 | 46 | After creating a `RepositoryState`, the next step is to fetch the list of existing branches. 47 | 48 | ``` js 49 | repofs.RepoUtils.fetchBranches(repoState, driver) 50 | .then(function (newRepoState) { 51 | var branches = newRepoState.getBranches(); // List 52 | ... 53 | }) 54 | ``` 55 | 56 | ### Checkout a branch 57 | 58 | Once the branches are fetched, you can checkout one. **This requires to fetch it first** using `repofs.RepoUtils.fetchTree`. This overrides any existing working tree for this branch. The `repofs.RepoUtils.checkout` operation is always sync. 59 | 60 | ```js 61 | var branch = repoState.getBranch('master'); 62 | 63 | repofs.RepoUtils.fetchTree(repoState, driver, branch) 64 | .then(function (repoState) { 65 | var checkoutState = repofs.RepoUtils.checkout(repoState, driver, branch); 66 | ... 67 | }) 68 | ``` 69 | 70 | ### Quick initialization 71 | 72 | There is a short way to initialize a `RepositoryState` from a driver, that will fetch the list of the branches, then fetch and checkout *master* or the first available branch. 73 | 74 | ``` js 75 | repofs.RepoUtils.initialize(driver) 76 | .then(function (repoState) { 77 | // repoState checked out on master 78 | ... 79 | }); 80 | ``` 81 | 82 | ### Reading files 83 | 84 | Reading a file requires to fetch the content from the remote repository inside the `RepositoryState` (See [Caching](#caching)): 85 | 86 | ```js 87 | repofs.WorkingUtil.fetchFile(repoState, driver, 'README.md') 88 | .then(function(newRepoState) { 89 | ... 90 | }) 91 | ``` 92 | 93 | Then the content can be accessed using sync methods: 94 | 95 | ```js 96 | // Read as a blob 97 | var blob = repofs.FileUtils.read(repoState, 'README.md'); 98 | 99 | // Read as a String 100 | var content = repofs.FileUtils.readAsString(repoState, 'README.md'); 101 | ``` 102 | 103 | ### Listing files 104 | 105 | repofs keeps the whole trees in the different `WorkingStates`, you can access the whole tree as a flat list... 106 | 107 | ```js 108 | var workingState = repoState.getCurrentState(); 109 | var treeEntries = workingState.getTreeEntries(); 110 | ``` 111 | 112 | ... or as an immutable tree structure (a `TreeNode`): 113 | 114 | ```js 115 | var dir = '.' // root 116 | var rootTree = repofs.TreeUtils.get(repoState, dir); 117 | ``` 118 | 119 | ### Working with files 120 | 121 | Create a new file: 122 | 123 | ```js 124 | var newRepoState = repofs.FileUtils.create(repoState, 'API.md'); 125 | ``` 126 | 127 | Write/Update the file 128 | 129 | ```js 130 | var newRepoState = repofs.FileUtils.write(repoState, 'API.md', 'content'); 131 | ``` 132 | 133 | Remove the file 134 | 135 | ```js 136 | var newRepoState = repofs.FileUtils.remove(repoState, 'API.md'); 137 | ``` 138 | 139 | Rename/Move the file 140 | 141 | ```js 142 | var newRepoState = repofs.FileUtils.move(repoState, 'API.md', 'API2.md'); 143 | ``` 144 | 145 | ### Working with directories 146 | 147 | List files in the directory 148 | 149 | ```js 150 | var pathList = repofs.DirUtils.read(repoState, 'myfolder'); 151 | ``` 152 | 153 | Remove the directory 154 | 155 | ```js 156 | var newRepoState = repofs.DirUtils.remove(repoState, 'myfolder'); 157 | ``` 158 | 159 | Rename/Move the directory 160 | 161 | ```js 162 | var newRepoState = repofs.DirUtils.move(repoState, 'myfolder', 'myfolder2'); 163 | ``` 164 | 165 | ### Changes 166 | 167 | Until being commited, repofs keeps a record of changes per files. 168 | 169 | Revert all non-commited changes using: 170 | 171 | ```js 172 | var newRepoState = repofs.ChangeUtils.revertAll(repoState); 173 | ``` 174 | 175 | Or revert changes for a specific file or directory: 176 | 177 | ```js 178 | // Revert change on a specific file 179 | var newRepoState = repofs.ChangeUtils.revertForFile(repoState, 'README.md'); 180 | 181 | // Revert change on a directory 182 | var newRepoState = repofs.ChangeUtils.revertForDir(repoState, 'src'); 183 | ``` 184 | 185 | ### Commiting changes 186 | 187 | ```js 188 | // Create an author / committer 189 | var john = repofs.Author.create('John Doe', 'john.doe@gmail.com'); 190 | 191 | // Create a CommitBuilder to define the commit 192 | var commitBuilder = repofs.CommitUtils.prepare(repoState, { 193 | author: john, 194 | message: 'Initial commit' 195 | }); 196 | 197 | // Flush commit using the driver 198 | repofs.CommitUtils.flush(repoState, driver, commitBuilder) 199 | .then(function(newRepoState) { 200 | // newRepoState updated with new working tree 201 | ... 202 | }); 203 | ``` 204 | 205 | ### Manipulating branches 206 | 207 | ``` js 208 | // Create a branch from current branch 209 | repofs.BranchUtils.create(repoState, driver, 'develop') 210 | .then(function (newRepoState) { 211 | var develop = newRepoState.getBranch('develop'); 212 | }); 213 | ``` 214 | 215 | ``` js 216 | // Remove a branch 217 | repofs.BranchUtils.remove(repoState, driver, branch) 218 | .then(function (newRepoState) { 219 | ... 220 | }); 221 | ``` 222 | 223 | ### Non fast forward commits 224 | 225 | Flushing a commit can fail with an `ERRORS.NOT_FAST_FORWARD` code. 226 | 227 | ```js 228 | // Flush commit using the driver 229 | repofs.CommitUtils.flush(repoState, driver, commitBuilder) 230 | .then(function success(newRepoState) { 231 | ... 232 | }, function failure(err) { 233 | // Catch non fast forward errors 234 | if(err.code !== repofs.ERRORS.NOT_FAST_FORWARD) { 235 | throw err; 236 | } 237 | ... 238 | }); 239 | ``` 240 | 241 | Non fast forward errors contains the created commit (that is currently not linked to any branch). This allows you to attempt to merge this commit back into the current branch: 242 | 243 | ```js 244 | ... function fail(err) { 245 | // Catch non fast forward errors 246 | if(err.code !== repofs.ERRORS.NOT_FAST_FORWARD) { 247 | throw err; 248 | } 249 | // The created commit 250 | var commit = err.commit; 251 | // Attempt automatic merge 252 | var from = commit.getSha(); 253 | var into = repoState.getCurrentBranch(); 254 | return repofs.BranchUtils.merge(repoState, driver, from, into) 255 | .then(function success(repoState) { 256 | ... 257 | }); 258 | } 259 | ``` 260 | 261 | ### Merging 262 | 263 | `repofs.BranchUtils.merge` allows to automatically merge a commit or a branch, into another branch. 264 | 265 | ``` js 266 | // from is either a Branch or a commit SHA string 267 | repofs.BranchUtils.merge(repoState, driver, from, into) 268 | .then(function success(repoState) { 269 | ... 270 | }); 271 | ``` 272 | 273 | ### Merge conflicts 274 | 275 | But conflicts can happen when the automatic merge failed. For example, after merging two branches, or after merging a non fast forward commit. It is possible then to solve the conflicts manually: 276 | 277 | ```js 278 | repofs.BranchUtils.merge(repoState, driver, from, into) 279 | .then(function success(repoState) { 280 | ... 281 | }, function failure(err) { 282 | // Catch merge conflict errors 283 | if(err.code !== repofs.ERRORS.CONFLICT) { 284 | throw err; 285 | } 286 | solveConflicts(repoState, driver, from, into) 287 | }); 288 | ``` 289 | 290 | The function `solveConflicts` would compute the `TreeConflict` representing all the conflicts between `from` and `into` references, solve it in some ways, and make a merge commit. Here is an example of such function: 291 | 292 | ``` js 293 | function solveConflicts(repoState, driver, from, into) { 294 | return repofs.ConflictUtils.compareRefs(driver, base, head) 295 | .then(function (treeConflict) { 296 | // Solve the list of conflicts in some way, for example by 297 | // asking a user to do it manually. 298 | var solvedConflicts // Map 299 | = solve(treeConflict.getConflicts()); 300 | 301 | // Create a solved conflict tree 302 | var solvedTreeConflict // TreeConflict 303 | = repofs.ConflictUtils.solveTree(treeConflict, solvedConflicts); 304 | 305 | // The SHAs of the parent commits 306 | var parentShas = [from.getSha(), into.getSha()]; 307 | // Create the merge commit 308 | var commitBuilder = repofs.ConflictUtils.mergeCommit(solvedTreeConflict, parents); 309 | 310 | // Flush it on the target branch 311 | return repofs.CommitUtils.flush(repoState, driver, commitBuilder, { 312 | branch: into 313 | }); 314 | }); 315 | } 316 | ``` 317 | 318 | ### Remotes operations 319 | 320 | When using a compatible API, you can also deal with remotes on the repository. 321 | 322 | #### List remotes 323 | 324 | ``` js 325 | repofs.RemoteUtils.list(driver) 326 | .then(function (remotes) { 327 | // remotes is an Array of remote: 328 | // { 329 | // name, 330 | // url 331 | // } 332 | }); 333 | ``` 334 | 335 | #### Edit remotes 336 | 337 | ``` js 338 | repofs.RemoteUtils.edit(driver, name, url) 339 | .then(function () { 340 | // Remote edited 341 | }); 342 | ``` 343 | 344 | #### Pulling 345 | 346 | You can update a branch to the state of the same branch on a remote, and get an updated `RepositoryState` with: 347 | 348 | ``` js 349 | var master = repoState.getBranch('master'); 350 | var remote = { 351 | name: 'origin' 352 | }; 353 | 354 | repofs.RemoteUtils.pull(repoState, driver, { 355 | branch: master, 356 | remote: remote, 357 | auth: { 358 | username: Shakespeare, 359 | password: 'f00lish wit' 360 | } 361 | }) 362 | .then(function (newRepoState) { 363 | ... 364 | }) 365 | ``` 366 | 367 | #### Pushing 368 | 369 | You can push a branch to a remote: 370 | 371 | ```js 372 | var master = repoState.getBranch('master'); 373 | var remote = { 374 | name: 'origin' 375 | }; 376 | 377 | repofs.RemoteUtils.push(repoState, driver, { 378 | branch: master, 379 | remote: remote, 380 | auth: { 381 | username: Shakespeare, 382 | password: 'f00lish wit' 383 | } 384 | }) 385 | .then(function () { 386 | // Pushed 387 | }) 388 | ``` 389 | 390 | ## Contributing 391 | 392 | ### Run tests 393 | 394 | You can run all the tests by providing a `GITHUB_TOKEN` with permission to create and write to a GitHub repository, and running `npm run test`. 395 | 396 | You can run tests with GitHub as a backend `npm run test-github`, or Uhub as a backend `npm run test-uhub`. 397 | 398 | Finally, you can run the tests without testing the API through the drivers by running `npm run test-no-api`. 399 | 400 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "repofs", 3 | "version": "8.1.2", 4 | "main": "lib/index.js", 5 | "author": "GitBook ", 6 | "description": "Simple and unified API to manipulate Git repositories", 7 | "bugs": "https://github.com/GitbookIO/repofs/issues", 8 | "license": "Apache-2.0", 9 | "engines": { 10 | "node": ">=0.12", 11 | "uhub": "3.2.1" 12 | }, 13 | "dependencies": { 14 | "array-flatten": "^2.1.0", 15 | "axios": "^0.8.0", 16 | "eslint-config-gitbook": "1.5.0", 17 | "immutable": "^3.7.6", 18 | "is": "^3.2.0", 19 | "md5-hex": "^1.1.0", 20 | "mime-types": "^2.1.1", 21 | "modify-values": "^1.0.0", 22 | "q": "^1.3.0", 23 | "unique-by": "^1.0.0", 24 | "urljoin.js": "^0.1.0" 25 | }, 26 | "devDependencies": { 27 | "babel-cli": "^6.18.0", 28 | "babel-core": "^6.18.2", 29 | "babel-preset-es2015": "^6.18.0", 30 | "babel-preset-stage-2": "^6.22.0", 31 | "eslint": "^3.1.1", 32 | "github-releases": "0.3.2", 33 | "lodash": "^4.13.1", 34 | "mocha": "2.4.5", 35 | "should": "8.2.2" 36 | }, 37 | "scripts": { 38 | "prepublish": "babel ./src --out-dir ./lib", 39 | "lint": "eslint ./src ./test", 40 | "test": "./scripts/test-uhub.sh && ./scripts/test-github.sh", 41 | "test-no-api": "./scripts/test-no-api.sh", 42 | "test-uhub": "./scripts/test-uhub.sh", 43 | "test-github": "./scripts/test-github.sh" 44 | }, 45 | "repository": { 46 | "type": "git", 47 | "url": "https://github.com/GitbookIO/repofs.git" 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /scripts/download-uhub.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -eo pipefail 3 | IFS=$'\n\t' 4 | 5 | UHUB_VERSION=$(node -pe "require('./package.json').engines.uhub") 6 | UHUB_SCRIPT=./.tmp/uhub-$UHUB_VERSION 7 | 8 | echo "Downloading uhub-$UHUB_VERSION..." 9 | 10 | # Download Uhub 11 | if [ -f $UHUB_SCRIPT ]; 12 | then 13 | echo "... uhub-$UHUB_VERSION already downloaded." 14 | else 15 | if [[ -z "$GITHUB_TOKEN" ]]; then 16 | cat </dev/null </dev/null 24 | 25 | # Initialize the repo like GitHub 26 | function initRepo() { 27 | cd $REPO_PATH 28 | git init . 29 | echo "# Uhub test repository\n" > README.md 30 | git add README.md 31 | git commit -m "Initial commit" 32 | git remote add origin $REMOTE_PATH 33 | cd - 34 | } 35 | initRepo >/dev/null 36 | 37 | # Start uhub 38 | UHUB_VERSION=$(node -pe "require('./package.json').engines.uhub") 39 | ./.tmp/uhub-$UHUB_VERSION --mode=single --root=$REPO_PATH --port=127.0.0.1:6666 > /dev/null & 40 | UHUBPID=$! 41 | function cleanUp() { 42 | kill -s 9 $UHUBPID 43 | rm -rf $REPO_PATH 44 | rm -rf $REMOTE_PATH 45 | } 46 | # Cleanup on exit 47 | trap cleanUp EXIT 48 | 49 | # Wait for uhub to be ready 50 | sleep 2 51 | 52 | # Run tests on uHub 53 | echo "Run tests for uhub" 54 | export REPOFS_DRIVER=uhub 55 | export REPOFS_HOST=http://localhost:6666 56 | export REPOFS_REPO=user/repo 57 | 58 | mocha --reporter spec --compilers js:babel-register --bail --timeout 15000 59 | -------------------------------------------------------------------------------- /src/constants/changeType.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | UPDATE: 'update', 3 | CREATE: 'create', 4 | REMOVE: 'remove' 5 | }; 6 | -------------------------------------------------------------------------------- /src/constants/errors.js: -------------------------------------------------------------------------------- 1 | // These codes are meant to be exported, for dev to react to runtime 2 | // errors. 3 | // We will always throw Errors with these values as code property. 4 | const ERRORS = { 5 | ALREADY_EXIST: '302 Already exist', 6 | NOT_FAST_FORWARD: '433 Not fast forward', 7 | NOT_FOUND: '404 Not found', 8 | CONFLICT: '409 Conflict', 9 | UNKNOWN_REMOTE: '404 Unknown Remote', 10 | AUTHENTICATION_FAILED: '401 Authentication Failed', 11 | BLOB_TOO_BIG: '507 Blog too large', 12 | REF_NOT_FOUND: '404 Reference not found' 13 | }; 14 | // TODO drop HTTP codes completely, for more reliable error checking (no collision) 15 | 16 | module.exports = ERRORS; 17 | -------------------------------------------------------------------------------- /src/constants/filetype.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | FILE: 'file', 3 | DIRECTORY: 'directory' 4 | }; 5 | -------------------------------------------------------------------------------- /src/drivers/driver.js: -------------------------------------------------------------------------------- 1 | class Driver { 2 | 3 | /** 4 | * Fetch a blob by its sha 5 | * @param {SHA} sha 6 | * @return {Promise} 7 | */ 8 | fetchBlob(sha) {} 9 | 10 | /** 11 | * Fetch a tree from a reference 12 | * @param {Ref} ref 13 | * @return {Promise} 14 | */ 15 | fetchWorkingState(ref) {} 16 | 17 | /** 18 | * Fetch branches listing 19 | * @return {Promise>} 20 | */ 21 | fetchBranches() {} 22 | 23 | /** 24 | * Flush a commit 25 | * @param {CommitBuilder} commitBuilder 26 | * @return {Promise} 27 | */ 28 | flushCommit(commitBuilder) {} 29 | 30 | /** 31 | * @param {Ref} [options.ref=master] 32 | * @param {Path} [options.path] Filter containing the file 33 | * @param {String} [options.author] Filter by author name 34 | * @param {Number} [options.per_page] Limite number of result 35 | * @return {Promise>} Commits without files patch 36 | */ 37 | listCommits(options) {} 38 | 39 | /** 40 | * Get a single commit, with files patch 41 | * @param {SHA} sha 42 | * @return {Promise} 43 | */ 44 | fetchCommit(sha) {} 45 | 46 | /** 47 | * Find the closest parent of two commits 48 | * @param {Ref} ref1 49 | * @param {Ref} ref2 50 | * @return {Promise} 51 | */ 52 | findParentCommit(ref1, ref2) { 53 | return this.fetchComparison(ref1, ref2) 54 | .then(compare => compare.closest.sha ? compare.closest : null); 55 | } 56 | 57 | /** 58 | * List all the commits reachable from head, but not from base. Most 59 | * recent first. 60 | * @param {Branch | SHA} base 61 | * @param {Branch | SHA} head 62 | * @return {Promise>} 63 | */ 64 | fetchOwnCommits(base, head) { 65 | return this.fetchComparison(base, head) 66 | .then(compare => compare.commits); 67 | } 68 | 69 | /** 70 | * Compare two commits. 71 | * @param {Branch | SHA} base 72 | * @param {Branch | SHA} head 73 | * @return {Promise} 74 | */ 75 | fetchComparison(base, head) {} 76 | 77 | /** 78 | * Update a branch forward to a given commit 79 | * @param {Branch} branch 80 | * @param {SHA} sha Commit sha 81 | * @return {Promise} 82 | */ 83 | forwardBranch(branch, sha) {} 84 | 85 | /** 86 | * Create a reference 87 | * @param {String} ref name of the ref 88 | * @param {SHA} sha 89 | * @return {Promise} 90 | */ 91 | createRef(ref, sha) {} 92 | 93 | /** 94 | * Create a branch based on another one 95 | * @param {Branch} base 96 | * @param {String} name 97 | * @return {Promise} 98 | */ 99 | createBranch(base, name) {} 100 | 101 | /** 102 | * Delete a branch 103 | * @param {Branch} branch 104 | * @return {Promise} 105 | */ 106 | deleteBranch(branch) {} 107 | 108 | /** 109 | * Attempts an automatic merging through the API. 110 | * @param {Branch | SHA} from A branch, or a commit SHA 111 | * @param {Branch} into Branch to merge into 112 | * @param {String} [options.message] Merge commit message 113 | * @return {Promise} Returns the merge commit, 114 | * or null if `into` already hase `from` as parent 115 | */ 116 | merge(from, into, options) {} 117 | 118 | // ---- Remotes (Not implemented by all APIs) ---- 119 | 120 | /** 121 | * Checkout a branch, by syncing the filesystem 122 | * @param {Branch} branch 123 | * @return {Promise} 124 | */ 125 | checkout(branch) {} 126 | 127 | /** 128 | * List remotes on the repository. 129 | * @return {Promise>} 130 | */ 131 | listRemotes() {} 132 | 133 | /** 134 | * Edit a remote. 135 | * @param {String} [name] Name of the remote 136 | * @param {String} [url] New URL of the remote 137 | */ 138 | editRemotes(name, url) {} 139 | 140 | /** 141 | * Pull changes for local branch, from remote repository 142 | * @param {Branch} opts.branch Branch to pull. Default to current 143 | * @param {String} opts.remote.name Name of the remote 144 | * @param {String} [opts.remote.url] URL if the remote needs to be created 145 | * @param {Boolean} [opts.force=false] Ignore non fast forward 146 | * @param {String} [opts.auth.username] Authentication username 147 | * @param {String} [opts.auth.password] Authentication password 148 | * @return {Promise} 149 | * @throws {Promise} 150 | * @throws {Promise} 151 | * @throws {Promise} 152 | * @throws {Promise} 153 | */ 154 | pull(opts) {} 155 | 156 | 157 | /** 158 | * Push a local branch to a remote repository 159 | * @param {Branch} opts.branch Branch to push. Default to current 160 | * @param {String} opts.remote.name Name of the remote 161 | * @param {String} [opts.remote.url] URL if the remote needs to be created 162 | * @param {Boolean} [opts.force=false] Ignore non fast forward 163 | * @param {String} [opts.auth.username] Authentication username 164 | * @param {String} [opts.auth.password] Authentication password 165 | * @return {Promise} 166 | * @throws {Promise} 167 | * @throws {Promise} 168 | * @throws {Promise} 169 | */ 170 | push(opts) {} 171 | 172 | /** 173 | * Fetch status information from a remote repository 174 | * @return {Promise<{ files: , head: Reference }>} 175 | */ 176 | status() {} 177 | 178 | /** 179 | * Track local files into a git repository. Performs a post request to /track 180 | * endpoint with a JSON payload in the form of: 181 | * 182 | * 183 | * { 184 | * "message": "Commit message", 185 | * "files": [{ 186 | * "name": "README.md", 187 | * "status": "modified" 188 | * }, { 189 | * "name": "foobar.md", 190 | * "status": "untracked" 191 | * }], 192 | * 193 | * "author": { 194 | * "name": "John Doe", 195 | * "email": "johndoe@example.com", 196 | * "date": "Mon Nov 07 2016 16:40:35 GMT+0100 (CET)" 197 | * }, 198 | * 199 | * "committer": { 200 | * "name": "John Doe", 201 | * "email": "johndoe@example.com", 202 | * "date": "Mon Nov 07 2016 16:40:35 GMT+0100 (CET)" 203 | * } 204 | * } 205 | * 206 | * Status and expected behaviour: 207 | * 208 | * added -> File will be added and committed 209 | * copied -> not supported 210 | * removed -> File will be removed 211 | * modified -> Modification will be committed 212 | * renamed -> not supported 213 | * unmodified -> Nothing 214 | * untracked -> File will be added and committed 215 | * 216 | * @param {String} opts.message commit message. 217 | * @param {Array} opts.files List of files with name and status. 218 | * @param {Author} opts.author Optional author information with name, email and 219 | * date. 220 | * @param {Author} opts.committer Optional committer information with name, 221 | * email and date. 222 | * @return {Promise} 223 | */ 224 | track(opts) {} 225 | } 226 | 227 | module.exports = Driver; 228 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | 2 | const GitHubDriver = require('./drivers/github'); 3 | 4 | const RepositoryState = require('./models/repositoryState'); 5 | const WorkingState = require('./models/workingState'); 6 | const TreeEntry = require('./models/treeEntry'); 7 | const Author = require('./models/author'); 8 | const Branch = require('./models/branch'); 9 | const CommitBuilder = require('./models/commitBuilder'); 10 | const Conflict = require('./models/conflict'); 11 | const TreeConflict = require('./models/treeConflict'); 12 | const File = require('./models/file'); 13 | const Blob = require('./models/blob'); 14 | const FileDiff = require('./models/fileDiff'); 15 | const Comparison = require('./models/comparison'); 16 | 17 | const CHANGE = require('./constants/changeType'); 18 | const ERRORS = require('./constants/errors'); 19 | 20 | const WorkingUtils = require('./utils/working'); 21 | const TreeUtils = require('./utils/filestree'); 22 | const FileUtils = require('./utils/file'); 23 | const LocalUtils = require('./utils/localFile'); 24 | const BlobUtils = require('./utils/blob'); 25 | const DirUtils = require('./utils/directory'); 26 | const RepoUtils = require('./utils/repo'); 27 | const BranchUtils = require('./utils/branches'); 28 | const ChangeUtils = require('./utils/change'); 29 | const CommitUtils = require('./utils/commit'); 30 | const ConflictUtils = require('./utils/conflict'); 31 | const RemoteUtils = require('./utils/remote'); 32 | 33 | module.exports = { 34 | // Drivers 35 | GitHubDriver, 36 | 37 | // Models 38 | RepositoryState, 39 | WorkingState, 40 | TreeEntry, 41 | File, 42 | Blob, 43 | Author, 44 | Branch, 45 | CommitBuilder, 46 | Conflict, 47 | TreeConflict, 48 | FileDiff, 49 | Comparison, 50 | 51 | // Constants 52 | CHANGE, 53 | ERRORS, 54 | 55 | // Utilities 56 | WorkingUtils, 57 | TreeUtils, 58 | FileUtils, 59 | LocalUtils, 60 | BlobUtils, 61 | DirUtils, 62 | RepoUtils, 63 | BranchUtils, 64 | ChangeUtils, 65 | CommitUtils, 66 | ConflictUtils, 67 | RemoteUtils, 68 | 69 | // Decoding 70 | decodeWorkingState: WorkingState.decode 71 | }; 72 | -------------------------------------------------------------------------------- /src/models/author.js: -------------------------------------------------------------------------------- 1 | const { Record } = require('immutable'); 2 | 3 | const DEFAULTS = { 4 | name: String(), 5 | email: String(), 6 | date: new Date(), 7 | avatar: String() // url 8 | }; 9 | 10 | /** 11 | * Represents a commit author. 12 | * @type {Class} 13 | */ 14 | class Author extends Record(DEFAULTS) { 15 | // ---- Properties Getter ---- 16 | getName() { 17 | return this.get('name'); 18 | } 19 | 20 | getEmail() { 21 | return this.get('email'); 22 | } 23 | 24 | getDate() { 25 | return this.get('date'); 26 | } 27 | 28 | getAvatar() { 29 | return this.get('avatar'); 30 | } 31 | 32 | // ---- Statics 33 | 34 | /** 35 | * Create a new author 36 | * @param {Object} infos 37 | * @return {Author} 38 | */ 39 | static create(opts) { 40 | if (opts instanceof Author) { 41 | return opts; 42 | } 43 | 44 | return new Author({ 45 | name: opts.name, 46 | email: opts.email, 47 | avatar: opts.avatar, 48 | date: new Date(opts.date) 49 | }); 50 | } 51 | 52 | static encode(author) { 53 | return author.toJS(); 54 | } 55 | 56 | static decode(json) { 57 | return Author.create(json); 58 | } 59 | } 60 | 61 | module.exports = Author; 62 | -------------------------------------------------------------------------------- /src/models/blob.js: -------------------------------------------------------------------------------- 1 | const { Record } = require('immutable'); 2 | 3 | const arrayBuffer = require('../utils/arraybuffer'); 4 | const ERRORS = require('../constants/errors'); 5 | 6 | // Don't read blob over 1MB 7 | const BLOB_MAX_SIZE = 1 * 1024 * 1024; 8 | 9 | const DEFAULTS = { 10 | // Size of the file 11 | byteLength: 0, 12 | // Content as a buffer 13 | content: arrayBuffer.fromString('') 14 | }; 15 | 16 | class Blob extends Record(DEFAULTS) { 17 | // ---- Properties Getter ---- 18 | getByteLength() { 19 | return this.get('byteLength'); 20 | } 21 | 22 | getContent() { 23 | return this.get('content'); 24 | } 25 | 26 | // ---- Methods ---- 27 | 28 | /** 29 | * Return content as an ArrayBuffer 30 | */ 31 | getAsArrayBuffer() { 32 | return this.getContent(); 33 | } 34 | 35 | /** 36 | * Return content as a base64 string 37 | */ 38 | getAsBase64() { 39 | return arrayBuffer.toBase64(this.getContent()); 40 | } 41 | 42 | /** 43 | * Return blob content as a string 44 | * @param {encoding} 45 | */ 46 | getAsString(encoding) { 47 | encoding = encoding || 'utf8'; 48 | return arrayBuffer.enforceString(this.getContent(), encoding); 49 | } 50 | 51 | /** 52 | * @return {Buffer} the blob as Buffer 53 | */ 54 | getAsBuffer() { 55 | return arrayBuffer.enforceBuffer(this.getContent()); 56 | } 57 | 58 | /** 59 | * Test equality to another Blob 60 | * @return {Boolean} 61 | */ 62 | // TODO implement and use Blob.prototype.hashCode, since Immutable 63 | // will assume hashCodes are equals when equals returns true. 64 | equals(blob) { 65 | return this.getByteLength() === blob.getByteLength() 66 | && arrayBuffer.equals(this.getContent(), blob.getContent()); 67 | } 68 | 69 | // ---- Static ---- 70 | 71 | /** 72 | * Create a blob from a string or buffer, returns null if blob is too big 73 | * @param {String|Buffer|ArrayBuffer} buf 74 | * @return {?Blob} 75 | */ 76 | static create(buf) { 77 | if (buf instanceof Blob) { 78 | return buf; 79 | } 80 | 81 | return Blob.createFromArrayBuffer(arrayBuffer.enforceArrayBuffer(buf)); 82 | } 83 | 84 | /** 85 | * Create a blob from an array buffer, returns null if blob is too big 86 | * @param {ArrayBuffer} buf 87 | * @return {?Blob} 88 | */ 89 | static createFromArrayBuffer(buf) { 90 | const isTooBig = (buf.byteLength > BLOB_MAX_SIZE); 91 | 92 | if (isTooBig) { 93 | const err = new Error('File content is too big to be processed'); 94 | err.code = ERRORS.BLOB_TOO_BIG; 95 | 96 | throw err; 97 | } 98 | 99 | return new Blob({ 100 | byteLength: buf.byteLength, 101 | content: buf 102 | }); 103 | } 104 | 105 | /** 106 | * Create a blob from a base64 string, returns null if blob is too big 107 | * @param {String} content 108 | * @return {?Blob} 109 | */ 110 | static createFromBase64(content) { 111 | const buf = arrayBuffer.fromBase64(content); 112 | return Blob.createFromArrayBuffer(buf); 113 | } 114 | 115 | /** 116 | * Create a blob from a buffer, returns null if blob is too big 117 | * @param {String} content 118 | * @return {?Blob} 119 | */ 120 | static createFromBuffer(content) { 121 | const buf = arrayBuffer.fromBuffer(content); 122 | return Blob.createFromArrayBuffer(buf); 123 | } 124 | 125 | /** 126 | * Create a blob from a string, returns null if blob is too big 127 | * @param {String} content 128 | * @return {?Blob} 129 | */ 130 | static createFromString(content) { 131 | const buf = arrayBuffer.fromString(content); 132 | return Blob.createFromArrayBuffer(buf); 133 | } 134 | 135 | /** 136 | * Encode a blob to JSON 137 | * @param {Blob} blob 138 | * @return {JSON} 139 | */ 140 | static encode(blob) { 141 | return { 142 | byteLength: blob.getByteLength(), 143 | content: blob.getAsBase64() 144 | }; 145 | } 146 | 147 | /** 148 | * Decode a blob from JSON 149 | * @param {JSON} json 150 | * @return {Blob} 151 | */ 152 | static decode(json) { 153 | const properties = {}; 154 | if (json.content) { 155 | properties.content = arrayBuffer.fromBase64(json.content); 156 | } 157 | properties.byteLength = json.byteLength; 158 | 159 | return new Blob(properties); 160 | } 161 | } 162 | 163 | module.exports = Blob; 164 | -------------------------------------------------------------------------------- /src/models/branch.js: -------------------------------------------------------------------------------- 1 | const { Record } = require('immutable'); 2 | const Commit = require('./commit'); 3 | 4 | const DEFAULTS = { 5 | // Such as 'master' 6 | name: '', 7 | // Pointing commit 8 | commit: new Commit(), 9 | // Potential remote name such as 'origin'. Empty for no remote 10 | remote: '' 11 | }; 12 | 13 | class Branch extends Record(DEFAULTS) { 14 | // ---- Properties Getter ---- 15 | 16 | get sha() { 17 | return this.commit.sha; 18 | } 19 | 20 | /** 21 | * Returns the full name for the branch, such as 'origin/master' 22 | * This is used as key and should be unique 23 | */ 24 | getFullName() { 25 | if (this.isRemote()) { 26 | return `${this.remote}/${this.name}`; 27 | } else { 28 | return this.name; 29 | } 30 | } 31 | 32 | getRemote() { 33 | return this.get('remote'); 34 | } 35 | 36 | isRemote() { 37 | return this.remote !== ''; 38 | } 39 | 40 | getName() { 41 | return this.get('name'); 42 | } 43 | 44 | getSha() { 45 | return this.sha; 46 | } 47 | 48 | setRemote(name) { 49 | return this.set('remote', name); 50 | } 51 | 52 | // ---- Static ---- 53 | 54 | static encode(branch) { 55 | return { 56 | name: branch.name, 57 | remote: branch.remote, 58 | commit: Commit.encode(branch.commit) 59 | }; 60 | } 61 | 62 | static decode(json) { 63 | const { name, remote, commit } = json; 64 | 65 | return new Branch({ 66 | name, 67 | remote, 68 | commit: Commit.decode(commit) 69 | }); 70 | } 71 | } 72 | 73 | module.exports = Branch; 74 | -------------------------------------------------------------------------------- /src/models/cache.js: -------------------------------------------------------------------------------- 1 | const { Record, OrderedMap } = require('immutable'); 2 | 3 | const DEFAULTS = { 4 | blobs: OrderedMap() // OrderedMap 5 | }; 6 | 7 | class Cache extends Record(DEFAULTS) { 8 | // ---- Properties Getter ---- 9 | getBlobs() { 10 | return this.get('blobs'); 11 | } 12 | 13 | // ---- Methods ---- 14 | 15 | /** 16 | * Return blob content 17 | */ 18 | getBlob(blobSHA) { 19 | const blobs = this.getBlobs(); 20 | return blobs.get(blobSHA); 21 | } 22 | } 23 | 24 | module.exports = Cache; 25 | -------------------------------------------------------------------------------- /src/models/change.js: -------------------------------------------------------------------------------- 1 | const { Record } = require('immutable'); 2 | const CHANGE_TYPE = require('../constants/changeType'); 3 | const Blob = require('./blob'); 4 | 5 | const DEFAULTS = { 6 | type: CHANGE_TYPE.UPDATE, 7 | // New content of the file (for a CREATE/UPDATE) 8 | content: new Blob(), 9 | // or sha of the origin (for a rename/move) 10 | sha: null // String 11 | }; 12 | 13 | /** 14 | * A Change represents a local modification, not yet commited. 15 | * @type {Class} 16 | */ 17 | class Change extends Record(DEFAULTS) { 18 | 19 | // ---- Properties Getter ---- 20 | getType() { 21 | return this.get('type'); 22 | } 23 | 24 | getSha() { 25 | return this.get('sha'); 26 | } 27 | 28 | hasSha() { 29 | return !!this.get('sha'); 30 | } 31 | 32 | getContent() { 33 | return this.get('content'); 34 | } 35 | 36 | // ---- Static ---- 37 | 38 | /** 39 | * @param {Buffer | ArrayBuffer | String} content 40 | * @return {Change} CREATE with content and optional message 41 | */ 42 | static createCreate(content) { 43 | return new Change({ 44 | type: CHANGE_TYPE.CREATE, 45 | content: Blob.create(content) 46 | }); 47 | } 48 | 49 | /** 50 | * @param {SHA} sha 51 | * @return {Change} CREATE with origin sha and optional message 52 | */ 53 | static createCreateFromSha(sha) { 54 | return new Change({ 55 | type: CHANGE_TYPE.CREATE, 56 | sha 57 | }); 58 | } 59 | 60 | /** 61 | * @param {Buffer | ArrayBuffer | String} content 62 | * @return {Change} UPDATE with content and optional message 63 | */ 64 | static createUpdate(content) { 65 | return new Change({ 66 | type: CHANGE_TYPE.UPDATE, 67 | content: Blob.create(content) 68 | }); 69 | } 70 | 71 | /** 72 | * @return {Change} REMOVE with optional message 73 | */ 74 | static createRemove() { 75 | return new Change({ 76 | type: CHANGE_TYPE.REMOVE 77 | }); 78 | } 79 | 80 | static encode(change) { 81 | return { 82 | type: change.get('type'), 83 | // Encode Blob as base64 string 84 | content: change.get('content').getAsString('base64'), 85 | sha: change.get('sha') 86 | }; 87 | } 88 | 89 | static decode(json) { 90 | // Useless optimization to use the original String reference 91 | const type = CHANGE_TYPE[json.type.toUpperCase()]; 92 | 93 | if (!type) { 94 | throw new Error('Unrecognized change type'); 95 | } 96 | 97 | const content = Blob.createFromBase64(json.content); 98 | 99 | return new Change({ 100 | type, 101 | content, 102 | sha: json.sha 103 | }); 104 | } 105 | } 106 | 107 | module.exports = Change; 108 | -------------------------------------------------------------------------------- /src/models/commit.js: -------------------------------------------------------------------------------- 1 | const { Record, List } = require('immutable'); 2 | const Author = require('./author'); 3 | const FileDiff = require('./fileDiff'); 4 | 5 | /** 6 | * Represents a Commit in the history (already created) 7 | */ 8 | 9 | const DEFAULTS = { 10 | // Message for the commit 11 | message: String(), 12 | // SHA of the commit 13 | sha: String(), 14 | // Author name 15 | author: new Author(), 16 | // String formatted date of the commit 17 | date: String(), 18 | // List of files modified with their SHA and patch. 19 | files: List(), // List 20 | // Parents of the commit (List) 21 | parents: List() 22 | }; 23 | 24 | /** 25 | * A Change represents a local modification, not yet commited. 26 | * @type {Class} 27 | */ 28 | class Commit extends Record(DEFAULTS) { 29 | getMessage() { 30 | return this.get('message'); 31 | } 32 | 33 | getSha() { 34 | return this.get('sha'); 35 | } 36 | 37 | getAuthor() { 38 | return this.get('author'); 39 | } 40 | 41 | getDate() { 42 | return this.get('date'); 43 | } 44 | 45 | getFiles() { 46 | return this.get('files'); 47 | } 48 | 49 | getParents() { 50 | return this.get('parents'); 51 | } 52 | 53 | // ---- Statics 54 | 55 | /** 56 | * @param {SHA} opts.sha 57 | * @param {Array} opts.parents 58 | * @param {String} [opts.message] 59 | * @param {String} [opts.date] 60 | * @param {Author} [opts.author] 61 | * @param {Array} [opts.files] Modified files objects, as returned by the GitHub API 62 | * @return {Commit} 63 | */ 64 | static create(opts) { 65 | if (opts instanceof Commit) { 66 | return opts; 67 | } 68 | 69 | return new Commit({ 70 | sha: opts.sha, 71 | message: opts.message, 72 | author: Author.create(opts.author), 73 | date: new Date(opts.date), 74 | files: List(opts.files || []).map(file => FileDiff.create(file)), 75 | parents: List(opts.parents || []) 76 | }); 77 | } 78 | 79 | static encode(commit) { 80 | const { message, sha, date, author, parents } = commit; 81 | 82 | return { 83 | message, 84 | sha, 85 | date, 86 | parents: parents.toJS(), 87 | author: Author.encode(author) 88 | }; 89 | } 90 | 91 | static decode(json) { 92 | return Commit.create(json); 93 | } 94 | } 95 | 96 | module.exports = Commit; 97 | -------------------------------------------------------------------------------- /src/models/commitBuilder.js: -------------------------------------------------------------------------------- 1 | const { Record, List, Map } = require('immutable'); 2 | const Author = require('./author'); 3 | 4 | const DEFAULTS = { 5 | // Commiter / Author 6 | committer: new Author(), 7 | author: new Author(), 8 | 9 | // Message for the commit 10 | message: String(), 11 | 12 | // Does the commit bring any modification ? 13 | empty: true, 14 | 15 | // Parents 16 | parents: new List(), // List 17 | 18 | // Tree entries 19 | treeEntries: new Map(), // Map 20 | 21 | // New blobs to create 22 | blobs: new Map() // Map 23 | }; 24 | 25 | /** 26 | * CommitBuilder instance are created before creating the new commit 27 | * using the driver. 28 | * 29 | * @type {Class} 30 | */ 31 | class CommitBuilder extends Record(DEFAULTS) { 32 | getMessage() { 33 | return this.get('message'); 34 | } 35 | 36 | getParents() { 37 | return this.get('parents'); 38 | } 39 | 40 | getAuthor() { 41 | return this.get('author'); 42 | } 43 | 44 | getTreeEntries() { 45 | return this.get('treeEntries'); 46 | } 47 | 48 | getBlobs() { 49 | return this.get('blobs'); 50 | } 51 | 52 | getCommitter() { 53 | return this.get('committer'); 54 | } 55 | 56 | /** 57 | * Returns true if the commit does not contain any change. 58 | */ 59 | isEmpty() { 60 | return this.get('empty'); 61 | } 62 | } 63 | 64 | // ---- Statics 65 | 66 | /** 67 | * Create a commit builder from a definition 68 | * @return {CommitBuilder} 69 | */ 70 | CommitBuilder.create = function(opts) { 71 | opts.committer = opts.committer || opts.author; 72 | return new CommitBuilder(opts); 73 | }; 74 | 75 | module.exports = CommitBuilder; 76 | -------------------------------------------------------------------------------- /src/models/comparison.js: -------------------------------------------------------------------------------- 1 | const { Record, List } = require('immutable'); 2 | const FileDiff = require('./fileDiff'); 3 | const Commit = require('./commit'); 4 | 5 | /** 6 | * Represents a comparison in the history. 7 | */ 8 | 9 | const DEFAULTS = { 10 | base: String(), 11 | head: String(), 12 | // Closest parent in the compare 13 | closest: new Commit(), 14 | // List of files modified with their SHA and patch. 15 | files: List(), // List 16 | // List of commits in the range (List) 17 | commits: List() 18 | }; 19 | 20 | /** 21 | * @type {Class} 22 | */ 23 | class Comparison extends Record(DEFAULTS) { 24 | 25 | /** 26 | * @return {Commit} 27 | */ 28 | static create(opts) { 29 | if (opts instanceof Comparison) { 30 | return opts; 31 | } 32 | 33 | return new Comparison({ 34 | base: opts.base, 35 | head: opts.head, 36 | closest: Commit.create(opts.closest), 37 | files: List(opts.files).map(file => FileDiff.create(file)), 38 | commits: List(opts.commits).map(commit => Commit.create(commit)) 39 | }); 40 | } 41 | 42 | } 43 | 44 | module.exports = Comparison; 45 | -------------------------------------------------------------------------------- /src/models/conflict.js: -------------------------------------------------------------------------------- 1 | const { Record } = require('immutable'); 2 | 3 | const Blob = require('./blob'); 4 | 5 | 6 | // Describe a change made on a reference, compared to its parent 7 | const CHANGE = { 8 | ADDED: 'added', 9 | DELETED: 'deleted', 10 | IDENTICAL: 'identical', 11 | MODIFIED: 'modified' 12 | }; 13 | 14 | // There is four types of conflicts depending on changes on each branch: 15 | // |-----------+------------+-----------------+-----------+-----------------| 16 | // | base\head | ADDED | DELETED | IDENTICAL | MODIFIED | 17 | // |-----------+------------+-----------------+-----------+-----------------| 18 | // | ADDED | Both added | X | X | X | 19 | // |-----------+------------+-----------------+-----------+-----------------| 20 | // | DELETED | X | X | X | Deleted on base | 21 | // |-----------+------------+-----------------+-----------+-----------------| 22 | // | IDENTICAL | X | X | X | X | 23 | // |-----------+------------+-----------------+-----------+-----------------| 24 | // | MODIFIED | X | Deleted on head | X | Both modified | 25 | // |-----------+------------+-----------------+-----------+-----------------| 26 | const TYPE = { 27 | BOTH_ADDED: 'added', 28 | DELETED_BASE: 'deleted-base', 29 | DELETED_HEAD: 'deleted-head', 30 | BOTH_MODIFIED: 'modified', 31 | NO_CONFLICT: 'no-conflict' 32 | }; 33 | 34 | const DEFAULTS = { 35 | // Blob SHA in head. Null when absent 36 | headSha: null, // String 37 | 38 | // Blob SHA in base. Null when absent 39 | baseSha: null, // String 40 | 41 | // Blob SHA in the closest parent. Null when absent 42 | parentSha: null, // String 43 | 44 | // Is solved ? 45 | solved: false, 46 | 47 | // The SHA it is solved with. null when using solvedContent, or to solve by 48 | // deleting the entry. 49 | solvedSha: null, // SHA 50 | 51 | // The solved content 52 | solvedContent: null // Blob 53 | }; 54 | 55 | /** 56 | * Conflict represents a git conflict. 57 | * @type {Class} 58 | */ 59 | class Conflict extends Record(DEFAULTS) { 60 | // ---- Properties Getter ---- 61 | 62 | getBaseSha() { 63 | return this.get('baseSha'); 64 | } 65 | 66 | getHeadSha() { 67 | return this.get('headSha'); 68 | } 69 | 70 | getParentSha() { 71 | return this.get('parentSha'); 72 | } 73 | 74 | isSolved() { 75 | return this.get('solved'); 76 | } 77 | 78 | getSolvedSha() { 79 | return this.get('solvedSha'); 80 | } 81 | 82 | getSolvedContent() { 83 | return this.get('solvedContent'); 84 | } 85 | 86 | // ---- Methods ---- 87 | 88 | /** 89 | * @return {Boolean} 90 | */ 91 | isDeleted() { 92 | return this.isSolved() 93 | && this.getSolvedContent() === null 94 | && this.getSolvedSha() === null; 95 | } 96 | 97 | /** 98 | * @return {Conflict.CHANGE} Return the kind of change made by base 99 | */ 100 | getBaseStatus() { 101 | return getChange(this.getParentSha(), this.getBaseSha()); 102 | } 103 | 104 | /** 105 | * @return {Conflict.CHANGE} Return the kind of change made by head 106 | */ 107 | getHeadStatus() { 108 | return getChange(this.getParentSha(), this.getHeadSha()); 109 | } 110 | 111 | /** 112 | * @return {Conflict.TYPE} Return the type of this conflict 113 | */ 114 | getType() { 115 | const base = this.getBaseStatus(); 116 | const head = this.getHeadStatus(); 117 | 118 | // Based on the TYPE matrix : 119 | if (base === CHANGE.ADDED && head === CHANGE.ADDED) { 120 | return TYPE.BOTH_ADDED; 121 | } else if (base === CHANGE.DELETED && head === CHANGE.MODIFIED) { 122 | return TYPE.DELETED_BASE; 123 | } else if (base === CHANGE.MODIFIED && head === CHANGE.DELETED) { 124 | return TYPE.DELETED_HEAD; 125 | } else if (base === CHANGE.MODIFIED && head === CHANGE.MODIFIED) { 126 | return TYPE.BOTH_MODIFIED; 127 | } else { 128 | return TYPE.NO_CONFLICT; 129 | } 130 | } 131 | 132 | /** 133 | * @param {Boolean} [solved] Default to toggle 134 | * @return {Conflict} Set the solved state. Does not alter previous 135 | * solved content/sha 136 | */ 137 | toggleSolved(solved) { 138 | // toggle 139 | solved = (solved === undefined) ? !this.isSolved() : solved; 140 | return this.merge({ 141 | solved 142 | }); 143 | } 144 | 145 | /** 146 | * @param {String} sha 147 | * @return {Conflict} 148 | */ 149 | solveWithSha(sha) { 150 | return this.merge({ 151 | solved: true, 152 | solvedSha: sha, 153 | solvedContent: null 154 | }); 155 | } 156 | 157 | /** 158 | * @param {Blob | String} content 159 | * @return {Conflict} 160 | */ 161 | solveWithContent(content) { 162 | const blob = content instanceof Blob ? content : Blob.createFromString(content); 163 | return this.merge({ 164 | solved: true, 165 | solvedSha: null, 166 | solvedContent: blob 167 | }); 168 | } 169 | 170 | /** 171 | * @return {Conflict} Solved by removing the entry 172 | */ 173 | solveByDeletion() { 174 | return this.merge({ 175 | solved: true, 176 | solvedSha: null, 177 | solvedContent: null 178 | }); 179 | } 180 | 181 | /** 182 | * @return {Conflict} Solved by keeping head's version 183 | */ 184 | keepHead(content) { 185 | return this.solveWithSha(this.getHeadSha()); 186 | } 187 | 188 | /** 189 | * @return {Conflict} Solved by keeping base's version 190 | */ 191 | keepBase(content) { 192 | return this.solveWithSha(this.getBaseSha()); 193 | } 194 | 195 | /** 196 | * @return {Conflict} Reset to unsolved state 197 | */ 198 | resetUnsolved() { 199 | return this.merge({ 200 | solved: false, 201 | solvedSha: null, 202 | solvedContent: null 203 | }); 204 | } 205 | } 206 | 207 | // ---- Static ---- 208 | 209 | Conflict.create = function(parentSha, baseSha, headSha) { 210 | return new Conflict({ 211 | parentSha, 212 | baseSha, 213 | headSha 214 | }); 215 | }; 216 | 217 | // ---- utils ---- 218 | 219 | /** 220 | * @param {SHA | Null} parent 221 | * @param {SHA | Null} sha 222 | * @return {Conflict.CHANGE} 223 | */ 224 | function getChange(parent, sha) { 225 | if (parent === sha) { 226 | return CHANGE.IDENTICAL; 227 | } else if (parent === null) { 228 | return CHANGE.ADDED; 229 | } else if (sha === null) { 230 | return CHANGE.DELETED; 231 | } else { 232 | // Both are not null but different 233 | return CHANGE.MODIFIED; 234 | } 235 | } 236 | Conflict.TYPE = TYPE; 237 | Conflict.CHANGE = CHANGE; 238 | 239 | module.exports = Conflict; 240 | -------------------------------------------------------------------------------- /src/models/file.js: -------------------------------------------------------------------------------- 1 | const { Record } = require('immutable'); 2 | const path = require('path'); 3 | const mime = require('mime-types'); 4 | 5 | const FILETYPE = require('../constants/filetype'); 6 | 7 | const DEFAULTS = { 8 | // Size of the file. 0 if the file was not fetched 9 | fileSize: 0, 10 | 11 | // Content of the blob containing the file's content 12 | // Null if the file was not fetched 13 | content: null, // Blob 14 | 15 | // Path of the file 16 | path: '', 17 | 18 | // Type of entry (see constants/filetype.js) 19 | type: FILETYPE.FILE 20 | }; 21 | 22 | /** 23 | * @type {Class} 24 | */ 25 | class File extends Record(DEFAULTS) { 26 | // ---- Properties Getter ---- 27 | 28 | getContent() { 29 | return this.get('content'); 30 | } 31 | 32 | getFileSize() { 33 | return this.get('fileSize'); 34 | } 35 | 36 | getPath() { 37 | return this.get('path'); 38 | } 39 | 40 | getType() { 41 | return this.get('type'); 42 | } 43 | 44 | isDirectory() { 45 | return this.getType() == FILETYPE.DIRECTORY; 46 | } 47 | 48 | getMime() { 49 | return mime.lookup(path.extname(this.getPath())) || 'application/octet-stream'; 50 | } 51 | 52 | getName() { 53 | return path.basename(this.getPath()); 54 | } 55 | } 56 | 57 | /** 58 | * Create a File representing a directory at the given path (empty content etc.). 59 | */ 60 | File.createDir = function(filepath) { 61 | return new File({ 62 | path: filepath, 63 | type: FILETYPE.DIRECTORY 64 | }); 65 | }; 66 | 67 | /** 68 | * Create a File representing a directory at the given path (empty content etc.). 69 | */ 70 | File.create = function(filepath, fileSize) { 71 | return new File({ 72 | path: filepath, 73 | fileSize: fileSize || 0, 74 | type: FILETYPE.FILE 75 | }); 76 | }; 77 | 78 | module.exports = File; 79 | -------------------------------------------------------------------------------- /src/models/fileDiff.js: -------------------------------------------------------------------------------- 1 | const { Record, List } = require('immutable'); 2 | const Author = require('./author'); 3 | 4 | /** 5 | * Represents a file in a compare. 6 | */ 7 | 8 | const STATUS = { 9 | MODIFIED: 'modified', 10 | ADDED: 'added', 11 | REMOVED: 'removed' 12 | }; 13 | 14 | const DEFAULTS = { 15 | sha: String(), 16 | filename: String(), 17 | status: String(STATUS.ADDED), 18 | additions: Number(0), 19 | deletions: Number(0), 20 | changes: Number(0), 21 | patch: String() 22 | }; 23 | 24 | /** 25 | * @type {Class} 26 | */ 27 | class FileDiff extends Record(DEFAULTS) { 28 | 29 | /** 30 | * @return {FileDiff} 31 | */ 32 | static create(opts) { 33 | if (opts instanceof FileDiff) { 34 | return opts; 35 | } 36 | 37 | return new FileDiff(opts); 38 | } 39 | } 40 | 41 | module.exports = FileDiff; 42 | -------------------------------------------------------------------------------- /src/models/localFile.js: -------------------------------------------------------------------------------- 1 | const { Record } = require('immutable'); 2 | 3 | const DEFAULTS = { 4 | // Sha1 of the modified blob, 5 | sha: null, 6 | 7 | // Path of the file 8 | filename: '', 9 | 10 | // File status 11 | status: '', 12 | 13 | // Number of additions 14 | additions: 0, 15 | 16 | // Number of deletions 17 | deletions: 0, 18 | 19 | // Number of changes 20 | changes: 0, 21 | 22 | // Git patch to apply 23 | patch: '' 24 | }; 25 | 26 | /** 27 | * LocalFile represents a status result 28 | * @type {Class} 29 | */ 30 | class LocalFile extends Record(DEFAULTS) { 31 | // ---- Properties Getter ---- 32 | getSha() { 33 | return this.get('sha'); 34 | } 35 | 36 | getFilename() { 37 | return this.get('filename'); 38 | } 39 | 40 | getStatus() { 41 | return this.get('status'); 42 | } 43 | 44 | getAdditions() { 45 | return this.get('additions'); 46 | } 47 | 48 | getDeletions() { 49 | return this.get('deletions'); 50 | } 51 | 52 | getChanges() { 53 | return this.get('changes'); 54 | } 55 | 56 | getPatch() { 57 | return this.get('patch'); 58 | } 59 | } 60 | 61 | /** 62 | * Create a LocalFile representing a status result at the given path (filename etc.) 63 | */ 64 | LocalFile.create = function(file) { 65 | return new LocalFile(file); 66 | }; 67 | 68 | module.exports = LocalFile; 69 | -------------------------------------------------------------------------------- /src/models/reference.js: -------------------------------------------------------------------------------- 1 | const { Record } = require('immutable'); 2 | 3 | const DEFAULTS = { 4 | ref: '', // git reference as `refs/heads/master`, 5 | sha: '' // sha1 reference 6 | }; 7 | 8 | class Reference extends Record(DEFAULTS) { 9 | getRef() { 10 | return this.get('ref'); 11 | } 12 | 13 | getSha() { 14 | return this.get('sha'); 15 | } 16 | 17 | getLocalBranchName() { 18 | const ref = this.get('ref'); 19 | return localBranchName(ref); 20 | } 21 | 22 | isLocalBranch(refstr) { 23 | return hasPrefix(refstr, 'refs/heads/'); 24 | } 25 | } 26 | 27 | function hasPrefix(str, prefix) { 28 | return str.indexOf(prefix) === 0; 29 | } 30 | 31 | function trimPrefix(str, prefix) { 32 | return hasPrefix(str, prefix) ? str.slice(prefix.length) : str; 33 | } 34 | 35 | function localBranchName(refstr) { 36 | return trimPrefix(refstr, 'refs/heads/'); 37 | } 38 | 39 | module.exports = Reference; 40 | -------------------------------------------------------------------------------- /src/models/repositoryState.js: -------------------------------------------------------------------------------- 1 | const { Record, Map, List } = require('immutable'); 2 | 3 | const modifyValues = require('modify-values'); 4 | const Normalize = require('../utils/normalize'); 5 | const WorkingState = require('./workingState'); 6 | const Branch = require('./branch'); 7 | const Cache = require('./cache'); 8 | 9 | const DEFAULTS = { 10 | currentBranchName: null, // Current branch full name 11 | workingStates: new Map(), // Map indexed by local branch fullnames 12 | branches: new List(), // List 13 | cache: new Cache() 14 | }; 15 | 16 | /** 17 | * Repository represents a map of WorkingTree with a current active 18 | * one 19 | * @type {Class} 20 | */ 21 | class RepositoryState extends Record(DEFAULTS) { 22 | // ---- Properties Getter ---- 23 | 24 | /** 25 | * @return {String} Current branch fullname 26 | */ 27 | getCurrentBranchName() { 28 | return this.get('currentBranchName'); 29 | } 30 | 31 | getWorkingStates() { 32 | return this.get('workingStates'); 33 | } 34 | 35 | getBranches() { 36 | return this.get('branches'); 37 | } 38 | 39 | getCache() { 40 | return this.get('cache'); 41 | } 42 | 43 | // ---- Methods ---- 44 | 45 | /** 46 | * Return a branch by its name 47 | * @param {String} 48 | * @return {Branch | Null} 49 | */ 50 | getBranch(branchName) { 51 | const branch = this.getBranches() 52 | .find((_branch) => { 53 | return branchName == _branch.getFullName(); 54 | }); 55 | return branch || null; 56 | } 57 | 58 | /** 59 | * Return all local branches 60 | * @return {List} 61 | */ 62 | getLocalBranches() { 63 | return this.getBranches().filter( 64 | function onlyLocal(branch) { 65 | return !branch.isRemote(); 66 | } 67 | ); 68 | } 69 | 70 | /** 71 | * Return current active branch 72 | * @return {Branch | Null} 73 | */ 74 | getCurrentBranch() { 75 | return this.getBranch(this.getCurrentBranchName()); 76 | } 77 | 78 | /** 79 | * Return working state for the current branch 80 | * @return {WorkingState} 81 | */ 82 | getCurrentState() { 83 | const currentBranch = this.getCurrentBranch(); 84 | if (currentBranch === null) { 85 | return WorkingState.createEmpty(); 86 | } else { 87 | return this.getWorkingStateForBranch(currentBranch); 88 | } 89 | } 90 | 91 | /** 92 | * Returns working state for given branch 93 | * @param {Branch} 94 | * @return {WorkingState | Null} 95 | */ 96 | getWorkingStateForBranch(branch) { 97 | const states = this.getWorkingStates(); 98 | return states.get(branch.getFullName()) || null; 99 | } 100 | 101 | /** 102 | * Check if a branch exists with the given name 103 | * @param {String} fullname Such as 'origin/master' or 'develop' 104 | */ 105 | hasBranch(fullname) { 106 | return this.getBranches().some((branch) => { 107 | return branch.getFullName() === fullname; 108 | }); 109 | } 110 | 111 | /** 112 | * Check that a branch has been fetched 113 | * @param {Branch} 114 | */ 115 | isFetched(branch) { 116 | return this.getWorkingStates().has(branch.getFullName()); 117 | } 118 | 119 | /** 120 | * @param {Branch | String} branchName Branch to update 121 | * @param {Branch | Null} value New branch value, null to delete 122 | */ 123 | updateBranch(branchName, value) { 124 | branchName = Normalize.branchName(branchName); 125 | 126 | let branches = this.getBranches(); 127 | const index = branches.findIndex((branch) => { 128 | return branch.getFullName() === branchName; 129 | }); 130 | if (value === null) { 131 | // Delete 132 | branches = branches.remove(index); 133 | } else { 134 | // Update 135 | branches = branches.set(index, value); 136 | } 137 | return this.set('branches', branches); 138 | } 139 | } 140 | 141 | // ---- Statics ---- 142 | 143 | /** 144 | * Creates a new empty WorkingTree 145 | */ 146 | RepositoryState.createEmpty = function createEmpty() { 147 | return new RepositoryState({}); 148 | }; 149 | 150 | 151 | /** 152 | * Encodes a RepositoryState as a JSON object 153 | * @param {RepositoryState} 154 | * @return {Object} As plain JS 155 | */ 156 | RepositoryState.encode = function(repoState) { 157 | return { 158 | currentBranchName: repoState.get('currentBranchName'), 159 | workingStates: repoState.get('workingStates').map(WorkingState.encode).toJS(), 160 | branches: repoState.get('branches').map(Branch.encode).toJS() 161 | }; 162 | }; 163 | 164 | RepositoryState.decode = function(json) { 165 | const workingStates = new Map(modifyValues(json.workingStates, WorkingState.decode)); 166 | const branches = new List(json.branches.map(Branch.decode)); 167 | 168 | return new RepositoryState({ 169 | currentBranchName: json.currentBranchName, 170 | workingStates, 171 | branches 172 | }); 173 | }; 174 | 175 | module.exports = RepositoryState; 176 | -------------------------------------------------------------------------------- /src/models/treeConflict.js: -------------------------------------------------------------------------------- 1 | const { Record, Map } = require('immutable'); 2 | 3 | const WorkingState = require('./workingState'); 4 | 5 | const DEFAULTS = { 6 | base: WorkingState.createEmpty(), 7 | head: WorkingState.createEmpty(), 8 | // Nearest parent 9 | parent: WorkingState.createEmpty(), 10 | 11 | // Map 12 | conflicts: new Map() 13 | }; 14 | 15 | /** 16 | * A TreeConflict represents a comparison between two Git Trees 17 | * @type {Class} 18 | */ 19 | class TreeConflict extends Record(DEFAULTS) { 20 | // ---- Properties Getter ---- 21 | 22 | getBase() { 23 | return this.get('base'); 24 | } 25 | 26 | getHead() { 27 | return this.get('head'); 28 | } 29 | 30 | getParent() { 31 | return this.get('parent'); 32 | } 33 | 34 | getConflicts() { 35 | return this.get('conflicts'); 36 | } 37 | 38 | // ---- Methods ---- 39 | 40 | /** 41 | * Returns the status of the tree conflict. Possible values are 42 | * described in TreeConflict.STATUS. 43 | */ 44 | getStatus() { 45 | const base = this.getBase().getHead(); 46 | const head = this.getHead().getHead(); 47 | const parent = this.getParent().getHead(); 48 | 49 | if (base === head) { 50 | return TreeConflict.STATUS.IDENTICAL; 51 | } else if (base === parent) { 52 | return TreeConflict.STATUS.AHEAD; 53 | } else if (head === parent) { 54 | return TreeConflict.STATUS.BEHIND; 55 | } else { 56 | return TreeConflict.STATUS.DIVERGED; 57 | } 58 | } 59 | } 60 | 61 | // ---- Static ---- 62 | 63 | TreeConflict.STATUS = { 64 | // Both trees are identical 65 | IDENTICAL: 'identical', 66 | // They both diverged from a common parent 67 | DIVERGED: 'diverged', 68 | // Base is a parent of head 69 | AHEAD: 'ahead', 70 | // Head is a parent of base 71 | BEHIND: 'behind' 72 | }; 73 | 74 | module.exports = TreeConflict; 75 | -------------------------------------------------------------------------------- /src/models/treeEntry.js: -------------------------------------------------------------------------------- 1 | const { Record } = require('immutable'); 2 | 3 | const TYPES = { 4 | BLOB: 'blob', 5 | // tree: 'tree', we don't yet support this one 6 | COMMIT: 'commit' 7 | }; 8 | 9 | const DEFAULTS = { 10 | // SHA of the corresponding blob 11 | sha: null, // String, null when content is not available as blob 12 | 13 | // Mode of the file 14 | mode: '100644', 15 | 16 | // Can be a 'tree', 'commit', or 'blob' 17 | type: TYPES.BLOB, 18 | 19 | // Size of the blob 20 | blobSize: 0 21 | }; 22 | 23 | /** 24 | * A TreeEntry represents an entry from the git tree (Tree). 25 | * @type {Class} 26 | */ 27 | class TreeEntry extends Record(DEFAULTS) { 28 | // ---- Properties Getter ---- 29 | getBlobSize() { 30 | return this.get('blobSize'); 31 | } 32 | 33 | getMode() { 34 | return this.get('mode'); 35 | } 36 | 37 | getSha() { 38 | return this.get('sha'); 39 | } 40 | 41 | getType() { 42 | return this.get('type'); 43 | } 44 | 45 | hasSha() { 46 | return this.getSha() !== null; 47 | } 48 | } 49 | 50 | // ---- Static ---- 51 | 52 | TreeEntry.encode = function(treeEntry) { 53 | return { 54 | sha: treeEntry.getSha(), 55 | type: treeEntry.getType(), 56 | mode: treeEntry.getMode(), 57 | size: treeEntry.getBlobSize() 58 | }; 59 | }; 60 | 61 | TreeEntry.decode = function(json) { 62 | return new TreeEntry({ 63 | sha: json.sha, 64 | type: json.type, 65 | mode: json.mode, 66 | blobSize: json.size 67 | }); 68 | }; 69 | 70 | TreeEntry.TYPES = TYPES; 71 | 72 | module.exports = TreeEntry; 73 | -------------------------------------------------------------------------------- /src/models/workingState.js: -------------------------------------------------------------------------------- 1 | const { Record, Map, OrderedMap } = require('immutable'); 2 | 3 | const modifyValues = require('modify-values'); 4 | const TreeEntry = require('./treeEntry'); 5 | const Change = require('./change'); 6 | 7 | const DEFAULTS = { 8 | head: String(), // SHA 9 | treeEntries: new Map(), // Map 10 | changes: new OrderedMap() // OrderedMap 11 | }; 12 | 13 | /** 14 | * @type {Class} 15 | */ 16 | class WorkingState extends Record(DEFAULTS) { 17 | // ---- Properties Getter ---- 18 | 19 | getTreeEntries() { 20 | return this.get('treeEntries'); 21 | } 22 | 23 | getChanges() { 24 | return this.get('changes'); 25 | } 26 | 27 | getHead() { 28 | return this.get('head'); 29 | } 30 | 31 | // ---- Methods ---- 32 | 33 | /** 34 | * Return true if working directory has no changes 35 | */ 36 | isClean() { 37 | return this.getChanges().size == 0; 38 | } 39 | 40 | /** 41 | * Return a change for a specific path 42 | * @return {Change} 43 | */ 44 | getChange(filePath) { 45 | const changes = this.getChanges(); 46 | return changes.get(filePath); 47 | } 48 | 49 | /** 50 | * Return this working state as clean. 51 | * @return {WorkingState} 52 | */ 53 | asClean() { 54 | return this.set('changes', new OrderedMap()); 55 | } 56 | 57 | 58 | // ---- Statics ---- 59 | 60 | /** 61 | * Create a new empty WorkingState 62 | */ 63 | static createEmpty() { 64 | return new WorkingState({}); 65 | } 66 | 67 | /** 68 | * Create a clean WorkingState from a head SHA and a map of tree entries 69 | * @param {SHA} head 70 | * @param {Map} treeEntries 71 | * @return {WorkingState} 72 | */ 73 | static createWithTree(head, treeEntries) { 74 | return new WorkingState({ 75 | head, 76 | treeEntries 77 | }); 78 | } 79 | 80 | static encode(workingState) { 81 | return { 82 | head: workingState.get('head'), 83 | treeEntries: workingState.get('treeEntries').map(TreeEntry.encode).toJS(), 84 | changes: workingState.get('changes').map(Change.encode).toJS() 85 | }; 86 | } 87 | 88 | static decode(json) { 89 | const treeEntries = new Map(modifyValues(json.treeEntries, TreeEntry.decode)); 90 | const changes = new OrderedMap(modifyValues(json.changes, Change.decode)); 91 | 92 | return new WorkingState({ 93 | head: json.head, 94 | treeEntries, 95 | changes 96 | }); 97 | } 98 | } 99 | 100 | module.exports = WorkingState; 101 | -------------------------------------------------------------------------------- /src/utils/arraybuffer.js: -------------------------------------------------------------------------------- 1 | const Buffer = require('buffer').Buffer; 2 | const is = require('is'); 3 | 4 | /** 5 | * Test if is arraybuffer 6 | */ 7 | function isArrayBuffer(b) { 8 | return Object.prototype.toString.call(b) === '[object ArrayBuffer]'; 9 | } 10 | function isBuffer(b) { 11 | return Object.prototype.toString.call(b) === '[object Buffer]'; 12 | } 13 | 14 | /** 15 | * Convert from a string 16 | */ 17 | function fromString(str, encoding) { 18 | return fromBuffer(new Buffer(str, encoding || 'utf8')); 19 | } 20 | 21 | /** 22 | * Convert from a base64 string 23 | */ 24 | function fromBase64(str) { 25 | return fromString(str, 'base64'); 26 | } 27 | 28 | /** 29 | * Convert from a buffer to an ArrayBuffer 30 | */ 31 | function fromBuffer(buffer) { 32 | const ab = new ArrayBuffer(buffer.length); 33 | const view = new Uint8Array(ab); 34 | for (let i = 0; i < buffer.length; ++i) { 35 | view[i] = buffer[i]; 36 | } 37 | return ab; 38 | } 39 | 40 | /** 41 | * Convert to a buffer 42 | */ 43 | function toBuffer(ab) { 44 | const buffer = new Buffer(ab.byteLength); 45 | const view = new Uint8Array(ab); 46 | for (let i = 0; i < buffer.length; ++i) { 47 | buffer[i] = view[i]; 48 | } 49 | return buffer; 50 | } 51 | 52 | /** 53 | * Force conversion to a Base64 string 54 | */ 55 | function enforceBase64(b) { 56 | return enforceBuffer(b).toString('base64'); 57 | } 58 | 59 | /** 60 | * Force conversion to a buffer 61 | */ 62 | function enforceBuffer(b) { 63 | if (isArrayBuffer(b)) return toBuffer(b); 64 | else return new Buffer(b); 65 | } 66 | 67 | /** 68 | * Force conversion to an arraybuffer 69 | */ 70 | function enforceArrayBuffer(b, encoding) { 71 | if (isArrayBuffer(b)) return b; 72 | else if (isBuffer(b)) return fromBuffer(b); 73 | else return fromString(b, encoding); 74 | } 75 | 76 | /** 77 | * Force conversion to string with specific encoding 78 | */ 79 | function enforceString(b, encoding) { 80 | if (is.string(b)) return b; 81 | if (isArrayBuffer(b)) b = toBuffer(b); 82 | 83 | return b.toString(encoding); 84 | } 85 | 86 | /** 87 | * Tests equality of two ArrayBuffer 88 | * @param {ArrayBuffer} buf1 89 | * @param {ArrayBuffer} buf2 90 | * @return {Boolean} 91 | */ 92 | function equals(buf1, buf2) { 93 | if (buf1.byteLength != buf2.byteLength) return false; 94 | const dv1 = new Int8Array(buf1); 95 | const dv2 = new Int8Array(buf2); 96 | for (let i = 0 ; i != buf1.byteLength ; i++) { 97 | if (dv1[i] != dv2[i]) return false; 98 | } 99 | return true; 100 | } 101 | 102 | const BufferUtils = { 103 | equals, 104 | fromBuffer, 105 | fromString, 106 | fromBase64, 107 | toBuffer, 108 | toBase64: enforceBase64, 109 | enforceBase64, 110 | enforceBuffer, 111 | enforceArrayBuffer, 112 | enforceString 113 | }; 114 | 115 | module.exports = BufferUtils; 116 | -------------------------------------------------------------------------------- /src/utils/base64.js: -------------------------------------------------------------------------------- 1 | const Buffer = require('buffer').Buffer; 2 | 3 | function encode(s) { 4 | return (new Buffer(s)).toString('base64'); 5 | } 6 | 7 | function decode(s, encoding) { 8 | return (new Buffer(s, 'base64')).toString(encoding || 'utf8'); 9 | } 10 | 11 | module.exports = { 12 | encode, 13 | decode 14 | }; 15 | -------------------------------------------------------------------------------- /src/utils/blob.js: -------------------------------------------------------------------------------- 1 | const Q = require('q'); 2 | 3 | const CacheUtils = require('./cache'); 4 | 5 | /** 6 | * Get a blob from cache 7 | * @param {SHA} sha 8 | * @return {Blob} 9 | */ 10 | function read(repoState, sha) { 11 | return repoState.getCache().getBlob(sha); 12 | } 13 | 14 | /** 15 | * Fetch a blob from SHA. 16 | * @param {RepositoryState} repoState 17 | * @param {Driver} driver 18 | * @param {SHA} sha 19 | * @return {Promise} 20 | */ 21 | function fetch(repoState, driver, sha) { 22 | if (isFetched(repoState, sha)) { 23 | // No op if already fetched 24 | return Q(repoState); 25 | } 26 | 27 | const cache = repoState.getCache(); 28 | // Fetch the blob 29 | return driver.fetchBlob(sha) 30 | // Then store it in the cache 31 | .then((blob) => { 32 | const newCache = CacheUtils.addBlob(cache, sha, blob); 33 | return repoState.set('cache', newCache); 34 | }); 35 | } 36 | 37 | /** 38 | * @param {RepositoryState} repoState 39 | * @param {SHA} sha 40 | * @return {Boolean} True if a the corresponding blob is in cache. 41 | */ 42 | function isFetched(repoState, sha) { 43 | return repoState.getCache().getBlobs().has(sha); 44 | } 45 | 46 | const BlobUtils = { 47 | read, 48 | isFetched, 49 | fetch 50 | }; 51 | module.exports = BlobUtils; 52 | -------------------------------------------------------------------------------- /src/utils/branches.js: -------------------------------------------------------------------------------- 1 | const Normalize = require('./normalize'); 2 | const RepoUtils = require('./repo'); 3 | 4 | /** 5 | * Create a new branch with the given name. 6 | * @param {RepositoryState} repoState 7 | * @param {Driver} driver 8 | * @param {String} name 9 | * @param {Branch} [opts.base] Base branch, default to current branch 10 | * @param {Boolean} [opts.checkout=false] Directly fetch and checkout the branch 11 | * @return {Promise} 12 | */ 13 | function create(repositoryState, driver, name, opts = {}) { 14 | const { 15 | // Base branch for the new branch 16 | base = repositoryState.getCurrentBranch(), 17 | // Fetch the working state and switch to it ? 18 | checkout = true, 19 | // Drop changes from base branch the new working state ? 20 | clean = true, 21 | // Drop changes from the base branch ? 22 | cleanBase = false 23 | } = opts; 24 | 25 | let createdBranch; 26 | 27 | return driver.createBranch(base, name) 28 | // Update list of branches 29 | .then((branch) => { 30 | createdBranch = branch; 31 | let branches = repositoryState.getBranches(); 32 | branches = branches.push(createdBranch); 33 | return repositoryState.set('branches', branches); 34 | }) 35 | 36 | // Update working state or fetch it if needed 37 | .then((repoState) => { 38 | let baseWk = repoState.getWorkingStateForBranch(base); 39 | 40 | if (!baseWk) { 41 | return checkout ? RepoUtils.fetchTree(repoState, driver, createdBranch) : repoState; 42 | } 43 | 44 | // Reuse base WorkingState clean 45 | const headWk = clean ? baseWk.asClean() : baseWk; 46 | repoState = RepoUtils.updateWorkingState(repoState, createdBranch, headWk); 47 | 48 | // Clean base WorkingState 49 | baseWk = cleanBase ? baseWk.asClean() : baseWk; 50 | repoState = RepoUtils.updateWorkingState(repoState, base, baseWk); 51 | 52 | return repoState; 53 | }) 54 | 55 | // Checkout the branch 56 | .then((repoState) => { 57 | if (!checkout) { 58 | return repoState; 59 | } 60 | 61 | return RepoUtils.checkout(repoState, createdBranch); 62 | }); 63 | } 64 | 65 | /** 66 | * Fetch the list of branches, and update the given branch only. Will update 67 | * the WorkingState of the branch (and discard previous 68 | * @param {RepositoryState} repoState 69 | * @param {Driver} driver 70 | * @param {Branch | String} branchName The branch to update 71 | * @return {Promise} with the branch updated 72 | */ 73 | function update(repoState, driver, branchName) { 74 | branchName = Normalize.branchName(branchName || repoState.getCurrentBranch()); 75 | 76 | return driver.fetchBranches() 77 | .then((branches) => { 78 | const newBranch = branches.find((branch) => { 79 | return branch.getFullName() === branchName; 80 | }); 81 | 82 | if (!newBranch) { 83 | return repoState; 84 | } else { 85 | return RepoUtils.fetchTree(repoState, driver, newBranch); 86 | } 87 | }); 88 | } 89 | 90 | /** 91 | * Remove the given branch from the repository. 92 | * @param {RepositoryState} repoState 93 | * @param {Driver} driver 94 | * @param {Branch} branch to remove 95 | * @return {Promise} 96 | */ 97 | function remove(repoState, driver, branch) { 98 | return driver.deleteBranch(branch) 99 | .then(() => { 100 | return repoState.updateBranch(branch, null); 101 | }); 102 | } 103 | 104 | /** 105 | * Merge a branch/commit into a branch, and update that branch's tree. 106 | * @param {RepositoryState} repoState 107 | * @param {Driver} driver 108 | * @param {Branch | SHA} from The branch to merge from, or a commit SHA 109 | * @param {Branch} into The branch to merge into, receives the new 110 | * commit. Must be clean 111 | * @param {String} [options.message] Merge commit message 112 | * @param {Boolean} [options.fetch=true] Fetch the updated tree on `into` branch ? 113 | * @return {Promise} Fails with 114 | * CONFLICT error if automatic merge is not possible 115 | */ 116 | function merge(repoState, driver, from, into, options = {}) { 117 | options = Object.assign({ 118 | fetch: true 119 | }, options); 120 | 121 | let updatedInto; // closure 122 | 123 | return driver.merge(from, into, { 124 | message: options.message 125 | }) // Can fail here with ERRORS.CONFLICT 126 | .then(function updateInto(mergeCommit) { 127 | if (!mergeCommit) { 128 | // Was a no op 129 | return repoState; 130 | } else { 131 | updatedInto = into.merge({ commit: mergeCommit }); 132 | repoState = repoState.updateBranch(into, updatedInto); 133 | // invalidate working state 134 | return RepoUtils.updateWorkingState(repoState, into, null); 135 | } 136 | }) 137 | .then(function fetchTree(repositoryState) { 138 | if (!options.fetch) { 139 | return repositoryState; 140 | } else { 141 | return RepoUtils.fetchTree(repositoryState, driver, updatedInto); 142 | } 143 | }); 144 | } 145 | 146 | const BranchUtils = { 147 | remove, 148 | create, 149 | update, 150 | merge 151 | }; 152 | module.exports = BranchUtils; 153 | -------------------------------------------------------------------------------- /src/utils/cache.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Add a new blob to a cache instance 3 | * @param {Cache} cache 4 | * @param {String} sha Used as key 5 | * @param {Blob} blob 6 | * @return {Cache} 7 | */ 8 | function addBlob(cache, sha, blob) { 9 | const blobs = cache.getBlobs(); 10 | const newBlobs = blobs.set(sha, blob); 11 | 12 | const newCache = cache.set('blobs', newBlobs); 13 | return newCache; 14 | } 15 | 16 | const CacheUtils = { 17 | addBlob 18 | }; 19 | module.exports = CacheUtils; 20 | -------------------------------------------------------------------------------- /src/utils/change.js: -------------------------------------------------------------------------------- 1 | const Immutable = require('immutable'); 2 | 3 | const CHANGE_TYPE = require('../constants/changeType'); 4 | const RepoUtils = require('./repo'); 5 | const PathUtils = require('./path'); 6 | 7 | /** 8 | * Returns the pending change of a file on the current branch 9 | * @param {RepositoryState} state 10 | * @param {Path} filepath 11 | * @return {Change | Null} Null if no change, or the file does not exist 12 | */ 13 | function getChange(state, filepath) { 14 | return state.getCurrentState().getChanges().get(filepath) 15 | || null; 16 | } 17 | 18 | /** 19 | * Set a new change to the current WorkingState. 20 | * Attempt to resolve some cases like removing a file that was added 21 | * in the first place. 22 | * @param {RepositoryState} 23 | * @param {String} 24 | * @param {Change} 25 | */ 26 | function setChange(repoState, filepath, change) { 27 | let workingState = repoState.getCurrentState(); 28 | let changes = workingState.getChanges(); 29 | const type = change.getType(); 30 | 31 | // Simplify change when possible 32 | if (type === CHANGE_TYPE.REMOVE 33 | && !workingState.getTreeEntries().has(filepath)) { 34 | // Removing a file that did not exist before 35 | changes = changes.delete(filepath); 36 | 37 | } else if (type === CHANGE_TYPE.CREATE 38 | && workingState.getTreeEntries().has(filepath)) { 39 | // Adding back a file that existed already 40 | changes = changes.set(filepath, change.set('type', CHANGE_TYPE.UPDATE)); 41 | 42 | } else { 43 | // Push changes to list 44 | changes = changes.set(filepath, change); 45 | } 46 | 47 | // Update workingState and repoState 48 | workingState = workingState.set('changes', changes); 49 | return RepoUtils.updateCurrentWorkingState(repoState, workingState); 50 | } 51 | 52 | /** 53 | * Revert all changes 54 | * @param {RepositoryState} 55 | * @return {RepositoryState} 56 | */ 57 | function revertAll(repoState) { 58 | let workingState = repoState.getCurrentState(); 59 | 60 | // Create empty list of changes 61 | const changes = new Immutable.OrderedMap(); 62 | 63 | // Update workingState and repoState 64 | workingState = workingState.set('changes', changes); 65 | return RepoUtils.updateCurrentWorkingState(repoState, workingState); 66 | } 67 | 68 | /** 69 | * Revert change for a specific file 70 | * @param {RepositoryState} 71 | * @param {Path} 72 | * @return {RepositoryState} 73 | */ 74 | function revertForFile(repoState, filePath) { 75 | let workingState = repoState.getCurrentState(); 76 | 77 | // Remove file from changes map 78 | const changes = workingState.getChanges().delete(filePath); 79 | 80 | // Update workingState and repoState 81 | workingState = workingState.set('changes', changes); 82 | return RepoUtils.updateCurrentWorkingState(repoState, workingState); 83 | } 84 | 85 | /** 86 | * Revert changes for a specific directory 87 | * @param {RepositoryState} 88 | * @param {Path} 89 | * @return {RepositoryState} 90 | */ 91 | function revertForDir(repoState, dirPath) { 92 | let workingState = repoState.getCurrentState(); 93 | let changes = workingState.getChanges(); 94 | 95 | // Remove all changes that are in the directory 96 | changes = changes.filter((change, filePath) => { 97 | return !PathUtils.contains(dirPath, filePath); 98 | }); 99 | 100 | // Update workingState and repoState 101 | workingState = workingState.set('changes', changes); 102 | return RepoUtils.updateCurrentWorkingState(repoState, workingState); 103 | } 104 | 105 | /** 106 | * Revert all removed files 107 | * @param {RepositoryState} 108 | * @return {RepositoryState} 109 | */ 110 | function revertAllRemoved(repoState) { 111 | let workingState = repoState.getCurrentState(); 112 | const changes = workingState.getChanges().filter( 113 | // Remove all changes that are in the directory 114 | (change) => { 115 | return change.getType() === CHANGE_TYPE.REMOVE; 116 | } 117 | ); 118 | 119 | // Update workingState and repoState 120 | workingState = workingState.set('changes', changes); 121 | return RepoUtils.updateCurrentWorkingState(repoState, workingState); 122 | } 123 | 124 | const ChangeUtils = { 125 | getChange, 126 | setChange, 127 | revertAll, 128 | revertForFile, 129 | revertForDir, 130 | revertAllRemoved 131 | }; 132 | module.exports = ChangeUtils; 133 | -------------------------------------------------------------------------------- /src/utils/commit.js: -------------------------------------------------------------------------------- 1 | const Q = require('q'); 2 | const Immutable = require('immutable'); 3 | 4 | const ERRORS = require('../constants/errors'); 5 | 6 | const CommitBuilder = require('../models/commitBuilder'); 7 | const WorkingUtils = require('./working'); 8 | const RepoUtils = require('./repo'); 9 | 10 | /** 11 | * Create a commit builder from the changes on current branch 12 | * @param {RepositoryState} 13 | * @param {Author} opts.author 14 | * @param {String} [opts.message] 15 | * @return {CommitBuilder} 16 | */ 17 | function prepare(repoState, opts) { 18 | const workingState = repoState.getCurrentState(); 19 | const changes = workingState.getChanges(); 20 | 21 | // Is this an empty commit ? 22 | opts.empty = workingState.isClean(); 23 | 24 | // Parent SHA 25 | opts.parents = new Immutable.List([ 26 | workingState.getHead() 27 | ]); 28 | 29 | // Get merged tree (with applied changes) 30 | opts.treeEntries = WorkingUtils.getMergedTreeEntries(workingState); 31 | 32 | // Create map of blobs that needs to be created 33 | opts.blobs = changes.filter((change) => { 34 | return !change.hasSha(); 35 | }).map((change) => { 36 | return change.getContent(); 37 | }); 38 | 39 | return CommitBuilder.create(opts); 40 | } 41 | 42 | /** 43 | * Flush a commit from the current branch using a driver 44 | * Then update the reference, and pull new workingState 45 | * @param {RepositoryState} repoState 46 | * @param {Driver} driver 47 | * @param {CommitBuilder} commitBuilder 48 | * @param {Branch} [options.branch] Optional branch to use instead of 49 | * current branch 50 | * @param {Boolean} [options.ignoreEmpty=true] Empty commits are 51 | * ignored, unless they merge several branches. 52 | * @return {Promise} If the 53 | * branch cannot be fast forwarded to the created commit, fails with 54 | * NOT_FAST_FORWARD. The error will contains the created Commit. 55 | */ 56 | function flush(repoState, driver, commitBuilder, options = {}) { 57 | options = Object.assign({ 58 | branch: repoState.getCurrentBranch(), 59 | ignoreEmpty: true 60 | }, options); 61 | 62 | if (options.ignoreEmpty 63 | && commitBuilder.isEmpty() 64 | && commitBuilder.getParents().count() < 2) { 65 | return Q(repoState); 66 | } 67 | 68 | // Create new commit 69 | return driver.flushCommit(commitBuilder) 70 | // Forward the branch 71 | .then((commit) => { 72 | return driver.forwardBranch(options.branch, commit.getSha()) 73 | // Fetch new workingState and replace old one 74 | .then(function updateBranch() { 75 | const updated = options.branch.merge({ commit }); 76 | return repoState.updateBranch(options.branch, updated); 77 | 78 | }, function nonFF(err) { 79 | if (err.code === ERRORS.NOT_FAST_FORWARD) { 80 | // Provide the created commit to allow merging it back. 81 | err.commit = commit; 82 | } 83 | throw err; 84 | }); 85 | }) 86 | .then(function updateWorkingState(forwardedRepoState) { 87 | const forwardedBranch = forwardedRepoState.getBranch(options.branch.getFullName()); 88 | return RepoUtils.fetchTree(forwardedRepoState, driver, forwardedBranch); 89 | }); 90 | } 91 | 92 | /** 93 | * @param {Driver} driver 94 | * @param {Branch} options.branch Branch to list commit on 95 | * @param {Ref} [options.ref] Use a ref or SHA instead of a branch 96 | * @param {Path} [options.path] Filter by file 97 | * @param {String} [options.author] Filter by author name 98 | * @param {Number} [options.per_page] Limits number of result 99 | * @return {Promise>} The history of commits behind this 100 | * branch or ref. Recent first. 101 | */ 102 | function fetchList(driver, options) { 103 | // do we really need repoState as argument ? 104 | options = options || {}; 105 | 106 | let ref; 107 | if (options.ref) { 108 | ref = options.ref; 109 | } else { 110 | ref = options.branch.getFullName(); 111 | } 112 | 113 | options.ref = ref; 114 | 115 | return driver.listCommits(options); 116 | } 117 | 118 | /** 119 | * List all the commits reachable from head, but not from base. Most 120 | * recent first. 121 | * @param {Driver} driver 122 | * @param {Branch | SHA} base 123 | * @param {Branch | SHA} head 124 | * @return {Promise>} 125 | */ 126 | function fetchOwnCommits(driver, base, head) { 127 | return driver.fetchOwnCommits(base, head); 128 | } 129 | 130 | /** 131 | * Get a single commit, with files patch 132 | * @param {Driver} driver 133 | * @param {SHA} sha 134 | * @return {Promise} 135 | */ 136 | function fetch(driver, sha) { 137 | return driver.fetchCommit(sha); 138 | } 139 | 140 | /** 141 | * Compare two commits/branch 142 | * @param {Driver} driver 143 | * @param {SHA or Branch} base 144 | * @param {SHA or Branch} head 145 | * @return {Promise} 146 | */ 147 | function fetchComparison(driver, base, head) { 148 | return driver.fetchComparison(base, head); 149 | } 150 | 151 | const CommitUtils = { 152 | prepare, 153 | flush, 154 | fetchList, 155 | fetchOwnCommits, 156 | fetch, 157 | fetchComparison 158 | }; 159 | module.exports = CommitUtils; 160 | -------------------------------------------------------------------------------- /src/utils/conflict.js: -------------------------------------------------------------------------------- 1 | const Immutable = require('immutable'); 2 | const Q = require('q'); 3 | 4 | const CommitBuilder = require('../models/commitBuilder'); 5 | const TreeEntry = require('../models/treeEntry'); 6 | const Branch = require('../models/branch'); 7 | const Conflict = require('../models/conflict'); 8 | const TreeConflict = require('../models/treeConflict'); 9 | const WorkingState = require('../models/workingState'); 10 | 11 | /** 12 | * Computes a TreeConflict between to tree references. Fetches the 13 | * trees from the repo. The list of conflicts is the minimal set of 14 | * conflicts. 15 | * @param {Driver} driver 16 | * @param {Branch | String} base A branch, branch name, or SHA 17 | * @param {Branch | String} head A branch, branch name, or SHA 18 | * @return {Promise} 19 | */ 20 | function compareRefs(driver, base, head) { 21 | const baseRef = base instanceof Branch ? base.getFullName() : base; 22 | const headRef = head instanceof Branch ? head.getFullName() : head; 23 | 24 | return driver.findParentCommit(baseRef, headRef) 25 | .then((parentCommit) => { 26 | // There can be no parent commit 27 | return Q.all([ 28 | parentCommit ? parentCommit.getSha() : null, 29 | baseRef, 30 | headRef 31 | ].map((ref) => { 32 | return ref ? driver.fetchWorkingState(ref) : WorkingState.createEmpty(); 33 | })); 34 | }) 35 | .spread((parent, base, head) => { 36 | const conflicts = _compareTrees(parent.getTreeEntries(), 37 | base.getTreeEntries(), 38 | head.getTreeEntries()); 39 | 40 | return new TreeConflict({ 41 | base, 42 | head, 43 | parent, 44 | conflicts 45 | }); 46 | }); 47 | } 48 | 49 | /** 50 | * Merge solved Conflicts back into a TreeConflict. Unsolved conflicts 51 | * default to keep base. 52 | * @param {TreeConflict} treeConflict 53 | * @param {Map} solved 54 | * @return {TreeConflict} 55 | */ 56 | function solveTree(treeConflict, solved) { 57 | solved = treeConflict.getConflicts() 58 | .merge(solved) 59 | // Solve unresolved conflicts 60 | .map(function defaultSolve(conflict) { 61 | if (!conflict.isSolved()) { 62 | return conflict.keepBase(); 63 | } else { 64 | return conflict; 65 | } 66 | }); 67 | return treeConflict.set('conflicts', solved); 68 | } 69 | 70 | /** 71 | * Create a merge commit builder 72 | * @param {TreeConflict} treeConflict The solved TreeConflict 73 | * @param {Array} parents Parent commits 74 | * @param {Author} options.author 75 | * @param {String} [options.message='Merge commit'] 76 | * @return {CommitBuilder} 77 | */ 78 | function mergeCommit(treeConflict, parents, options) { 79 | options = options || {}; 80 | 81 | const opts = {}; 82 | 83 | // Assume the commit is not empty 84 | opts.empty = false; 85 | 86 | // Parent SHAs 87 | opts.parents = new Immutable.List(parents); 88 | 89 | opts.author = options.author; 90 | opts.message = options.message || 'Merged commit'; 91 | 92 | // Get the solved tree entries 93 | const solvedEntries = _getSolvedEntries(treeConflict); 94 | opts.treeEntries = solvedEntries; 95 | 96 | // Create map of blobs that needs to be created 97 | const solvedConflicts = treeConflict.getConflicts(); 98 | opts.blobs = solvedEntries.filter((treeEntry) => { 99 | return !treeEntry.hasSha(); 100 | }).map((treeEntry, path) => { 101 | return solvedConflicts.get(path).getSolvedContent(); 102 | }); 103 | 104 | return CommitBuilder.create(opts); 105 | } 106 | 107 | // ---- Auxiliaries ---- 108 | 109 | /** 110 | * @param {Map} parentEntries 111 | * @param {Map} baseEntries 112 | * @param {Map} headEntries 113 | * @return {Map} The minimal set of conflicts. 114 | */ 115 | function _compareTrees(parentEntries, baseEntries, headEntries) { 116 | const headDiff = _diffEntries(parentEntries, headEntries); 117 | const baseDiff = _diffEntries(parentEntries, baseEntries); 118 | 119 | // Conflicting paths are paths... 120 | // ... modified by both branches 121 | const headSet = Immutable.Set.fromKeys(headDiff); 122 | const baseSet = Immutable.Set.fromKeys(baseDiff); 123 | const conflictSet = headSet.intersect(baseSet).filter((filepath) => { 124 | // ...in different manners 125 | return !Immutable.is(headDiff.get(filepath), baseDiff.get(filepath)); 126 | }); 127 | 128 | // Create the map of Conflict 129 | return (new Immutable.Map()).withMutations((map) => { 130 | return conflictSet.reduce((map, filepath) => { 131 | const shas = [ 132 | parentEntries, 133 | baseEntries, 134 | headEntries 135 | ].map(function getSha(entries) { 136 | if (!entries.has(filepath)) return null; 137 | return entries.get(filepath).getSha() || null; 138 | }); 139 | 140 | return map.set(filepath, Conflict.create.apply(undefined, shas)); 141 | }, map); 142 | }); 143 | } 144 | 145 | /** 146 | * @param {Map} parent 147 | * @param {Map} child 148 | * @return {Map} The set of Path that have 149 | * been modified by the child. Null entries mean deletion. 150 | */ 151 | function _diffEntries(parent, child) { 152 | const parentKeys = Immutable.Set.fromKeys(parent); 153 | const childKeys = Immutable.Set.fromKeys(child); 154 | const all = parentKeys.union(childKeys); 155 | 156 | const changes = all.filter(function hasChanged(path) { 157 | // Removed unchanged 158 | return !Immutable.is(parent.get(path), child.get(path)); 159 | }); 160 | 161 | return (new Immutable.Map()).withMutations((map) => { 162 | return changes.reduce((map, path) => { 163 | // Add new TreeEntry or null when deleted 164 | const treeEntry = child.get(path) || null; 165 | return map.set(path, treeEntry); 166 | }, map); 167 | }); 168 | } 169 | 170 | /** 171 | * Returns the final TreeEntries for a solved TreeConflict. 172 | * @param {TreeConflict} treeConflict 173 | * @return {Map} Some TreeEntries have a null SHA 174 | * because of new solved content. 175 | */ 176 | function _getSolvedEntries(treeConflict) { 177 | const parentEntries = treeConflict.getParent().getTreeEntries(); 178 | const baseEntries = treeConflict.getBase().getTreeEntries(); 179 | const headEntries = treeConflict.getHead().getTreeEntries(); 180 | 181 | const baseDiff = _diffEntries(parentEntries, baseEntries); 182 | const headDiff = _diffEntries(parentEntries, headEntries); 183 | 184 | const resolvedEntries = treeConflict.getConflicts().map((solvedConflict) => { 185 | // Convert to TreeEntries (or null for deletion) 186 | if (solvedConflict.isDeleted()) { 187 | return null; 188 | } else { 189 | return new TreeEntry({ 190 | sha: solvedConflict.getSolvedSha() || null 191 | }); 192 | } 193 | }); 194 | 195 | return parentEntries.merge(baseDiff, headDiff, resolvedEntries) 196 | // Remove deleted entries 197 | .filter(function nonNull(entry) { 198 | return entry !== null; 199 | }); 200 | } 201 | 202 | const ConflictUtils = { 203 | solveTree, 204 | mergeCommit, 205 | compareRefs, 206 | // Exposed for testing purpose 207 | _diffEntries, 208 | _getSolvedEntries, 209 | _compareTrees 210 | }; 211 | module.exports = ConflictUtils; 212 | -------------------------------------------------------------------------------- /src/utils/directory.js: -------------------------------------------------------------------------------- 1 | const Path = require('path'); 2 | const flatten = require('array-flatten'); 3 | const uniqueBy = require('unique-by'); 4 | 5 | const FILETYPE = require('../constants/filetype'); 6 | const File = require('../models/file'); 7 | const TreeEntry = require('../models/treeEntry'); 8 | 9 | const PathUtils = require('./path'); 10 | const WorkingUtils = require('./working'); 11 | const FileUtils = require('./file'); 12 | 13 | /** 14 | * List files in a directory (shallow) 15 | * @param {RepositoryState} repoState 16 | * @param {Path} dirName 17 | * @return {Array} 18 | */ 19 | function read(repoState, dirName) { 20 | dirName = PathUtils.norm(dirName); 21 | 22 | const workingState = repoState.getCurrentState(); 23 | const changes = workingState.getChanges(); 24 | const treeEntries = WorkingUtils.getMergedTreeEntries(workingState); 25 | 26 | const files = []; 27 | 28 | treeEntries.forEach((treeEntry, filepath) => { 29 | // Ignore git submodules 30 | if (treeEntry.getType() !== TreeEntry.TYPES.BLOB) return; 31 | if (!PathUtils.contains(dirName, filepath)) return; 32 | 33 | const innerPath = PathUtils.norm(filepath.replace(dirName, '')); 34 | const isDirectory = innerPath.indexOf('/') >= 0; 35 | // Make it shallow 36 | const name = innerPath.split('/')[0]; 37 | 38 | const file = new File({ 39 | path: Path.join(dirName, name), 40 | type: isDirectory ? FILETYPE.DIRECTORY : FILETYPE.FILE, 41 | change: changes.get(filepath), 42 | fileSize: treeEntry.blobSize 43 | }); 44 | 45 | files.push(file); 46 | }); 47 | 48 | // Remove duplicate from entries within directories 49 | return uniqueBy(files, (file) => { 50 | return file.getName(); 51 | }); 52 | } 53 | 54 | /** 55 | * List files and directories in a directory (recursive). 56 | * Warning: This recursive implementation is very costly. 57 | * @param {RepositoryState} repoState 58 | * @param {Path} dirName 59 | * @return {Array} 60 | */ 61 | function readRecursive(repoState, dirName) { 62 | // TODO improve performance and don't use .read() directly 63 | const files = read(repoState, dirName); 64 | 65 | let filesInDirs = files 66 | .filter((file) => { 67 | return file.isDirectory(); 68 | }) 69 | .map((dir) => { 70 | return readRecursive(repoState, dir.path); 71 | }); 72 | 73 | filesInDirs = flatten(filesInDirs); 74 | 75 | return Array.prototype.concat(files, filesInDirs); 76 | } 77 | 78 | /** 79 | * List files in a directory (shallow) 80 | * @param {RepositoryState} repoState 81 | * @param {Path} dirName 82 | * @return {Array} 83 | */ 84 | function readFilenames(repoState, dirName) { 85 | const files = read(repoState, dirName); 86 | 87 | return files.map((file) => { 88 | return file.getPath(); 89 | }); 90 | } 91 | 92 | /** 93 | * List files recursively in a directory 94 | * @param {RepositoryState} repoState 95 | * @param {Path} dirName 96 | * @return {Array} 97 | */ 98 | function readFilenamesRecursive(repoState, dirName) { 99 | dirName = PathUtils.norm(dirName); 100 | 101 | const workingState = repoState.getCurrentState(); 102 | const fileSet = WorkingUtils.getMergedFileSet(workingState); 103 | 104 | return fileSet.filter((path) => { 105 | return PathUtils.contains(dirName, path); 106 | }).toArray(); 107 | } 108 | 109 | /** 110 | * Rename a directory 111 | */ 112 | function move(repoState, dirName, newDirName) { 113 | // List entries to move 114 | const filesToMove = readFilenamesRecursive(repoState, dirName); 115 | 116 | // Push change to remove all entries 117 | return filesToMove.reduce((repoState, oldPath) => { 118 | const newPath = Path.join( 119 | newDirName, 120 | Path.relative(dirName, oldPath) 121 | ); 122 | 123 | return FileUtils.move(repoState, oldPath, newPath); 124 | }, repoState); 125 | } 126 | 127 | /** 128 | * Remove a directory: push REMOVE changes for all entries in the directory 129 | */ 130 | function remove(repoState, dirName) { 131 | // List entries to move 132 | const filesToRemove = readFilenamesRecursive(repoState, dirName); 133 | 134 | // Push change to remove all entries 135 | return filesToRemove.reduce((repoState, path) => { 136 | return FileUtils.remove(repoState, path); 137 | }, repoState); 138 | } 139 | 140 | const DirUtils = { 141 | read, 142 | readRecursive, 143 | readFilenames, 144 | readFilenamesRecursive, 145 | remove, 146 | move 147 | }; 148 | module.exports = DirUtils; 149 | -------------------------------------------------------------------------------- /src/utils/error.js: -------------------------------------------------------------------------------- 1 | const ERRORS = require('../constants/errors'); 2 | 3 | /** 4 | * Return an error with a specific code 5 | */ 6 | function withCode(code, msg) { 7 | const err = new Error(msg); 8 | err.code = code; 9 | return err; 10 | } 11 | 12 | module.exports = { 13 | invalidArgs(err) { 14 | return withCode(400, err); 15 | }, 16 | fileAlreadyExist(filepath) { 17 | return withCode(ERRORS.ALREADY_EXIST, 'File already exist "' + filepath + '"'); 18 | }, 19 | dirAlreadyExist(filepath) { 20 | return withCode(ERRORS.ALREADY_EXIST, 'Directory already exist "' + filepath + '"'); 21 | }, 22 | fileNotFound(filepath) { 23 | return withCode(ERRORS.NOT_FOUND, 'File not found "' + filepath + '"'); 24 | }, 25 | notDirectory(filepath) { 26 | return withCode(406, '"' + filepath + '" is not a directory'); 27 | }, 28 | refNotFound(ref) { 29 | return withCode(ERRORS.NOT_FOUND, 'Ref not found "' + ref + '"'); 30 | }, 31 | commitNotFound(sha) { 32 | return withCode(ERRORS.NOT_FOUND, 'Commit not found "' + sha + '"'); 33 | }, 34 | fileHasBeenModified(filepath) { 35 | return withCode(406, 'File "' + filepath + '" has been modified'); 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /src/utils/file.js: -------------------------------------------------------------------------------- 1 | const Q = require('q'); 2 | const bufferUtils = require('./arraybuffer'); 3 | 4 | const FILETYPE = require('../constants/filetype'); 5 | const Change = require('../models/change'); 6 | const File = require('../models/file'); 7 | 8 | const error = require('./error'); 9 | const ChangeUtils = require('./change'); 10 | const WorkingUtils = require('./working'); 11 | const BlobUtils = require('./blob'); 12 | 13 | /** 14 | * Fetch a file blob. Required for content access with 15 | * stat/read. No-op if the file is already fetched. 16 | * @param {RepositoryState} repoState 17 | * @param {Driver} driver 18 | * @param {Path} filepath 19 | * @return {Promise} 20 | */ 21 | function fetch(repoState, driver, filepath) { 22 | if (isFetched(repoState, filepath)) { 23 | // No op if already fetched 24 | return Q(repoState); 25 | } 26 | 27 | const workingState = repoState.getCurrentState(); 28 | const blobSha = WorkingUtils.findSha(workingState, filepath); 29 | 30 | return BlobUtils.fetch(repoState, driver, blobSha); 31 | } 32 | 33 | /** 34 | * @param {RepositoryState} repoState 35 | * @param {Path} filepath 36 | * @return {Boolean} True if the file's content is in cache 37 | */ 38 | function isFetched(repoState, filepath) { 39 | const workingState = repoState.getCurrentState(); 40 | const blobSha = WorkingUtils.findSha(workingState, filepath); 41 | // If sha is null then there are changes (those which are stored 42 | // and need not be fetched) 43 | return (blobSha === null) || BlobUtils.isFetched(repoState, blobSha); 44 | } 45 | 46 | /** 47 | * Stat details about a file. 48 | * @param {RepositoryState} repoState 49 | * @param {Path} filepath 50 | * @return {File} 51 | */ 52 | function stat(repoState, filepath) { 53 | const workingState = repoState.getCurrentState(); 54 | 55 | // Lookup potential changes 56 | const change = workingState.getChanges().get(filepath); 57 | // Lookup file entry 58 | const treeEntry = workingState.getTreeEntries().get(filepath); 59 | 60 | // Determine SHA of the blob 61 | let blobSHA; 62 | if (change) { 63 | blobSHA = change.getSha(); 64 | } else { 65 | blobSHA = treeEntry.getSha(); 66 | } 67 | 68 | // Get the blob from change or cache 69 | let blob; 70 | if (blobSHA) { 71 | // Get content from cache 72 | blob = repoState.getCache().getBlob(blobSHA); 73 | } else { 74 | // No sha, so it must be in changes 75 | blob = change.getContent(); 76 | } 77 | 78 | let fileSize; 79 | if (blob) { 80 | fileSize = blob.getByteLength(); 81 | } else { 82 | // It might have been moved (but not fetched) 83 | const originalEntry = workingState.getTreeEntries().find((entry) => { 84 | return entry.getSha() === blobSHA; 85 | }); 86 | fileSize = originalEntry.getBlobSize(); 87 | } 88 | 89 | return new File({ 90 | type: FILETYPE.FILE, 91 | fileSize, 92 | path: filepath, 93 | content: blob 94 | }); 95 | } 96 | 97 | /** 98 | * Read content of a file 99 | * @param {Path} filepath 100 | * @return {Blob} 101 | */ 102 | function read(repoState, filepath) { 103 | const file = stat(repoState, filepath); 104 | return file.getContent(); 105 | } 106 | 107 | /** 108 | * Read content of a file, returns a String 109 | * @return {String} 110 | */ 111 | function readAsString(repoState, filepath, encoding) { 112 | const blob = read(repoState, filepath); 113 | return blob.getAsString(encoding); 114 | } 115 | 116 | /** 117 | * Return true if file exists in working tree, false otherwise 118 | */ 119 | function exists(repoState, filepath) { 120 | const workingState = repoState.getCurrentState(); 121 | const mergedFileSet = WorkingUtils.getMergedTreeEntries(workingState); 122 | 123 | return mergedFileSet.has(filepath); 124 | } 125 | 126 | /** 127 | * Create a new file (must not exists already) 128 | * @param {RepositoryState} repoState 129 | * @param {Path} filepath 130 | * @param {String} [content=''] 131 | * @return {RepositoryState} 132 | */ 133 | function create(repoState, filepath, content) { 134 | content = content || ''; 135 | if (exists(repoState, filepath)) { 136 | throw error.fileAlreadyExist(filepath); 137 | } 138 | const change = Change.createCreate(content); 139 | return ChangeUtils.setChange(repoState, filepath, change); 140 | } 141 | 142 | /** 143 | * Write a file (must exists) 144 | * @return {RepositoryState} 145 | */ 146 | function write(repoState, filepath, content) { 147 | if (!exists(repoState, filepath)) { 148 | throw error.fileNotFound(filepath); 149 | } 150 | 151 | const change = Change.createUpdate(content); 152 | return ChangeUtils.setChange(repoState, filepath, change); 153 | } 154 | 155 | /** 156 | * Remove a file 157 | * @return {RepositoryState} 158 | */ 159 | function remove(repoState, filepath) { 160 | if (!exists(repoState, filepath)) { 161 | throw error.fileNotFound(filepath); 162 | } 163 | 164 | const change = Change.createRemove(); 165 | return ChangeUtils.setChange(repoState, filepath, change); 166 | } 167 | 168 | /** 169 | * Rename a file 170 | * @return {RepositoryState} 171 | */ 172 | function move(repoState, filepath, newFilepath) { 173 | if (filepath === newFilepath) { 174 | return repoState; 175 | } 176 | 177 | const initialWorkingState = repoState.getCurrentState(); 178 | 179 | // Create new file, with Sha if possible 180 | const sha = WorkingUtils.findSha(initialWorkingState, filepath); 181 | let changeNewFile; 182 | if (sha) { 183 | changeNewFile = Change.createCreateFromSha(sha); 184 | } else { 185 | // Content not available as blob 186 | const blob = read(repoState, filepath); 187 | const contentBuffer = blob.getAsBuffer(); 188 | changeNewFile = Change.createCreate(contentBuffer); 189 | } 190 | 191 | // Remove old file 192 | const removedRepoState = remove(repoState, filepath); 193 | // Add new file 194 | return ChangeUtils.setChange(removedRepoState, newFilepath, changeNewFile); 195 | } 196 | 197 | /** 198 | * Returns true if the given file has the same content in both 199 | * RepositoryState's current working state, or is absent from both. 200 | * @param {RepositoryState} previousState 201 | * @param {RepositoryState} newState 202 | * @param {Path} filepath 203 | * @return {Boolean} 204 | */ 205 | function hasChanged(previousState, newState, filepath) { 206 | const previouslyExists = exists(previousState, filepath); 207 | const newExists = exists(newState, filepath); 208 | if (!previouslyExists && !newExists) { 209 | // Still non existing 210 | return false; 211 | } else if (exists(previousState, filepath) !== exists(newState, filepath)) { 212 | // The file is absent from one 213 | return true; 214 | } else { 215 | // Both files exist 216 | const prevWorking = previousState.getCurrentState(); 217 | const newWorking = newState.getCurrentState(); 218 | 219 | const prevSha = WorkingUtils.findSha(prevWorking, filepath); 220 | const newSha = WorkingUtils.findSha(newWorking, filepath); 221 | if (prevSha === null && newSha === null) { 222 | // Both have are in pending changes. We can compare their contents 223 | return read(previousState, filepath).getAsString() !== 224 | read(newState, filepath).getAsString(); 225 | } else { 226 | // Content changed if Shas are different, or one of them is null 227 | return prevSha !== newSha; 228 | } 229 | } 230 | } 231 | 232 | const FileUtils = { 233 | stat, 234 | fetch, 235 | isFetched, 236 | exists, 237 | read, 238 | readAsString, 239 | create, 240 | write, 241 | remove, 242 | move, 243 | hasChanged 244 | }; 245 | module.exports = FileUtils; 246 | -------------------------------------------------------------------------------- /src/utils/filestree.js: -------------------------------------------------------------------------------- 1 | const Immutable = require('immutable'); 2 | const Path = require('path'); 3 | 4 | const File = require('../models/file'); 5 | const TreeNode = require('./treeNode'); 6 | const DirUtils = require('./directory'); 7 | const FileUtils = require('./file'); 8 | const WorkingUtils = require('./working'); 9 | 10 | /** 11 | * Utils to create tree structures for files. 12 | */ 13 | 14 | /** 15 | * Convert a filepath to a Seq to use as key path 16 | * @param {Path} path 17 | * @return {Seq} 18 | */ 19 | function pathToKeySeq(path) { 20 | // Remove trailing '/' etc. 21 | path = Path.join(path, '.'); 22 | if (path === '.') { 23 | return Immutable.Seq([]); 24 | } else { 25 | return Immutable.Seq(path.split('/')); 26 | } 27 | } 28 | 29 | 30 | /** 31 | * Find a node at the given path in a TreeNode of Files. 32 | * @param {TreeNode} tree 33 | * @param {Path} path 34 | * @return {TreeNode | Null} null if no found 35 | */ 36 | function getInPath(tree, path) { 37 | return tree.getIn(pathToKeySeq(path)); 38 | } 39 | 40 | /** 41 | * Generate a files tree from the current branch, taking pending changes into account. 42 | * @param {RepositoryState} repoState 43 | * @param {Path} [dir='.'] The directory to get the subtree from, default to root 44 | * @return {TreeNode} The directory TreeNode with all its children 45 | */ 46 | function get(repoState, dirPath) { 47 | // Remove trailing '/' etc. 48 | const normDirPath = Path.join(dirPath, '.'); 49 | const filepaths = DirUtils.readFilenamesRecursive(repoState, normDirPath); 50 | 51 | const tree = { 52 | value: File.createDir(normDirPath), 53 | children: {} 54 | }; 55 | 56 | for (let i = 0; i < filepaths.length; i++) { 57 | const relativePath = Path.relative(normDirPath, filepaths[i]); 58 | const parts = relativePath.split('/'); 59 | let node = tree; 60 | let prefix = normDirPath; 61 | for (let j = 0; j < parts.length; j++) { 62 | const head = parts[j]; 63 | const isLeaf = (j === parts.length - 1); 64 | prefix = Path.join(prefix, head); 65 | 66 | // Create node if doesn't exist 67 | if (!node.children[head]) { 68 | if (isLeaf) { 69 | node.children[head] = { 70 | value: FileUtils.stat(repoState, filepaths[i]) 71 | }; 72 | } else { 73 | node.children[head] = { 74 | value: File.createDir(prefix), 75 | children: {} 76 | }; 77 | } 78 | } 79 | node = node.children[head]; 80 | } 81 | } 82 | 83 | return TreeNode.fromJS(tree); 84 | } 85 | 86 | /** 87 | * @param {RepositoryState} previousRepo 88 | * @param {RepositoryState} newRepo 89 | * @return {Boolean} True if the files structure changed. 90 | */ 91 | function hasChanged(previousRepo, newRepo, dir) { 92 | const previousWorking = previousRepo.getCurrentState(); 93 | const newWorking = newRepo.getCurrentState(); 94 | 95 | const previousFiles = WorkingUtils.getMergedFileSet(previousWorking); 96 | const newFiles = WorkingUtils.getMergedFileSet(newWorking); 97 | 98 | return !Immutable.is(previousFiles, newFiles); 99 | } 100 | 101 | 102 | const TreeUtils = { 103 | TreeNode, 104 | get, 105 | getInPath, 106 | hasChanged 107 | }; 108 | module.exports = TreeUtils; 109 | -------------------------------------------------------------------------------- /src/utils/gravatar.js: -------------------------------------------------------------------------------- 1 | const md5Hex = require('md5-hex'); 2 | 3 | // Get gravatar url 4 | function gravatarUrl(email) { 5 | return 'https://www.gravatar.com/avatar/' + md5Hex(email) + '?s=200&d=' + encodeURIComponent('https://www.gitbook.com/assets/images/avatars/user.png'); 6 | } 7 | 8 | module.exports = { 9 | url: gravatarUrl 10 | }; 11 | -------------------------------------------------------------------------------- /src/utils/localFile.js: -------------------------------------------------------------------------------- 1 | const LocalFile = require('../models/localFile'); 2 | 3 | const LocalUtils = module.exports; 4 | 5 | /** 6 | * Perform a git status on a given repository branch 7 | * 8 | * @param {Driver} driver 9 | * @return {Promise>} 10 | */ 11 | LocalUtils.status = function status(driver) { 12 | return driver.status(); 13 | }; 14 | 15 | /** 16 | * Perform track / untrack of working directory. 17 | * 18 | * @param {RepositoryState} repoState 19 | * @param {Driver} driver 20 | * @param {String} message 21 | * @param {Author} author 22 | * @return {Promise} 23 | */ 24 | LocalUtils.track = function track(driver, files, message, author) { 25 | files = files.map((file) => { 26 | return LocalFile.create(file); 27 | }); 28 | 29 | return driver.track({ 30 | message, 31 | files, 32 | author, 33 | committer: author 34 | }); 35 | }; 36 | -------------------------------------------------------------------------------- /src/utils/normalize.js: -------------------------------------------------------------------------------- 1 | /** 2 | * A set of utilities to coerce arguments. 3 | */ 4 | 5 | 6 | /** 7 | * @param {Branch | String} 8 | * @return {String} The branch fullname 9 | */ 10 | function branchName(branch) { 11 | return typeof branch === 'string' 12 | ? branch 13 | : branch.getFullName(); 14 | } 15 | 16 | module.exports = { 17 | branchName 18 | }; 19 | 20 | -------------------------------------------------------------------------------- /src/utils/path.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | /** 4 | * Normalize a path 5 | */ 6 | function normPath(p) { 7 | p = path.normalize(p); 8 | if (p[0] == '/') p = p.slice(1); 9 | if (p[p.length - 1] == '/') p = p.slice(0, -1); 10 | if (p == '.') p = ''; 11 | return p; 12 | } 13 | 14 | /** 15 | * Returns true if the path is under dir 16 | */ 17 | function pathContains(dir, path) { 18 | dir = dir ? normPath(dir) + '/' : dir; 19 | path = normPath(path); 20 | 21 | return path.indexOf(dir) === 0; 22 | } 23 | 24 | module.exports = { 25 | norm: normPath, 26 | contains: pathContains 27 | }; 28 | -------------------------------------------------------------------------------- /src/utils/remote.js: -------------------------------------------------------------------------------- 1 | const Promise = require('q'); 2 | const ERRORS = require('../constants/errors'); 3 | const RepoUtils = require('./repo'); 4 | 5 | /** 6 | * Push a local branch to a remote repository 7 | * @param {RepositoryState} repoState 8 | * @param {Driver} driver 9 | * @param {Branch} [opts.branch] Branch to push. Default to current 10 | * @param {String} [opts.remote.name=origin] Name of the remote 11 | * @param {String} [opts.remote.url] URL if the remote needs to be created 12 | * @param {Boolean} [opts.force=false] Ignore non fast forward 13 | * @param {String} [opts.auth.username] Authentication username 14 | * @param {String} [opts.auth.password] Authentication password 15 | * @return {Promise} 16 | * @throws {Promise} 17 | * @throws {Promise} 18 | * @throws {Promise} 19 | */ 20 | function push(repoState, driver, opts = {}) { 21 | opts = Object.assign({ 22 | branch: repoState.getCurrentBranch(), 23 | force: false, 24 | remote: { 25 | name: 'origin' 26 | } 27 | }, opts); 28 | 29 | return driver.push(opts) // Can fail with NOT_FAST_FORWARD 30 | // TODO update remote branch in repoState list of branches 31 | .thenResolve(repoState); 32 | } 33 | 34 | /** 35 | * Pulls changes for local branch, from remote repository. Loses any 36 | * pending changes on it. 37 | * @param {RepositoryState} repoState 38 | * @param {Driver} driver 39 | * @param {Branch} [opts.branch] Branch to pull. Default to current 40 | * @param {String} [opts.remote.name=origin] Name of the remote 41 | * @param {String} [opts.remote.url] URL if the remote needs to be created 42 | * @param {Boolean} [opts.force=false] Ignore non fast forward 43 | * @param {String} [opts.auth.username] Authentication username 44 | * @param {String} [opts.auth.password] Authentication password 45 | * @return {Promise} 46 | * @throws {Promise} 47 | * @throws {Promise} 48 | * @throws {Promise} 49 | */ 50 | function pull(repoState, driver, opts = {}) { 51 | opts = Object.assign({ 52 | branch: repoState.getCurrentBranch(), 53 | force: false, 54 | remote: { 55 | name: 'origin' 56 | } 57 | }, opts); 58 | 59 | return driver.pull(opts) 60 | // Update branch SHA 61 | .then(() => { 62 | return driver.fetchBranches(); 63 | }) 64 | .then((branches) => { 65 | const updatedBranch = branches.find((br) => { 66 | return br.name === opts.branch.name; 67 | }); 68 | repoState = repoState.updateBranch(opts.branch, updatedBranch); 69 | 70 | return RepoUtils.fetchTree(repoState, driver, updatedBranch); 71 | }); 72 | } 73 | 74 | /** 75 | * List remotes on the repository. 76 | * @param {Driver} driver 77 | * @return {Promise>} 78 | */ 79 | function list(driver) { 80 | return driver.listRemotes(); 81 | } 82 | 83 | /** 84 | * Edit or create a remote. 85 | * @param {Driver} driver 86 | * @param {String} name Name of the remote 87 | * @param {String} url New URL of the remote 88 | */ 89 | function edit(driver, name, url) { 90 | return driver.editRemotes(name, url); 91 | } 92 | 93 | /** 94 | * Sync repository with remote by pulling / pushing to the remote. 95 | * @param {RepositoryState} repoState 96 | * @param {Driver} driver 97 | * @param {Branch} [opts.branch] Branch to push. Default to current 98 | * @param {String} [opts.remote.name=origin] Name of the remote 99 | * @param {String} [opts.remote.url] URL if the remote needs to be created 100 | * @param {Boolean} [opts.force=false] Ignore non fast forward 101 | * @param {String} [opts.auth.username] Authentication username 102 | * @param {String} [opts.auth.password] Authentication password 103 | * @return {Promise} 104 | * @throws {Promise} 105 | * @throws {Promise} 106 | * @throws {Promise} 107 | */ 108 | function sync(repoState, driver, opts = {}) { 109 | opts = Object.assign({ 110 | branch: repoState.getCurrentBranch(), 111 | force: false, 112 | remote: { 113 | name: 'origin' 114 | } 115 | }, opts); 116 | 117 | return pull(repoState, driver, opts) 118 | .fail((err) => { 119 | if (err.code === ERRORS.REF_NOT_FOUND) { 120 | return Promise(repoState); 121 | } 122 | 123 | return Promise.reject(err); 124 | }) 125 | .then((newRepoState) => { 126 | return push(newRepoState, driver, opts); 127 | }); 128 | } 129 | 130 | const RemoteUtils = { 131 | push, 132 | pull, 133 | list, 134 | edit, 135 | sync 136 | }; 137 | 138 | module.exports = RemoteUtils; 139 | -------------------------------------------------------------------------------- /src/utils/repo.js: -------------------------------------------------------------------------------- 1 | const Branch = require('../models/branch'); 2 | const WorkingUtils = require('./working'); 3 | const RepositoryState = require('../models/repositoryState'); 4 | 5 | /** 6 | * Change workinState for a specific branch 7 | * @param {RepositoryState} repoState 8 | * @param {Branch} branch 9 | * @param {WorkingState | Null} newWorkingState Pass null to delete 10 | * @return {RepositoryState} 11 | */ 12 | function updateWorkingState(repoState, branch, newWorkingState) { 13 | let workingStates = repoState.getWorkingStates(); 14 | 15 | const key = branch.getFullName(); 16 | if (newWorkingState === null) { 17 | // Delete 18 | workingStates = workingStates.delete(key); 19 | } else { 20 | // Update the entry in the map 21 | workingStates = workingStates.set(key, newWorkingState); 22 | } 23 | 24 | return repoState.set('workingStates', workingStates); 25 | } 26 | 27 | /** 28 | * Change current working tree 29 | */ 30 | function updateCurrentWorkingState(repoState, workingState) { 31 | return updateWorkingState(repoState, repoState.getCurrentBranch(), workingState); 32 | } 33 | 34 | /** 35 | * Fetches the given branch's tree, from its SHA, and __resets any 36 | * WorkingState for it__ 37 | * @param {RepositoryState} 38 | * @param {Driver} 39 | * @param {Branch} 40 | * @return {Promise} 41 | */ 42 | function fetchTree(repoState, driver, branch) { 43 | // Fetch a working tree for this branch 44 | return WorkingUtils.fetch(driver, branch) 45 | .then((newWorkingState) => { 46 | return updateWorkingState(repoState, branch, newWorkingState); 47 | }); 48 | } 49 | 50 | /** 51 | * Change current branch in the repository (sync). Requires to have 52 | * fetched the branch. 53 | * @param {RepositoryState} repoState 54 | * @param {Branch | String} branch Can provide the fullname of the branch instead 55 | * @return {RepositoryState} 56 | */ 57 | function checkout(repoState, branch) { 58 | let _branch = branch; 59 | if (!(branch instanceof Branch)) { 60 | _branch = repoState.getBranch(branch); 61 | if (branch === null) { 62 | throw Error('Unknown branch ' + branch); 63 | } 64 | } 65 | 66 | if (!repoState.isFetched(_branch)) { 67 | throw Error('Tree for branch ' + _branch.getFullName() + ' must be fetched first'); 68 | } 69 | return repoState.set('currentBranchName', _branch.getFullName()); 70 | } 71 | 72 | /** 73 | * Fetch the list of branches in the repository and update them all. Will clear the 74 | * WorkingStates of all branches that have updated. 75 | * @param {RepositoryState} repoState 76 | * @param {Driver} driver 77 | * @return {Promise} with list of branches fetched 78 | */ 79 | function fetchBranches(repoState, driver) { 80 | const oldBranches = repoState.getBranches(); 81 | return driver.fetchBranches() 82 | .then((branches) => { 83 | return repoState.set('branches', branches); 84 | }) 85 | .then(function refreshWorkingStates(repoState) { 86 | // Remove outdated WorkingStates 87 | return oldBranches.reduce((repoState, oldBranch) => { 88 | const fullName = oldBranch.getFullName(); 89 | const newBranch = repoState.getBranch(fullName); 90 | if (newBranch === null || newBranch.getSha() !== oldBranch.getSha()) { 91 | // Was removed OR updated 92 | return updateWorkingState(repoState, oldBranch, null); 93 | } else { 94 | // Unchanged 95 | return repoState; 96 | } 97 | }, repoState); 98 | }); 99 | } 100 | 101 | /** 102 | * Initialize a new RepositoryState from the repo of a Driver. Fetch 103 | * the branches, and checkout master or the first available branch. 104 | * @param {Driver} driver 105 | * @return {Promise} 106 | */ 107 | function initialize(driver) { 108 | const repoState = RepositoryState.createEmpty(); 109 | return fetchBranches(repoState, driver) 110 | .then((repoState) => { 111 | const branches = repoState.getBranches(); 112 | const master = branches.find(function isMaster(branch) { 113 | return branch.getFullName() === 'master'; 114 | }); 115 | const branch = master || branches.first(); 116 | 117 | return fetchTree(repoState, driver, branch) 118 | .then((repoState) => { 119 | return checkout(repoState, branch); 120 | }); 121 | }); 122 | } 123 | 124 | /** 125 | * Synchronize the filesystem to reflect the repository state on the given branch. 126 | * @param {Branch} branch 127 | * @return {Promise} 128 | */ 129 | function syncFilesystem(driver, branch) { 130 | return driver.checkout(branch); 131 | } 132 | 133 | /** 134 | * @param {RepositoryState} repoState 135 | * @return {Boolean} True if the repository has some changes on any branch 136 | */ 137 | function isClean(repoState) { 138 | return repoState 139 | .getWorkingStates() 140 | .every(workingState => workingState.isClean()); 141 | } 142 | 143 | const RemoteUtils = { 144 | initialize, 145 | isClean, 146 | checkout, 147 | syncFilesystem, 148 | fetchTree, 149 | fetchBranches, 150 | updateWorkingState, 151 | updateCurrentWorkingState 152 | }; 153 | module.exports = RemoteUtils; 154 | -------------------------------------------------------------------------------- /src/utils/treeNode.js: -------------------------------------------------------------------------------- 1 | const Immutable = require('immutable'); 2 | 3 | /** 4 | * Simple Immutable tree structure. Uses Seq of keys for ease of use. 5 | * TreeNode with K as key type and V as value type. 6 | */ 7 | const TreeNode = Immutable.Record({ 8 | // V 9 | value: null, 10 | // Map 11 | children: new Immutable.Map() 12 | }, 'TreeNode'); 13 | 14 | function getter(property) { 15 | return function() { 16 | return this.get(property); 17 | }; 18 | } 19 | TreeNode.prototype.getChildren = getter('children'); 20 | TreeNode.prototype.getValue = getter('value'); 21 | 22 | TreeNode.prototype.hasChildren = function() { 23 | return !this.getChildren().isEmpty(); 24 | }; 25 | 26 | function partialThis(fun) { 27 | return function() { 28 | const args = Array.prototype.slice.call(arguments); 29 | return fun(...[this].concat(args)); 30 | }; 31 | } 32 | // Functions applied to this 33 | TreeNode.prototype.setIn = partialThis(setIn); 34 | TreeNode.prototype.getIn = partialThis(getIn); 35 | TreeNode.prototype.asImmutable = partialThis(asImmutable); 36 | TreeNode.prototype.asMutable = partialThis(asMutable); 37 | 38 | // ---- Static ---- // 39 | 40 | /** 41 | * Creates a TreeNode with no children 42 | * @param {V} value 43 | * @return {TreeNode} 44 | */ 45 | TreeNode.createLeaf = function(value) { 46 | return new TreeNode({ 47 | value 48 | }); 49 | }; 50 | 51 | /** 52 | * Creates a directory TreeNode with given path 53 | * @param {V} value 54 | * @param {Map} children 55 | * @return {TreeNode} 56 | */ 57 | TreeNode.create = function(value, children) { 58 | return new TreeNode({ 59 | value, 60 | children 61 | }); 62 | }; 63 | 64 | /** 65 | * Create a TreeNode from a JS object 66 | * @param {Object} object 67 | * @return {TreeNode} 68 | */ 69 | TreeNode.fromJS = function(object) { 70 | return TreeNode.create( 71 | object.value, 72 | Immutable.Map(object.children).map(TreeNode.fromJS) 73 | ); 74 | }; 75 | 76 | /** 77 | * Get the TreeNode at the given relative key Seq 78 | * @param {TreeNode} tree 79 | * @param {Seq} keySeq 80 | * @return {TreeNode | Null} null if not found 81 | */ 82 | TreeNode.getIn = getIn; 83 | function getIn(tree, keySeq) { 84 | if (keySeq.count() === 0) { 85 | return tree; 86 | } else { 87 | const children = tree.getChildren(); 88 | const key = keySeq.first(); 89 | if (children.has(key)) { 90 | return children.get(key).getIn(keySeq.rest()); 91 | } else { 92 | return null; 93 | } 94 | } 95 | } 96 | 97 | /** 98 | * Insert a TreeNode into another tree, creating intermediate nodes as 99 | * necessary. 100 | * @param {TreeNode} tree 101 | * @param {Seq} keySeq The key sequence at which the node should 102 | * be. An empty Seq will return tree node 103 | * @param {TreeNode} node 104 | * @param {Boolean} [options.mutable=false] To use mutable instance of 105 | * Map in created nodes. 106 | * @param {Function} [options.createValue=constant(null)] Callback to 107 | * generate intermediate nodes' values, taking as argument the parent 108 | * tree of the node being created, and the Seq of keys leading to the node 109 | * @return {TreeNode} 110 | */ 111 | TreeNode.setIn = setIn; 112 | function setIn(tree, keySeq, node, options = {}) { 113 | options = Object.assign({ 114 | mutable: false, 115 | createValue: () => null 116 | }, options); 117 | const initialKeySeq = keySeq; 118 | 119 | return (function auxSetIn(tree, keySeq) { 120 | if (keySeq.count() === 0) { 121 | // Insert here 122 | return node; 123 | } else { 124 | // Insert in tree 125 | const key = keySeq.first(); 126 | const exists = tree.getChildren().has(key); 127 | 128 | // Find the node to insert into 129 | let subNode; 130 | if (!exists) { 131 | // Create children for subdirectory 132 | const children = new Immutable.Map(); 133 | // Create a directory node 134 | const value = options.createValue( 135 | // parent tree 136 | tree, 137 | // Key seq of the created node (include current key) 138 | initialKeySeq.take(initialKeySeq.count() - keySeq.count() + 1) 139 | ); 140 | subNode = TreeNode.create(value, children); 141 | if (options.mutable) subNode = subNode.asMutable(); 142 | } else { 143 | // Update directory node 144 | subNode = tree.getChildren().get(key); 145 | } 146 | 147 | const newSubNode = auxSetIn(subNode, keySeq.rest()); 148 | return tree.set('children', tree.getChildren().set(key, newSubNode)); 149 | } 150 | })(tree, keySeq); 151 | } 152 | 153 | TreeNode.asImmutable = asImmutable; 154 | function asImmutable(tree) { 155 | tree = Immutable.Record.prototype.asImmutable.call(tree); 156 | const children = tree.getChildren().map(asImmutable).asImmutable(); 157 | return tree.set('children', children); 158 | } 159 | 160 | TreeNode.asMutable = asMutable; 161 | function asMutable(tree) { 162 | const children = tree.getChildren().map(asMutable).asMutable(); 163 | tree = tree.set('children', children); 164 | return Immutable.Record.prototype.asMutable.call(tree); 165 | } 166 | 167 | module.exports = TreeNode; 168 | -------------------------------------------------------------------------------- /src/utils/working.js: -------------------------------------------------------------------------------- 1 | const Immutable = require('immutable'); 2 | 3 | const error = require('./error'); 4 | const TreeEntry = require('../models/treeEntry'); 5 | const CHANGES = require('../constants/changeType'); 6 | 7 | /** 8 | * Returns a Seq of tree mixing changes and the fetched tree 9 | * @param {WorkingState} 10 | * @return {Set} 11 | */ 12 | function getMergedFileSet(workingState) { 13 | return Immutable.Set.fromKeys( 14 | getMergedTreeEntries(workingState).filter( 15 | treeEntry => treeEntry.getType() === TreeEntry.TYPES.BLOB 16 | ) 17 | ); 18 | } 19 | 20 | /** 21 | * Returns a Map of TreeEntry, with sha null when the content is not available as sha. 22 | * @param {WorkingState} 23 | * @return {Map} 24 | */ 25 | function getMergedTreeEntries(workingState) { 26 | const removedOrModified = workingState.getChanges().groupBy((change, path) => { 27 | if (change.getType() === CHANGES.REMOVE) { 28 | return 'remove'; 29 | } else { 30 | // Must be UDPATE or CREATE 31 | return 'modified'; 32 | } 33 | }); 34 | 35 | const setToRemove = Immutable.Set.fromKeys(removedOrModified.get('remove', [])); 36 | 37 | const withoutRemoved = workingState.getTreeEntries().filter((treeEntry, path) => { 38 | return !setToRemove.contains(path); 39 | }); 40 | 41 | const addedTreeEntries = removedOrModified.get('modified', []).map( 42 | function toTreeEntry(change) { 43 | return new TreeEntry({ 44 | sha: change.hasSha() ? change.getSha() : null, 45 | mode: '100644' 46 | }); 47 | } 48 | ); 49 | 50 | return withoutRemoved.concat(addedTreeEntries); 51 | } 52 | 53 | /** 54 | * Returns true if the file differs from initial tree (including removed) 55 | * @return {Boolean} 56 | * @throws FILE_NOT_FOUND 57 | */ 58 | // TODO unused, remove 59 | function hasPendingChanges(workingState, filepath) { 60 | // Lookup potential changes 61 | const change = workingState.getChanges().get(filepath); 62 | if (change) { 63 | return true; 64 | } else { 65 | // Else lookup tree 66 | const treeEntry = workingState.getTreeEntries().get(filepath); 67 | if (!treeEntry) { 68 | throw error.fileNotFound(filepath); 69 | } else { 70 | return false; 71 | } 72 | } 73 | } 74 | 75 | /** 76 | * Attempts to find a SHA if available for the given file 77 | * @param {Path} 78 | * @return {Sha | Null} null if Sha cannot be retrieved (because of pending change) 79 | * @throws NOT_FOUND if the file does not exist or was removed 80 | */ 81 | function findSha(workingState, filepath) { 82 | // Lookup potential changes 83 | const change = workingState.getChanges().get(filepath); 84 | // Else lookup tree 85 | const treeEntry = workingState.getTreeEntries().get(filepath); 86 | 87 | if (change) { 88 | if (change.getType() == CHANGES.REMOVE) { 89 | throw error.fileNotFound(filepath); 90 | } else { 91 | return change.getSha(); 92 | } 93 | } else if (treeEntry) { 94 | return treeEntry.getSha(); 95 | } else { 96 | throw error.fileNotFound(filepath); 97 | } 98 | } 99 | 100 | /** 101 | * Fetch tree for a branch (using its SHA) and return an clean WorkingState for it 102 | * @param {Driver} driver 103 | * @param {Branch} branch 104 | */ 105 | function fetch(driver, branch) { 106 | // Fetch the tree 107 | return driver.fetchWorkingState(branch.getSha()); 108 | } 109 | 110 | const WorkingUtils = { 111 | getMergedFileSet, 112 | getMergedTreeEntries, 113 | fetch, 114 | hasPendingChanges, // TODO remove, unused 115 | findSha 116 | }; 117 | module.exports = WorkingUtils; 118 | -------------------------------------------------------------------------------- /test/api.js: -------------------------------------------------------------------------------- 1 | // These tests are backed by real repositories and make use of API 2 | // calls. They are grouped for optimisation. 3 | 4 | // We typically want to run these after all the local test pass, so enforce order 5 | ['./decoding.js', 6 | './dir.js', 7 | './file.js', 8 | './filestree.js', 9 | './conflict.js', 10 | './repository.js', 11 | './workingState.js' 12 | ] 13 | .map(require); 14 | 15 | const repofs = require('../src/'); 16 | const GitHubDriver = repofs.GitHubDriver; 17 | 18 | // Defined API values 19 | const DRIVERS = { 20 | // Only one for now 21 | GITHUB: 'github', 22 | UHUB: 'uhub' 23 | }; 24 | const DRIVER = process.env.REPOFS_DRIVER || DRIVERS.UHUB; 25 | 26 | const REPO = process.env.REPOFS_REPO; 27 | const HOST = process.env.REPOFS_HOST; 28 | const TOKEN = process.env.REPOFS_TOKEN; 29 | 30 | // Assumes that a repository was created with one commit on 'master': 31 | // Commit "Initial commit\n" 32 | // 1 addition README.md: 33 | // "# " 34 | describe('API tests', () => { 35 | 36 | const shouldSkip = process.env.REPOFS_SKIP_API_TEST; 37 | if (shouldSkip) { 38 | it('WAS SKIPPED', () => { 39 | }); 40 | return; 41 | } 42 | if (!DRIVER) throw new Error('Testing requires to select a DRIVER'); 43 | if (!REPO || !HOST) throw new Error('Testing requires a REPO and HOST configuration'); 44 | 45 | const driver = createDriver(DRIVER, REPO, TOKEN, HOST); 46 | 47 | require('./api/local')(driver); 48 | 49 | require('./api/driver')(driver); 50 | 51 | require('./api/commit')(driver); 52 | 53 | require('./api/branch')(driver); 54 | }); 55 | 56 | // Utilities 57 | function createDriver(type, repo, token, host) { 58 | switch (type) { 59 | case DRIVERS.GITHUB: 60 | case DRIVERS.UHUB: 61 | return new GitHubDriver({ 62 | repository: repo, 63 | host, 64 | token 65 | }); 66 | default: 67 | throw new Error('Unknown API: ' + DRIVERS); 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /test/api/branch.js: -------------------------------------------------------------------------------- 1 | const Immutable = require('immutable'); 2 | const Q = require('q'); 3 | const should = require('should'); 4 | const repofs = require('../../src/'); 5 | 6 | module.exports = function(driver) { 7 | return describe('BranchUtils', testBranch.bind(this, driver)); 8 | }; 9 | 10 | // Test the commit API on a basic repo 11 | function testBranch(driver) { 12 | 13 | let repoState; 14 | 15 | before(() => { 16 | return repofs.RepoUtils.initialize(driver) 17 | .then((initRepo) => { 18 | repoState = initRepo; 19 | }); 20 | }); 21 | 22 | describe('.create', () => { 23 | it('should create a branch and optionally checkout on it', () => { 24 | return repofs.BranchUtils.create(repoState, driver, 'test-branch-create', { 25 | checkout: true 26 | }) 27 | .then((_repoState) => { 28 | // Update for next test 29 | repoState = _repoState; 30 | const master = repoState.getBranch('master'); 31 | const createdBr = repoState.getBranch('test-branch-create'); 32 | master.getSha().should.eql(createdBr.getSha()); 33 | Immutable.is(createdBr, repoState.getCurrentBranch()).should.be.true(); 34 | }); 35 | }); 36 | 37 | it('should drop changes if option "clean" is at true', () => { 38 | repoState = repofs.RepoUtils.checkout(repoState, 'master'); 39 | const withChange = repofs.FileUtils.create(repoState, 'New', 'New Content'); 40 | const branchName = 'test-branch-create-clean'; 41 | 42 | return repofs.BranchUtils.create(withChange, driver, branchName, { 43 | checkout: true, 44 | clean: true 45 | }) 46 | .then((resultRepoState) => { 47 | const master = resultRepoState.getBranch('master'); 48 | const createdBr = resultRepoState.getBranch(branchName); 49 | 50 | const wkMaster = resultRepoState.getWorkingStateForBranch(master); 51 | const wk = resultRepoState.getWorkingStateForBranch(createdBr); 52 | 53 | wkMaster.getChange('New').should.be.ok(); 54 | should.not.exist(wk.getChange('New')); 55 | }); 56 | }); 57 | 58 | it('should preserve changes if option "clean" is at false', () => { 59 | repoState = repofs.RepoUtils.checkout(repoState, 'master'); 60 | const withChange = repofs.FileUtils.create(repoState, 'New', 'New Content'); 61 | const branchName = 'test-branch-create-not-clean'; 62 | 63 | return repofs.BranchUtils.create(withChange, driver, branchName, { 64 | checkout: true, 65 | clean: false 66 | }) 67 | .then((resultRepoState) => { 68 | const master = resultRepoState.getBranch('master'); 69 | const createdBr = resultRepoState.getBranch(branchName); 70 | 71 | const wkMaster = resultRepoState.getWorkingStateForBranch(master); 72 | const wk = resultRepoState.getWorkingStateForBranch(createdBr); 73 | 74 | // Both should have the change and be identical 75 | wk.getChange('New').should.be.ok(); 76 | Immutable.is(wkMaster, wk).should.be.true(); 77 | }); 78 | }); 79 | 80 | it('should transfer changes if option "clean" is at false and "cleanBase" at true', () => { 81 | repoState = repofs.RepoUtils.checkout(repoState, 'master'); 82 | const withChange = repofs.FileUtils.create(repoState, 'New', 'New Content'); 83 | const branchName = 'test-branch-create-not-clean'; 84 | 85 | return repofs.BranchUtils.create(withChange, driver, branchName, { 86 | checkout: true, 87 | clean: false, 88 | cleanBase: true 89 | }) 90 | .then((resultRepoState) => { 91 | const master = resultRepoState.getBranch('master'); 92 | const createdBr = resultRepoState.getBranch(branchName); 93 | 94 | const wkMaster = resultRepoState.getWorkingStateForBranch(master); 95 | const wk = resultRepoState.getWorkingStateForBranch(createdBr); 96 | 97 | // Only the new branch should have the change 98 | wk.getChange('New').should.be.ok(); 99 | should.not.exist(wkMaster.getChange('New')); 100 | }); 101 | }); 102 | 103 | }); 104 | 105 | describe('.merge', () => { 106 | // Depends on previous test 107 | it('should merge two branches', () => { 108 | let intoBranch; 109 | let fromBranch; 110 | return Q() 111 | .then(function createFrom() { 112 | repoState = repofs.RepoUtils.checkout(repoState, 'master'); 113 | return repofs.BranchUtils.create(repoState, driver, 'test-branch-merge-from', { 114 | checkout: true 115 | }); 116 | }) 117 | .then(function prepareFrom(repoState) { 118 | fromBranch = repoState.getCurrentBranch(); 119 | repoState = repofs.FileUtils.create( 120 | repoState, 'merge_branch_file1', 'File 1'); 121 | return commitAndFlush(repoState, driver, 'Head commit'); 122 | }) 123 | .then(function createInto() { 124 | repoState = repofs.RepoUtils.checkout(repoState, 'master'); 125 | return repofs.BranchUtils.create(repoState, driver, 'test-branch-merge-into', { 126 | checkout: true 127 | }); 128 | }) 129 | .then(function prepareInto(repoState) { 130 | intoBranch = repoState.getCurrentBranch(); 131 | repoState = repofs.FileUtils.create( 132 | repoState, 'merge_branch_file2', 'File 2'); 133 | return commitAndFlush(repoState, driver, 'Base commit'); 134 | }) 135 | .then(function doMerge(repoState) { 136 | return repofs.BranchUtils.merge(repoState, driver, fromBranch, intoBranch, { 137 | message: 'Merge branch test commit', 138 | fetch: true 139 | }); 140 | }) 141 | .then((repoState) => { 142 | repoState = repofs.RepoUtils.checkout(repoState, intoBranch); 143 | repofs.FileUtils.exists(repoState, 'merge_branch_file1'); 144 | repofs.FileUtils.exists(repoState, 'merge_branch_file2'); 145 | return repofs.FileUtils.fetch(repoState, driver, 'merge_branch_file1'); 146 | }) 147 | .then((repoState) => { 148 | repofs.FileUtils.read(repoState, 'merge_branch_file1').getAsString() 149 | .should.eql('File 1'); 150 | }); 151 | }); 152 | 153 | it('should fail with merge conflict', () => { 154 | let intoBranch; 155 | let fromBranch; 156 | return Q() 157 | .then(function createFrom() { 158 | repoState = repofs.RepoUtils.checkout(repoState, 'master'); 159 | return repofs.BranchUtils.create(repoState, driver, 'test-branch-merge-conflict-from', { 160 | checkout: true 161 | }); 162 | }) 163 | .then(function prepareFrom(repoState) { 164 | fromBranch = repoState.getCurrentBranch(); 165 | repoState = repofs.FileUtils.create( 166 | repoState, 'merge_branch_conflict', 'Content 1'); 167 | return commitAndFlush(repoState, driver, 'Head commit'); 168 | }) 169 | .then(function createInto() { 170 | repoState = repofs.RepoUtils.checkout(repoState, 'master'); 171 | return repofs.BranchUtils.create(repoState, driver, 'test-branch-merge-conflict-into', { 172 | checkout: true 173 | }); 174 | }) 175 | .then(function prepareInto(repoState) { 176 | intoBranch = repoState.getCurrentBranch(); 177 | repoState = repofs.FileUtils.create( 178 | repoState, 'merge_branch_conflict', 'Content 2'); 179 | return commitAndFlush(repoState, driver, 'Base commit'); 180 | }) 181 | .then(function doMerge(repoState) { 182 | return repofs.BranchUtils.merge(repoState, driver, fromBranch, intoBranch, { 183 | message: 'Merge branch conflict', 184 | fetch: true 185 | }); 186 | }) 187 | .then(() => { 188 | should.fail('CONFLICT was not detected'); 189 | }, (err) => { 190 | err.code.should.eql(repofs.ERRORS.CONFLICT); 191 | }); 192 | }); 193 | }); 194 | 195 | describe('.update', () => { 196 | it('should update an old branch that was updated on the repo', () => { 197 | let oldBranchState; 198 | let updatedBranch; 199 | 200 | return repofs.BranchUtils.create(repoState, driver, 'test-branch-update', { 201 | checkout: true 202 | }) 203 | .then((_repoState) => { 204 | oldBranchState = _repoState; 205 | return commitAndFlush(_repoState, driver, 'New commit'); 206 | }) 207 | .then((_repoState) => { 208 | updatedBranch = _repoState.getCurrentBranch(); 209 | return repofs.BranchUtils.update(oldBranchState, driver, 'test-branch-update'); 210 | }) 211 | .then((_repoState) => { 212 | Immutable.is(updatedBranch, _repoState.getCurrentBranch()).should.be.true(); 213 | }); 214 | }); 215 | }); 216 | 217 | describe('.remove', () => { 218 | // Depends on previous 219 | it('should delete a branch', () => { 220 | const createdBr = repoState.getBranch('test-branch-create'); 221 | return repofs.BranchUtils.remove(repoState, driver, createdBr) 222 | .then((_repoState) => { 223 | should(_repoState.getBranch('test-branch-create')).be.null(); 224 | }); 225 | }); 226 | }); 227 | } 228 | 229 | function commitAndFlush(repoState, driver, message) { 230 | const commitBuilder = repofs.CommitUtils.prepare(repoState, { 231 | author: repofs.Author.create({ 232 | name: 'Shakespeare', 233 | email: 'shakespeare@hotmail.com' 234 | }), 235 | message 236 | }); 237 | 238 | return repofs.CommitUtils.flush(repoState, driver, commitBuilder); 239 | } 240 | -------------------------------------------------------------------------------- /test/api/commit.js: -------------------------------------------------------------------------------- 1 | const should = require('should'); 2 | const repofs = require('../../src/'); 3 | 4 | module.exports = function(driver) { 5 | return describe('CommitUtils', testCommit.bind(this, driver)); 6 | }; 7 | 8 | // Test the commit API on a basic repo 9 | function testCommit(driver) { 10 | 11 | let repoState; 12 | 13 | before(() => { 14 | return repofs.RepoUtils.initialize(driver) 15 | .then((initRepo) => { 16 | return repofs.BranchUtils.create(initRepo, driver, 'test-commit', { 17 | checkout: true 18 | }); 19 | }) 20 | .then((branchedRepo) => { 21 | repoState = branchedRepo; 22 | }); 23 | }); 24 | 25 | describe('.flush', () => { 26 | it('should flush a prepared commit', () => { 27 | // Create a file for test 28 | repoState = repofs.FileUtils.create( 29 | repoState, 'flushCommitFile', 'Flush CommitContent'); 30 | const commitBuilder = repofs.CommitUtils.prepare(repoState, { 31 | author: repofs.Author.create({ 32 | name: 'Shakespeare', 33 | email: 'shakespeare@hotmail.com' 34 | }), 35 | message: 'Test message' 36 | }); 37 | 38 | return repofs.CommitUtils.flush(repoState, driver, commitBuilder) 39 | .then((newState) => { 40 | repoState = newState; 41 | // No more pending changes 42 | repoState.getCurrentState().isClean().should.be.true(); 43 | 44 | // The file was created 45 | repofs.FileUtils.exists(repoState, 'flushCommitFile').should.be.true(); 46 | }); 47 | }); 48 | 49 | it('should detect not fast forward errors', () => { 50 | // Simulate another person trying to commit 51 | const otherState = repofs.FileUtils.create( 52 | repoState, 'not_ff_detection', 'I will get a NOT_FAST_FORWARD'); 53 | // Make a change 54 | repoState = repofs.FileUtils.create( 55 | repoState, 'not_ff_detection', 'Not ff detection'); 56 | 57 | return emptyCommitAndFlush(repoState, driver, 'Not ff detection') 58 | .then((_repoState) => { 59 | repoState = _repoState; 60 | return emptyCommitAndFlush(otherState, driver, 'Not ff detection'); 61 | }) 62 | .then(() => { 63 | should.fail('NOT_FAST_FORWARD was not detected'); 64 | }, (err) => { 65 | err.code.should.eql(repofs.ERRORS.NOT_FAST_FORWARD); 66 | // Should have the created commit available for merging 67 | err.commit.should.be.ok(); 68 | }); 69 | }); 70 | }); 71 | 72 | describe('.fetchList', () => { 73 | it('should list commits on current branch', () => { 74 | // Work on a different branch 75 | const listTestState = repofs.RepoUtils.checkout(repoState, 'master'); 76 | 77 | return repofs.BranchUtils.create(listTestState, driver, 'test-list-commit', { 78 | checkout: true 79 | }) 80 | .then((listTestState) => { 81 | return emptyCommitAndFlush(listTestState, driver, 'List commit test'); 82 | }) 83 | .then((listTestState) => { 84 | return repofs.CommitUtils.fetchList(driver, { 85 | branch: listTestState.getCurrentBranch() 86 | }); 87 | }) 88 | .then((commits) => { 89 | commits.count().should.be.greaterThan(1); 90 | const commit = commits.first(); 91 | commit.getAuthor().getName().should.eql('Shakespeare'); 92 | commit.getMessage().should.eql('List commit test'); 93 | }); 94 | }); 95 | }); 96 | 97 | } 98 | 99 | function emptyCommitAndFlush(repoState, driver, message) { 100 | const commitBuilder = repofs.CommitUtils.prepare(repoState, { 101 | author: repofs.Author.create({ 102 | name: 'Shakespeare', 103 | email: 'shakespeare@hotmail.com' 104 | }), 105 | message 106 | }); 107 | 108 | return repofs.CommitUtils.flush(repoState, driver, commitBuilder, { 109 | ignoreEmpty: false 110 | }); 111 | } 112 | -------------------------------------------------------------------------------- /test/api/local.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const path = require('path'); 3 | const Immutable = require('immutable'); 4 | const LocalFile = require('../../src/models/localFile'); 5 | const Reference = require('../../src/models/reference'); 6 | 7 | const repofs = require('../..'); 8 | const LocalUtils = repofs.LocalUtils; 9 | 10 | const REPO_DIR = '.tmp/repo/'; 11 | 12 | module.exports = function(driver) { 13 | if (process.env.REPOFS_DRIVER !== 'uhub') return; 14 | 15 | return describe('LocalUtils', testLocal.bind(this, driver)); 16 | }; 17 | 18 | function testLocal(driver) { 19 | describe('.status', () => { 20 | 21 | before(function() { 22 | this.initialReadme = fs.readFileSync(path.join(REPO_DIR, 'README.md'), 'utf8'); 23 | fs.writeFileSync(path.join(REPO_DIR, 'README.md'), 'New content'); 24 | fs.writeFileSync(path.join(REPO_DIR, 'readme2.md'), 'New content'); 25 | }); 26 | 27 | after(function() { 28 | fs.writeFileSync(path.join(REPO_DIR, 'README.md'), this.initialReadme); 29 | fs.unlinkSync(path.join(REPO_DIR, 'readme2.md')); 30 | }); 31 | 32 | it('should return list of changed files', (done) => { 33 | LocalUtils.status(driver) 34 | .then((result) => { 35 | const localFiles = result.files; 36 | 37 | // is an immutable list 38 | localFiles.should.be.instanceof(Immutable.List); 39 | 40 | // size equals 2 41 | localFiles.size.should.be.equal(2); 42 | 43 | // instance of file are LocalFile 44 | localFiles.get(0).should.be.instanceof(LocalFile); 45 | localFiles.get(1).should.be.instanceof(LocalFile); 46 | 47 | // Content should be consistant 48 | localFiles.get(1).should.eql(LocalFile.create({ 49 | sha: '0000000000000000000000000000000000000000', 50 | filename: 'readme2.md', 51 | status: 'untracked', 52 | additions: 0, 53 | deletions: 0, 54 | changes: 0, 55 | patch: '' 56 | })); 57 | 58 | localFiles.get(0).get('filename').should.equal('README.md'); 59 | localFiles.get(0).get('status').should.equal('modified'); 60 | localFiles.get(0).get('additions').should.equal(1); 61 | localFiles.get(0).get('deletions').should.equal(1); 62 | localFiles.get(0).get('changes').should.equal(2); 63 | localFiles.get(0).get('patch').should.equal('@@ -1 +1 @@\n-# Uhub test repository\\n\n+New content\n\\ No newline at end of file\n'); 64 | 65 | // head is a Reference 66 | const head = result.head; 67 | head.should.be.instanceof(Reference); 68 | head.getRef().should.be.equal('refs/heads/master'); 69 | head.getSha().length.should.be.equal(40); 70 | head.getLocalBranchName().should.be.equal('master'); 71 | }) 72 | .then(done) 73 | .catch(done); 74 | }); 75 | }); 76 | } 77 | -------------------------------------------------------------------------------- /test/blob.js: -------------------------------------------------------------------------------- 1 | const should = require('should'); 2 | 3 | const Blob = require('../src/models/blob'); 4 | 5 | describe('Blob', () => { 6 | it('should fail creating a blob too big', () => { 7 | should.throws(() => { 8 | const ab = new ArrayBuffer(256 * 1024 * 1024); 9 | Blob.createFromArrayBuffer(ab); 10 | }); 11 | }); 12 | }); 13 | 14 | -------------------------------------------------------------------------------- /test/changes.js: -------------------------------------------------------------------------------- 1 | const repofs = require('../src/'); 2 | 3 | const Blob = require('../src/models/blob'); 4 | const Change = require('../src/models/change'); 5 | const ChangeUtils = repofs.ChangeUtils; 6 | 7 | const mock = require('./mock'); 8 | 9 | describe('ChangeUtils', () => { 10 | const DEFAULT_BOOK = mock.DEFAULT_BOOK; 11 | 12 | const create = new Change({ 13 | type: repofs.CHANGE.CREATE, 14 | content: Blob.createFromString('Create') 15 | }); 16 | 17 | const remove = new Change({ 18 | type: repofs.CHANGE.REMOVE 19 | }); 20 | 21 | describe('.setChange', () => { 22 | 23 | it('should resolve REMOVE after a CREATE', () => { 24 | let state = DEFAULT_BOOK; 25 | 26 | state = ChangeUtils.setChange(DEFAULT_BOOK, 'new', create); 27 | ChangeUtils.getChange(state, 'new') 28 | .should.equal(create); 29 | 30 | state = ChangeUtils.setChange(DEFAULT_BOOK, 'new', remove); 31 | Boolean(ChangeUtils.getChange(state, 'new')) 32 | .should.equal(false); 33 | }); 34 | 35 | it('should resolve CREATE after a REMOVE', () => { 36 | let state = DEFAULT_BOOK; 37 | 38 | state = ChangeUtils.setChange(DEFAULT_BOOK, 'README.md', remove); 39 | ChangeUtils.getChange(state, 'README.md') 40 | .should.equal(remove); 41 | 42 | state = ChangeUtils.setChange(DEFAULT_BOOK, 'README.md', create); 43 | ChangeUtils.getChange(state, 'README.md').getType() 44 | .should.equal(repofs.CHANGE.UPDATE); 45 | }); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /test/conflict.js: -------------------------------------------------------------------------------- 1 | require('should'); 2 | 3 | const Immutable = require('immutable'); 4 | 5 | const repofs = require('../src/'); 6 | 7 | const ConflictUtils = require('../src/utils/conflict'); 8 | const TreeEntry = require('../src/models/treeEntry'); 9 | const TreeConflict = repofs.TreeConflict; 10 | const Conflict = repofs.Conflict; 11 | const WorkingState = repofs.WorkingState; 12 | 13 | describe('ConflictUtils', () => { 14 | 15 | const parentEntries = new Immutable.Map({ 16 | bothDeleted: entry('bothDeleted'), 17 | bothModified: entry('bothModified-parent'), // conflict 18 | deletedBase: entry('deletedBase'), 19 | // Deleted by base, modified by parent 20 | deletedModified: entry('deletedModified-parent'), // conflict 21 | modifiedBase: entry('modifiedBase-parent'), 22 | unchanged: entry('unchanged') 23 | }); 24 | 25 | const baseEntries = new Immutable.Map({ 26 | addedBase: entry('addedBase'), 27 | bothAddedDifferent: entry('bothAddedDifferent-base'), // conflict 28 | bothAddedSame: entry('bothAddedSame'), 29 | bothModified: entry('bothModified-base'), // conflict 30 | modifiedBase: entry('modifiedBase-base'), 31 | unchanged: entry('unchanged') 32 | }); 33 | 34 | const headEntries = new Immutable.Map({ 35 | bothAddedDifferent: entry('bothAddedDifferent-head'), // conflict 36 | bothAddedSame: entry('bothAddedSame'), 37 | bothModified: entry('bothModified-head'), // conflict 38 | deletedBase: entry('deletedBase'), 39 | deletedModified: entry('deletedModified-head'), // conflict 40 | modifiedBase: entry('modifiedBase-parent'), 41 | unchanged: entry('unchanged') 42 | }); 43 | 44 | const parentWK = WorkingState.createWithTree('parentWK', parentEntries); 45 | const headWK = WorkingState.createWithTree('headWK', headEntries); 46 | const baseWK = WorkingState.createWithTree('baseWK', baseEntries); 47 | 48 | const CONFLICTS = { 49 | bothModified: new Conflict({ 50 | parentSha: 'bothModified-parent', 51 | baseSha: 'bothModified-base', 52 | headSha: 'bothModified-head' 53 | }), 54 | bothAddedDifferent: new Conflict({ 55 | parentSha: null, 56 | baseSha: 'bothAddedDifferent-base', 57 | headSha: 'bothAddedDifferent-head' 58 | }), 59 | deletedModified: new Conflict({ 60 | parentSha: 'deletedModified-parent', 61 | baseSha: null, 62 | headSha: 'deletedModified-head' 63 | }) 64 | }; 65 | 66 | const treeConflict = new TreeConflict({ 67 | base: baseWK, 68 | head: headWK, 69 | parent: parentWK, 70 | conflicts: new Immutable.Map(CONFLICTS) 71 | }); 72 | 73 | // The list of solved conflicts, as returned after resolution 74 | const solvedConflicts = treeConflict.getConflicts().merge({ 75 | bothModified: CONFLICTS.bothModified.solveWithContent('Solved content'), 76 | // bothAddedDifferent not solved, should default to keep base in the end 77 | deletedModified: CONFLICTS.deletedModified.keepHead() 78 | }); 79 | 80 | 81 | // ---- TESTS ---- 82 | 83 | describe('._diffEntries', () => { 84 | 85 | it('should detect modified entries, added entries, and deleted entries', () => { 86 | const result = ConflictUtils._diffEntries(parentEntries, baseEntries); 87 | 88 | const expectedDiff = new Immutable.Map({ 89 | addedBase: entry('addedBase'), 90 | bothAddedDifferent: entry('bothAddedDifferent-base'), 91 | bothAddedSame: entry('bothAddedSame'), 92 | bothDeleted: null, 93 | bothModified: entry('bothModified-base'), 94 | deletedBase: null, 95 | deletedModified: null, 96 | modifiedBase: entry('modifiedBase-base') 97 | }); 98 | 99 | Immutable.is(result, expectedDiff).should.be.true(); 100 | }); 101 | 102 | }); 103 | 104 | describe('._compareTrees', () => { 105 | 106 | it('should detect minimum set of conflicts', () => { 107 | const result = ConflictUtils._compareTrees(parentEntries, baseEntries, headEntries); 108 | const expected = treeConflict.getConflicts(); 109 | 110 | Immutable.is(result, expected).should.be.true(); 111 | }); 112 | 113 | }); 114 | 115 | describe('.solveTree', () => { 116 | 117 | it('should merge back solved conflicts into a TreeConflict, defaulting to base version', () => { 118 | const solvedTreeConflict = ConflictUtils.solveTree(treeConflict, solvedConflicts); 119 | 120 | // Expect tree to be unchanged outside of conflicts 121 | Immutable.is(solvedTreeConflict.set('conflicts', null), 122 | treeConflict.set('conflicts', null)); 123 | 124 | // Expect conflicts to be solved 125 | const expected = solvedConflicts.set('bothAddedDifferent', 126 | CONFLICTS.bothAddedDifferent.keepBase()); 127 | const result = solvedTreeConflict.getConflicts(); 128 | Immutable.is(result, expected).should.be.true(); 129 | }); 130 | }); 131 | 132 | describe('._getSolvedEntries', () => { 133 | 134 | it('should generate the solved tree entries', () => { 135 | const solvedTreeConflict = ConflictUtils.solveTree(treeConflict, solvedConflicts); 136 | const result = ConflictUtils._getSolvedEntries(solvedTreeConflict); 137 | 138 | const expected = new Immutable.Map({ 139 | deletedModified: entry('deletedModified-head'), // keeped head 140 | bothAddedSame: entry('bothAddedSame'), 141 | bothModified: entry(null), // solved with content 142 | addedBase: entry('addedBase'), 143 | bothAddedDifferent: entry('bothAddedDifferent-base'), // keeped base 144 | modifiedBase: entry('modifiedBase-base'), 145 | unchanged: entry('unchanged') 146 | }); 147 | 148 | Immutable.is(result, expected).should.be.true(); 149 | }); 150 | 151 | }); 152 | 153 | describe('.mergeCommit', () => { 154 | const solvedTreeConflict = ConflictUtils.solveTree(treeConflict, solvedConflicts); 155 | const mergeCommit = ConflictUtils.mergeCommit(solvedTreeConflict, [ 156 | 'parentCommitSha1', 157 | 'parentCommitSha2' 158 | ], { 159 | author: 'Shakespeare' 160 | }); 161 | 162 | it('should create a merge CommitBuilder with two parents', () => { 163 | mergeCommit.getParents().toJS().should.eql([ 164 | 'parentCommitSha1', 165 | 'parentCommitSha2' 166 | ]); 167 | }); 168 | 169 | it('should create a merge CommitBuilder with an author', () => { 170 | mergeCommit.getAuthor().should.eql('Shakespeare'); 171 | }); 172 | 173 | it('should create a merge CommitBuilder with solved content as blob', () => { 174 | mergeCommit.getBlobs().get('bothModified').getAsString().should.eql('Solved content'); 175 | mergeCommit.getBlobs().count().should.eql(1); 176 | }); 177 | 178 | it('should create a merge CommitBuilder with final solved entries', () => { 179 | const solvedEntries = new Immutable.Map({ 180 | deletedModified: entry('deletedModified-head'), // keeped head 181 | bothAddedSame: entry('bothAddedSame'), 182 | bothModified: entry(null), // solved with content 183 | addedBase: entry('addedBase'), 184 | bothAddedDifferent: entry('bothAddedDifferent-base'), // keeped base 185 | modifiedBase: entry('modifiedBase-base'), 186 | unchanged: entry('unchanged') 187 | }); 188 | 189 | Immutable.is(mergeCommit.getTreeEntries(), solvedEntries).should.be.true(); 190 | }); 191 | }); 192 | }); 193 | 194 | // ---- Utils ---- 195 | function entry(sha) { 196 | return new TreeEntry({ sha }); 197 | } 198 | 199 | -------------------------------------------------------------------------------- /test/decoding.js: -------------------------------------------------------------------------------- 1 | require('should'); 2 | const Immutable = require('immutable'); 3 | const _ = require('lodash'); 4 | 5 | const repofs = require('../src/'); 6 | 7 | const Author = require('../src/models/author'); 8 | const Blob = require('../src/models/blob'); 9 | const Change = require('../src/models/change'); 10 | const Commit = require('../src/models/commit'); 11 | const TreeEntry = require('../src/models/treeEntry'); 12 | const Branch = require('../src/models/branch'); 13 | const WorkingState = require('../src/models/workingState'); 14 | const RepositoryState = require('../src/models/repositoryState'); 15 | 16 | describe('Decoding, encoding', () => { 17 | 18 | const blob = Blob.createFromString('Test'); 19 | 20 | const change = new Change({ 21 | type: repofs.CHANGE.UPDATE, 22 | content: Blob.createFromString('ChangeBuffer'), 23 | sha: 'changeSha' 24 | }); 25 | 26 | const author = new Author({ 27 | name: 'John Doe', 28 | email: 'john@gitbook.com', 29 | date: new Date(), 30 | avatar: 'avatarUrl' 31 | }); 32 | 33 | const commit = new Commit({ 34 | sha: 'commitSha', 35 | date: new Date(), 36 | message: 'Commit message', 37 | author 38 | }); 39 | 40 | const branch = new Branch({ 41 | name: 'branchShortName', 42 | remote: 'branchRemote', 43 | commit 44 | }); 45 | 46 | const treeEntry = new TreeEntry({ 47 | blobSize: 10, 48 | sha: 'treeEntrySha', 49 | mode: '100644' 50 | }); 51 | 52 | const workingState = new WorkingState({ 53 | head: 'headSha', 54 | treeEntries: new Immutable.Map({ 55 | 'README.md': treeEntry 56 | }), 57 | changes: new Immutable.OrderedMap({ 58 | 'README.md': change 59 | }) 60 | }); 61 | 62 | const repositoryState = new RepositoryState({ 63 | currentBranchName: branch.getName(), 64 | workingStates: new Immutable.Map().set(branch.getName(), workingState), 65 | branches: new Immutable.List().push(branch) 66 | }); 67 | 68 | function testDecodeEncode(type, source) { 69 | return function() { 70 | const encdec = _.flow(type.encode, type.decode); 71 | const decenc = _.flow(type.decode, type.encode); 72 | 73 | Immutable.is(source, encdec(source)).should.be.true(); 74 | 75 | const encoded = type.encode(source); 76 | encoded.should.eql(decenc(encoded)); 77 | }; 78 | } 79 | 80 | it('should encode and decode back a Blob', () => { 81 | const encoded = Blob.encode(blob); 82 | const decoded = Blob.decode(encoded); 83 | 84 | blob.getByteLength().should.eql(decoded.getByteLength()); 85 | blob.getAsString().should.eql(decoded.getAsString()); 86 | }); 87 | 88 | it('should encode and decode back a Author', testDecodeEncode(Author, author)); 89 | it('should encode and decode back a Commit', testDecodeEncode(Commit, commit)); 90 | it('should encode and decode back a Change', testDecodeEncode(Change, change)); 91 | it('should encode and decode back a TreeEntry', testDecodeEncode(TreeEntry, treeEntry)); 92 | it('should encode and decode back a Branch', testDecodeEncode(Branch, branch)); 93 | it('should encode and decode back a WorkingState', testDecodeEncode(WorkingState, workingState)); 94 | it('should encode and decode back a RepositoryState', testDecodeEncode(RepositoryState, repositoryState)); 95 | 96 | it('should encode and decode an empty RepositoryState', testDecodeEncode(RepositoryState, RepositoryState.createEmpty())); 97 | }); 98 | -------------------------------------------------------------------------------- /test/dir.js: -------------------------------------------------------------------------------- 1 | require('should'); 2 | 3 | const _ = require('lodash'); 4 | 5 | const repofs = require('../src/'); 6 | const DirUtils = repofs.DirUtils; 7 | const FileUtils = repofs.FileUtils; 8 | const mock = require('./mock'); 9 | 10 | describe('DirUtils', () => { 11 | 12 | const INITIAL_FILES = [ 13 | 'file.root', 14 | 'dir/file1', 15 | 'dir/file2', 16 | 'dir.deep/file1', 17 | 'dir.deep/dir/file1' 18 | ]; 19 | 20 | const NESTED_DIRECTORY = mock.directoryStructure(INITIAL_FILES); 21 | 22 | describe('.read', () => { 23 | 24 | it('should list files from root', () => { 25 | const files = DirUtils.read(NESTED_DIRECTORY, '.'); 26 | const filenames = _.map(files, method('getPath')); 27 | _.difference([ 28 | 'file.root', 29 | 'dir', 30 | 'dir.deep' 31 | ], filenames).should.be.empty(); 32 | }); 33 | 34 | it('should list files from dir', () => { 35 | const files = DirUtils.read(NESTED_DIRECTORY, 'dir.deep/'); 36 | const filenames = _.map(files, method('getPath')); 37 | _.difference([ 38 | 'dir.deep/file1', 39 | 'dir.deep/dir' 40 | ], filenames).should.be.empty(); 41 | }); 42 | 43 | it('should differentiate directories and files', () => { 44 | const all = _.partition(DirUtils.read(NESTED_DIRECTORY, '.'), (file) => { 45 | return file.isDirectory(); 46 | }); 47 | const dirs = all[0]; 48 | const files = all[1]; 49 | 50 | const filenames = _.map(files, method('getPath')); 51 | const dirnames = _.map(dirs, method('getPath')); 52 | 53 | _.difference([ 54 | 'file.root' 55 | ], filenames).should.be.empty(); 56 | _.difference([ 57 | 'dir', 58 | 'dir.deep' 59 | ], dirnames).should.be.empty(); 60 | }); 61 | 62 | it('should be flexible with paths', () => { 63 | [ 64 | 'dir.deep', 65 | 'dir.deep/', 66 | './dir.deep/' 67 | ] 68 | .map((path) => { 69 | return DirUtils.read(NESTED_DIRECTORY, path); 70 | }) 71 | .map((files) => { 72 | return files.map(method('getPath')); 73 | }) 74 | .map((currentValue, index, array) => { 75 | // Should all equal the first 76 | _.difference(currentValue, array[0]).should.be.empty(); 77 | }); 78 | }); 79 | }); 80 | 81 | describe('.readRecursive', () => { 82 | 83 | it('should list files from root', () => { 84 | const files = DirUtils.readRecursive(NESTED_DIRECTORY, '.'); 85 | const filenames = _.map(files, method('getPath')); 86 | _.difference([ 87 | 'file.root', 88 | 'dir', 89 | 'dir/file1', 90 | 'dir/file2', 91 | 'dir.deep', 92 | 'dir.deep/file1', 93 | 'dir.deep/dir', 94 | 'dir.deep/dir/file1' 95 | ], filenames).should.be.empty(); 96 | }); 97 | 98 | it('should list files from dir', () => { 99 | const files = DirUtils.readRecursive(NESTED_DIRECTORY, 'dir.deep/'); 100 | const filenames = _.map(files, method('getPath')); 101 | _.difference([ 102 | 'dir.deep/file1', 103 | 'dir.deep/dir', 104 | 'dir.deep/dir/file1' 105 | ], filenames).should.be.empty(); 106 | }); 107 | 108 | it('should differentiate directories and files', () => { 109 | const all = _.partition(DirUtils.readRecursive(NESTED_DIRECTORY, '.'), (file) => { 110 | return file.isDirectory(); 111 | }); 112 | const dirs = all[0]; 113 | const files = all[1]; 114 | 115 | const filenames = _.map(files, method('getPath')); 116 | const dirnames = _.map(dirs, method('getPath')); 117 | 118 | _.difference([ 119 | 'file.root', 120 | 'dir/file1', 121 | 'dir/file2', 122 | 'dir.deep/file1', 123 | 'dir.deep/dir/file1' 124 | ], filenames).should.be.empty(); 125 | _.difference([ 126 | 'dir', 127 | 'dir.deep', 128 | 'dir.deep/dir' 129 | ], dirnames).should.be.empty(); 130 | }); 131 | 132 | it('should be flexible with paths', () => { 133 | [ 134 | 'dir.deep', 135 | 'dir.deep/', 136 | './dir.deep/' 137 | ] 138 | .map((path) => { 139 | return DirUtils.readRecursive(NESTED_DIRECTORY, path); 140 | }) 141 | .map((files) => { 142 | return files.map(method('getPath')); 143 | }) 144 | .map((currentValue, index, array) => { 145 | // Should all equal the first 146 | _.difference(currentValue, array[0]).should.be.empty(); 147 | }); 148 | }); 149 | }); 150 | 151 | describe('.readFilenamesRecursive', () => { 152 | 153 | it('should list filenames recursively from root', () => { 154 | const files = DirUtils.readFilenamesRecursive(NESTED_DIRECTORY, '.'); 155 | _.difference(INITIAL_FILES, files).should.be.empty(); 156 | }); 157 | 158 | it('should list filenames recursively from dir', () => { 159 | const filesDeep = DirUtils.readFilenamesRecursive(NESTED_DIRECTORY, 'dir/'); 160 | _.difference([ 161 | 'dir/file1', 162 | 'dir/file2' 163 | ], filesDeep).should.be.empty(); 164 | }); 165 | 166 | it('should be flexible with paths', () => { 167 | [ 168 | 'dir.deep', 169 | 'dir.deep/', 170 | './dir.deep/' 171 | ].map((path) => { 172 | return DirUtils.readFilenamesRecursive(NESTED_DIRECTORY, path); 173 | }).map((files) => { 174 | _.difference([ 175 | 'dir.deep/file1', 176 | 'dir.deep/dir/file1' 177 | ], files).should.be.empty(); 178 | }); 179 | }); 180 | }); 181 | 182 | describe('.readFilenames', () => { 183 | it('should shallow list root filenames', () => { 184 | const files = DirUtils.readFilenames(NESTED_DIRECTORY, './'); 185 | _.difference(['file.root'], files).should.be.empty(); 186 | }); 187 | 188 | it('should shallow list all filenames in a dir', () => { 189 | const files = DirUtils.readFilenames(NESTED_DIRECTORY, 'dir'); 190 | _.difference([ 191 | 'dir/file1', 192 | 'dir/file2' 193 | ], files).should.be.empty(); 194 | }); 195 | 196 | it('should shallow list all filenames and dir in a dir', () => { 197 | const files = DirUtils.readFilenames(NESTED_DIRECTORY, './dir.deep/'); 198 | _.difference([ 199 | 'dir.deep/file1', 200 | 'dir.deep/dir' 201 | ], files).should.be.empty(); 202 | }); 203 | }); 204 | 205 | describe('.move', () => { 206 | it('should be able to rename a dir', () => { 207 | const renamedRepo = DirUtils.move(NESTED_DIRECTORY, 'dir', 'newName'); 208 | 209 | const files = DirUtils.readFilenamesRecursive(renamedRepo, '.'); 210 | _.difference([ 211 | 'file.root', 212 | 'newName/file1', 213 | 'newName/file2', 214 | 'dir.deep/file1', 215 | 'dir.deep/dir/file1' 216 | ], files).should.be.empty(); 217 | }); 218 | 219 | it('should be kind with the cache (keeping SHAs when possible)', () => { 220 | const renamedRepo = DirUtils.move(NESTED_DIRECTORY, 'dir', 'newName'); 221 | 222 | // The read should not fail because the content should be fetched 223 | FileUtils.readAsString(renamedRepo, 'newName/file1') 224 | .should.equal('dir/file1'); // original content 225 | }); 226 | }); 227 | 228 | describe('.remove', () => { 229 | it('should be able to remove a dir', () => { 230 | const removedRepo = DirUtils.remove(NESTED_DIRECTORY, 'dir.deep'); 231 | 232 | const files = DirUtils.readFilenamesRecursive(removedRepo, '.'); 233 | _.difference([ 234 | 'file.root', 235 | 'dir/file1', 236 | 'dir/file2' 237 | ], files).should.be.empty(); 238 | }); 239 | }); 240 | }); 241 | 242 | // Utils 243 | function method(name) { 244 | return function(object) { 245 | return object[name](); 246 | }; 247 | } 248 | -------------------------------------------------------------------------------- /test/file.js: -------------------------------------------------------------------------------- 1 | const should = require('should'); 2 | 3 | const repofs = require('../src/'); 4 | const Blob = require('../src/models/blob'); 5 | const File = require('../src/models/file'); 6 | const FileUtils = repofs.FileUtils; 7 | const FILE_TYPE = require('../src/constants/filetype.js'); 8 | 9 | const mock = require('./mock'); 10 | 11 | describe('FileUtils', () => { 12 | const DEFAULT_BOOK = mock.DEFAULT_BOOK; 13 | 14 | describe('.exists', () => { 15 | 16 | it('should true if file exists', () => { 17 | FileUtils.exists(DEFAULT_BOOK, 'README.md').should.equal(true); 18 | FileUtils.exists(DEFAULT_BOOK, 'SUMMARY.md').should.equal(true); 19 | }); 20 | 21 | it('should false if file does not exists', () => { 22 | FileUtils.exists(DEFAULT_BOOK, 'Notexist.md').should.equal(false); 23 | FileUtils.exists(DEFAULT_BOOK, 'dir/SUMMARY.md').should.equal(false); 24 | }); 25 | 26 | }); 27 | 28 | describe('.read', () => { 29 | it('should read content as Blob if file exists', () => { 30 | const blob = FileUtils.read(DEFAULT_BOOK, 'README.md'); 31 | blob.should.be.an.instanceof(Blob); 32 | blob.getAsString().should.equal('# Introduction'); 33 | }); 34 | 35 | it('should read content as Blob for modified files', () => { 36 | const modifiedState = FileUtils.write(DEFAULT_BOOK, 'README.md', 'New'); 37 | const blob = FileUtils.read(modifiedState, 'README.md'); 38 | blob.should.be.an.instanceof(Blob); 39 | blob.getAsString().should.equal('New'); 40 | }); 41 | }); 42 | 43 | describe('.stat', () => { 44 | it('should return a File without content when not fetched', () => { 45 | const repo = mock.addFile(DEFAULT_BOOK, 'notfetched.txt', { 46 | fetched: false 47 | }); 48 | const file = FileUtils.stat(repo, 'notfetched.txt'); 49 | file.should.be.an.instanceof(File); 50 | file.getFileSize().should.equal(14); 51 | file.isDirectory().should.be.false(); 52 | file.getPath().should.equal('notfetched.txt'); 53 | file.getType().should.equal(FILE_TYPE.FILE); 54 | file.getMime().should.equal('text/plain'); 55 | should(file.getContent()).not.be.ok(); 56 | }); 57 | 58 | it('should return a File with content when fetched', () => { 59 | const repo = mock.addFile(DEFAULT_BOOK, 'fetched.txt', { 60 | fetched: true 61 | }); 62 | const file = FileUtils.stat(repo, 'fetched.txt'); 63 | file.getFileSize().should.equal(11); 64 | file.getContent().getAsString().should.equal('fetched.txt'); 65 | }); 66 | 67 | it('should return a File with content when there is a change', () => { 68 | const repo = FileUtils.create(DEFAULT_BOOK, 'created', 'content'); 69 | const file = FileUtils.stat(repo, 'created'); 70 | file.getContent().getAsString().should.equal('content'); 71 | }); 72 | 73 | it('should return a File with content when there is a change with known sha', () => { 74 | const readmeBlob = FileUtils.read(DEFAULT_BOOK, 'README.md'); 75 | const repo = FileUtils.move(DEFAULT_BOOK, 'README.md', 'renamed'); 76 | const file = FileUtils.stat(repo, 'renamed'); 77 | file.getContent().getAsString().should.equal(readmeBlob.getAsString()); 78 | }); 79 | 80 | it('should return a File without content when there is a change with known sha, but not fetched', () => { 81 | let repo = mock.addFile(DEFAULT_BOOK, 'created', { 82 | fetched: false 83 | }); 84 | repo = FileUtils.move(repo, 'created', 'renamed'); 85 | const file = FileUtils.stat(repo, 'renamed'); 86 | file.getFileSize().should.equal(7); 87 | should(file.getContent()).not.be.ok(); 88 | }); 89 | }); 90 | 91 | describe('.readAsString', () => { 92 | it('should read content as String if file exists', () => { 93 | const read = FileUtils.readAsString(DEFAULT_BOOK, 'SUMMARY.md'); 94 | read.should.be.equal('# Summary'); 95 | }); 96 | }); 97 | 98 | describe('.create', () => { 99 | it('should create a file if it does not exist', () => { 100 | const repoState = FileUtils.create(DEFAULT_BOOK, 'New', 'New Content'); 101 | FileUtils.exists(repoState, 'New').should.be.true(); 102 | FileUtils.readAsString(repoState, 'New').should.be.equal('New Content'); 103 | }); 104 | 105 | it('should throw File Already Exists when file does exist', () => { 106 | (function createExisting() { 107 | FileUtils.create(DEFAULT_BOOK, 'README.md', ''); 108 | }).should.throw(Error, { code: repofs.ERRORS.ALREADY_EXIST }); 109 | }); 110 | }); 111 | 112 | describe('.write', () => { 113 | it('should write a file if it exists', () => { 114 | const repoState = FileUtils.write(DEFAULT_BOOK, 'README.md', 'New Content'); 115 | FileUtils.readAsString(repoState, 'README.md').should.be.equal('New Content'); 116 | }); 117 | 118 | it('should throw File Not Found when file does not exist', () => { 119 | (function writeAbsent() { 120 | FileUtils.write(DEFAULT_BOOK, 'Notexist.md', ''); 121 | }).should.throw(Error, { code: repofs.ERRORS.NOT_FOUND }); 122 | }); 123 | }); 124 | 125 | describe('.remove', () => { 126 | it('should remove a file if it exists', () => { 127 | const repoState = FileUtils.remove(DEFAULT_BOOK, 'README.md'); 128 | FileUtils.exists(repoState, 'README.md').should.equal(false); 129 | }); 130 | 131 | it('should throw File Not Found when file does not exist', () => { 132 | (function removeAbsent() { 133 | FileUtils.remove(DEFAULT_BOOK, 'Notexist.md'); 134 | }).should.throw(Error, { code: repofs.ERRORS.NOT_FOUND }); 135 | }); 136 | }); 137 | 138 | describe('.move', () => { 139 | it('should move a file if it exists', () => { 140 | const repoState = FileUtils.move(DEFAULT_BOOK, 'README.md', 'README_NEW.md'); 141 | FileUtils.exists(repoState, 'README.md').should.equal(false); 142 | FileUtils.exists(repoState, 'README_NEW.md').should.equal(true); 143 | }); 144 | 145 | it('should throw File Not Found when file does not exist', () => { 146 | (function removeAbsent() { 147 | FileUtils.move(DEFAULT_BOOK, 'Notexist.md', 'Notexist_new.md'); 148 | }).should.throw(Error, { code: repofs.ERRORS.NOT_FOUND }); 149 | }); 150 | 151 | it('should correctly move existing files', () => { 152 | const repoState = FileUtils.move(DEFAULT_BOOK, 'README.md', 'README_NEW.md'); 153 | FileUtils.exists(repoState, 'README.md').should.equal(false); 154 | FileUtils.exists(repoState, 'README_NEW.md').should.equal(true); 155 | 156 | FileUtils.readAsString(DEFAULT_BOOK, 'README.md').should.equal( 157 | FileUtils.readAsString(repoState, 'README_NEW.md') 158 | ); 159 | }); 160 | 161 | it('should correctly move new files', () => { 162 | const content = 'Hello world'; 163 | 164 | const withNewFile = FileUtils.create(DEFAULT_BOOK, 'aNewFile.txt', content); 165 | const repoState = FileUtils.move(withNewFile, 'aNewFile.txt', 'aNewFile2.txt'); 166 | 167 | FileUtils.exists(repoState, 'aNewFile.txt').should.equal(false); 168 | FileUtils.exists(repoState, 'aNewFile2.txt').should.equal(true); 169 | 170 | FileUtils.readAsString(repoState, 'aNewFile2.txt').should.equal(content); 171 | }); 172 | }); 173 | 174 | describe('.hasChanged', () => { 175 | it('should detect that an existing file has not changed', () => { 176 | const state1 = DEFAULT_BOOK; 177 | const state2 = DEFAULT_BOOK; 178 | FileUtils.hasChanged(state1, state2, 'README.md').should.be.false(); 179 | }); 180 | 181 | it('should detect that a non existing file has not changed', () => { 182 | const state1 = DEFAULT_BOOK; 183 | const state2 = DEFAULT_BOOK; 184 | FileUtils.hasChanged(state1, state2, 'does_not_exist.md').should.be.false(); 185 | }); 186 | 187 | it('should detect that an added file has changed', () => { 188 | const state1 = DEFAULT_BOOK; 189 | const state2 = FileUtils.create(DEFAULT_BOOK, 'created'); 190 | FileUtils.hasChanged(state1, state2, 'created').should.be.true(); 191 | }); 192 | 193 | it('should detect that a removed file has changed', () => { 194 | const state1 = DEFAULT_BOOK; 195 | const state2 = FileUtils.remove(DEFAULT_BOOK, 'README.md'); 196 | FileUtils.hasChanged(state1, state2, 'README.md').should.be.true(); 197 | }); 198 | 199 | it('should detect when the content of a file has changed', () => { 200 | const state1 = FileUtils.create(DEFAULT_BOOK, 'created', 'content1'); 201 | const state2 = FileUtils.write(state1, 'created', 'content2'); 202 | FileUtils.hasChanged(state1, state2, 'created').should.be.true(); 203 | }); 204 | 205 | it('should detect when a created file has not changed', () => { 206 | const state1 = FileUtils.create(DEFAULT_BOOK, 'created', 'content1'); 207 | const state2 = FileUtils.write(state1, 'created', 'content1'); 208 | FileUtils.hasChanged(state1, state2, 'created').should.be.false(); 209 | }); 210 | }); 211 | }); 212 | -------------------------------------------------------------------------------- /test/filestree.js: -------------------------------------------------------------------------------- 1 | const should = require('should'); 2 | 3 | const Immutable = require('immutable'); 4 | 5 | const repofs = require('../src/'); 6 | const TreeUtils = repofs.TreeUtils; 7 | const FileUtils = repofs.FileUtils; 8 | const File = require('../src/models/file'); 9 | const mock = require('./mock'); 10 | 11 | describe('TreeUtils', () => { 12 | 13 | const INITIAL_FILES = [ 14 | 'file.root', 15 | 'dir/file1', 16 | 'dir/file2', 17 | 'dir.deep/file1', 18 | 'dir.deep/dir/file1' 19 | ]; 20 | 21 | const REPO = mock.directoryStructure(INITIAL_FILES); 22 | 23 | describe('TreeNode', () => { 24 | const fileNode1 = createFileNode(REPO, 'dir/file1'); 25 | const fileNode2 = createFileNode(REPO, 'dir/file2'); 26 | let tree = createDirNode('.'); 27 | 28 | let dir = createDirNode('dir'); 29 | dir = dir.set('children', new Immutable.Map({ 30 | file1: fileNode1, 31 | file2: fileNode2 32 | })); 33 | 34 | tree = tree.set('children', new Immutable.Map({ 35 | dir 36 | })); 37 | 38 | 39 | describe('.getInPath', () => { 40 | it('should get a file in a TreeNode from a path', () => { 41 | const node = TreeUtils.getInPath(tree, 'dir/file1'); 42 | Immutable.is(fileNode1, node).should.be.true(); 43 | }); 44 | 45 | it('should return null when not found', () => { 46 | const node = TreeUtils.getInPath(tree, 'dir/notfound'); 47 | should(node).be.null(); 48 | }); 49 | 50 | it('should return the tree itself for root', () => { 51 | const node = TreeUtils.getInPath(tree, '.'); 52 | Immutable.is(tree, node).should.be.true(); 53 | }); 54 | }); 55 | }); 56 | 57 | describe('.get', () => { 58 | 59 | it('should create a tree from an empty repo', () => { 60 | const tree = TreeUtils.get(mock.emptyRepo(), ''); 61 | const file = tree.getValue(); 62 | file.getPath().should.eql('.'); 63 | file.isDirectory().should.be.true(); 64 | tree.getChildren().isEmpty().should.be.true(); 65 | }); 66 | 67 | it('should create a tree from a flat structure of files', () => { 68 | const tree = TreeUtils.get(REPO, 'dir'); 69 | const file = tree.getValue(); 70 | file.getPath().should.eql('dir'); 71 | file.isDirectory().should.be.true(); 72 | 73 | const children = tree.getChildren(); 74 | const expected = Immutable.Map({ 75 | file1: createFileNode(REPO, 'dir/file1'), 76 | file2: createFileNode(REPO, 'dir/file2') 77 | }); 78 | 79 | Immutable.is(expected, children).should.be.true(); 80 | }); 81 | 82 | it('should create a tree from root', () => { 83 | const tree = TreeUtils.get(REPO, ''); 84 | const node = TreeUtils.getInPath(tree, 'dir/file1'); 85 | const expectedNode = createFileNode(REPO, 'dir/file1'); 86 | Immutable.is(expectedNode, node).should.be.true(); 87 | }); 88 | 89 | it('should create Immutable objects', () => { 90 | const tree = TreeUtils.get(REPO, ''); 91 | const node = TreeUtils.getInPath(tree, 'dir'); 92 | // Add a file 93 | node.getChildren().set('modified', createDirNode(REPO, 'dir/modified')); 94 | // Should not appear in original tree 95 | should(TreeUtils.getInPath(tree, 'dir/modified')).be.null(); 96 | }); 97 | }); 98 | 99 | describe('.hasChanged', () => { 100 | it('should detect when file structure has NOT changed', () => { 101 | const updatedFile = FileUtils.write(REPO, 'dir/file1', 'Content updated'); 102 | TreeUtils.hasChanged(REPO, updatedFile).should.be.false(); 103 | }); 104 | 105 | it('should detect when file structure has changed', () => { 106 | const removedFile = FileUtils.remove(REPO, 'dir/file1'); 107 | TreeUtils.hasChanged(REPO, removedFile).should.be.true(); 108 | }); 109 | }); 110 | 111 | describe('should be performant', () => { 112 | function timeIt(N, maxMS, func) { 113 | return function() { 114 | const t1 = Date.now(); 115 | for (let i = 0; i < N; i++) { 116 | func.call(this); 117 | } 118 | const t2 = Date.now(); 119 | const ms = (t2 - t1) / N; 120 | should(ms).be.below(maxMS); 121 | }; 122 | } 123 | 124 | // 5 files at depth 200 125 | const DEEP = mock.directoryStructure(mock.bigFileList(5, 200)); 126 | // 200 files at depth 5 127 | const WIDE = mock.directoryStructure(mock.bigFileList(200, 5)); 128 | // 50 files at depth 100 129 | const DEEP_WIDE = mock.directoryStructure(mock.bigFileList(50, 50)); 130 | 131 | it('to create tree from a deep repo', timeIt(20, 200, function deep(done) { 132 | this.timeout(10000); 133 | TreeUtils.get(DEEP, '.'); 134 | })); 135 | it('to create tree from a wide repo', timeIt(20, 200, function wide(done) { 136 | this.timeout(10000); 137 | TreeUtils.get(WIDE, '.'); 138 | })); 139 | it('to create tree from a deep and wide repo', timeIt(20, 400, function deepwide(done) { 140 | this.timeout(10000); 141 | TreeUtils.get(DEEP_WIDE, '.'); 142 | })); 143 | }); 144 | 145 | }); 146 | 147 | // Returns a TreeNode with no children, containing the given File as value 148 | function createFileNode(repo, path) { 149 | return TreeUtils.TreeNode.createLeaf(FileUtils.stat(repo, path)); 150 | } 151 | function createDirNode(path) { 152 | return TreeUtils.TreeNode.createLeaf(File.createDir(path)); 153 | } 154 | -------------------------------------------------------------------------------- /test/mock.js: -------------------------------------------------------------------------------- 1 | const _ = require('lodash'); 2 | const Immutable = require('immutable'); 3 | 4 | 5 | const CacheUtils = require('../src/utils/cache'); 6 | const TreeEntry = require('../src/models/treeEntry'); 7 | const Branch = require('../src/models/branch'); 8 | const Blob = require('../src/models/blob'); 9 | const WorkingState = require('../src/models/workingState'); 10 | const RepositoryState = require('../src/models/repositoryState'); 11 | 12 | 13 | // Return empty repo with single master branch 14 | function emptyRepo() { 15 | const masterBranch = new Branch({ 16 | name: 'master', 17 | sha: 'masterSha', 18 | remote: '' 19 | }); 20 | 21 | const workingState = new WorkingState({ 22 | head: 'sha.working.master', 23 | treeEntries: new Immutable.Map() 24 | }); 25 | 26 | return new RepositoryState({ 27 | currentBranchName: 'master', 28 | workingStates: new Immutable.Map().set(masterBranch.getName(), workingState), 29 | branches: new Immutable.List().push(masterBranch) 30 | // No cache 31 | }); 32 | } 33 | 34 | // Adds a file to the repo, with content equal to filepath. 35 | // options.fetched for already fetched in cache 36 | // options.branch to specify a branch 37 | // options.content to specify content 38 | function addFile(repoState, filepath, options) { 39 | options = _.defaults({}, options || {}, { 40 | branch: repoState.getCurrentBranchName(), 41 | fetched: true, 42 | content: filepath 43 | }); 44 | options.branch = repoState.getBranch(options.branch); 45 | 46 | const treeEntry = new TreeEntry({ 47 | blobSize: options.content.length, 48 | sha: 'sha.' + options.content, 49 | mode: '100644' 50 | }); 51 | let resultState = repoState; 52 | 53 | // Update working state 54 | let workingState = resultState.getWorkingStateForBranch(options.branch); 55 | workingState = workingState 56 | .set('treeEntries', workingState 57 | .getTreeEntries().set(filepath, treeEntry)); 58 | 59 | const workingStates = resultState.getWorkingStates(); 60 | resultState = resultState 61 | .set('workingStates', workingStates 62 | .set(options.branch.getFullName(), workingState)); 63 | 64 | // Update cache 65 | if (options.fetched) { 66 | let cache = repoState.getCache(); 67 | cache = CacheUtils.addBlob( 68 | cache, 69 | 'sha.' + options.content, 70 | Blob.createFromString(options.content) 71 | ); 72 | resultState = resultState.set('cache', cache); 73 | } 74 | return resultState; 75 | } 76 | 77 | 78 | // Creates a clean repoState for a default book with branches and files already fetched 79 | // * SUMMARY.md "# Summary" 80 | // * README.md "# Introduction" 81 | function defaultBook() { 82 | let resultState = emptyRepo(); 83 | resultState = addFile(resultState, 'README.md', { 84 | content: '# Introduction' 85 | }); 86 | resultState = addFile(resultState, 'SUMMARY.md', { 87 | content: '# Summary' 88 | }); 89 | return resultState; 90 | } 91 | 92 | // Creates a nested directory structure for testing (already fetched) 93 | // file.root 94 | // dir.twoItems/file1 95 | // dir.twoItems/file2 96 | // dir.deep.oneItem/file1 97 | // dir.deep.oneItem/dir.oneItem/file1 98 | function directoryStructure(pathList) { 99 | return pathList.reduce((repo, path) => { 100 | return addFile(repo, path); 101 | }, emptyRepo()); 102 | } 103 | 104 | // Make a big repo with 'n' files each in a directory at 'depth' 105 | function bigFileList(n, depth) { 106 | depth = depth || 1; 107 | const indexes = Immutable.Range(1, n); 108 | return indexes.map((index) => { 109 | const depths = Immutable.Range(0, depth); 110 | return depths.map((depth) => { 111 | return index + '.' + depth; 112 | }).toArray().join('/'); 113 | }).toArray(); 114 | } 115 | 116 | module.exports = { 117 | emptyRepo, 118 | DEFAULT_BOOK: defaultBook(), 119 | bigFileList, 120 | addFile, 121 | directoryStructure 122 | }; 123 | -------------------------------------------------------------------------------- /test/remote.js: -------------------------------------------------------------------------------- 1 | require('should'); 2 | 3 | const path = require('path'); 4 | 5 | const repofs = require('../src/'); 6 | const { RemoteUtils, BranchUtils, GitHubDriver } = repofs; 7 | 8 | const REPO = process.env.REPOFS_REPO; 9 | const HOST = process.env.REPOFS_HOST; 10 | const TOKEN = process.env.REPOFS_TOKEN; 11 | const REMOTE_PATH = path.resolve('.tmp/repo-remote.git') + '/'; 12 | 13 | const mock = require('./mock'); 14 | 15 | describe('RemoteUtils', () => { 16 | if (process.env.REPOFS_DRIVER !== 'uhub') return; 17 | 18 | const DEFAULT_BOOK = mock.DEFAULT_BOOK; 19 | 20 | const driver = new GitHubDriver({ 21 | repository: REPO, 22 | host: HOST, 23 | token: TOKEN 24 | }); 25 | 26 | describe('.push', () => { 27 | it('should be a function', () => { 28 | RemoteUtils.push.should.be.a.Function(); 29 | }); 30 | 31 | it('should push remote repository', (done) => { 32 | RemoteUtils.edit(driver, 'origin', REMOTE_PATH) 33 | .then(() => { 34 | return RemoteUtils.push(DEFAULT_BOOK, driver); 35 | }) 36 | .then((repoState) => { 37 | done(); 38 | }) 39 | .catch(done); 40 | }); 41 | }); 42 | 43 | describe('.pull', () => { 44 | it('should be a function', () => { 45 | RemoteUtils.pull.should.be.a.Function(); 46 | }); 47 | 48 | it('should pull remote repository', (done) => { 49 | RemoteUtils.pull(DEFAULT_BOOK, driver) 50 | .then(() => { 51 | done(); 52 | }); 53 | }); 54 | }); 55 | 56 | describe('.list', () => { 57 | it('should be a function', () => { 58 | RemoteUtils.list.should.be.a.Function(); 59 | }); 60 | 61 | it('should list remotes', (done) => { 62 | RemoteUtils.list(driver) 63 | .then((remotes) => { 64 | remotes.should.be.an.instanceOf(Array); 65 | remotes[0].should.have.property('name'); 66 | remotes[0].should.have.property('url'); 67 | remotes[0].name.should.equal('origin'); 68 | remotes[0].url.should.equal(REMOTE_PATH); 69 | done(); 70 | }) 71 | .catch(done); 72 | }); 73 | }); 74 | 75 | describe('.edit', () => { 76 | it('should be a function', () => { 77 | RemoteUtils.edit.should.be.a.Function(); 78 | }); 79 | 80 | it('should edit remotes', (done) => { 81 | RemoteUtils.edit(driver, 'origin', 'foobar') 82 | .then(() => { 83 | return RemoteUtils.list(driver); 84 | }) 85 | .then((remotes) => { 86 | remotes[0].name.should.equal('origin'); 87 | remotes[0].url.should.equal('foobar'); 88 | }) 89 | // reset remote to its original path 90 | .then(() => { 91 | return RemoteUtils.edit(driver, 'origin', REMOTE_PATH); 92 | }) 93 | .then(() => { 94 | return RemoteUtils.list(driver); 95 | }) 96 | .then((remotes) => { 97 | remotes[0].name.should.equal('origin'); 98 | remotes[0].url.should.equal(REMOTE_PATH); 99 | }) 100 | .then(done) 101 | .catch(done); 102 | }); 103 | }); 104 | 105 | describe('.sync', () => { 106 | let repoState; 107 | 108 | before(() => { 109 | return repofs.RepoUtils.initialize(driver) 110 | .then((initRepo) => { 111 | repoState = initRepo; 112 | }); 113 | }); 114 | 115 | it('should be a function', () => { 116 | RemoteUtils.sync.should.be.a.Function(); 117 | }); 118 | 119 | it('should sync to remote test-remote branch name', () => { 120 | return BranchUtils.create(repoState, driver, 'test-remote', {}) 121 | .then((newRepoState) => { 122 | newRepoState = newRepoState.set('currentBranchName', 'test-remote'); 123 | return RemoteUtils.sync(newRepoState, driver); 124 | }) 125 | .then((newRepoState) => { 126 | newRepoState.currentBranchName.should.equal('test-remote'); 127 | }); 128 | }); 129 | 130 | it('should sync to remote master branch name which exists', () => { 131 | return RemoteUtils.sync(repoState, driver) 132 | .then((newRepoState) => { 133 | newRepoState.currentBranchName.should.equal('master'); 134 | }); 135 | }); 136 | }); 137 | 138 | }); 139 | -------------------------------------------------------------------------------- /test/repoUtils.js: -------------------------------------------------------------------------------- 1 | require('should'); 2 | 3 | const repofs = require('../src/'); 4 | const mock = require('./mock'); 5 | 6 | describe('RepoUtils', () => { 7 | 8 | describe('.isClean', () => { 9 | 10 | it('should be true for if repo has no change', () => { 11 | const repoState = mock.DEFAULT_BOOK; 12 | repofs.RepoUtils.isClean(repoState).should.equal(true); 13 | }); 14 | 15 | it('should be true for if current branch has changes', () => { 16 | let repoState = mock.DEFAULT_BOOK; 17 | repoState = repofs.FileUtils.write(repoState, 'README.md', 'New content'); 18 | repofs.RepoUtils.isClean(repoState).should.equal(false); 19 | }); 20 | 21 | it('should be true for if repo has some unclean working states', () => { 22 | let repoState = mock.DEFAULT_BOOK; 23 | const branch = new repofs.Branch({ 24 | name: 'newBranch', 25 | sha: 'abc' 26 | }); 27 | 28 | // Change branch 29 | repoState = repoState 30 | .set('branches', repoState.getBranches().push(branch)) 31 | .set( 32 | 'workingStates', 33 | repoState.getWorkingStates().set( 34 | branch.getFullName(), 35 | repoState.getCurrentState() 36 | ) 37 | ) 38 | .set('currentBranchName', branch.getFullName()); 39 | 40 | repoState = repofs.FileUtils.write(repoState, 'README.md', 'New content'); 41 | repofs.RepoUtils.isClean(repoState).should.equal(false); 42 | }); 43 | }); 44 | }); 45 | 46 | -------------------------------------------------------------------------------- /test/repository.js: -------------------------------------------------------------------------------- 1 | require('should'); 2 | 3 | const repofs = require('../src/'); 4 | 5 | describe('RepositoryState', () => { 6 | 7 | describe('.createEmpty', () => { 8 | 9 | it('should create an empty RepoState', () => { 10 | const repoState = repofs.RepositoryState.createEmpty(); 11 | repoState.should.be.ok(); 12 | repoState.getBranches().isEmpty().should.be.true(); 13 | }); 14 | }); 15 | }); 16 | -------------------------------------------------------------------------------- /test/workingState.js: -------------------------------------------------------------------------------- 1 | require('should'); 2 | 3 | const repofs = require('../src/'); 4 | const mock = require('./mock'); 5 | 6 | describe('WorkingState', () => { 7 | 8 | describe('.isClean', () => { 9 | 10 | it('should true if workingState has not changes', () => { 11 | const repoState = mock.DEFAULT_BOOK; 12 | repoState.getCurrentState().isClean().should.equal(true); 13 | }); 14 | 15 | it('should false if workingState has changes', () => { 16 | let repoState = mock.DEFAULT_BOOK; 17 | repoState = repofs.FileUtils.write(repoState, 'README.md', 'New content'); 18 | repoState.getCurrentState().isClean().should.equal(false); 19 | }); 20 | }); 21 | }); 22 | 23 | --------------------------------------------------------------------------------