├── LICENSE ├── README.md ├── TODO.md ├── bin ├── create-patch.js ├── importit-reversed.js ├── importit.js └── shipit.js ├── config ├── __mocks__ │ └── fs.js ├── __tests__ │ ├── babel-plugin-orbit-components.test.js │ ├── babel-preset-kiwicom.test.js │ ├── eslint-config-kiwicom.test.js │ ├── eslint-plugin-kiwicom-incubator.test.js │ ├── monorepo-shipit.test.js │ ├── monorepo-utils.test.js │ ├── no-missing-tests.test.js │ └── testExportedPaths.js ├── babel-plugin-orbit-components.js ├── babel-preset-kiwicom.js ├── eslint-config-kiwicom.js ├── eslint-plugin-kiwicom-incubator.js ├── monorepo-shipit.js ├── monorepo-utils.js └── reversed │ ├── __tests__ │ ├── orbit-components.test.js │ └── orbit-design-tokens.test.js │ ├── orbit-components.js │ └── orbit-design-tokens.js ├── package.json └── src ├── Changeset.js ├── RepoGit.js ├── RepoGitFake.js ├── ShipitConfig.js ├── __tests__ ├── Changeset.test.js ├── RepoGit.commitPatch.test.js ├── RepoGit.findFirstAvailableCommit.test.js ├── RepoGit.findLastSourceCommit.test.js ├── RepoGit.renderPatch.test.js ├── ShipitConfig.test.js ├── __snapshots__ │ ├── RepoGit.renderPatch.test.js.snap │ ├── parsePatch.test.js.snap │ └── parsePatchHeader.test.js.snap ├── fixtures │ ├── configs │ │ ├── invalid-additional-props-1.js │ │ ├── invalid-additional-props-2.js │ │ ├── invalid-misconfigured-branches.js │ │ ├── invalid-missing-props.js │ │ ├── valid-branches.js │ │ └── valid-minimal.js │ ├── diffs │ │ ├── chmod.patch │ │ ├── diff-in-diff.patch │ │ ├── file-delete.patch │ │ ├── file-modify-no-eol.patch │ │ ├── file-new.patch │ │ ├── file-rename.patch │ │ ├── files-modify.patch │ │ ├── lfs-removal.patch │ │ ├── nasty.patch │ │ └── unicode.patch │ └── headers │ │ ├── multiline-subject.header │ │ ├── simple.header │ │ └── unicode.header ├── parsePatch.test.js ├── parsePatchHeader.test.js └── requireAndValidateConfig.test.js ├── accounts.js ├── filters ├── __tests__ │ ├── __snapshots__ │ │ └── conditionalLines.test.js.snap │ ├── conditionalLines.test.js │ ├── fixtures │ │ ├── comment-lines-comment-end.patch │ │ ├── comment-lines-no-comment-end.patch │ │ ├── double-comment.patch │ │ └── enable-disable.patch │ ├── moveDirectories.test.js │ ├── moveDirectoriesReverse.test.js │ ├── stripDescriptions.test.js │ ├── stripExceptDirectories.test.js │ └── stripPaths.test.js ├── addTrackingData.js ├── conditionalLines.js ├── moveDirectories.js ├── moveDirectoriesReverse.js ├── stripDescriptions.js ├── stripExceptDirectories.js └── stripPaths.js ├── iterateConfigs.js ├── parsePatch.js ├── parsePatchHeader.js ├── phases ├── createCheckCorruptedRepoPhase.js ├── createCleanPhase.js ├── createClonePhase.js ├── createImportReverseSyncPhase.js ├── createImportSyncPhase.js ├── createPushPhase.js ├── createSyncPhase.js └── createVerifyRepoPhase.js ├── requireAndValidateConfig.js ├── splitHead.js └── utils └── createFakeChangeset.js /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018-present, Kiwi.com 4 | Copyright (c) 2013-present, Facebook, Inc. 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in all 14 | copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 22 | SOFTWARE. 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## This package is deprecated 2 | 3 | `@kiwicom/monorepo-shipit` is no longer maintained 4 | 5 | --- 6 | 7 | Monorepo Shipit takes care of exporting and importing our source-codes from our private GitLab monorepo into any other Git repository. It can export even from our monorepo to another monorepo. We use it open-source some of our packages to our [GitHub](https://github.com/kiwicom). This way we can develop just like we are used to in one monorepo but we can contribute back to the community by making some of our codes open. 8 | 9 | # Shipit part 10 | 11 | First, we try to extract relevant commits of our package we want to opensource. Each commit is converted into so called "changeset" which is immutable structure representing one commit. One changeset can contain many diffs which describe changes in one individual file. It's very common to modify many files in commit even outside of the public package. Moreover paths in our internal monorepo are very different from the open-sourced version. Therefore, we apply some filters to hide non-relevant or secret files and to adjust paths in the changeset to match open-source expectations. These modified changesets are then pushed applied in the cloned open-source repository and pushed to the GitHub service. 12 | 13 | ```text 14 | .-----------------------------------. 15 | | | 16 | | GitLab Monorepo | 17 | | | 18 | `-----------------------------------` 19 | v v v 20 | .-----------. .-----------. .-----------. 21 | | Changeset | | Changeset | | Changeset | 22 | `-----------` `-----------` `-----------` 23 | v v v 24 | .-----------------------------------. 25 | | Filters and Modifiers | 26 | `-----------------------------------` 27 | v v v 28 | .-----------. .-----------. .-----------. 29 | | Changeset | | Changeset | | Changeset | 30 | `-----------` `-----------` `-----------` 31 | | | v 32 | | | .---------. 33 | | | | GH repo | <------. 34 | | v `---------` | .--------------------. 35 | | .---------. | | | 36 | | | GH repo | <---------------------+----> | GitHub service | 37 | v `---------` | | | 38 | .---------. | `--------------------` 39 | | GH repo | <------------------------------------` 40 | `---------` 41 | ``` 42 | 43 | One of the filters modifies commit summaries and adds `kiwicom-source-id` signature which helps us to identify which changes we pushed last time and just amend latest internal changes. These filters work with the parsed changesets which gives you incredible flexibility: you can for example completely remove some lines from the open-source version. However, please note that this whole process works with diffs and therefore new filter won't update existing files in GitHub unless you touch them. So, for instance, if you want to remove some files from the public repository then just add a new filter and manually remove them from GitHub. 44 | 45 | ## Configuration 46 | 47 | Each project has its own configuration directly in Shipit workspace. If you want it to work with another project then you have to create a new configuration (with configuration tests): 48 | 49 | ```js 50 | module.exports = { 51 | getStaticConfig() { 52 | return { 53 | repository: 'git@github.com/kiwicom/relay-example.git', // see: https://git-scm.com/docs/git-clone#_git_urls_a_id_urls_a 54 | }; 55 | }, 56 | getPathMappings(): Map { 57 | return new Map([ 58 | ['src/incubator/example-relay/__github__/.circleci', '.circleci'], 59 | ['src/incubator/example-relay/__github__/.flowconfig', '.flowconfig'], 60 | ['src/incubator/example-relay/', ''], 61 | ]); 62 | }, 63 | getStrippedFiles(): Set { 64 | // this method is optional 65 | return new Set([/__github__/]); 66 | }, 67 | }; 68 | ``` 69 | 70 | Read more about available filters and how to use them below. 71 | 72 | ## Filters 73 | 74 | There are various filters applied on exported changesets to make it work properly. Currently we apply these filters: 75 | 76 | - `PathFilters.stripExceptDirectories` 77 | - `PathFilters.moveDirectories` 78 | - conditional comments filter (only `// @x-shipit-enable` and `// @x-shipit-disable` supported at this moment) 79 | 80 | The first filter makes sure that we publish only files relevant to the workspace that is being open-sourced. This filter is automatic. Second `moveDirectories` filter makes sure that we publish correct paths for opensource. It's because our packages are located in for example `src/packages/fetch` but we want to have these files in the root on GitHub (not nested in `src/packages/fetch`). 81 | 82 | ### Filter `PathFilters.moveDirectories` 83 | 84 | This filter maps our internal directories to OSS directories and vice versa. Typical minimalistic mapping looks like this: 85 | 86 | ```js 87 | new Map([ 88 | // from, to 89 | ['src/packages/fetch/', ''], 90 | ]); 91 | ``` 92 | 93 | This maps all the files from our [fetch](https://github.com/adeira/universe/tree/master/src/fetch) package to the GitHub root so OSS users have access to everything from this package. More advanced example when you need to publish some GitHub specific files: 94 | 95 | ```js 96 | new Map([ 97 | ['src/packages/fetch/__github__/', ''], // trailing slash is significant 98 | ['src/packages/fetch/', ''], 99 | ]); 100 | ``` 101 | 102 | This mapping moves all the files from `__github__` to the root. There are two things you should understand. Firstly, order matters. First mapping route takes precedence and should be therefore more specific. Secondly, this mapping is irreversible (read more about what does it mean in Importit part). 103 | 104 | And finally this is how you'd map your package to the subfolder on GitHub (good for shipping from our monorepo to different monorepo or when you are avoiding previously mentioned irreversibility): 105 | 106 | ```js 107 | new Map([['src/packages/fetch/', 'packages/fetch/']]); 108 | ``` 109 | 110 | ### Filter of conditional comments 111 | 112 | This filter is handy when you need to enable or disable some lines when exporting the project for OSS. Look at for example this example (code in our private monorepo): 113 | 114 | ```js 115 | someFunctionCallWithDifferentOSSRepresentation( 116 | // @x-oss-enable: true, 117 | false, // @x-oss-disable 118 | ); 119 | ``` 120 | 121 | The code above is written by our programmer. Shipit then automatically turns this code into the following code when exporting: 122 | 123 | ```js 124 | someFunctionCallWithDifferentOSSRepresentation( 125 | true, // @x-oss-enable 126 | // @x-oss-disable: false, 127 | ); 128 | ``` 129 | 130 | Please note: this is just an example, currently we support only `// @x-shipit-enable` and `// @x-shipit-disable` in this exact format. However, logic of this filter is independent on this marker so it's possible to build on top of this and even make it project specific. 131 | 132 | ## Renaming project roots 133 | 134 | It's fairly straightforward to rename things inside your specified root and ship them correctly to GitHub. However, it's far more challenging to rename the roots in monorepo while keeping the shipping working. It's because Shipit is looking at for example `src/packages/monorepo/` root but when you rename it then it seems like the project is completely new (missing history => new files). This would conflict with the code that is already exported on GitHub for example. 135 | 136 | ```js 137 | module.exports = { 138 | getPathMappings(): Map { 139 | return new Map([['src/packages/monorepo/', '']]); 140 | // ... add new root here, keep the old one as well 141 | }, 142 | }; 143 | ``` 144 | 145 | To deal with this you have to approach the roots renaming carefully. Our current best attempt is to do it in two steps: 146 | 147 | 1. Rename your root as needed and add it to the config. Do not delete the old one though. Shipit should understand what is going on and deploy an empty commit with correct `kiwicom-source-id`. 148 | 2. Delete the original root from the config when the previous step succeeds. You should be good to go. 149 | 150 | Don't worry if you mess up something. Monorepo is always a source of truth and it won't be messed up. Worst case scenario is that Shipit job will start failing. One way out of this situation is to either fix the previous steps or simply create manually an empty commit on GitHub with corrected `kiwicom-source-id` so that Shipit can catch up. 151 | 152 | ## Linear history 153 | 154 | One of the Shipit limitations (even the original one) is that it works correctly only on linear history (see [Major Limitations](https://github.com/facebook/fbshipit/tree/95180a49243caf14be883140436ee8ccbaa5954e#major-limitations)). Imagine following history (the numbers denote the order of commit timestamps): 155 | 156 | ```text 157 | * 158 | ---1----2----4----7 159 | \ \ 160 | 3----5----6----8--- 161 | * 162 | ``` 163 | 164 | In what order would you apply these changes considering Shipit takes one commit at the time and applies it as a patch? You can choose `1 2 3 4 5 6 7 8` which follows the dates or you can follow topology of the graph and get `1 2 4 7 3 5 6 8` (or `1 3 5 6 2 4 7 8`). Every option is going to be at some point wrong. Imagine that the commits marked with `*` are introducing the same change and therefore you won't be able to apply such patch. 165 | 166 | For this reason Shipit requires linear Git history only (it works with reversed ancestry path without merge commits). 167 | 168 | # Importit part _(unstable)_ 169 | 170 | **Only imports from GitHub are currently supported.** 171 | 172 | This is how you'd import a pull request #1 from GitHub into your local branch: 173 | 174 | ```text 175 | yarn monorepo-babel-node src/core/monorepo-shipit/bin/importit.js git@github.com:kiwicom/fetch.git 1 176 | ``` 177 | 178 | The idea is that you will tweak it for us if needed, test it in our monorepo and eventually send a merge request to the monorepo. Technically, _Importit_ part works just like _Shipit_ except in the opposite direction: 179 | 180 | ```text 181 | .-----------------------------------. 182 | | | 183 | | GitLab Monorepo | 184 | | | 185 | `-----------------------------------` 186 | ^ ^ ^ 187 | .-----------. .-----------. .-----------. 188 | | Changeset | | Changeset | | Changeset | 189 | `-----------` `-----------` `-----------` 190 | ^ ^ ^ 191 | .-----------------------------------. 192 | | Filters and Modifiers | 193 | `-----------------------------------` 194 | ^ ^ ^ 195 | .-----------. .-----------. .-----------. 196 | | Changeset | | Changeset | | Changeset | 197 | `-----------` `-----------` `-----------` 198 | ^ ^ ^ 199 | | | .---------. 200 | | | | GH repo | <------. 201 | | | `---------` | .--------------------. 202 | | .---------. | | | 203 | | | GH repo | <---------------------+----> | GitHub service | 204 | | `---------` | | | 205 | .---------. | `--------------------` 206 | | GH repo | <------------------------------------` 207 | `---------` 208 | ``` 209 | 210 | ## Filters 211 | 212 | The only filter being applied when importing the projects is filter which moves directories (see Shipit filters) except it's applied in the reversed order. One important drawback here to understand is that while Shipit filters can have duplicate destinations, Importit filters cannot. This means that not every Shipit filter can be inverted. It's because it would be impossible to figure out how should be the files restored when importing back to our monorepo. 213 | 214 | # Main differences from facebook/fbshipit 215 | 216 | - our version is tailored for Kiwi.com needs and infra, not Facebook ones 217 | - our version doesn't support [Mercurial](https://www.mercurial-scm.org/) and it's written in JS (not in Hack) 218 | - our version doesn't support [Git Submodules](https://git-scm.com/book/en/v2/Git-Tools-Submodules) 219 | - we _do not_ sync internal LFS storage with GitHub LFS (currently unused) 220 | - we currently cannot do this in one commit: 221 | - changed Shipit config: https://github.com/facebook/fbshipit/commit/939949dc1369295c910772c6e8eccbbef2a2db7f 222 | - effect in Relay repo: https://github.com/facebook/relay/commit/13b6436e406398065507efb9df2eae61cdc14dd9 223 | 224 | # Prior art 225 | 226 | - https://github.com/facebook/fbshipit 👍 227 | - https://git-scm.com/docs/git-filter-branch 😏 228 | - https://github.com/splitsh/lite 👎 229 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # TODO 2 | 3 | - We should run Shipit as a service. Currently it runs on CI (because we wanted to make it work fast) but it's not correct. The service should clone the repositories only once and keep them there (we currently clone the repos every time). This would significantly improve the performance. 4 | - Investigate how to ship the repositories in parallel. We currently ship them one by one which is by design since I was not sure how would Git process behave when I'd try to run it many times on one repository (Universe). There are some situations when Git creates a lock file and is not very happy. 5 | - UI for Shipit. Also, study [Configerator](https://research.fb.com/wp-content/uploads/2016/11/holistic-configuration-management-at-facebook.pdf) approach (Facebook doesn't use configs in the code anymore - we still do). 6 | - ... 7 | -------------------------------------------------------------------------------- /bin/create-patch.js: -------------------------------------------------------------------------------- 1 | // @flow strict-local 2 | 3 | import { findMonorepoRoot } from '@kiwicom/monorepo-utils'; 4 | 5 | import RepoGit from '../src/RepoGit'; 6 | 7 | // yarn monorepo-babel-node src/core/monorepo-shipit/bin/create-patch.js 8 | const argv = process.argv.slice(2); 9 | const revision = argv[0]; 10 | 11 | const repo = new RepoGit(findMonorepoRoot()); 12 | const patch = repo.getNativePatchFromID(revision); 13 | const header = repo.getNativeHeaderFromIDWithPatch(revision, patch); 14 | 15 | /* eslint-disable no-console */ 16 | console.log('~~~ HEADER ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~'); 17 | console.log(header); 18 | console.log('~~~ PATCH ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~'); 19 | console.log(patch); 20 | -------------------------------------------------------------------------------- /bin/importit-reversed.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // @flow strict-local 4 | 5 | import Logger from '@adeira/logger'; 6 | 7 | import { iterateReversedConfigs } from '../src/iterateConfigs'; 8 | import createClonePhase from '../src/phases/createClonePhase'; 9 | import createCheckCorruptedRepoPhase from '../src/phases/createCheckCorruptedRepoPhase'; 10 | import createCleanPhase from '../src/phases/createCleanPhase'; 11 | import createImportReverseSyncPhase from '../src/phases/createImportReverseSyncPhase'; 12 | 13 | // yarn monorepo-babel-node src/core/monorepo-shipit/bin/importit-reversed.js 14 | 15 | const importOnly = 'git@github.com:kiwicom/eslint-config-nitro.git'; 16 | 17 | iterateReversedConfigs(config => { 18 | const repoUrl = config.exportedRepoURL; 19 | if (repoUrl === importOnly) { 20 | Logger.log('Importing: %s', repoUrl); 21 | new Set<() => void>([ 22 | createClonePhase(repoUrl, config.destinationPath), 23 | createCheckCorruptedRepoPhase(config.destinationPath), 24 | createCleanPhase(config.destinationPath), 25 | createImportReverseSyncPhase(config), 26 | ]).forEach(phase => phase()); 27 | } else { 28 | Logger.log('Skipping: %s', repoUrl); 29 | } 30 | }); 31 | -------------------------------------------------------------------------------- /bin/importit.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // @flow strict-local 4 | 5 | import { invariant } from '@adeira/js'; 6 | 7 | import iterateConfigs from '../src/iterateConfigs'; 8 | import createClonePhase from '../src/phases/createClonePhase'; 9 | import createCheckCorruptedRepoPhase from '../src/phases/createCheckCorruptedRepoPhase'; 10 | import createCleanPhase from '../src/phases/createCleanPhase'; 11 | import createImportSyncPhase from '../src/phases/createImportSyncPhase'; 12 | 13 | // TODO: check we can actually import this package (whether we have config for it) 14 | // yarn monorepo-babel-node src/core/monorepo-shipit/bin/importit.js git@github.com:kiwicom/fetch.git 1 15 | 16 | const argv = process.argv.splice(2); // TODO: better CLI 17 | invariant(argv.length === 2, 'Importit expects two arguments: git URL and PR number.'); 18 | 19 | const exportedRepoURL = argv[0]; // git@github.com:kiwicom/fetch.git 20 | const pullRequestNumber = argv[1]; 21 | 22 | const gitRegex = /^git@github.com:(?.+)\.git$/; 23 | invariant( 24 | gitRegex.test(exportedRepoURL), 25 | 'We currently support imports only from GitHub.com - please open an issue to add additional services.', 26 | ); 27 | 28 | const match = exportedRepoURL.match(gitRegex); 29 | const packageName = match?.groups?.packageName; 30 | invariant(packageName != null, 'Cannot figure out package name from: %s', exportedRepoURL); 31 | 32 | iterateConfigs(config => { 33 | if (config.exportedRepoURL === exportedRepoURL) { 34 | new Set<() => void>([ 35 | createClonePhase(config.exportedRepoURL, config.destinationPath), 36 | createCheckCorruptedRepoPhase(config.destinationPath), 37 | createCleanPhase(config.destinationPath), 38 | createImportSyncPhase(config, packageName, pullRequestNumber), 39 | ]).forEach(phase => phase()); 40 | } 41 | }); 42 | 43 | // TODO: make it better 44 | -------------------------------------------------------------------------------- /bin/shipit.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // @flow strict-local 4 | 5 | import iterateConfigs from '../src/iterateConfigs'; 6 | import createClonePhase from '../src/phases/createClonePhase'; 7 | import createCheckCorruptedRepoPhase from '../src/phases/createCheckCorruptedRepoPhase'; 8 | import createCleanPhase from '../src/phases/createCleanPhase'; 9 | import createSyncPhase from '../src/phases/createSyncPhase'; 10 | import createVerifyRepoPhase from '../src/phases/createVerifyRepoPhase'; 11 | import createPushPhase from '../src/phases/createPushPhase'; 12 | 13 | iterateConfigs(config => { 14 | new Set<() => void>([ 15 | createClonePhase(config.exportedRepoURL, config.destinationPath), 16 | createCheckCorruptedRepoPhase(config.destinationPath), 17 | createCleanPhase(config.destinationPath), 18 | createSyncPhase(config), 19 | createVerifyRepoPhase(config), 20 | createPushPhase(config), 21 | ]).forEach(phase => phase()); 22 | }); 23 | -------------------------------------------------------------------------------- /config/__mocks__/fs.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | const fs = jest.genMockFromModule('fs'); 4 | 5 | fs.existsSync = path => path !== '/unknown_path'; 6 | 7 | export default fs; 8 | -------------------------------------------------------------------------------- /config/__tests__/babel-plugin-orbit-components.test.js: -------------------------------------------------------------------------------- 1 | // @flow strict-local 2 | 3 | import path from 'path'; 4 | 5 | import testExportedPaths from './testExportedPaths'; 6 | 7 | testExportedPaths(path.join(__dirname, '..', 'babel-plugin-orbit-components.js'), [ 8 | ['src/packages/babel-plugin-orbit-components/package.json', 'package.json'], 9 | ['src/packages/babel-plugin-orbit-components/index.js', 'index.js'], 10 | 11 | // OSS specific: 12 | ['src/packages/babel-plugin-orbit-components/__github__/.eslintignore', '.eslintignore'], 13 | ['src/packages/babel-plugin-orbit-components/__github__/.eslintrc.js', '.eslintrc.js'], 14 | ['src/packages/babel-plugin-orbit-components/__github__/.travis.yml', '.travis.yml'], 15 | ['src/packages/babel-plugin-orbit-components/__github__/babel.config.js', 'babel.config.js'], 16 | 17 | // invalid cases: 18 | ['src/packages/xyz/outsideScope.js', undefined], // correctly deleted 19 | ['package.json', undefined], // correctly deleted 20 | ]); 21 | -------------------------------------------------------------------------------- /config/__tests__/babel-preset-kiwicom.test.js: -------------------------------------------------------------------------------- 1 | // @flow strict-local 2 | 3 | import path from 'path'; 4 | 5 | import testExportedPaths from './testExportedPaths'; 6 | 7 | testExportedPaths(path.join(__dirname, '..', 'babel-preset-kiwicom.js'), [ 8 | ['src/packages/babel-preset-kiwicom/package.json', 'package.json'], 9 | ['src/packages/babel-preset-kiwicom/src/index.js', 'src/index.js'], 10 | 11 | // invalid cases: 12 | ['src/packages/xyz/outsideScope.js', undefined], // correctly deleted 13 | ['package.json', undefined], // correctly deleted 14 | ]); 15 | -------------------------------------------------------------------------------- /config/__tests__/eslint-config-kiwicom.test.js: -------------------------------------------------------------------------------- 1 | // @flow strict-local 2 | 3 | import path from 'path'; 4 | 5 | import testExportedPaths from './testExportedPaths'; 6 | 7 | testExportedPaths(path.join(__dirname, '..', 'eslint-config-kiwicom.js'), [ 8 | ['src/packages/eslint-config-kiwicom/package.json', 'package.json'], 9 | ['src/packages/eslint-config-kiwicom/src/index.js', 'src/index.js'], 10 | 11 | // invalid cases: 12 | ['src/packages/xyz/outsideScope.js', undefined], // correctly deleted 13 | ['package.json', undefined], // correctly deleted 14 | ]); 15 | -------------------------------------------------------------------------------- /config/__tests__/eslint-plugin-kiwicom-incubator.test.js: -------------------------------------------------------------------------------- 1 | // @flow strict-local 2 | 3 | import path from 'path'; 4 | 5 | import testExportedPaths from './testExportedPaths'; 6 | 7 | testExportedPaths(path.join(__dirname, '..', 'eslint-plugin-kiwicom-incubator.js'), [ 8 | ['src/packages/eslint-plugin-kiwicom-incubator/package.json', 'package.json'], 9 | ['src/packages/eslint-plugin-kiwicom-incubator/src/index.js', 'src/index.js'], 10 | 11 | // invalid cases: 12 | ['src/packages/xyz/outsideScope.js', undefined], // correctly deleted 13 | ['package.json', undefined], // correctly deleted 14 | ]); 15 | -------------------------------------------------------------------------------- /config/__tests__/monorepo-shipit.test.js: -------------------------------------------------------------------------------- 1 | // @flow strict-local 2 | 3 | import path from 'path'; 4 | 5 | import testExportedPaths from './testExportedPaths'; 6 | 7 | testExportedPaths(path.join(__dirname, '..', 'monorepo-shipit.js'), [ 8 | ['src/core/monorepo-shipit/package.json', 'package.json'], 9 | ['src/core/monorepo-shipit/src/index.js', 'src/index.js'], 10 | 11 | // invalid cases: 12 | ['src/packages/xyz/outsideScope.js', undefined], // correctly deleted 13 | ['package.json', undefined], // correctly deleted 14 | ]); 15 | -------------------------------------------------------------------------------- /config/__tests__/monorepo-utils.test.js: -------------------------------------------------------------------------------- 1 | // @flow strict-local 2 | 3 | import path from 'path'; 4 | 5 | import testExportedPaths from './testExportedPaths'; 6 | 7 | testExportedPaths(path.join(__dirname, '..', 'monorepo-utils.js'), [ 8 | ['src/core/monorepo-utils/package.json', 'package.json'], 9 | ['src/core/monorepo-utils/src/index.js', 'src/index.js'], 10 | 11 | // invalid cases: 12 | ['src/packages/xyz/outsideScope.js', undefined], // correctly deleted 13 | ['package.json', undefined], // correctly deleted 14 | ]); 15 | -------------------------------------------------------------------------------- /config/__tests__/no-missing-tests.test.js: -------------------------------------------------------------------------------- 1 | // @flow strict-local 2 | 3 | import fs from 'fs'; 4 | import path from 'path'; 5 | import { globSync } from '@kiwicom/monorepo-utils'; 6 | 7 | test('there is a test file for every config file', () => { 8 | const configFilenames = globSync('/**/*.js', { 9 | root: path.join(__dirname, '..'), 10 | ignore: [ 11 | '**/node_modules/**', 12 | '**/__[a-z]*__/**', // ignore __tests__, __mocks__, ... 13 | ], 14 | }); 15 | configFilenames.forEach(configFilename => { 16 | const testFilename = configFilename.replace( 17 | /^(?.+?)(?[^/]+)\.js$/, 18 | `$1__tests__${path.sep}$2.test.js`, 19 | ); 20 | expect({ 21 | isOK: fs.existsSync(testFilename), 22 | testFilename, 23 | }).toEqual({ 24 | isOK: true, 25 | testFilename, 26 | }); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /config/__tests__/testExportedPaths.js: -------------------------------------------------------------------------------- 1 | // @flow strict-local 2 | 3 | import Changeset from '../../src/Changeset'; 4 | import ShipitConfig from '../../src/ShipitConfig'; 5 | import requireAndValidateConfig from '../../src/requireAndValidateConfig'; 6 | 7 | jest.mock('fs'); 8 | 9 | // eslint-disable-next-line jest/no-export 10 | export default function testExportedPaths( 11 | configPath: string, 12 | mapping: $ReadOnlyArray< 13 | [ 14 | string, 15 | string | void, // void describes deleted file 16 | ], 17 | >, 18 | ) { 19 | const config = requireAndValidateConfig(configPath); 20 | 21 | test.each(mapping)('mapping: %s -> %s', (input, output) => { 22 | const defaultFilter = new ShipitConfig( 23 | 'mocked repo path', 24 | 'mocked repo URL', 25 | config.getPathMappings(), 26 | config.getStrippedFiles ? config.getStrippedFiles() : new Set(), 27 | ).getDefaultShipitFilter(); 28 | 29 | const inputChangeset = new Changeset().withDiffs(new Set([{ path: input, body: 'mocked' }])); 30 | 31 | const outputDataset = defaultFilter(inputChangeset); 32 | 33 | if (output === undefined) { 34 | expect(...outputDataset.getDiffs()).toBeUndefined(); 35 | } else { 36 | expect(...outputDataset.getDiffs()).toEqual({ 37 | body: 'mocked', 38 | path: output, 39 | }); 40 | } 41 | }); 42 | } 43 | -------------------------------------------------------------------------------- /config/babel-plugin-orbit-components.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | module.exports = { 4 | getStaticConfig() { 5 | return { 6 | repository: 'git@github.com:kiwicom/babel-plugin-orbit-components.git', 7 | }; 8 | }, 9 | getPathMappings(): Map { 10 | return new Map([ 11 | ['src/packages/babel-plugin-orbit-components/__github__/.eslintignore', '.eslintignore'], 12 | ['src/packages/babel-plugin-orbit-components/__github__/.eslintrc.js', '.eslintrc.js'], 13 | ['src/packages/babel-plugin-orbit-components/__github__/.travis.yml', '.travis.yml'], 14 | ['src/packages/babel-plugin-orbit-components/__github__/babel.config.js', 'babel.config.js'], 15 | ['src/packages/babel-plugin-orbit-components/', ''], 16 | ]); 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /config/babel-preset-kiwicom.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | module.exports = { 4 | getStaticConfig() { 5 | return { 6 | repository: 'git@github.com:kiwicom/babel-preset-kiwicom.git', 7 | }; 8 | }, 9 | getPathMappings(): Map { 10 | return new Map([['src/packages/babel-preset-kiwicom/', '']]); 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /config/eslint-config-kiwicom.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | module.exports = { 4 | getStaticConfig() { 5 | return { 6 | repository: 'git@github.com:kiwicom/eslint-config-kiwicom.git', 7 | }; 8 | }, 9 | getPathMappings(): Map { 10 | return new Map([['src/packages/eslint-config-kiwicom/', '']]); 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /config/eslint-plugin-kiwicom-incubator.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | module.exports = { 4 | getStaticConfig() { 5 | return { 6 | repository: 'git@github.com:kiwicom/eslint-plugin-kiwicom-incubator.git', 7 | }; 8 | }, 9 | getPathMappings(): Map { 10 | return new Map([['src/packages/eslint-plugin-kiwicom-incubator/', '']]); 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /config/monorepo-shipit.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | module.exports = { 4 | getStaticConfig() { 5 | return { 6 | repository: 'git@github.com:kiwicom/monorepo-shipit.git', 7 | }; 8 | }, 9 | getPathMappings(): Map { 10 | return new Map([['src/core/monorepo-shipit/', '']]); 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /config/monorepo-utils.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | module.exports = { 4 | getStaticConfig() { 5 | return { 6 | repository: 'git@github.com:kiwicom/monorepo-utils.git', 7 | }; 8 | }, 9 | getPathMappings(): Map { 10 | return new Map([['src/core/monorepo-utils/', '']]); 11 | }, 12 | }; 13 | -------------------------------------------------------------------------------- /config/reversed/__tests__/orbit-components.test.js: -------------------------------------------------------------------------------- 1 | // @flow strict-local 2 | 3 | import path from 'path'; 4 | 5 | import testExportedPaths from '../../__tests__/testExportedPaths'; 6 | 7 | const root = 'src/platform/orbit-components/'; 8 | 9 | testExportedPaths(path.join(__dirname, '..', 'orbit-components.js'), [ 10 | [`${root}package.json`, 'package.json'], 11 | [`${root}src/index.js`, 'src/index.js'], 12 | 13 | // special cases: 14 | [`${root}__github__/.circleci/config.yml`, '.circleci/config.yml'], 15 | [`${root}__github__/.editorconfig`, '.editorconfig'], 16 | [`${root}__github__/.flowconfig`, '.flowconfig'], 17 | [ 18 | `${root}__github__/.github/ISSUE_TEMPLATE/bug_Report.md`, 19 | '.github/ISSUE_TEMPLATE/bug_Report.md', 20 | ], 21 | [`${root}__github__/.prettierrc`, '.prettierrc'], 22 | [`${root}__github__/flow-typed/xyz.js`, 'flow-typed/xyz.js'], 23 | [`${root}yarn.lock`, undefined], // correctly deleted 24 | 25 | // invalid cases: 26 | ['src/packages/xyz/outsideScope.js', undefined], // correctly deleted 27 | ['package.json', undefined], // correctly deleted 28 | ]); 29 | -------------------------------------------------------------------------------- /config/reversed/__tests__/orbit-design-tokens.test.js: -------------------------------------------------------------------------------- 1 | // @flow strict-local 2 | 3 | import path from 'path'; 4 | 5 | import testExportedPaths from '../../__tests__/testExportedPaths'; 6 | 7 | const root = 'src/platform/orbit-design-tokens/'; 8 | 9 | testExportedPaths(path.join(__dirname, '..', 'orbit-design-tokens.js'), [ 10 | [`${root}package.json`, 'package.json'], 11 | [`${root}src/index.js`, 'src/index.js'], 12 | 13 | // special cases: 14 | [`${root}__github__/.circleci/config.yml`, '.circleci/config.yml'], 15 | [`${root}__github__/.editorconfig`, '.editorconfig'], 16 | [`${root}__github__/.flowconfig`, '.flowconfig'], 17 | [ 18 | `${root}__github__/.github/ISSUE_TEMPLATE/bug_Report.md`, 19 | '.github/ISSUE_TEMPLATE/bug_Report.md', 20 | ], 21 | [`${root}__github__/.prettierrc`, '.prettierrc'], 22 | [`${root}__github__/flow-typed/xyz.js`, 'flow-typed/xyz.js'], 23 | [`${root}yarn.lock`, undefined], // correctly deleted 24 | 25 | // invalid cases: 26 | ['src/packages/xyz/outsideScope.js', undefined], // correctly deleted 27 | ['package.json', undefined], // correctly deleted 28 | ]); 29 | -------------------------------------------------------------------------------- /config/reversed/orbit-components.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | module.exports = { 4 | getBranchConfig() { 5 | return { 6 | source: 'master', 7 | destination: 'shipit-reversed-test', 8 | }; 9 | }, 10 | getStaticConfig() { 11 | return { 12 | repository: 'git@github.com:kiwicom/orbit-components.git', 13 | }; 14 | }, 15 | getPathMappings(): Map { 16 | const root = 'src/platform/orbit-components/'; 17 | return new Map([ 18 | [`${root}__github__/.circleci`, '.circleci'], 19 | [`${root}__github__/.editorconfig`, '.editorconfig'], 20 | [`${root}__github__/.flowconfig`, '.flowconfig'], 21 | [`${root}__github__/.github`, '.github'], 22 | [`${root}__github__/.prettierrc`, '.prettierrc'], 23 | [`${root}__github__/flow-typed`, 'flow-typed'], 24 | [`${root}`, ''], 25 | ]); 26 | }, 27 | getStrippedFiles(): Set { 28 | return new Set([/yarn\.lock$/]); 29 | }, 30 | }; 31 | -------------------------------------------------------------------------------- /config/reversed/orbit-design-tokens.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | module.exports = { 4 | getBranchConfig() { 5 | return { 6 | source: 'master', 7 | destination: 'shipit-reversed-test', 8 | }; 9 | }, 10 | getStaticConfig() { 11 | return { 12 | repository: 'git@github.com:kiwicom/orbit-design-tokens.git', 13 | }; 14 | }, 15 | getPathMappings(): Map { 16 | const root = 'src/platform/orbit-design-tokens/'; 17 | return new Map([ 18 | [`${root}__github__/.circleci`, '.circleci'], 19 | [`${root}__github__/.editorconfig`, '.editorconfig'], 20 | [`${root}__github__/.flowconfig`, '.flowconfig'], 21 | [`${root}__github__/.github`, '.github'], 22 | [`${root}__github__/.prettierrc`, '.prettierrc'], 23 | [`${root}__github__/flow-typed`, 'flow-typed'], 24 | [`${root}`, ''], 25 | ]); 26 | }, 27 | getStrippedFiles(): Set { 28 | return new Set([/yarn\.lock$/]); 29 | }, 30 | }; 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@kiwicom/monorepo-shipit", 3 | "private": true, 4 | "license": "MIT", 5 | "version": "0.0.0", 6 | "bin": { 7 | "monorepo-shipit": "bin/shipit.js", 8 | "monorepo-importit": "bin/importit.js", 9 | "monorepo-importit-reversed": "bin/importit-reversed.js" 10 | }, 11 | "sideEffects": false, 12 | "dependencies": { 13 | "@adeira/js": "^0.1.0", 14 | "@adeira/logger": "^0.1.0", 15 | "@kiwicom/monorepo-utils": "^0.24.0", 16 | "@kiwicom/test-utils": "^0.10.0", 17 | "fast-levenshtein": "^2.0.6" 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Changeset.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | type Diff = {| 4 | +path: string, 5 | +body: string, 6 | |}; 7 | 8 | opaque type ChangesetData = {| 9 | +id: string, 10 | +timestamp: string, 11 | +author: string, 12 | +subject: string, 13 | +description: string, 14 | +diffs: Set, 15 | |}; 16 | 17 | export default class Changeset { 18 | id: string; 19 | timestamp: string; 20 | author: string; 21 | subject: string; 22 | description: string; 23 | diffs: Set; 24 | 25 | isValid(): boolean { 26 | return this.diffs.size > 0; 27 | } 28 | 29 | getID(): string { 30 | return this.id; 31 | } 32 | 33 | withID(id: string): Changeset { 34 | return this.__clone({ id }); 35 | } 36 | 37 | getTimestamp(): string { 38 | return this.timestamp; 39 | } 40 | 41 | withTimestamp(timestamp: string): Changeset { 42 | return this.__clone({ timestamp }); 43 | } 44 | 45 | getAuthor(): string { 46 | return this.author; 47 | } 48 | 49 | withAuthor(author: string): Changeset { 50 | return this.__clone({ author }); 51 | } 52 | 53 | getSubject(): string { 54 | return this.subject; 55 | } 56 | 57 | withSubject(subject: string): Changeset { 58 | return this.__clone({ subject }); 59 | } 60 | 61 | getDescription(): string { 62 | return this.description; 63 | } 64 | 65 | withDescription(description: string): Changeset { 66 | return this.__clone({ description }); 67 | } 68 | 69 | getCommitMessage(): string { 70 | return `${this.getSubject()}\n\n${this.getDescription()}`; 71 | } 72 | 73 | getDiffs(): Set { 74 | return this.diffs ?? new Set(); 75 | } 76 | 77 | withDiffs(diffs: Set): Changeset { 78 | return this.__clone({ diffs }); 79 | } 80 | 81 | __clone(newProps: { [$Keys]: $Values, ... }): Changeset { 82 | return Object.assign( 83 | Object.create(this), 84 | { 85 | id: this.id, 86 | timestamp: this.timestamp, 87 | author: this.author, 88 | subject: this.subject, 89 | description: this.description, 90 | diffs: this.diffs, 91 | }, 92 | newProps, 93 | ); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/RepoGit.js: -------------------------------------------------------------------------------- 1 | // @flow strict-local 2 | 3 | import fs from 'fs'; 4 | import path from 'path'; 5 | import { invariant } from '@adeira/js'; 6 | import { ShellCommand } from '@kiwicom/monorepo-utils'; 7 | import logger from '@adeira/logger'; 8 | 9 | import parsePatch from './parsePatch'; 10 | import parsePatchHeader from './parsePatchHeader'; 11 | import splitHead from './splitHead'; 12 | import Changeset from './Changeset'; 13 | import accounts from './accounts'; 14 | 15 | /** 16 | * This is our monorepo part - source of exports. 17 | */ 18 | export interface SourceRepo { 19 | findFirstAvailableCommit(): string; 20 | findDescendantsPath( 21 | baseRevision: string, 22 | headRevision: string, 23 | roots: Set, 24 | ): null | $ReadOnlyArray; 25 | getChangesetFromID(revision: string): Changeset; 26 | getNativePatchFromID(revision: string): string; 27 | getNativeHeaderFromIDWithPatch(revision: string, patch: string): string; 28 | getChangesetFromExportedPatch(header: string, patch: string): Changeset; 29 | } 30 | 31 | /** 32 | * Exported repository containing `kiwicom-source-id` handles. 33 | */ 34 | export interface DestinationRepo { 35 | findLastSourceCommit(roots: Set): null | string; 36 | renderPatch(changeset: Changeset): string; 37 | commitPatch(changeset: Changeset): string; 38 | checkoutBranch(branchName: string): void; 39 | push(branch: string): void; 40 | } 41 | 42 | interface AnyRepo { 43 | // Will most probably always return '4b825dc642cb6eb9a060e54bf8d69288fbee4904'. What? https://stackoverflow.com/q/9765453/3135248 44 | getEmptyTreeHash(): string; 45 | } 46 | 47 | export default class RepoGit implements AnyRepo, SourceRepo, DestinationRepo { 48 | #localPath: string; 49 | 50 | constructor(localPath: string) { 51 | invariant(fs.existsSync(path.join(localPath, '.git')), '%s is not a GIT repo.', localPath); 52 | this.#localPath = localPath; 53 | } 54 | 55 | _gitCommand = (...args: $ReadOnlyArray) => { 56 | return new ShellCommand(this.#localPath, 'git', '--no-pager', ...args).setEnvironmentVariables( 57 | new Map([ 58 | // https://git-scm.com/docs/git#_environment_variables 59 | ['GIT_CONFIG_NOSYSTEM', '1'], 60 | ['GIT_TERMINAL_PROMPT', '0'], 61 | ]), 62 | ); 63 | }; 64 | 65 | push = (destinationBranch: string) => { 66 | this._gitCommand('push', 'origin', destinationBranch) 67 | .setOutputToScreen() 68 | .runSynchronously(); 69 | }; 70 | 71 | configure = () => { 72 | const username = 'kiwicom-github-bot'; 73 | for (const [key, value] of Object.entries({ 74 | 'user.email': accounts.get(username), 75 | 'user.name': username, 76 | })) { 77 | // $FlowIssue: https://github.com/facebook/flow/issues/2174 78 | this._gitCommand('config', key, value) 79 | .setOutputToScreen() 80 | .runSynchronously(); 81 | } 82 | }; 83 | 84 | // https://git-scm.com/docs/git-checkout 85 | checkoutBranch = (branchName: string): void => { 86 | this._gitCommand( 87 | 'checkout', 88 | '-B', // create (or switch to) a new branch 89 | branchName, 90 | ) 91 | .setOutputToScreen() 92 | .runSynchronously(); 93 | }; 94 | 95 | clean = () => { 96 | this._gitCommand( 97 | 'clean', // remove untracked files from the working tree 98 | '-x', // ignore .gitignore 99 | '-f', // force 100 | '-f', // double force 101 | '-d', // remove untracked directories in addition to untracked files 102 | ) 103 | .setOutputToScreen() 104 | .runSynchronously(); 105 | }; 106 | 107 | isCorrupted = (): boolean => { 108 | const exitCode = this._gitCommand('fsck', '--strict') 109 | .setNoExceptions() 110 | .runSynchronously() 111 | .getExitCode(); 112 | return exitCode !== 0; 113 | }; 114 | 115 | findLastSourceCommit = (roots: Set): null | string => { 116 | const log = this._gitCommand( 117 | 'log', 118 | '-1', 119 | '--grep', 120 | '^kiwicom-source-id: \\?[a-z0-9]\\+\\s*$', 121 | ...roots, 122 | ) 123 | .setNoExceptions() // empty repo fails with: "your current branch 'master' does not have any commits yet" 124 | .runSynchronously() 125 | .getStdout() 126 | .trim(); 127 | const regex = /kiwicom-source-id: ?(?[a-z0-9]+)$/gm; 128 | let lastCommit = null; 129 | let match; 130 | while ((match = regex.exec(log)) !== null) { 131 | lastCommit = match?.groups?.commit; 132 | } 133 | return lastCommit ?? null; 134 | }; 135 | 136 | // https://stackoverflow.com/a/5189296/3135248 137 | findFirstAvailableCommit = (): string => { 138 | // Please note, the following command may return multiple roots. For example, 139 | // `git` repository has 6 roots (and we should take the last one). 140 | const rawOutput = this._gitCommand('rev-list', '--max-parents=0', 'HEAD') 141 | .runSynchronously() 142 | .getStdout(); 143 | const rootRevisions = rawOutput.trim().split('\n'); 144 | return rootRevisions[rootRevisions.length - 1]; 145 | }; 146 | 147 | getNativePatchFromID = (revision: string): string => { 148 | return this._gitCommand( 149 | 'format-patch', 150 | '--no-renames', 151 | '--no-stat', 152 | '--stdout', 153 | '--full-index', 154 | '--format=', // contain nothing but the code changes 155 | '-1', 156 | revision, 157 | ) 158 | .runSynchronously() 159 | .getStdout(); 160 | }; 161 | 162 | getNativeHeaderFromIDWithPatch = (revision: string, patch: string): string => { 163 | const fullPatch = this._gitCommand( 164 | 'format-patch', 165 | '--no-renames', 166 | '--no-stat', 167 | '--stdout', 168 | '--full-index', 169 | '-1', 170 | revision, 171 | ) 172 | .runSynchronously() 173 | .getStdout(); 174 | if (patch.length === 0) { 175 | // this is an empty commit, so everything is the header 176 | return fullPatch; 177 | } 178 | return fullPatch.replace(patch, ''); 179 | }; 180 | 181 | getChangesetFromID = (revision: string): Changeset => { 182 | logger.log(`Filtering changeset for: ${revision}`); 183 | const patch = this.getNativePatchFromID(revision); 184 | const header = this.getNativeHeaderFromIDWithPatch(revision, patch); 185 | const changeset = this.getChangesetFromExportedPatch(header, patch); 186 | return changeset.withID(revision); 187 | }; 188 | 189 | getChangesetFromExportedPatch = (header: string, patch: string): Changeset => { 190 | const changeset = parsePatchHeader(header); 191 | const diffs = new Set(); 192 | for (const hunk of parsePatch(patch)) { 193 | const diff = this.parseDiffHunk(hunk); 194 | if (diff !== null) { 195 | diffs.add(diff); 196 | } 197 | } 198 | return changeset.withDiffs(diffs); 199 | }; 200 | 201 | parseDiffHunk = (hunk: string) => { 202 | const [head, tail] = splitHead(hunk, '\n'); 203 | const match = head.match(/^diff --git [ab]\/(?:.*?) [ab]\/(?.*?)$/); 204 | if (!match) { 205 | return null; 206 | } 207 | const path = match.groups?.path; 208 | invariant(path != null, 'Cannot parse path from the hunk.'); 209 | return { path, body: tail }; 210 | }; 211 | 212 | // TODO: originally `findNextCommit` - pls reconsider 213 | findDescendantsPath = ( 214 | baseRevision: string, 215 | headRevision: string, 216 | roots: Set, 217 | ): null | $ReadOnlyArray => { 218 | const log = this._gitCommand( 219 | 'log', 220 | '--reverse', 221 | '--ancestry-path', 222 | '--no-merges', 223 | '--pretty=tformat:%H', 224 | `${baseRevision}..${headRevision}`, 225 | '--', // separates paths from revisions (so you can use non-existent paths) 226 | ...roots, 227 | ) 228 | .runSynchronously() 229 | .getStdout(); 230 | const trimmedLog = log.trim(); 231 | return trimmedLog === '' ? null : trimmedLog.split('\n'); 232 | }; 233 | 234 | commitPatch = (changeset: Changeset): string => { 235 | if (changeset.getDiffs().size === 0) { 236 | // This is an empty commit, which `git am` does not handle properly. 237 | this._gitCommand( 238 | 'commit', 239 | '--allow-empty', 240 | '--author', 241 | changeset.getAuthor(), 242 | '--date', 243 | changeset.getTimestamp(), 244 | '--message', 245 | changeset.getCommitMessage(), 246 | ).runSynchronously(); 247 | } else { 248 | const diff = this.renderPatch(changeset); 249 | try { 250 | this._gitCommand('am', '--keep-non-patch', '--keep-cr') 251 | .setStdin(diff) 252 | .runSynchronously(); 253 | } catch (error) { 254 | this._gitCommand('am', '--abort') 255 | .setOutputToScreen() 256 | .runSynchronously(); 257 | throw error; 258 | } 259 | } 260 | // git rev-parse --verify HEAD 261 | // git --no-pager log -1 --pretty=format:%H 262 | return this._gitCommand('rev-parse', '--verify', 'HEAD') 263 | .runSynchronously() 264 | .getStdout() 265 | .trim(); 266 | }; 267 | 268 | renderPatch = (changeset: Changeset): string => { 269 | let renderedDiffs = ''; 270 | const diffs = changeset.getDiffs(); 271 | invariant(diffs.size > 0, 'It is not possible to render empty commit.'); // https://stackoverflow.com/a/34692447 272 | 273 | for (const diff of diffs) { 274 | const path = diff.path; 275 | renderedDiffs += `diff --git a/${path} b/${path}\n${diff.body}`; 276 | } 277 | 278 | // Mon Sep 17 is a magic date used by format-patch to distinguish from real mailboxes 279 | // see: https://git-scm.com/docs/git-format-patch 280 | return `From ${changeset.getID()} Mon Sep 17 00:00:00 2001 281 | From: ${changeset.getAuthor()} 282 | Date: ${changeset.getTimestamp()} 283 | Subject: [PATCH] ${changeset.getCommitMessage()} 284 | 285 | ${renderedDiffs} 286 | -- 287 | 2.21.0 288 | `; 289 | }; 290 | 291 | /** 292 | * This function exports specified roots from the monorepo. It takes a 293 | * snapshot of HEAD revision and exports it to the destination path. 294 | * Please note: this export is unfiltered. 295 | */ 296 | export = (exportedRepoPath: string, roots: Set) => { 297 | const archivePath = path.join(exportedRepoPath, 'archive.tar.gz'); 298 | this._gitCommand( 299 | 'archive', 300 | '--format=tar', 301 | `--output=${archivePath}`, 302 | 'HEAD', // TODO 303 | ...roots, 304 | ) 305 | .setOutputToScreen() 306 | .runSynchronously(); 307 | // Previously, we used only STDIN but that didn't work for some binary files like images for some reason. 308 | // So now we create an actual archive and use this instead. 309 | return new ShellCommand(exportedRepoPath, 'tar', '-xvf', archivePath) 310 | .setOutputToScreen() 311 | .runSynchronously(); 312 | }; 313 | 314 | getEmptyTreeHash(): string { 315 | return this._gitCommand('hash-object', '-t', 'tree', '/dev/null') 316 | .runSynchronously() 317 | .getStdout() 318 | .trim(); 319 | } 320 | } 321 | -------------------------------------------------------------------------------- /src/RepoGitFake.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import fs from 'fs'; 4 | import os from 'os'; 5 | import path from 'path'; 6 | import { ShellCommand } from '@kiwicom/monorepo-utils'; 7 | 8 | import accounts from './accounts'; 9 | import RepoGit from './RepoGit'; 10 | 11 | export default class RepoGitFake extends RepoGit { 12 | #testRepoPath: string; 13 | 14 | constructor( 15 | testRepoPath: string = fs.mkdtempSync(path.join(os.tmpdir(), 'kiwicom-shipit-tests-')), 16 | ) { 17 | new ShellCommand(testRepoPath, 'git', 'init').runSynchronously(); 18 | const username = 'kiwicom-shipit-tests'; 19 | for (const [key, value] of Object.entries({ 20 | 'user.email': accounts.get(username), 21 | 'user.name': username, 22 | })) { 23 | new ShellCommand( 24 | testRepoPath, 25 | 'git', 26 | 'config', 27 | key, 28 | // $FlowIssue: https://github.com/facebook/flow/issues/2174 29 | value, 30 | ).runSynchronously(); 31 | } 32 | super(testRepoPath); 33 | this.#testRepoPath = testRepoPath; 34 | } 35 | 36 | push = () => {}; 37 | 38 | configure = () => {}; 39 | 40 | checkoutBranch = () => {}; 41 | 42 | clean = () => {}; 43 | 44 | // $FlowExpectedError: this function overwrites the original and returns nothing 45 | export = (): void => {}; 46 | 47 | getFakeRepoPath(): string { 48 | return this.#testRepoPath; 49 | } 50 | 51 | printFakeRepoHistory() { 52 | return this._gitCommand('log', '--stat', '--pretty=format:SUBJ: %s%nDESC: %b') 53 | .runSynchronously() 54 | .getStdout() 55 | .trim(); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/ShipitConfig.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | import os from 'os'; 4 | import fs from 'fs'; 5 | import path from 'path'; 6 | import { warning } from '@adeira/js'; 7 | 8 | import Changeset from './Changeset'; 9 | import addTrackingData from './filters/addTrackingData'; 10 | import { commentLines, uncommentLines } from './filters/conditionalLines'; 11 | import moveDirectories from './filters/moveDirectories'; 12 | import moveDirectoriesReverse from './filters/moveDirectoriesReverse'; 13 | import stripExceptDirectories from './filters/stripExceptDirectories'; 14 | import stripPaths from './filters/stripPaths'; 15 | 16 | type ChangesetFilter = Changeset => Changeset; 17 | 18 | export default class ShipitConfig { 19 | sourcePath: string; 20 | destinationPath: string; 21 | exportedRepoURL: string; // TODO: what to do with this? 22 | directoryMapping: Map; 23 | strippedFiles: Set; 24 | 25 | #sourceBranch; 26 | #destinationBranch; 27 | 28 | constructor( 29 | sourcePath: string, 30 | exportedRepoURL: string, 31 | directoryMapping: Map, 32 | strippedFiles: Set, 33 | sourceBranch: string = 'origin/master', // our GitLab CI doesn't have master branch 34 | destinationBranch: string = 'master', 35 | ) { 36 | this.sourcePath = sourcePath; 37 | // This is currently not configurable. We could (should) eventually keep 38 | // the temp directory, cache it and just update it. 39 | this.destinationPath = fs.mkdtempSync(path.join(os.tmpdir(), 'kiwicom-shipit-')); 40 | this.exportedRepoURL = exportedRepoURL; 41 | this.directoryMapping = directoryMapping; 42 | this.strippedFiles = strippedFiles; 43 | this.#sourceBranch = sourceBranch; 44 | this.#destinationBranch = destinationBranch; 45 | } 46 | 47 | getSourceRoots(): Set { 48 | const roots = new Set(); 49 | for (const root of this.directoryMapping.keys()) { 50 | warning(fs.existsSync(root) === true, `Directory mapping uses non-existent root: ${root}`); 51 | roots.add(root); 52 | } 53 | return roots; 54 | } 55 | 56 | getDestinationRoots(): Set { 57 | // In out cases root is always "". However, if we'd like to export our 58 | // workspaces to another monorepo then the root would change to something 59 | // like "my-oss-project/" 60 | return new Set(); 61 | } 62 | 63 | /** 64 | * Shipit by default maps directory to match OSS version and strips everything 65 | * else so we don't publish something outside of the roots scope. 66 | */ 67 | getDefaultShipitFilter(): ChangesetFilter { 68 | return (changeset: Changeset) => { 69 | const ch1 = addTrackingData(changeset); 70 | const ch2 = stripExceptDirectories(ch1, this.getSourceRoots()); 71 | const ch3 = moveDirectories(ch2, this.directoryMapping); 72 | const ch4 = stripPaths(ch3, this.strippedFiles); 73 | 74 | // First we comment out lines marked with `@x-shipit-disable`. 75 | const ch5 = commentLines(ch4, '@x-shipit-disable', '//', null); 76 | // Then we uncomment lines marked with `@x-shipit-enable`. 77 | return uncommentLines(ch5, '@x-shipit-enable', '//', null); 78 | }; 79 | } 80 | 81 | /** 82 | * Importit reverses the directory mapping and strip some predefined files. 83 | * It should be in the reversed order from Shipit filters. 84 | * Please note: there are usually less filters when importing the project (not 1:1 with Shipit). 85 | */ 86 | getDefaultImportitFilter(): ChangesetFilter { 87 | return (changeset: Changeset) => { 88 | // Comment out lines which are only for OSS. 89 | const ch1 = commentLines(changeset, '@x-shipit-enable', '//', null); 90 | // Uncomment private code which is disabled in OSS. 91 | const ch2 = uncommentLines(ch1, '@x-shipit-disable', '//', null); 92 | 93 | const ch3 = stripPaths(ch2, this.strippedFiles); 94 | return moveDirectoriesReverse(ch3, this.directoryMapping); 95 | }; 96 | } 97 | 98 | getSourceBranch(): string { 99 | return this.#sourceBranch; 100 | } 101 | 102 | getDestinationBranch(): string { 103 | return this.#destinationBranch; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /src/__tests__/Changeset.test.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | import Changeset from '../Changeset'; 4 | 5 | test('immutability of the changesets', () => { 6 | const originalChangeset = new Changeset(); 7 | const modifiedChangeset1 = originalChangeset 8 | .withID('2f0f6ca5039506a1ebc5651a0b7e2b617e28544c') 9 | .withTimestamp('Mon, 4 Feb 2019 13:29:04 -0600') 10 | .withAuthor('John Doe') 11 | .withSubject('Subject 1') 12 | .withDescription('new description') 13 | .withDiffs( 14 | new Set([ 15 | { path: 'aaa', body: 'AAA' }, 16 | { path: 'bbb', body: 'BBB' }, 17 | ]), 18 | ); 19 | const modifiedChangeset2 = modifiedChangeset1 20 | .withDescription('even newer description') 21 | .withDiffs(new Set([{ path: 'ccc', body: 'CCC' }])); 22 | 23 | // everything in the original changeset should be undefined 24 | expect(originalChangeset).toMatchInlineSnapshot(`Changeset {}`); 25 | 26 | // this should have new values 27 | expect(modifiedChangeset1).toMatchInlineSnapshot(` 28 | Changeset { 29 | "author": "John Doe", 30 | "description": "new description", 31 | "diffs": Set { 32 | Object { 33 | "body": "AAA", 34 | "path": "aaa", 35 | }, 36 | Object { 37 | "body": "BBB", 38 | "path": "bbb", 39 | }, 40 | }, 41 | "id": "2f0f6ca5039506a1ebc5651a0b7e2b617e28544c", 42 | "subject": "Subject 1", 43 | "timestamp": "Mon, 4 Feb 2019 13:29:04 -0600", 44 | } 45 | `); 46 | 47 | // should be similar to modified changeset 1 but with some changed values 48 | expect(modifiedChangeset2).toMatchInlineSnapshot(` 49 | Changeset { 50 | "author": "John Doe", 51 | "description": "even newer description", 52 | "diffs": Set { 53 | Object { 54 | "body": "CCC", 55 | "path": "ccc", 56 | }, 57 | }, 58 | "id": "2f0f6ca5039506a1ebc5651a0b7e2b617e28544c", 59 | "subject": "Subject 1", 60 | "timestamp": "Mon, 4 Feb 2019 13:29:04 -0600", 61 | } 62 | `); 63 | }); 64 | -------------------------------------------------------------------------------- /src/__tests__/RepoGit.commitPatch.test.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import RepoGitFake from '../RepoGitFake'; 4 | import createFakeChangeset from '../utils/createFakeChangeset'; 5 | 6 | it('can commit empty changeset', () => { 7 | const repo = new RepoGitFake(); 8 | const changeset = createFakeChangeset(0); 9 | repo.commitPatch(changeset); 10 | expect(repo.printFakeRepoHistory()).toMatchInlineSnapshot(` 11 | "SUBJ: Test subject 12 | DESC: Test description" 13 | `); 14 | }); 15 | 16 | it('commits changeset with single diff correctly', () => { 17 | const repo = new RepoGitFake(); 18 | const changeset = createFakeChangeset(1); 19 | repo.commitPatch(changeset); 20 | 21 | expect(repo.printFakeRepoHistory()).toMatchInlineSnapshot(` 22 | "SUBJ: Test subject 23 | DESC: Test description 24 | 25 | fakeFile_1.txt | 1 + 26 | 1 file changed, 1 insertion(+)" 27 | `); 28 | }); 29 | 30 | it('commits changeset with multiple diffs correctly', () => { 31 | const repo = new RepoGitFake(); 32 | const changeset = createFakeChangeset(3); 33 | repo.commitPatch(changeset); 34 | 35 | expect(repo.printFakeRepoHistory()).toMatchInlineSnapshot(` 36 | "SUBJ: Test subject 37 | DESC: Test description 38 | 39 | fakeFile_1.txt | 1 + 40 | fakeFile_2.txt | 1 + 41 | fakeFile_3.txt | 1 + 42 | 3 files changed, 3 insertions(+)" 43 | `); 44 | }); 45 | -------------------------------------------------------------------------------- /src/__tests__/RepoGit.findFirstAvailableCommit.test.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import RepoGit from '../RepoGit'; 4 | 5 | jest.mock('fs', () => ({ 6 | // workarounding fake Git repo paths 7 | existsSync: () => true, 8 | })); 9 | 10 | jest.mock('@kiwicom/monorepo-utils', () => { 11 | return { 12 | ShellCommand: class { 13 | stdout = null; 14 | constructor(path, git, noPager, ...commandArray) { 15 | const command = commandArray.toString(); 16 | if (command === 'rev-list,--max-parents=0,HEAD') { 17 | this.stdout = 18 | path === 'mocked_repo_path_1' 19 | ? 'd30a77bd2fe0fdfe5739d68fc9592036e94364dd' 20 | : '0ca71b3737cbb26fbf037aa15b3f58735785e6e3\n' + 21 | '16d6b8ab6fd7f68bfd9f4d312965cb99e8ad911b\n' + 22 | 'cb07fc2a29c86d1bc11f5415368f778d25d3d20a\n' + 23 | '161332a521fe10c41979bcd493d95e2ac562b7ff\n' + 24 | '1db95b00a2d2a001fd91cd860a71c639ea04eb53\n' + 25 | '2744b2344dc42fa2a1ddf17f4818975cd48f6d42\n' + 26 | 'e83c5163316f89bfbde7d9ab23ca2e25604af290'; // we should be interested only in this one 27 | } else { 28 | throw new Error(`There is no available dataset for command: ${command}`); 29 | } 30 | } 31 | 32 | getStdout() { 33 | return this.stdout; 34 | } 35 | 36 | runSynchronously() { 37 | return this; 38 | } 39 | 40 | setEnvironmentVariables() { 41 | return this; 42 | } 43 | }, 44 | }; 45 | }); 46 | 47 | beforeEach(() => { 48 | jest.spyOn(console, 'warn').mockImplementation(() => {}); 49 | }); 50 | 51 | afterEach(() => { 52 | jest.restoreAllMocks(); 53 | }); 54 | 55 | it('returns first available revision when single root available', () => { 56 | const repo = new RepoGit('mocked_repo_path_1'); 57 | expect(repo.findFirstAvailableCommit()).toBe('d30a77bd2fe0fdfe5739d68fc9592036e94364dd'); 58 | }); 59 | 60 | it('returns first available revision when multiple roots available', () => { 61 | const repo = new RepoGit('mocked_repo_path_2'); 62 | expect(repo.findFirstAvailableCommit()).toBe('e83c5163316f89bfbde7d9ab23ca2e25604af290'); 63 | }); 64 | -------------------------------------------------------------------------------- /src/__tests__/RepoGit.findLastSourceCommit.test.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import Changeset from '../Changeset'; 4 | import RepoGitFake from '../RepoGitFake'; 5 | import addTrackingData from '../filters/addTrackingData'; 6 | 7 | function createGITRepoWithCommit(message) { 8 | const repo = new RepoGitFake(); 9 | repo.commitPatch( 10 | new Changeset() 11 | .withSubject('test subject') 12 | .withDescription(message) 13 | .withAuthor('John Doe ') 14 | .withTimestamp('Mon, 4 Feb 2019 13:29:04 -0600'), 15 | ); 16 | return repo; 17 | } 18 | 19 | function generateCommitID() { 20 | const randomInt1 = Math.floor(Math.random() * 1000); 21 | const randomInt2 = Math.floor(Math.random() * 1000); 22 | return `${randomInt1}abcdef012345${randomInt2}`; // must be [a-z0-9]+ 23 | } 24 | 25 | it('can find last source commit', () => { 26 | const fakeCommitID = generateCommitID(); 27 | const description = addTrackingData(new Changeset().withID(fakeCommitID)).getDescription(); 28 | const repo = createGITRepoWithCommit(description); 29 | expect(repo.findLastSourceCommit(new Set())).toBe(fakeCommitID); 30 | }); 31 | 32 | it('can find last source commit with multiple markers', () => { 33 | const fakeCommitID1 = generateCommitID(); 34 | const fakeCommitID2 = generateCommitID(); 35 | const description1 = addTrackingData(new Changeset().withID(fakeCommitID1)).getDescription(); 36 | const description2 = addTrackingData(new Changeset().withID(fakeCommitID2)).getDescription(); 37 | const repo = createGITRepoWithCommit(`${description1}\n\n${description2}`); 38 | expect(repo.findLastSourceCommit(new Set())).toBe(fakeCommitID2); 39 | }); 40 | 41 | it('can find last source commit with trailing whitespace', () => { 42 | const fakeCommitID = generateCommitID(); 43 | const description = addTrackingData(new Changeset().withID(fakeCommitID)).getDescription(); 44 | const repo = createGITRepoWithCommit(`${description} `); 45 | expect(repo.findLastSourceCommit(new Set())).toBe(fakeCommitID); 46 | }); 47 | 48 | it('can find last source commit without whitespaces', () => { 49 | const fakeCommitID = generateCommitID(); 50 | const description = `kiwicom-source-id:${fakeCommitID}`; 51 | const repo = createGITRepoWithCommit(`${description} `); 52 | expect(repo.findLastSourceCommit(new Set())).toBe(fakeCommitID); 53 | }); 54 | -------------------------------------------------------------------------------- /src/__tests__/RepoGit.renderPatch.test.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import RepoGit from '../RepoGitFake'; 4 | import Changeset from '../Changeset'; 5 | 6 | function createChangeset(diffs) { 7 | const emptyChangeset = new Changeset() 8 | .withID('mocked_id') 9 | .withTimestamp('mocked_timestamp') 10 | .withAuthor('mocked_author') 11 | .withSubject('mocked_subject') 12 | .withDescription('mocked_description'); 13 | if (diffs === null) { 14 | return emptyChangeset; 15 | } 16 | return emptyChangeset.withDiffs(diffs); 17 | } 18 | 19 | it('throws when trying to render empty changeset', () => { 20 | const repo = new RepoGit(); 21 | const changeset = createChangeset(null); 22 | expect(() => repo.renderPatch(changeset)).toThrowErrorMatchingInlineSnapshot( 23 | `"It is not possible to render empty commit."`, 24 | ); 25 | }); 26 | 27 | it('renders patch with one diff as expected', () => { 28 | const repo = new RepoGit(); 29 | const changeset = createChangeset(new Set([{ path: 'mocked_path', body: 'mocked_body\n' }])); 30 | expect(repo.renderPatch(changeset)).toMatchSnapshot(); 31 | }); 32 | 33 | it('renders patch with more diffs as expected', () => { 34 | const repo = new RepoGit(); 35 | const changeset = createChangeset( 36 | new Set([ 37 | { path: 'mocked_path1', body: 'mocked_body1\n' }, 38 | { path: 'mocked_path2', body: 'mocked_body2\n' }, 39 | { path: 'mocked_path3', body: 'mocked_body3\n' }, 40 | ]), 41 | ); 42 | expect(repo.renderPatch(changeset)).toMatchSnapshot(); 43 | }); 44 | -------------------------------------------------------------------------------- /src/__tests__/ShipitConfig.test.js: -------------------------------------------------------------------------------- 1 | // @flow strict-local 2 | 3 | import Config from '../ShipitConfig'; 4 | 5 | jest.mock('fs'); 6 | 7 | it('returns set of paths when trying to access monorepo roots', () => { 8 | expect( 9 | new Config( 10 | 'fake monorepo path', 11 | 'fake exported repo URL', 12 | new Map([['/known_path', '/destination_path']]), 13 | new Set([/mocked/]), 14 | ).getSourceRoots(), 15 | ).toEqual(new Set(['/known_path'])); 16 | }); 17 | 18 | it('returns empty set when trying to get roots of the exported repo', () => { 19 | expect( 20 | new Config( 21 | 'fake monorepo path', 22 | 'fake exported repo URL', 23 | new Map([['/known_path', '/destination_path']]), 24 | new Set([/mocked/]), 25 | ).getDestinationRoots(), 26 | ).toEqual( 27 | new Set(), // empty set expected 28 | ); 29 | }); 30 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/RepoGit.renderPatch.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`renders patch with more diffs as expected 1`] = ` 4 | "From mocked_id Mon Sep 17 00:00:00 2001 5 | From: mocked_author 6 | Date: mocked_timestamp 7 | Subject: [PATCH] mocked_subject 8 | 9 | mocked_description 10 | 11 | diff --git a/mocked_path1 b/mocked_path1 12 | mocked_body1 13 | diff --git a/mocked_path2 b/mocked_path2 14 | mocked_body2 15 | diff --git a/mocked_path3 b/mocked_path3 16 | mocked_body3 17 | 18 | -- 19 | 2.21.0 20 | " 21 | `; 22 | 23 | exports[`renders patch with one diff as expected 1`] = ` 24 | "From mocked_id Mon Sep 17 00:00:00 2001 25 | From: mocked_author 26 | Date: mocked_timestamp 27 | Subject: [PATCH] mocked_subject 28 | 29 | mocked_description 30 | 31 | diff --git a/mocked_path b/mocked_path 32 | mocked_body 33 | 34 | -- 35 | 2.21.0 36 | " 37 | `; 38 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/parsePatch.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`matches expected output: chmod.patch 1`] = ` 4 | ~~~~~~~~~~ INPUT ~~~~~~~~~~ 5 | diff --git a/src/platform/terraform-gitlab-project-cluster/environment_scope.sh b/src/platform/terraform-gitlab-project-cluster/environment_scope.sh 6 | old mode 100644 7 | new mode 100755 8 | -- 9 | 2.20.1 (Apple Git-117) 10 | 11 | 12 | ~~~~~~~~~~ OUTPUT ~~~~~~~~~~ 13 | ~~~ HUNK 1: 14 | 15 | diff --git a/src/platform/terraform-gitlab-project-cluster/environment_scope.sh b/src/platform/terraform-gitlab-project-cluster/environment_scope.sh 16 | old mode 100644 17 | new mode 100755 18 | -- 19 | 2.20.1 (Apple Git-117) 20 | 21 | 22 | 23 | `; 24 | 25 | exports[`matches expected output: diff-in-diff.patch 1`] = ` 26 | ~~~~~~~~~~ INPUT ~~~~~~~~~~ 27 | diff --git a/unicode.txt b/unicode.txt 28 | index 1f60525b30fedbea3660714fb6198053f3457168..485cb7f13081f005c2f83890e155c0fcf4b16cd6 100644 29 | --- a/unicode.txt 30 | +++ b/unicode.txt 31 | @@ -1,6 +1,16 @@ 32 | -Apostrophe: don’t 33 | -Emoji: 🍺 🍻 🍷 🍸😀 😬 😁 😂 😃 😄 😅 34 | -Russian: Все люди рождаются свободными и равными в своем достоинстве и правах. 35 | -Japanese: すべての人間は、生まれながらにして自由であり、かつ、尊厳と権利とについて平等である。 36 | -Arabic: يولد جميع الناس أحرارًا متساوين في الكرامة والحقوق. 37 | -Chinese (traditional): 人人生而自由,在尊嚴和權利上一律平等。 38 | +diff --git a/unicode.txt b/unicode.txt 39 | +new file mode 100644 40 | +index 0000000000000000000000000000000000000000..1f60525b30fedbea3660714fb6198053f3457168 41 | +--- /dev/null 42 | ++++ b/unicode.txt 43 | +@@ -0,0 +1,6 @@ 44 | ++Apostrophe: don’t 45 | ++Emoji: 🍺 🍻 🍷 🍸😀 😬 😁 😂 😃 😄 😅 46 | ++Russian: Все люди рождаются свободными и равными в своем достоинстве и правах. 47 | ++Japanese: すべての人間は、生まれながらにして自由であり、かつ、尊厳と権利とについて平等である。 48 | ++Arabic: يولد جميع الناس أحرارًا متساوين في الكرامة والحقوق. 49 | ++Chinese (traditional): 人人生而自由,在尊嚴和權利上一律平等。 50 | +-- 51 | +2.21.0 52 | + 53 | + 54 | -- 55 | 2.21.0 56 | 57 | 58 | ~~~~~~~~~~ OUTPUT ~~~~~~~~~~ 59 | ~~~ HUNK 1: 60 | 61 | diff --git a/unicode.txt b/unicode.txt 62 | index 1f60525b30fedbea3660714fb6198053f3457168..485cb7f13081f005c2f83890e155c0fcf4b16cd6 100644 63 | --- a/unicode.txt 64 | +++ b/unicode.txt 65 | @@ -1,6 +1,16 @@ 66 | -Apostrophe: don’t 67 | -Emoji: 🍺 🍻 🍷 🍸😀 😬 😁 😂 😃 😄 😅 68 | -Russian: Все люди рождаются свободными и равными в своем достоинстве и правах. 69 | -Japanese: すべての人間は、生まれながらにして自由であり、かつ、尊厳と権利とについて平等である。 70 | -Arabic: يولد جميع الناس أحرارًا متساوين في الكرامة والحقوق. 71 | -Chinese (traditional): 人人生而自由,在尊嚴和權利上一律平等。 72 | +diff --git a/unicode.txt b/unicode.txt 73 | +new file mode 100644 74 | +index 0000000000000000000000000000000000000000..1f60525b30fedbea3660714fb6198053f3457168 75 | +--- /dev/null 76 | ++++ b/unicode.txt 77 | +@@ -0,0 +1,6 @@ 78 | ++Apostrophe: don’t 79 | ++Emoji: 🍺 🍻 🍷 🍸😀 😬 😁 😂 😃 😄 😅 80 | ++Russian: Все люди рождаются свободными и равными в своем достоинстве и правах. 81 | ++Japanese: すべての人間は、生まれながらにして自由であり、かつ、尊厳と権利とについて平等である。 82 | ++Arabic: يولد جميع الناس أحرارًا متساوين في الكرامة والحقوق. 83 | ++Chinese (traditional): 人人生而自由,在尊嚴和權利上一律平等。 84 | +-- 85 | +2.21.0 86 | + 87 | + 88 | 89 | `; 90 | 91 | exports[`matches expected output: file-delete.patch 1`] = ` 92 | ~~~~~~~~~~ INPUT ~~~~~~~~~~ 93 | diff --git a/bbb.txt b/bbb.txt 94 | deleted file mode 100644 95 | index dbee0265d31298531773537e6e37e4fd1ee71d62..0000000000000000000000000000000000000000 96 | --- a/bbb.txt 97 | +++ /dev/null 98 | @@ -1,2 +0,0 @@ 99 | -aaa 100 | -bbb 101 | -- 102 | 2.21.0 103 | 104 | 105 | ~~~~~~~~~~ OUTPUT ~~~~~~~~~~ 106 | ~~~ HUNK 1: 107 | 108 | diff --git a/bbb.txt b/bbb.txt 109 | deleted file mode 100644 110 | index dbee0265d31298531773537e6e37e4fd1ee71d62..0000000000000000000000000000000000000000 111 | --- a/bbb.txt 112 | +++ /dev/null 113 | @@ -1,2 +0,0 @@ 114 | -aaa 115 | -bbb 116 | 117 | `; 118 | 119 | exports[`matches expected output: file-modify-no-eol.patch 1`] = ` 120 | ~~~~~~~~~~ INPUT ~~~~~~~~~~ 121 | diff --git a/aaa.txt b/aaa.txt 122 | index 72943a16fb2c8f38f9dde202b7a70ccc19c52f34..01f02e32ce8a128dd7b1d16a45f2eff66ec23c2d 100644 123 | --- a/aaa.txt 124 | +++ b/aaa.txt 125 | @@ -1 +1 @@ 126 | -aaa 127 | +bbb 128 | \\ No newline at end of file 129 | -- 130 | 2.21.0 131 | 132 | 133 | ~~~~~~~~~~ OUTPUT ~~~~~~~~~~ 134 | ~~~ HUNK 1: 135 | 136 | diff --git a/aaa.txt b/aaa.txt 137 | index 72943a16fb2c8f38f9dde202b7a70ccc19c52f34..01f02e32ce8a128dd7b1d16a45f2eff66ec23c2d 100644 138 | --- a/aaa.txt 139 | +++ b/aaa.txt 140 | @@ -1 +1 @@ 141 | -aaa 142 | +bbb 143 | \\ No newline at end of file 144 | 145 | `; 146 | 147 | exports[`matches expected output: file-new.patch 1`] = ` 148 | ~~~~~~~~~~ INPUT ~~~~~~~~~~ 149 | diff --git a/aaa.txt b/aaa.txt 150 | new file mode 100644 151 | index 0000000000000000000000000000000000000000..72943a16fb2c8f38f9dde202b7a70ccc19c52f34 152 | --- /dev/null 153 | +++ b/aaa.txt 154 | @@ -0,0 +1 @@ 155 | +aaa 156 | -- 157 | 2.21.0 158 | 159 | 160 | ~~~~~~~~~~ OUTPUT ~~~~~~~~~~ 161 | ~~~ HUNK 1: 162 | 163 | diff --git a/aaa.txt b/aaa.txt 164 | new file mode 100644 165 | index 0000000000000000000000000000000000000000..72943a16fb2c8f38f9dde202b7a70ccc19c52f34 166 | --- /dev/null 167 | +++ b/aaa.txt 168 | @@ -0,0 +1 @@ 169 | +aaa 170 | 171 | `; 172 | 173 | exports[`matches expected output: file-rename.patch 1`] = ` 174 | ~~~~~~~~~~ INPUT ~~~~~~~~~~ 175 | diff --git a/aaa.txt b/aaa.txt 176 | deleted file mode 100644 177 | index 01f02e32ce8a128dd7b1d16a45f2eff66ec23c2d..0000000000000000000000000000000000000000 178 | --- a/aaa.txt 179 | +++ /dev/null 180 | @@ -1 +0,0 @@ 181 | -bbb 182 | \\ No newline at end of file 183 | diff --git a/bbb.txt b/bbb.txt 184 | new file mode 100644 185 | index 0000000000000000000000000000000000000000..01f02e32ce8a128dd7b1d16a45f2eff66ec23c2d 186 | --- /dev/null 187 | +++ b/bbb.txt 188 | @@ -0,0 +1 @@ 189 | +bbb 190 | \\ No newline at end of file 191 | -- 192 | 2.21.0 193 | 194 | 195 | ~~~~~~~~~~ OUTPUT ~~~~~~~~~~ 196 | ~~~ HUNK 1: 197 | 198 | diff --git a/aaa.txt b/aaa.txt 199 | deleted file mode 100644 200 | index 01f02e32ce8a128dd7b1d16a45f2eff66ec23c2d..0000000000000000000000000000000000000000 201 | --- a/aaa.txt 202 | +++ /dev/null 203 | @@ -1 +0,0 @@ 204 | -bbb 205 | \\ No newline at end of file 206 | 207 | ~~~ HUNK 2: 208 | 209 | diff --git a/bbb.txt b/bbb.txt 210 | new file mode 100644 211 | index 0000000000000000000000000000000000000000..01f02e32ce8a128dd7b1d16a45f2eff66ec23c2d 212 | --- /dev/null 213 | +++ b/bbb.txt 214 | @@ -0,0 +1 @@ 215 | +bbb 216 | \\ No newline at end of file 217 | 218 | `; 219 | 220 | exports[`matches expected output: files-modify.patch 1`] = ` 221 | ~~~~~~~~~~ INPUT ~~~~~~~~~~ 222 | diff --git a/bbb.txt b/bbb.txt 223 | index 01f02e32ce8a128dd7b1d16a45f2eff66ec23c2d..dbee0265d31298531773537e6e37e4fd1ee71d62 100644 224 | --- a/bbb.txt 225 | +++ b/bbb.txt 226 | @@ -1 +1,2 @@ 227 | -bbb 228 | \\ No newline at end of file 229 | +aaa 230 | +bbb 231 | diff --git a/ccc.txt b/ccc.txt 232 | index b2a7546679fdf79ca0eb7bfbee1e1bb342487380..66bde67332bef8dfb2c70698c51bf37b80b73ef3 100644 233 | --- a/ccc.txt 234 | +++ b/ccc.txt 235 | @@ -1 +1,2 @@ 236 | ccc 237 | +ddd 238 | -- 239 | 2.21.0 240 | 241 | 242 | ~~~~~~~~~~ OUTPUT ~~~~~~~~~~ 243 | ~~~ HUNK 1: 244 | 245 | diff --git a/bbb.txt b/bbb.txt 246 | index 01f02e32ce8a128dd7b1d16a45f2eff66ec23c2d..dbee0265d31298531773537e6e37e4fd1ee71d62 100644 247 | --- a/bbb.txt 248 | +++ b/bbb.txt 249 | @@ -1 +1,2 @@ 250 | -bbb 251 | \\ No newline at end of file 252 | +aaa 253 | +bbb 254 | 255 | ~~~ HUNK 2: 256 | 257 | diff --git a/ccc.txt b/ccc.txt 258 | index b2a7546679fdf79ca0eb7bfbee1e1bb342487380..66bde67332bef8dfb2c70698c51bf37b80b73ef3 100644 259 | --- a/ccc.txt 260 | +++ b/ccc.txt 261 | @@ -1 +1,2 @@ 262 | ccc 263 | +ddd 264 | 265 | `; 266 | 267 | exports[`matches expected output: lfs-removal.patch 1`] = ` 268 | ~~~~~~~~~~ INPUT ~~~~~~~~~~ 269 | diff --git a/.yarn/cache/@kiwicom-js-0.9.0.tgz b/.yarn/cache/@kiwicom-js-0.9.0.tgz 270 | deleted file mode 100644 271 | index f2572f9c9d2f2ab4f2a61695a09ee50430f8ccbe..0000000000000000000000000000000000000000 272 | --- a/.yarn/cache/@kiwicom-js-0.9.0.tgz 273 | +++ /dev/null 274 | @@ -1,3 +0,0 @@ 275 | -version https://git-lfs.github.com/spec/v1 276 | -oid sha256:389a46fe92555e912f515578f718f546e58a0c9b81174a66068a35211d5e54ff 277 | -size 4330 278 | -- 279 | 2.20.1 (Apple Git-117) 280 | 281 | 282 | ~~~~~~~~~~ OUTPUT ~~~~~~~~~~ 283 | ~~~ HUNK 1: 284 | 285 | diff --git a/.yarn/cache/@kiwicom-js-0.9.0.tgz b/.yarn/cache/@kiwicom-js-0.9.0.tgz 286 | deleted file mode 100644 287 | index f2572f9c9d2f2ab4f2a61695a09ee50430f8ccbe..0000000000000000000000000000000000000000 288 | --- a/.yarn/cache/@kiwicom-js-0.9.0.tgz 289 | +++ /dev/null 290 | @@ -1,3 +0,0 @@ 291 | -version https://git-lfs.github.com/spec/v1 292 | -oid sha256:389a46fe92555e912f515578f718f546e58a0c9b81174a66068a35211d5e54ff 293 | -size 4330 294 | 295 | `; 296 | 297 | exports[`matches expected output: nasty.patch 1`] = ` 298 | ~~~~~~~~~~ INPUT ~~~~~~~~~~ 299 | diff --git a/nasty.txt b/nasty.txt 300 | index 044b936071f4da77efec5b15794785ecde6b6791..e316f565a5386462ecbfd01d245888570a00ca85 100644 301 | --- a/nasty.txt 302 | +++ b/nasty.txt 303 | @@ -1 +1 @@ 304 | --- a/nasty.txt 305 | +++ b/nasty.txt 306 | -- 307 | 2.21.0 308 | 309 | 310 | ~~~~~~~~~~ OUTPUT ~~~~~~~~~~ 311 | ~~~ HUNK 1: 312 | 313 | diff --git a/nasty.txt b/nasty.txt 314 | index 044b936071f4da77efec5b15794785ecde6b6791..e316f565a5386462ecbfd01d245888570a00ca85 100644 315 | --- a/nasty.txt 316 | +++ b/nasty.txt 317 | @@ -1 +1 @@ 318 | --- a/nasty.txt 319 | +++ b/nasty.txt 320 | 321 | `; 322 | 323 | exports[`matches expected output: unicode.patch 1`] = ` 324 | ~~~~~~~~~~ INPUT ~~~~~~~~~~ 325 | diff --git a/unicode.txt b/unicode.txt 326 | new file mode 100644 327 | index 0000000000000000000000000000000000000000..1f60525b30fedbea3660714fb6198053f3457168 328 | --- /dev/null 329 | +++ b/unicode.txt 330 | @@ -0,0 +1,6 @@ 331 | +Apostrophe: don’t 332 | +Emoji: 🍺 🍻 🍷 🍸😀 😬 😁 😂 😃 😄 😅 333 | +Russian: Все люди рождаются свободными и равными в своем достоинстве и правах. 334 | +Japanese: すべての人間は、生まれながらにして自由であり、かつ、尊厳と権利とについて平等である。 335 | +Arabic: يولد جميع الناس أحرارًا متساوين في الكرامة والحقوق. 336 | +Chinese (traditional): 人人生而自由,在尊嚴和權利上一律平等。 337 | -- 338 | 2.21.0 339 | 340 | 341 | ~~~~~~~~~~ OUTPUT ~~~~~~~~~~ 342 | ~~~ HUNK 1: 343 | 344 | diff --git a/unicode.txt b/unicode.txt 345 | new file mode 100644 346 | index 0000000000000000000000000000000000000000..1f60525b30fedbea3660714fb6198053f3457168 347 | --- /dev/null 348 | +++ b/unicode.txt 349 | @@ -0,0 +1,6 @@ 350 | +Apostrophe: don’t 351 | +Emoji: 🍺 🍻 🍷 🍸😀 😬 😁 😂 😃 😄 😅 352 | +Russian: Все люди рождаются свободными и равными в своем достоинстве и правах. 353 | +Japanese: すべての人間は、生まれながらにして自由であり、かつ、尊厳と権利とについて平等である。 354 | +Arabic: يولد جميع الناس أحرارًا متساوين في الكرامة والحقوق. 355 | +Chinese (traditional): 人人生而自由,在尊嚴和權利上一律平等。 356 | 357 | `; 358 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/parsePatchHeader.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`matches expected output: multiline-subject.header 1`] = ` 4 | ~~~~~~~~~~ INPUT ~~~~~~~~~~ 5 | From 93ee65de02e77c52eb9d677b859554b40253bafa Mon Sep 17 00:00:00 2001 6 | From: =?UTF-8?q?Martin=20Zl=C3=A1mal?= 7 | Date: Wed, 10 Apr 2019 16:50:47 -0500 8 | Subject: [PATCH] Docs: add Relay and GraphQL related videos from our internal 9 | JS Saturday 10 | 11 | 12 | ~~~~~~~~~~ OUTPUT ~~~~~~~~~~ 13 | { 14 | "timestamp": "Wed, 10 Apr 2019 16:50:47 -0500", 15 | "author": "=?UTF-8?q?Martin=20Zl=C3=A1mal?= ", 16 | "subject": "Docs: add Relay and GraphQL related videos from our internal JS Saturday", 17 | "description": "" 18 | } 19 | `; 20 | 21 | exports[`matches expected output: simple.header 1`] = ` 22 | ~~~~~~~~~~ INPUT ~~~~~~~~~~ 23 | From bf0a8783cf416f796659e60fe735bba98e676e67 Mon Sep 17 00:00:00 2001 24 | From: Automator 25 | Date: Mon, 4 Feb 2019 13:29:04 -0600 26 | Subject: [PATCH] Import and publish '@kiwicom/relay' (0.1.0) 27 | 28 | Formerly known as '@mrtnzlml/relay' 29 | 30 | 31 | ~~~~~~~~~~ OUTPUT ~~~~~~~~~~ 32 | { 33 | "timestamp": "Mon, 4 Feb 2019 13:29:04 -0600", 34 | "author": "Automator ", 35 | "subject": "Import and publish '@kiwicom/relay' (0.1.0)", 36 | "description": "Formerly known as '@mrtnzlml/relay'" 37 | } 38 | `; 39 | 40 | exports[`matches expected output: unicode.header 1`] = ` 41 | ~~~~~~~~~~ INPUT ~~~~~~~~~~ 42 | From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 43 | From: =?UTF-8?q?Martin=20Zl=C3=A1mal?= 44 | Date: Wed, 10 Apr 2019 16:50:47 -0500 45 | Subject: [PATCH] Let's party 🍺 🍻 🍷 🍸😀 😬 😁 😂 😃 😄 😅 46 | 47 | 48 | ~~~~~~~~~~ OUTPUT ~~~~~~~~~~ 49 | { 50 | "timestamp": "Wed, 10 Apr 2019 16:50:47 -0500", 51 | "author": "=?UTF-8?q?Martin=20Zl=C3=A1mal?= ", 52 | "subject": "Let's party 🍺 🍻 🍷 🍸😀 😬 😁 😂 😃 😄 😅", 53 | "description": "" 54 | } 55 | `; 56 | -------------------------------------------------------------------------------- /src/__tests__/fixtures/configs/invalid-additional-props-1.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | module.exports = { 4 | defaultStrippedFiles() { 5 | // this configuration is not supported and should be removed (should be 'getStrippedFiles') 6 | }, 7 | getStaticConfig() { 8 | return { 9 | repository: 'git@github.com/kiwicom/relay-example.git', 10 | }; 11 | }, 12 | getPathMappings(): Map { 13 | return new Map([['src/apps/example-relay/', '']]); 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /src/__tests__/fixtures/configs/invalid-additional-props-2.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | module.exports = { 4 | getStrippedFiles() { 5 | return new Set([/__github__/]); 6 | }, 7 | getStaticConfig() { 8 | return { 9 | repository: 'git@github.com/kiwicom/relay-example.git', 10 | }; 11 | }, 12 | defaultPathMappings() { 13 | // this configuration is not supported and should be removed (should be 'getPathMappings') 14 | }, 15 | }; 16 | -------------------------------------------------------------------------------- /src/__tests__/fixtures/configs/invalid-misconfigured-branches.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | import path from 'path'; 4 | 5 | module.exports = { 6 | getBranchConfig() { 7 | return { 8 | // should be 'source' and 'destination' 9 | what_is_this: 'source_branch', 10 | }; 11 | }, 12 | getStaticConfig() { 13 | return { 14 | repository: 'git@github.com/kiwicom/relay-example.git', 15 | }; 16 | }, 17 | getPathMappings(): Map { 18 | const ossRoot = 'src/apps/example-relay/'; 19 | return new Map([ 20 | [path.join(ossRoot, '__github__', '.flowconfig'), '.flowconfig'], 21 | [ossRoot, ''], 22 | ]); 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /src/__tests__/fixtures/configs/invalid-missing-props.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | module.exports = { 4 | // getStaticConfig missing 5 | getPathMappings(): Map { 6 | return new Map([['src/apps/example-relay/', '']]); 7 | }, 8 | }; 9 | -------------------------------------------------------------------------------- /src/__tests__/fixtures/configs/valid-branches.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | import path from 'path'; 4 | 5 | module.exports = { 6 | getBranchConfig() { 7 | return { 8 | source: 'source_branch', 9 | destination: 'destination_branch', 10 | }; 11 | }, 12 | getStaticConfig() { 13 | return { 14 | repository: 'git@github.com/kiwicom/relay-example.git', 15 | }; 16 | }, 17 | getPathMappings(): Map { 18 | const ossRoot = 'src/apps/example-relay/'; 19 | return new Map([ 20 | [path.join(ossRoot, '__github__', '.flowconfig'), '.flowconfig'], 21 | [ossRoot, ''], 22 | ]); 23 | }, 24 | }; 25 | -------------------------------------------------------------------------------- /src/__tests__/fixtures/configs/valid-minimal.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | import path from 'path'; 4 | 5 | module.exports = { 6 | getStaticConfig() { 7 | return { 8 | repository: 'git@github.com/kiwicom/relay-example.git', 9 | }; 10 | }, 11 | getPathMappings(): Map { 12 | const ossRoot = 'src/apps/example-relay/'; 13 | return new Map([ 14 | [path.join(ossRoot, '__github__', '.flowconfig'), '.flowconfig'], 15 | [ossRoot, ''], 16 | ]); 17 | }, 18 | }; 19 | -------------------------------------------------------------------------------- /src/__tests__/fixtures/diffs/chmod.patch: -------------------------------------------------------------------------------- 1 | diff --git a/src/platform/terraform-gitlab-project-cluster/environment_scope.sh b/src/platform/terraform-gitlab-project-cluster/environment_scope.sh 2 | old mode 100644 3 | new mode 100755 4 | -- 5 | 2.20.1 (Apple Git-117) 6 | 7 | -------------------------------------------------------------------------------- /src/__tests__/fixtures/diffs/diff-in-diff.patch: -------------------------------------------------------------------------------- 1 | diff --git a/unicode.txt b/unicode.txt 2 | index 1f60525b30fedbea3660714fb6198053f3457168..485cb7f13081f005c2f83890e155c0fcf4b16cd6 100644 3 | --- a/unicode.txt 4 | +++ b/unicode.txt 5 | @@ -1,6 +1,16 @@ 6 | -Apostrophe: don’t 7 | -Emoji: 🍺 🍻 🍷 🍸😀 😬 😁 😂 😃 😄 😅 8 | -Russian: Все люди рождаются свободными и равными в своем достоинстве и правах. 9 | -Japanese: すべての人間は、生まれながらにして自由であり、かつ、尊厳と権利とについて平等である。 10 | -Arabic: يولد جميع الناس أحرارًا متساوين في الكرامة والحقوق. 11 | -Chinese (traditional): 人人生而自由,在尊嚴和權利上一律平等。 12 | +diff --git a/unicode.txt b/unicode.txt 13 | +new file mode 100644 14 | +index 0000000000000000000000000000000000000000..1f60525b30fedbea3660714fb6198053f3457168 15 | +--- /dev/null 16 | ++++ b/unicode.txt 17 | +@@ -0,0 +1,6 @@ 18 | ++Apostrophe: don’t 19 | ++Emoji: 🍺 🍻 🍷 🍸😀 😬 😁 😂 😃 😄 😅 20 | ++Russian: Все люди рождаются свободными и равными в своем достоинстве и правах. 21 | ++Japanese: すべての人間は、生まれながらにして自由であり、かつ、尊厳と権利とについて平等である。 22 | ++Arabic: يولد جميع الناس أحرارًا متساوين في الكرامة والحقوق. 23 | ++Chinese (traditional): 人人生而自由,在尊嚴和權利上一律平等。 24 | +-- 25 | +2.21.0 26 | + 27 | + 28 | -- 29 | 2.21.0 30 | 31 | -------------------------------------------------------------------------------- /src/__tests__/fixtures/diffs/file-delete.patch: -------------------------------------------------------------------------------- 1 | diff --git a/bbb.txt b/bbb.txt 2 | deleted file mode 100644 3 | index dbee0265d31298531773537e6e37e4fd1ee71d62..0000000000000000000000000000000000000000 4 | --- a/bbb.txt 5 | +++ /dev/null 6 | @@ -1,2 +0,0 @@ 7 | -aaa 8 | -bbb 9 | -- 10 | 2.21.0 11 | 12 | -------------------------------------------------------------------------------- /src/__tests__/fixtures/diffs/file-modify-no-eol.patch: -------------------------------------------------------------------------------- 1 | diff --git a/aaa.txt b/aaa.txt 2 | index 72943a16fb2c8f38f9dde202b7a70ccc19c52f34..01f02e32ce8a128dd7b1d16a45f2eff66ec23c2d 100644 3 | --- a/aaa.txt 4 | +++ b/aaa.txt 5 | @@ -1 +1 @@ 6 | -aaa 7 | +bbb 8 | \ No newline at end of file 9 | -- 10 | 2.21.0 11 | 12 | -------------------------------------------------------------------------------- /src/__tests__/fixtures/diffs/file-new.patch: -------------------------------------------------------------------------------- 1 | diff --git a/aaa.txt b/aaa.txt 2 | new file mode 100644 3 | index 0000000000000000000000000000000000000000..72943a16fb2c8f38f9dde202b7a70ccc19c52f34 4 | --- /dev/null 5 | +++ b/aaa.txt 6 | @@ -0,0 +1 @@ 7 | +aaa 8 | -- 9 | 2.21.0 10 | 11 | -------------------------------------------------------------------------------- /src/__tests__/fixtures/diffs/file-rename.patch: -------------------------------------------------------------------------------- 1 | diff --git a/aaa.txt b/aaa.txt 2 | deleted file mode 100644 3 | index 01f02e32ce8a128dd7b1d16a45f2eff66ec23c2d..0000000000000000000000000000000000000000 4 | --- a/aaa.txt 5 | +++ /dev/null 6 | @@ -1 +0,0 @@ 7 | -bbb 8 | \ No newline at end of file 9 | diff --git a/bbb.txt b/bbb.txt 10 | new file mode 100644 11 | index 0000000000000000000000000000000000000000..01f02e32ce8a128dd7b1d16a45f2eff66ec23c2d 12 | --- /dev/null 13 | +++ b/bbb.txt 14 | @@ -0,0 +1 @@ 15 | +bbb 16 | \ No newline at end of file 17 | -- 18 | 2.21.0 19 | 20 | -------------------------------------------------------------------------------- /src/__tests__/fixtures/diffs/files-modify.patch: -------------------------------------------------------------------------------- 1 | diff --git a/bbb.txt b/bbb.txt 2 | index 01f02e32ce8a128dd7b1d16a45f2eff66ec23c2d..dbee0265d31298531773537e6e37e4fd1ee71d62 100644 3 | --- a/bbb.txt 4 | +++ b/bbb.txt 5 | @@ -1 +1,2 @@ 6 | -bbb 7 | \ No newline at end of file 8 | +aaa 9 | +bbb 10 | diff --git a/ccc.txt b/ccc.txt 11 | index b2a7546679fdf79ca0eb7bfbee1e1bb342487380..66bde67332bef8dfb2c70698c51bf37b80b73ef3 100644 12 | --- a/ccc.txt 13 | +++ b/ccc.txt 14 | @@ -1 +1,2 @@ 15 | ccc 16 | +ddd 17 | -- 18 | 2.21.0 19 | 20 | -------------------------------------------------------------------------------- /src/__tests__/fixtures/diffs/lfs-removal.patch: -------------------------------------------------------------------------------- 1 | diff --git a/.yarn/cache/@kiwicom-js-0.9.0.tgz b/.yarn/cache/@kiwicom-js-0.9.0.tgz 2 | deleted file mode 100644 3 | index f2572f9c9d2f2ab4f2a61695a09ee50430f8ccbe..0000000000000000000000000000000000000000 4 | --- a/.yarn/cache/@kiwicom-js-0.9.0.tgz 5 | +++ /dev/null 6 | @@ -1,3 +0,0 @@ 7 | -version https://git-lfs.github.com/spec/v1 8 | -oid sha256:389a46fe92555e912f515578f718f546e58a0c9b81174a66068a35211d5e54ff 9 | -size 4330 10 | -- 11 | 2.20.1 (Apple Git-117) 12 | 13 | -------------------------------------------------------------------------------- /src/__tests__/fixtures/diffs/nasty.patch: -------------------------------------------------------------------------------- 1 | diff --git a/nasty.txt b/nasty.txt 2 | index 044b936071f4da77efec5b15794785ecde6b6791..e316f565a5386462ecbfd01d245888570a00ca85 100644 3 | --- a/nasty.txt 4 | +++ b/nasty.txt 5 | @@ -1 +1 @@ 6 | --- a/nasty.txt 7 | +++ b/nasty.txt 8 | -- 9 | 2.21.0 10 | 11 | -------------------------------------------------------------------------------- /src/__tests__/fixtures/diffs/unicode.patch: -------------------------------------------------------------------------------- 1 | diff --git a/unicode.txt b/unicode.txt 2 | new file mode 100644 3 | index 0000000000000000000000000000000000000000..1f60525b30fedbea3660714fb6198053f3457168 4 | --- /dev/null 5 | +++ b/unicode.txt 6 | @@ -0,0 +1,6 @@ 7 | +Apostrophe: don’t 8 | +Emoji: 🍺 🍻 🍷 🍸😀 😬 😁 😂 😃 😄 😅 9 | +Russian: Все люди рождаются свободными и равными в своем достоинстве и правах. 10 | +Japanese: すべての人間は、生まれながらにして自由であり、かつ、尊厳と権利とについて平等である。 11 | +Arabic: يولد جميع الناس أحرارًا متساوين في الكرامة والحقوق. 12 | +Chinese (traditional): 人人生而自由,在尊嚴和權利上一律平等。 13 | -- 14 | 2.21.0 15 | 16 | -------------------------------------------------------------------------------- /src/__tests__/fixtures/headers/multiline-subject.header: -------------------------------------------------------------------------------- 1 | From 93ee65de02e77c52eb9d677b859554b40253bafa Mon Sep 17 00:00:00 2001 2 | From: =?UTF-8?q?Martin=20Zl=C3=A1mal?= 3 | Date: Wed, 10 Apr 2019 16:50:47 -0500 4 | Subject: [PATCH] Docs: add Relay and GraphQL related videos from our internal 5 | JS Saturday 6 | 7 | -------------------------------------------------------------------------------- /src/__tests__/fixtures/headers/simple.header: -------------------------------------------------------------------------------- 1 | From bf0a8783cf416f796659e60fe735bba98e676e67 Mon Sep 17 00:00:00 2001 2 | From: Automator 3 | Date: Mon, 4 Feb 2019 13:29:04 -0600 4 | Subject: [PATCH] Import and publish '@kiwicom/relay' (0.1.0) 5 | 6 | Formerly known as '@mrtnzlml/relay' 7 | 8 | -------------------------------------------------------------------------------- /src/__tests__/fixtures/headers/unicode.header: -------------------------------------------------------------------------------- 1 | From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 2 | From: =?UTF-8?q?Martin=20Zl=C3=A1mal?= 3 | Date: Wed, 10 Apr 2019 16:50:47 -0500 4 | Subject: [PATCH] Let's party 🍺 🍻 🍷 🍸😀 😬 😁 😂 😃 😄 😅 5 | 6 | -------------------------------------------------------------------------------- /src/__tests__/parsePatch.test.js: -------------------------------------------------------------------------------- 1 | // @flow strict-local 2 | 3 | import path from 'path'; 4 | import { generateTestsFromFixtures } from '@kiwicom/test-utils'; 5 | 6 | import parsePatch from '../parsePatch'; 7 | 8 | generateTestsFromFixtures(path.join(__dirname, 'fixtures', 'diffs'), operation); 9 | 10 | function operation(input) { 11 | return new Promise(resolve => { 12 | let hunks = ''; 13 | let hunkNumber = 1; 14 | let hunkSeparator = ''; 15 | for (const hunk of parsePatch(input)) { 16 | hunks += `${hunkSeparator}~~~ HUNK ${hunkNumber++}:\n\n${hunk}`; 17 | hunkSeparator = '\n'; 18 | } 19 | resolve(hunks); 20 | }); 21 | } 22 | -------------------------------------------------------------------------------- /src/__tests__/parsePatchHeader.test.js: -------------------------------------------------------------------------------- 1 | // @flow strict-local 2 | 3 | import path from 'path'; 4 | import { generateTestsFromFixtures } from '@kiwicom/test-utils'; 5 | 6 | import parsePatchHeader from '../parsePatchHeader'; 7 | 8 | generateTestsFromFixtures(path.join(__dirname, 'fixtures', 'headers'), parsePatchHeader); 9 | -------------------------------------------------------------------------------- /src/__tests__/requireAndValidateConfig.test.js: -------------------------------------------------------------------------------- 1 | // @flow strict-local 2 | 3 | import requireAndValidate from '../requireAndValidateConfig'; 4 | 5 | it('returns minimal valid config correctly', () => { 6 | const config = requireAndValidate(`${__dirname}/fixtures/configs/valid-minimal.js`); 7 | expect(config).toMatchInlineSnapshot(` 8 | Object { 9 | "getPathMappings": [Function], 10 | "getStaticConfig": [Function], 11 | } 12 | `); 13 | expect(config.getPathMappings()).toMatchInlineSnapshot(` 14 | Map { 15 | "src/apps/example-relay/__github__/.flowconfig" => ".flowconfig", 16 | "src/apps/example-relay/" => "", 17 | } 18 | `); 19 | expect(config.getStaticConfig()).toMatchInlineSnapshot(` 20 | Object { 21 | "repository": "git@github.com/kiwicom/relay-example.git", 22 | } 23 | `); 24 | }); 25 | 26 | it('returns valid config with branches correctly', () => { 27 | const config = requireAndValidate(`${__dirname}/fixtures/configs/valid-branches.js`); 28 | expect(config).toMatchInlineSnapshot(` 29 | Object { 30 | "getBranchConfig": [Function], 31 | "getPathMappings": [Function], 32 | "getStaticConfig": [Function], 33 | } 34 | `); 35 | expect(config.getPathMappings()).toMatchInlineSnapshot(` 36 | Map { 37 | "src/apps/example-relay/__github__/.flowconfig" => ".flowconfig", 38 | "src/apps/example-relay/" => "", 39 | } 40 | `); 41 | expect(config.getStaticConfig()).toMatchInlineSnapshot(` 42 | Object { 43 | "repository": "git@github.com/kiwicom/relay-example.git", 44 | } 45 | `); 46 | }); 47 | 48 | it('fails when config contains unsupported fields', () => { 49 | expect(() => 50 | requireAndValidate(`${__dirname}/fixtures/configs/invalid-additional-props-1.js`), 51 | ).toThrowError( 52 | "Your config contains field 'defaultStrippedFiles' but this is not allowed key. Did you mean 'getStrippedFiles' instead?", 53 | ); 54 | expect(() => 55 | requireAndValidate(`${__dirname}/fixtures/configs/invalid-additional-props-2.js`), 56 | ).toThrowError( 57 | "Your config contains field 'defaultPathMappings' but this is not allowed key. Did you mean 'getPathMappings' instead?", 58 | ); 59 | }); 60 | 61 | it("fails when branch config doesn't have valid keys", () => { 62 | expect(() => 63 | requireAndValidate(`${__dirname}/fixtures/configs/invalid-misconfigured-branches.js`), 64 | ).toThrowError( 65 | "Your config contains field 'what_is_this' but this is not allowed key. Did you mean 'destination' instead?", 66 | ); 67 | }); 68 | 69 | it('fails when config does not contain all the required props', () => { 70 | expect(() => 71 | requireAndValidate(`${__dirname}/fixtures/configs/invalid-missing-props.js`), 72 | ).toThrowError("Configuration field 'getStaticConfig' is required but it's missing."); 73 | }); 74 | -------------------------------------------------------------------------------- /src/accounts.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | const availableAccounts: $ReadOnlyMap = new Map([ 4 | ['kiwicom-github-bot', 'mrtnzlml+kiwicom-github-bot@gmail.com'], 5 | ['kiwicom-shipit-tests', 'shipit-tests@kiwi.com'], 6 | ]); 7 | 8 | export default availableAccounts; 9 | -------------------------------------------------------------------------------- /src/filters/__tests__/__snapshots__/conditionalLines.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`comments lines correctly - no comment end 1`] = ` 4 | Object { 5 | "input": "index 1fa2ce4..499116a 100644 6 | --- a/foo 7 | +++ b/foo 8 | @@ -1,7 +1,7 @@ 9 | foo // @x-oss-disable 10 | normal_1 11 | bar // @x-oss-disable 12 | +baz // @x-oss-disable 13 | 14 | herp // @x-oss-disable 15 | normal_2 16 | - derp // @x-oss-disable 17 | ", 18 | "output": "index 1fa2ce4..499116a 100644 19 | --- a/foo 20 | +++ b/foo 21 | @@ -1,7 +1,7 @@ 22 | // @x-oss-disable: foo 23 | normal_1 24 | // @x-oss-disable: bar 25 | +// @x-oss-disable: baz 26 | 27 | // @x-oss-disable: herp 28 | normal_2 29 | - // @x-oss-disable: derp 30 | ", 31 | } 32 | `; 33 | 34 | exports[`comments lines correctly - with comment end 1`] = ` 35 | Object { 36 | "input": "index 5bcc9b1..17467d2 100644 37 | --- a/foo 38 | +++ b/foo 39 | @@ -1,7 +1,7 @@ 40 | foo /* @x-oss-disable */ 41 | normal_1 42 | bar /* @x-oss-disable */ 43 | +baz /* @x-oss-disable */ 44 | 45 | herp /* @x-oss-disable */ 46 | normal_2 47 | - derp /* @x-oss-disable */ 48 | ", 49 | "output": "index 5bcc9b1..17467d2 100644 50 | --- a/foo 51 | +++ b/foo 52 | @@ -1,7 +1,7 @@ 53 | /* @x-oss-disable: foo */ 54 | normal_1 55 | /* @x-oss-disable: bar */ 56 | +/* @x-oss-disable: baz */ 57 | 58 | /* @x-oss-disable: herp */ 59 | normal_2 60 | - /* @x-oss-disable: derp */ 61 | ", 62 | } 63 | `; 64 | 65 | exports[`enables and disables lines as needed 1`] = ` 66 | Object { 67 | "input": "index 1fa2ce4..499116a 100644 68 | --- a/foo 69 | +++ b/foo 70 | @@ -1,5 +1,5 @@ 71 | // @x-oss-enable: foo 72 | bar // @x-oss-disable 73 | +baz // @x-oss-disable 74 | 75 | herp // @x-oss-disable 76 | - // @x-oss-enable: derp 77 | ", 78 | "output": "index 1fa2ce4..499116a 100644 79 | --- a/foo 80 | +++ b/foo 81 | @@ -1,5 +1,5 @@ 82 | foo // @x-oss-enable 83 | // @x-oss-disable: bar 84 | +// @x-oss-disable: baz 85 | 86 | // @x-oss-disable: herp 87 | - derp // @x-oss-enable 88 | ", 89 | } 90 | `; 91 | 92 | exports[`works with comments in comments 1`] = ` 93 | Object { 94 | "input": "index 1fa2ce4..499116a 100644 95 | --- a/foo 96 | +++ b/foo 97 | @@ -1,5 +1,5 @@ 98 | foo // first-comment // @x-oss-disable 99 | bar // first-comment // @x-oss-disable 100 | +baz // first-comment // @x-oss-disable 101 | 102 | herp // first-comment // @x-oss-disable 103 | - derp // first-comment // @x-oss-disable 104 | ", 105 | "output": "index 1fa2ce4..499116a 100644 106 | --- a/foo 107 | +++ b/foo 108 | @@ -1,5 +1,5 @@ 109 | // @x-oss-disable: foo // first-comment 110 | // @x-oss-disable: bar // first-comment 111 | +// @x-oss-disable: baz // first-comment 112 | 113 | // @x-oss-disable: herp // first-comment 114 | - // @x-oss-disable: derp // first-comment 115 | ", 116 | } 117 | `; 118 | -------------------------------------------------------------------------------- /src/filters/__tests__/conditionalLines.test.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import fs from 'fs'; 4 | import path from 'path'; 5 | 6 | import { commentLines, uncommentLines } from '../conditionalLines'; 7 | import RepoGitFake from '../../RepoGitFake'; 8 | import Changeset from '../../Changeset'; 9 | 10 | function printBodies(changeset: Changeset): string { 11 | return [...changeset.getDiffs()].map(diff => diff.body).toString(); 12 | } 13 | 14 | function getChangeset(patchName: string): Changeset { 15 | const repo = new RepoGitFake(); 16 | const patch = fs.readFileSync(path.join(__dirname, 'fixtures', patchName), 'utf8'); 17 | return repo.getChangesetFromExportedPatch('MOCKED', patch); 18 | } 19 | 20 | it('comments lines correctly - no comment end', () => { 21 | const changeset = getChangeset('comment-lines-no-comment-end.patch'); 22 | const newChangeset = commentLines(changeset, '@x-oss-disable', '//', null); 23 | const revertedChangeset = uncommentLines(newChangeset, '@x-oss-disable', '//', null); 24 | 25 | expect({ 26 | input: printBodies(changeset), 27 | output: printBodies(newChangeset), 28 | }).toMatchSnapshot(); 29 | 30 | expect(printBodies(revertedChangeset)).toBe(printBodies(changeset)); 31 | }); 32 | 33 | it('comments lines correctly - with comment end', () => { 34 | const changeset = getChangeset('comment-lines-comment-end.patch'); 35 | const newChangeset = commentLines(changeset, '@x-oss-disable', '/*', '*/'); 36 | const revertedChangeset = uncommentLines(newChangeset, '@x-oss-disable', '/*', '*/'); 37 | 38 | expect({ 39 | input: printBodies(changeset), 40 | output: printBodies(newChangeset), 41 | }).toMatchSnapshot(); 42 | 43 | expect(printBodies(revertedChangeset)).toBe(printBodies(changeset)); 44 | }); 45 | 46 | it('works with comments in comments', () => { 47 | const changeset = getChangeset('double-comment.patch'); 48 | const newChangeset = commentLines(changeset); 49 | const revertedChangeset = uncommentLines(newChangeset); 50 | 51 | expect({ 52 | input: printBodies(changeset), 53 | output: printBodies(newChangeset), 54 | }).toMatchSnapshot(); 55 | 56 | expect(printBodies(revertedChangeset)).toBe(printBodies(changeset)); 57 | }); 58 | 59 | it('enables and disables lines as needed', () => { 60 | // This is a real-life example where we disable some lines but enable others. 61 | 62 | const changeset = getChangeset('enable-disable.patch'); 63 | const newChangeset = uncommentLines( 64 | commentLines(changeset, '@x-oss-disable', '//', null), 65 | '@x-oss-enable', 66 | '//', 67 | null, 68 | ); 69 | 70 | expect({ 71 | input: printBodies(changeset), 72 | output: printBodies(newChangeset), 73 | }).toMatchSnapshot(); 74 | }); 75 | -------------------------------------------------------------------------------- /src/filters/__tests__/fixtures/comment-lines-comment-end.patch: -------------------------------------------------------------------------------- 1 | diff --git a/foo b/foo 2 | index 5bcc9b1..17467d2 100644 3 | --- a/foo 4 | +++ b/foo 5 | @@ -1,7 +1,7 @@ 6 | foo /* @x-oss-disable */ 7 | normal_1 8 | bar /* @x-oss-disable */ 9 | +baz /* @x-oss-disable */ 10 | 11 | herp /* @x-oss-disable */ 12 | normal_2 13 | - derp /* @x-oss-disable */ 14 | -- 15 | 2.9.3 16 | 17 | -------------------------------------------------------------------------------- /src/filters/__tests__/fixtures/comment-lines-no-comment-end.patch: -------------------------------------------------------------------------------- 1 | diff --git a/foo b/foo 2 | index 1fa2ce4..499116a 100644 3 | --- a/foo 4 | +++ b/foo 5 | @@ -1,7 +1,7 @@ 6 | foo // @x-oss-disable 7 | normal_1 8 | bar // @x-oss-disable 9 | +baz // @x-oss-disable 10 | 11 | herp // @x-oss-disable 12 | normal_2 13 | - derp // @x-oss-disable 14 | -- 15 | 2.9.3 16 | 17 | -------------------------------------------------------------------------------- /src/filters/__tests__/fixtures/double-comment.patch: -------------------------------------------------------------------------------- 1 | diff --git a/foo b/foo 2 | index 1fa2ce4..499116a 100644 3 | --- a/foo 4 | +++ b/foo 5 | @@ -1,5 +1,5 @@ 6 | foo // first-comment // @x-oss-disable 7 | bar // first-comment // @x-oss-disable 8 | +baz // first-comment // @x-oss-disable 9 | 10 | herp // first-comment // @x-oss-disable 11 | - derp // first-comment // @x-oss-disable 12 | -- 13 | 2.9.3 14 | 15 | -------------------------------------------------------------------------------- /src/filters/__tests__/fixtures/enable-disable.patch: -------------------------------------------------------------------------------- 1 | diff --git a/foo b/foo 2 | index 1fa2ce4..499116a 100644 3 | --- a/foo 4 | +++ b/foo 5 | @@ -1,5 +1,5 @@ 6 | // @x-oss-enable: foo 7 | bar // @x-oss-disable 8 | +baz // @x-oss-disable 9 | 10 | herp // @x-oss-disable 11 | - // @x-oss-enable: derp 12 | -- 13 | 2.9.3 14 | 15 | -------------------------------------------------------------------------------- /src/filters/__tests__/moveDirectories.test.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | import moveDirectories from '../moveDirectories'; 4 | import Changeset from '../../Changeset'; 5 | 6 | test.each([ 7 | [ 8 | 'first takes precedence (first is more specific)', 9 | new Map([ 10 | // from => to 11 | ['foo/public_tld/', ''], 12 | ['foo/', ''], 13 | ]), 14 | ['foo/orig_root_file', 'foo/public_tld/public_root_file'], 15 | ['orig_root_file', 'public_root_file'], 16 | ], 17 | [ 18 | // this mapping doesn't make sense given the behavior, just using it to check that order matters 19 | 'first takes precedence (second is more specific)', 20 | new Map([ 21 | ['foo/', ''], 22 | ['foo/public_tld/', ''], 23 | ]), 24 | ['foo/orig_root_file', 'foo/public_tld/public_root_file'], 25 | ['orig_root_file', 'public_tld/public_root_file'], 26 | ], 27 | [ 28 | 'only one rule applied', 29 | new Map([ 30 | ['foo/', ''], 31 | ['bar/', 'project_bar/'], 32 | ]), 33 | ['foo/bar/part of project foo', 'bar/part of project bar'], 34 | [ 35 | 'bar/part of project foo', // this shouldn't turn into 'project_bar/part of project foo' 36 | 'project_bar/part of project bar', 37 | ], 38 | ], 39 | [ 40 | 'missing trailing slashes', 41 | new Map([ 42 | ['foo', 'bar'], 43 | ['xyz/', 'aaa'], 44 | ]), 45 | ['foo/file', 'foo_baz/file', 'xyz/file'], 46 | [ 47 | 'bar/file', 48 | 'bar_baz/file', 49 | 'aaafile', // this can be a gotcha for someone 50 | ], 51 | ], 52 | ])('%s', (testName, mapping, inputPaths, expected) => { 53 | const changeset = new Changeset().withDiffs( 54 | new Set(inputPaths.map(path => ({ path, body: 'placeholder' }))), 55 | ); 56 | const diffs = moveDirectories(changeset, mapping).getDiffs(); 57 | expect([...diffs].map(diff => diff.path)).toEqual(expected); 58 | }); 59 | -------------------------------------------------------------------------------- /src/filters/__tests__/moveDirectoriesReverse.test.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | import moveDirectories from '../moveDirectories'; 4 | import moveDirectoriesReverse from '../moveDirectoriesReverse'; 5 | import Changeset from '../../Changeset'; 6 | 7 | test.each([ 8 | [ 9 | 'second takes precedence (first is more specific)', 10 | new Map([ 11 | // from => to 12 | ['foo/public_tld/', ''], 13 | ['foo/', 'bar/'], 14 | ]), 15 | ['foo/orig_root_file', 'foo/public_tld/public_root_file'], 16 | ['bar/orig_root_file', 'public_root_file'], 17 | ], 18 | [ 19 | 'only one rule applied', 20 | new Map([ 21 | ['foo/', ''], 22 | ['bar/', 'project_bar/'], 23 | ]), 24 | ['foo/bar/part of project foo', 'bar/part of project bar'], 25 | [ 26 | 'bar/part of project foo', // this shouldn't turn into 'project_bar/part ...' 27 | 'project_bar/part of project bar', 28 | ], 29 | ], 30 | [ 31 | 'subdirectories', 32 | new Map([ 33 | ['foo/test/', 'testing/'], 34 | ['foo/', ''], 35 | ]), 36 | ['foo/test/README', 'foo/src.c'], 37 | ['testing/README', 'src.c'], 38 | ], 39 | ])('%s', (testName, mapping, inputPaths, expected) => { 40 | const changeset = new Changeset().withDiffs( 41 | new Set(inputPaths.map(path => ({ path, body: 'placeholder' }))), 42 | ); 43 | const diffs = moveDirectories(changeset, mapping).getDiffs(); 44 | expect([...diffs].map(diff => diff.path)).toEqual(expected); 45 | 46 | const reversedChangeset = new Changeset().withDiffs(diffs); 47 | const reversedDiffs = moveDirectoriesReverse(reversedChangeset, mapping).getDiffs(); 48 | expect([...reversedDiffs].map(diff => diff.path)).toEqual(inputPaths); 49 | }); 50 | 51 | it('throw exception when mapping contains duplicate destinations', () => { 52 | // Please note: this is technically OK for normal mapping. However, it's 53 | // not possible to revers such a mapping because it's not clear how 54 | // should be the paths restored. 55 | const changeset = new Changeset(); 56 | const brokenMapping = new Map([ 57 | ['foo/', 'duplicate/'], 58 | ['bar/', 'duplicate/'], 59 | ]); 60 | expect(() => moveDirectoriesReverse(changeset, brokenMapping)).toThrowError( 61 | 'It is not possible to reverse mapping with duplicate destinations.', 62 | ); 63 | }); 64 | -------------------------------------------------------------------------------- /src/filters/__tests__/stripDescriptions.test.js: -------------------------------------------------------------------------------- 1 | // @flow strict-local 2 | 3 | import stripDescriptions from '../stripDescriptions'; 4 | import Changeset from '../../Changeset'; 5 | 6 | it('strips commit descriptions correctly', () => { 7 | const changeset = new Changeset() 8 | .withAuthor('John Doe') 9 | .withSubject('Commit subject') 10 | .withDescription('This description should be stripped.'); 11 | 12 | expect(changeset).toMatchInlineSnapshot(` 13 | Changeset { 14 | "author": "John Doe", 15 | "description": "This description should be stripped.", 16 | "diffs": undefined, 17 | "id": undefined, 18 | "subject": "Commit subject", 19 | "timestamp": undefined, 20 | } 21 | `); 22 | 23 | expect(stripDescriptions(changeset)).toMatchInlineSnapshot(` 24 | Changeset { 25 | "author": "John Doe", 26 | "description": "", 27 | "diffs": undefined, 28 | "id": undefined, 29 | "subject": "Commit subject", 30 | "timestamp": undefined, 31 | } 32 | `); 33 | }); 34 | -------------------------------------------------------------------------------- /src/filters/__tests__/stripExceptDirectories.test.js: -------------------------------------------------------------------------------- 1 | // @flow strict-local 2 | 3 | import stripExceptDirectories from '../stripExceptDirectories'; 4 | import Changeset from '../../Changeset'; 5 | 6 | test.each([ 7 | [['foo'], ['foo/bar', 'herp/derp'], ['foo/bar']], 8 | [['foo/'], ['foo/bar', 'herp/derp'], ['foo/bar']], 9 | [['foo'], ['foo/bar', 'foobaz'], ['foo/bar']], 10 | [ 11 | ['foo', 'herp'], 12 | ['foo/bar', 'herp/derp', 'baz'], 13 | ['foo/bar', 'herp/derp'], 14 | ], 15 | ])('strips packages outside of the defined scope correctly: %#', (roots, inputPaths, expected) => { 16 | const changeset = new Changeset().withDiffs( 17 | new Set(inputPaths.map(path => ({ path, body: 'placeholder' }))), 18 | ); 19 | const diffs = stripExceptDirectories(changeset, roots).getDiffs(); 20 | expect([...diffs].map(diff => diff.path)).toEqual(expected); 21 | }); 22 | -------------------------------------------------------------------------------- /src/filters/__tests__/stripPaths.test.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | import stripPaths from '../stripPaths'; 4 | import Changeset from '../../Changeset'; 5 | 6 | test.each([ 7 | ['No change', [], ['foo', 'bar', 'herp/derp', 'herp/derp-derp', 'derp']], 8 | ['Remove top level file', [/^bar$/], ['foo', 'herp/derp', 'herp/derp-derp', 'derp']], 9 | ['Remove directory', [/^herp\//], ['foo', 'bar', 'derp']], 10 | ['Remove file', [/(?:^|\/)derp(?:\/|$)/], ['foo', 'bar', 'herp/derp-derp']], 11 | ['Multiple patterns', [/^foo$/, /^bar$/], ['herp/derp', 'herp/derp-derp', 'derp']], 12 | ])('%s', (testName, stripPatterns, expectedFiles) => { 13 | const paths = ['foo', 'bar', 'herp/derp', 'herp/derp-derp', 'derp']; 14 | const changeset = new Changeset().withDiffs( 15 | new Set(paths.map(path => ({ path, body: 'placeholder' }))), 16 | ); 17 | const diffs = stripPaths(changeset, stripPatterns).getDiffs(); 18 | expect([...diffs].map(diff => diff.path)).toEqual(expectedFiles); 19 | }); 20 | -------------------------------------------------------------------------------- /src/filters/addTrackingData.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | import Changeset from '../Changeset'; 4 | 5 | export default function addTrackingData(changeset: Changeset): Changeset { 6 | const revision = changeset.getID(); 7 | const newDescription = `${changeset.getDescription()}\n\nkiwicom-source-id: ${revision}`; 8 | return changeset.withDescription(newDescription.trim()); 9 | } 10 | -------------------------------------------------------------------------------- /src/filters/conditionalLines.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | import Changeset from '../Changeset'; 4 | 5 | // https://github.com/lodash/lodash/blob/f8c7064d450cc068144c4dad1d63535cba25ae6d/escapeRegExp.js 6 | function _e(s) { 7 | // RegExp.escape substitute 8 | return s.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&'); 9 | } 10 | 11 | function process(changeset: Changeset, pattern: RegExp, replacement: string) { 12 | const diffs = new Set(); 13 | for (const diff of changeset.getDiffs()) { 14 | const newDiff = { 15 | ...diff, 16 | body: diff.body 17 | .split('\n') 18 | .map(line => line.replace(pattern, replacement)) 19 | .join('\n'), 20 | }; 21 | diffs.add(newDiff); 22 | } 23 | return changeset.withDiffs(diffs); 24 | } 25 | 26 | export function commentLines( 27 | changeset: Changeset, 28 | marker: string = '@x-oss-disable', 29 | commentStart: string = '//', 30 | commentEnd: null | string = null, 31 | ) { 32 | const ending = commentEnd === null ? '' : ` ${commentEnd}`; 33 | const pattern = new RegExp(`^([-+ ]\\s*)(\\S.*) ${_e(commentStart)} ${_e(marker)}${_e(ending)}$`); 34 | return process(changeset, pattern, `$1${commentStart} ${marker}: $2${ending}`); 35 | } 36 | 37 | export function uncommentLines( 38 | changeset: Changeset, 39 | marker: string = '@x-oss-disable', 40 | commentStart: string = '//', 41 | commentEnd: null | string = null, 42 | ) { 43 | const ending = commentEnd === null ? '' : ` ${commentEnd}`; 44 | const pattern = new RegExp(`^([-+ ]\\s*)${_e(commentStart)} ${_e(marker)}: (.+)${_e(ending)}$`); 45 | return process(changeset, pattern, `$1$2 ${commentStart} ${marker}${ending}`); 46 | } 47 | -------------------------------------------------------------------------------- /src/filters/moveDirectories.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | import Changeset from '../Changeset'; 4 | 5 | /** 6 | * Apply patches to a different directory in the destination repository. 7 | */ 8 | export default function moveDirectories( 9 | changeset: Changeset, 10 | mapping: Map, 11 | ): Changeset { 12 | const rewriteCallback = oldPath => { 13 | let newPath = oldPath; 14 | for (const [src, dest] of mapping.entries()) { 15 | let matchFound = false; 16 | if (new RegExp(`^${src}`).test(newPath)) { 17 | matchFound = true; 18 | } 19 | newPath = newPath.replace(new RegExp(`^${src}`), dest); 20 | if (matchFound) { 21 | break; // only first match in the map 22 | } 23 | } 24 | return newPath; 25 | }; 26 | 27 | const diffs = new Set(); 28 | for (const diff of changeset.getDiffs()) { 29 | const oldPath = diff.path; 30 | const newPath = rewriteCallback(oldPath); 31 | if (oldPath === newPath) { 32 | diffs.add(diff); 33 | continue; 34 | } 35 | 36 | let body = diff.body; 37 | body = body.replace(new RegExp(`^--- a/${oldPath}`, 'm'), `--- a/${newPath}`); 38 | body = body.replace(new RegExp(`^\\+\\+\\+ b/${oldPath}`, 'm'), `+++ b/${newPath}`); 39 | 40 | diffs.add({ 41 | path: newPath, 42 | body, 43 | }); 44 | } 45 | 46 | return changeset.withDiffs(diffs); 47 | } 48 | -------------------------------------------------------------------------------- /src/filters/moveDirectoriesReverse.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | import { invariant } from '@adeira/js'; 4 | 5 | import moveDirectories from './moveDirectories'; 6 | import Changeset from '../Changeset'; 7 | 8 | export default function moveDirectoriesReverse( 9 | changeset: Changeset, 10 | mapping: Map, 11 | ): Changeset { 12 | const reversedMapping = new Map(); 13 | for (const [src, dest] of mapping.entries()) { 14 | invariant( 15 | !reversedMapping.has(dest), 16 | 'It is not possible to reverse mapping with duplicate destinations.', 17 | ); 18 | reversedMapping.set(dest, src); 19 | } 20 | // subdirectories (most specific) should go first 21 | return moveDirectories(changeset, new Map([...reversedMapping].sort().reverse())); 22 | } 23 | -------------------------------------------------------------------------------- /src/filters/stripDescriptions.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | import Changeset from '../Changeset'; 4 | 5 | export default function stripDescriptions(changeset: Changeset): Changeset { 6 | return changeset.withDescription(''); 7 | } 8 | -------------------------------------------------------------------------------- /src/filters/stripExceptDirectories.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | import path from 'path'; 4 | 5 | import Changeset from '../Changeset'; 6 | 7 | /** 8 | * Remove any modifications outside of specified roots. 9 | */ 10 | export default function stripExceptDirectories( 11 | changeset: Changeset, 12 | rawRoots: Set, 13 | ): Changeset { 14 | const roots = new Set(); 15 | rawRoots.forEach(rawRoot => roots.add(rawRoot.endsWith(path.sep) ? rawRoot : rawRoot + path.sep)); 16 | const diffs = new Set(); 17 | for (const diff of changeset.getDiffs()) { 18 | const path = diff.path; 19 | for (const root of roots) { 20 | if (path.startsWith(root)) { 21 | diffs.add(diff); 22 | break; 23 | } 24 | } 25 | } 26 | return changeset.withDiffs(diffs); 27 | } 28 | -------------------------------------------------------------------------------- /src/filters/stripPaths.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | import Changeset from '../Changeset'; 4 | 5 | function matchesAnyPattern(path: string, stripPatterns: Set) { 6 | for (const stripPattern of stripPatterns) { 7 | if (stripPattern.test(path)) { 8 | return true; 9 | } 10 | } 11 | return false; 12 | } 13 | 14 | /** 15 | * Remove any modifications to paths matching `stripPatterns`. 16 | */ 17 | export default function stripPaths(changeset: Changeset, stripPatterns: Set): Changeset { 18 | if (stripPatterns.size === 0) { 19 | return changeset; 20 | } 21 | const diffs = new Set(); 22 | for (const diff of changeset.getDiffs()) { 23 | const path = diff.path; 24 | if (matchesAnyPattern(path, stripPatterns)) { 25 | // stripping because matching pattern was found 26 | continue; 27 | } 28 | diffs.add(diff); 29 | } 30 | return changeset.withDiffs(diffs); 31 | } 32 | -------------------------------------------------------------------------------- /src/iterateConfigs.js: -------------------------------------------------------------------------------- 1 | // @flow strict-local 2 | 3 | import path from 'path'; 4 | import { findMonorepoRoot, globSync } from '@kiwicom/monorepo-utils'; 5 | import logger from '@adeira/logger'; 6 | 7 | import requireAndValidateConfig from './requireAndValidateConfig'; 8 | import ShipitConfig from './ShipitConfig'; 9 | 10 | function iterateConfigsInPath(rootPath: string, callback: ShipitConfig => void): void { 11 | const configFiles = globSync('/*.js', { 12 | root: rootPath, 13 | ignore: [ 14 | '**/node_modules/**', 15 | '**/__[a-z]*__/**', // ignore __tests__, __mocks__, ... 16 | ], 17 | }); 18 | 19 | const monorepoPath = findMonorepoRoot(); 20 | const throwedErrors = new Set(); 21 | 22 | configFiles.forEach(configFile => { 23 | const config = requireAndValidateConfig(configFile); 24 | const staticConfig = config.getStaticConfig(); 25 | const branches = config.getBranchConfig 26 | ? config.getBranchConfig() 27 | : { 28 | source: undefined, 29 | destination: undefined, 30 | }; 31 | 32 | const cfg = new ShipitConfig( 33 | monorepoPath, 34 | staticConfig.repository, 35 | config.getPathMappings(), 36 | config.getStrippedFiles ? config.getStrippedFiles() : new Set(), 37 | branches.source, 38 | branches.destination, 39 | ); 40 | 41 | // We collect all the errors but we do not stop the iteration. These errors 42 | // are being displayed at the end of the process so that projects which are 43 | // OK can be exported successfully (and not being affected by irrelevant 44 | // failures). 45 | try { 46 | logger.log(`~~~~~ ${cfg.exportedRepoURL}`); 47 | logger.log(`Cloning into: ${cfg.destinationPath}`); 48 | callback(cfg); 49 | logger.log('✅ done'); 50 | } catch (error) { 51 | throwedErrors.add(new Error(`${cfg.exportedRepoURL}: ${error}`)); 52 | } 53 | }); 54 | 55 | if (throwedErrors.size > 0) { 56 | throwedErrors.forEach(error => { 57 | logger.error(error.message); 58 | }); 59 | process.exitCode = 1; 60 | } else { 61 | process.exitCode = 0; 62 | } 63 | } 64 | 65 | export function iterateReversedConfigs(callback: ShipitConfig => void): void { 66 | iterateConfigsInPath(path.join(__dirname, '..', 'config', 'reversed'), callback); 67 | } 68 | 69 | export default function iterateConfigs(callback: ShipitConfig => void): void { 70 | iterateConfigsInPath(path.join(__dirname, '..', 'config'), callback); 71 | } 72 | -------------------------------------------------------------------------------- /src/parsePatch.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | import { invariant } from '@adeira/js'; 4 | 5 | opaque type Yield: string = string; 6 | opaque type Return = void; 7 | opaque type Next = void; 8 | 9 | // See: https://github.com/facebook/fbshipit/blob/640eb8640bdf6e024a3a6eff7703f188d8a0d66a/src/shipit/ShipItUtil.php 10 | export default function* parsePatch(patch: string): Generator { 11 | let contents = ''; 12 | let lineNumber = 0; 13 | 14 | let minusLines = 0; 15 | let plusLines = 0; 16 | let seenRangeHeader = false; 17 | 18 | for (const line of patch.split('\n')) { 19 | ++lineNumber; 20 | 21 | if (line.trimRight().match(/^diff --git [ab]\/(?:.*?) [ab]\/(?:.*?)$/)) { 22 | if (contents !== '') { 23 | yield contents; 24 | } 25 | seenRangeHeader = false; 26 | contents = `${line}\n`; 27 | continue; 28 | } 29 | 30 | const matches = line.match( 31 | /^@@ -\d+(?:,(?\d+))? \+\d+(?:,(?\d+))? @@/, 32 | ); 33 | if (matches) { 34 | const rawMinusLines = matches.groups?.minus_lines; 35 | const rawPlusLines = matches.groups?.plus_lines; 36 | minusLines = rawMinusLines == null ? 1 : parseInt(rawMinusLines, 10); 37 | plusLines = rawPlusLines == null ? 1 : parseInt(rawPlusLines, 10); 38 | 39 | contents += `${line}\n`; 40 | seenRangeHeader = true; 41 | continue; 42 | } 43 | 44 | if (seenRangeHeader === false) { 45 | contents += `${line}\n`; 46 | continue; 47 | } 48 | 49 | const leftmost = line.charAt(0); 50 | if (leftmost === '\\') { 51 | contents += `${line}\n`; 52 | // doesn't count as a + or - line whatever happens; if NL at EOF 53 | // changes, there is a + and - for the last line of content 54 | continue; 55 | } 56 | 57 | if (minusLines <= 0 && plusLines <= 0) { 58 | continue; 59 | } 60 | 61 | if (leftmost === '+') { 62 | --plusLines; 63 | } else if (leftmost === '-') { 64 | --minusLines; 65 | } else if (leftmost === ' ' || leftmost === '') { 66 | // context goes from both 67 | --plusLines; 68 | --minusLines; 69 | } else { 70 | invariant(false, "Can't parse hunk line %s: %s", lineNumber, line); 71 | } 72 | contents += `${line}\n`; 73 | } 74 | 75 | if (contents !== '') { 76 | yield contents; 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/parsePatchHeader.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | import Changeset from './Changeset'; 4 | import splitHead from './splitHead'; 5 | 6 | export default function parsePatchHeader(header: string): Changeset { 7 | const [rawEnvelope, rawBody] = splitHead(header, '\n\n'); 8 | const description = rawBody.trim(); 9 | let changeset = new Changeset().withDescription(description); 10 | const envelope = rawEnvelope.replace(/(?:\n\t|\n )/, ' '); 11 | for (const line of envelope.split('\n')) { 12 | if (!line.includes(':')) { 13 | continue; 14 | } 15 | const [key, rawValue] = splitHead(line, ':'); 16 | const value = rawValue.trim(); 17 | switch (key.trim().toLowerCase()) { 18 | case 'from': 19 | changeset = changeset.withAuthor(value); 20 | break; 21 | case 'subject': 22 | changeset = changeset.withSubject(value.replace(/^\[PATCH] /, '')); 23 | break; 24 | case 'date': 25 | changeset = changeset.withTimestamp(value); 26 | break; 27 | } 28 | } 29 | return changeset; 30 | } 31 | -------------------------------------------------------------------------------- /src/phases/createCheckCorruptedRepoPhase.js: -------------------------------------------------------------------------------- 1 | // @flow strict-local 2 | 3 | import { invariant } from '@adeira/js'; 4 | 5 | import RepoGit from '../RepoGit'; 6 | 7 | export default function createCheckCorruptedRepoPhase(repoPath: string) { 8 | return () => { 9 | const repo = new RepoGit(repoPath); 10 | 11 | // We should eventually nuke the repo and clone it again. But we do not 12 | // store the repos in CI yet so it's not necessary. Also, be careful not 13 | // to nuke monorepo in CI. 14 | invariant(repo.isCorrupted() === false, `Repo located in '${repoPath}' is corrupted.`); 15 | }; 16 | } 17 | -------------------------------------------------------------------------------- /src/phases/createCleanPhase.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import RepoGit from '../RepoGit'; 4 | 5 | export default function createCleanPhase(repoPath: string) { 6 | return function() { 7 | const repo = new RepoGit(repoPath); 8 | repo.clean(); 9 | }; 10 | } 11 | -------------------------------------------------------------------------------- /src/phases/createClonePhase.js: -------------------------------------------------------------------------------- 1 | // @flow strict-local 2 | 3 | import path from 'path'; 4 | import { ShellCommand } from '@kiwicom/monorepo-utils'; 5 | 6 | import RepoGit from '../RepoGit'; 7 | 8 | export default function createClonePhase(exportedRepoURL: string, exportedRepoPath: string) { 9 | return function() { 10 | // from destination path '/x/y/universe' to: 11 | const dirname = path.dirname(exportedRepoPath); // '/x/y' 12 | const basename = path.basename(exportedRepoPath); // 'universe' 13 | 14 | // TODO: make it Git agnostic 15 | 16 | new ShellCommand(dirname, 'git', 'clone', exportedRepoURL, basename) 17 | .setOutputToScreen() 18 | .runSynchronously(); 19 | 20 | const exportedRepo = new RepoGit(exportedRepoPath); 21 | exportedRepo.configure(); 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /src/phases/createImportReverseSyncPhase.js: -------------------------------------------------------------------------------- 1 | // @flow strict-local 2 | 3 | import { ShellCommand } from '@kiwicom/monorepo-utils'; 4 | 5 | import accounts from '../accounts'; 6 | import RepoGit, { type DestinationRepo } from '../RepoGit'; 7 | import Changeset from '../Changeset'; 8 | import ShipitConfig from '../ShipitConfig'; 9 | 10 | export default function createImportReverseSyncPhase(config: ShipitConfig) { 11 | function getFilteredChangeset(): Changeset { 12 | const destRepo = new RepoGit(config.destinationPath); 13 | const emptyTreeHash = destRepo.getEmptyTreeHash(); 14 | 15 | const patch = new ShellCommand( 16 | config.destinationPath, 17 | 'git', 18 | 'diff', 19 | '--binary', 20 | emptyTreeHash, 21 | 'HEAD', 22 | ) 23 | .runSynchronously() 24 | .getStdout() 25 | .trim(); 26 | 27 | const author = 'kiwicom-github-bot'; 28 | const authorEmail = accounts.get(author) ?? ''; 29 | const changeset = destRepo 30 | .getChangesetFromExportedPatch(``, patch) 31 | .withSubject(`Auto-import ${config.exportedRepoURL}`) 32 | .withAuthor(`${author} <${authorEmail}>`) 33 | .withTimestamp(new Date().toUTCString()); 34 | 35 | const filter = config.getDefaultImportitFilter(); 36 | return filter(changeset); 37 | } 38 | 39 | return function() { 40 | const monorepo: DestinationRepo = new RepoGit(config.sourcePath); 41 | const changeset = getFilteredChangeset(); 42 | 43 | monorepo.checkoutBranch('monorepo-importit-github'); 44 | 45 | if (changeset.isValid()) { 46 | monorepo.commitPatch(changeset); 47 | } 48 | }; 49 | } 50 | -------------------------------------------------------------------------------- /src/phases/createImportSyncPhase.js: -------------------------------------------------------------------------------- 1 | // @flow strict-local 2 | 3 | import { ShellCommand } from '@kiwicom/monorepo-utils'; 4 | import logger from '@adeira/logger'; 5 | 6 | import RepoGit, { type SourceRepo, type DestinationRepo } from '../RepoGit'; 7 | import Changeset from '../Changeset'; 8 | import ShipitConfig from '../ShipitConfig'; 9 | 10 | export default function createImportSyncPhase( 11 | config: ShipitConfig, 12 | packageName: string, 13 | pullRequestNumber: string, 14 | ) { 15 | // TODO: make it Git independent 16 | 17 | function getFilteredChangesets(): Set { 18 | new ShellCommand( 19 | config.destinationPath, 20 | 'git', 21 | 'fetch', 22 | 'origin', 23 | `refs/pull/${pullRequestNumber}/head`, 24 | ) 25 | .setOutputToScreen() 26 | .runSynchronously(); 27 | 28 | // 'git rev-parse FETCH_HEAD' to get actual hash 29 | const mergeBase = new ShellCommand( 30 | config.destinationPath, 31 | 'git', 32 | 'merge-base', 33 | 'FETCH_HEAD', 34 | config.getDestinationBranch(), 35 | ) 36 | .runSynchronously() 37 | .getStdout() 38 | .trim(); 39 | 40 | const changesets = new Set(); 41 | const exportedRepo: SourceRepo = new RepoGit(config.destinationPath); 42 | const descendantsPath = exportedRepo.findDescendantsPath(mergeBase, 'FETCH_HEAD', new Set([])); 43 | if (descendantsPath !== null) { 44 | descendantsPath.forEach(revision => { 45 | const changeset = exportedRepo.getChangesetFromID(revision); 46 | const filter = config.getDefaultImportitFilter(); 47 | changesets.add(filter(changeset)); 48 | }); 49 | } else { 50 | logger.warn(`Skipping since there are no changes to filter from ${mergeBase}.`); 51 | } 52 | return changesets; 53 | } 54 | 55 | return function() { 56 | const monorepo: DestinationRepo = new RepoGit(config.sourcePath); 57 | const changesets = getFilteredChangesets(); 58 | 59 | const branchName = `shipit-import-github-${packageName}-pr-${pullRequestNumber}`; 60 | monorepo.checkoutBranch(branchName); 61 | 62 | changesets.forEach(changeset => { 63 | if (changeset.isValid()) { 64 | monorepo.commitPatch(changeset); 65 | } 66 | }); 67 | }; 68 | } 69 | -------------------------------------------------------------------------------- /src/phases/createPushPhase.js: -------------------------------------------------------------------------------- 1 | // @flow strict-local 2 | 3 | import RepoGit from '../RepoGit'; 4 | import ShipitConfig from '../ShipitConfig'; 5 | 6 | export default function createPushPhase(config: ShipitConfig) { 7 | return function() { 8 | const repo = new RepoGit(config.destinationPath); 9 | repo.push(config.getDestinationBranch()); 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /src/phases/createSyncPhase.js: -------------------------------------------------------------------------------- 1 | // @flow strict-local 2 | 3 | import logger from '@adeira/logger'; 4 | 5 | import RepoGit, { type SourceRepo, type DestinationRepo } from '../RepoGit'; 6 | import Changeset from '../Changeset'; 7 | import ShipitConfig from '../ShipitConfig'; 8 | 9 | export default function createSyncPhase(config: ShipitConfig) { 10 | function _getSourceRepo(): SourceRepo { 11 | return new RepoGit(config.sourcePath); 12 | } 13 | 14 | function _getDestinationRepo(): DestinationRepo { 15 | return new RepoGit(config.destinationPath); 16 | } 17 | 18 | function getSourceChangesets(): Set { 19 | const destinationRepo = _getDestinationRepo(); 20 | const sourceRepo = _getSourceRepo(); 21 | let initialRevision = destinationRepo.findLastSourceCommit(config.getDestinationRoots()); 22 | if (initialRevision === null) { 23 | // Seems like it's a new repo so there is no signed commit. 24 | // Let's take the first one from our source repo instead. 25 | initialRevision = sourceRepo.findFirstAvailableCommit(); 26 | } 27 | const sourceChangesets = new Set(); 28 | const descendantsPath = sourceRepo.findDescendantsPath( 29 | initialRevision, 30 | config.getSourceBranch(), 31 | config.getSourceRoots(), 32 | ); 33 | if (descendantsPath !== null) { 34 | descendantsPath.forEach(revision => { 35 | sourceChangesets.add(sourceRepo.getChangesetFromID(revision)); 36 | }); 37 | } else { 38 | logger.warn(`Skipping since there are no changes to filter from ${initialRevision}.`); 39 | } 40 | return sourceChangesets; 41 | } 42 | 43 | function getFilteredChangesets(): Set { 44 | const filteredChangesets = new Set(); 45 | getSourceChangesets().forEach(changeset => { 46 | const filter = config.getDefaultShipitFilter(); 47 | filteredChangesets.add(filter(changeset)); 48 | }); 49 | return filteredChangesets; 50 | } 51 | 52 | return function() { 53 | const destinationRepo = _getDestinationRepo(); 54 | const changesets = getFilteredChangesets(); 55 | 56 | destinationRepo.checkoutBranch(config.getDestinationBranch()); 57 | 58 | changesets.forEach(changeset => { 59 | if (changeset.isValid()) { 60 | destinationRepo.commitPatch(changeset); 61 | } 62 | }); 63 | }; 64 | } 65 | -------------------------------------------------------------------------------- /src/phases/createVerifyRepoPhase.js: -------------------------------------------------------------------------------- 1 | // @flow strict-local 2 | 3 | import fs from 'fs'; 4 | import os from 'os'; 5 | import path from 'path'; 6 | import { ShellCommand } from '@kiwicom/monorepo-utils'; 7 | import logger from '@adeira/logger'; 8 | 9 | import RepoGit from '../RepoGit'; 10 | import ShipitConfig from '../ShipitConfig'; 11 | 12 | /** 13 | * This phase verifies integrity of the exported repository. This does so by 14 | * following these steps: 15 | * 16 | * 1) It exports every project from monorepo and filters it. This way we'll get 17 | * fresh state of the project. 18 | * 2) It adds exported remote and compares these two repositories. 19 | * 20 | * There should not be any differences if everything goes well. Otherwise it 21 | * means that either source and destination are out of sync or there is a bug 22 | * in Shipit project. 23 | */ 24 | export default function createVerifyRepoPhase(config: ShipitConfig) { 25 | function createNewEmptyRepo(path: string) { 26 | new ShellCommand(path, 'git', 'init').setOutputToScreen().runSynchronously(); 27 | const repo = new RepoGit(path); 28 | repo.configure(); 29 | return repo; 30 | } 31 | 32 | function getDirtyExportedRepoPath() { 33 | return fs.mkdtempSync(path.join(os.tmpdir(), 'kiwicom-shipit-verify-dirty-')); 34 | } 35 | 36 | function getFilteredExportedRepoPath() { 37 | return fs.mkdtempSync(path.join(os.tmpdir(), 'kiwicom-shipit-verify-filtered-')); 38 | } 39 | 40 | const monorepoPath = config.sourcePath; 41 | 42 | return function() { 43 | const dirtyExportedRepoPath = getDirtyExportedRepoPath(); 44 | const dirtyExportedRepo = createNewEmptyRepo(dirtyExportedRepoPath); 45 | 46 | const monorepo = new RepoGit(monorepoPath); 47 | monorepo.export(dirtyExportedRepoPath, config.getSourceRoots()); 48 | 49 | new ShellCommand(dirtyExportedRepoPath, 'git', 'add', '.', '--force') 50 | .setOutputToScreen() 51 | .runSynchronously(); 52 | 53 | new ShellCommand( 54 | dirtyExportedRepoPath, 55 | 'git', 56 | 'commit', 57 | '-m', 58 | 'Initial filtered commit', 59 | ).runSynchronously(); 60 | 61 | const dirtyChangeset = dirtyExportedRepo.getChangesetFromID('HEAD'); 62 | const filter = config.getDefaultShipitFilter(); 63 | const filteredChangeset = filter(dirtyChangeset).withSubject('Initial filtered commit'); 64 | 65 | const filteredRepoPath = getFilteredExportedRepoPath(); 66 | const filteredRepo = createNewEmptyRepo(filteredRepoPath); 67 | filteredRepo.commitPatch(filteredChangeset); 68 | 69 | new ShellCommand( 70 | filteredRepoPath, 71 | 'git', 72 | 'remote', 73 | 'add', 74 | 'shipit_destination', 75 | config.destinationPath, // notice we don't use URL here but locally updated repo instead 76 | ) 77 | .setOutputToScreen() 78 | .runSynchronously(); 79 | 80 | new ShellCommand(filteredRepoPath, 'git', 'fetch', 'shipit_destination') 81 | .setOutputToScreen() 82 | .runSynchronously(); 83 | 84 | const diffStats = new ShellCommand( 85 | filteredRepoPath, 86 | 'git', 87 | '--no-pager', 88 | 'diff', 89 | '--stat', 90 | 'HEAD', 91 | 'shipit_destination/master', 92 | ) 93 | .runSynchronously() 94 | .getStdout() 95 | .trim(); 96 | 97 | if (diffStats === '') { 98 | logger.log('✅ Exported repo is in SYNC!'); 99 | } else { 100 | const diff = new ShellCommand( 101 | filteredRepoPath, 102 | 'git', 103 | 'diff', 104 | '--full-index', 105 | '--binary', 106 | '--no-color', 107 | 'shipit_destination/master', 108 | 'HEAD', 109 | ) 110 | .runSynchronously() 111 | .getStdout(); 112 | logger.error(diff); 113 | throw new Error('👾 Repository is out of SYNC!'); 114 | } 115 | }; 116 | } 117 | -------------------------------------------------------------------------------- /src/requireAndValidateConfig.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { invariant } from '@adeira/js'; 4 | import levenshtein from 'fast-levenshtein'; 5 | 6 | function suggest(name: string, alternativeNames: Array): string { 7 | return alternativeNames.sort((firstEl, secondEl) => { 8 | const firstScore = levenshtein.get(name, firstEl); 9 | const secondScore = levenshtein.get(name, secondEl); 10 | return firstScore - secondScore; 11 | })[0]; 12 | } 13 | 14 | function validateObjectKeys( 15 | object: { [string]: mixed, ... }, 16 | allowedFields: Map, 17 | ): void | empty { 18 | for (const key of Object.keys(object)) { 19 | invariant( 20 | allowedFields.has(key), 21 | "Your config contains field '%s' but this is not allowed key. Did you mean '%s' instead?", 22 | key, 23 | suggest(key, [...allowedFields.keys()]), 24 | ); 25 | } 26 | 27 | for (const [fieldName, isRequired] of allowedFields) { 28 | if (isRequired) { 29 | invariant( 30 | object[fieldName] !== undefined, 31 | "Configuration field '%s' is required but it's missing.", 32 | fieldName, 33 | ); 34 | } 35 | } 36 | } 37 | 38 | export default function requireAndValidateConfig(configFile: string) { 39 | // $FlowAllowDynamicImport 40 | const config = require(configFile); 41 | const allowedFields = new Map([ 42 | // filed name => is required 43 | ['getBranchConfig', false], 44 | ['getStaticConfig', true], 45 | ['getPathMappings', true], 46 | ['getStrippedFiles', false], 47 | ]); 48 | 49 | // TODO: consider Ajv but with good error messages! 50 | validateObjectKeys(config, allowedFields); 51 | 52 | if (config.getBranchConfig) { 53 | validateObjectKeys( 54 | config.getBranchConfig(), 55 | new Map([ 56 | ['source', true], 57 | ['destination', true], 58 | ]), 59 | ); 60 | } 61 | 62 | if (config.getStaticConfig) { 63 | validateObjectKeys(config.getStaticConfig(), new Map([['repository', true]])); 64 | } 65 | 66 | return config; 67 | } 68 | -------------------------------------------------------------------------------- /src/splitHead.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | export default function splitHead(subject: string, separator: string): [string, string] { 4 | const pos = subject.indexOf(separator); 5 | const head = subject.substr(0, pos); 6 | const tail = subject.substr(pos + 1); 7 | return [head, tail]; 8 | } 9 | -------------------------------------------------------------------------------- /src/utils/createFakeChangeset.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | import Changeset from '../Changeset'; 4 | 5 | /** 6 | * This changeset contains valid but fake data and should be used only in tests. 7 | */ 8 | export default function createFakeChangeset(numberOfDiffs: number = 2, basePath: string = '') { 9 | const diffs = new Set(); 10 | 11 | for (let i = 1; i <= numberOfDiffs; i++) { 12 | const filename = `${basePath}fakeFile_${i}.txt`; 13 | diffs.add({ 14 | path: filename, 15 | body: 16 | 'new file mode 100644\n' + 17 | 'index 0000000000000000000000000000000000000000..72943a16fb2c8f38f9dde202b7a70ccc19c52f34\n' + 18 | '--- /dev/null\n' + 19 | `+++ b/${filename}\n` + 20 | '@@ -0,0 +1 @@\n' + 21 | `+fake content ${i}`, 22 | }); 23 | } 24 | 25 | return new Changeset() 26 | .withSubject('Test subject') 27 | .withDescription('Test description') 28 | .withAuthor('John Doe ') 29 | .withTimestamp('Mon, 16 July 2019 10:55:04 -0100') 30 | .withDiffs(diffs); 31 | } 32 | --------------------------------------------------------------------------------