├── .npmignore ├── types.flow.js ├── config ├── __mocks__ │ └── fs.js ├── js.js ├── icons.js ├── relay.js ├── monorepo-shipit.js ├── fixtures-tester.js ├── monorepo-utils.js ├── babel-preset-adeira.js ├── eslint-config-adeira.js ├── eslint-fixtures-tester.js ├── monorepo-npm-publisher.js ├── __tests__ │ ├── fixtures-tester.test.js │ ├── eslint-fixtures-tester.test.js │ ├── monorepo-npm-publisher.test.js │ ├── monorepo-utils.test.js │ ├── js.test.js │ ├── eslint-config-adeira.test.js │ ├── babel-preset-adeira.test.js │ ├── relay.test.js │ ├── monorepo-shipit.test.js │ ├── icons.test.js │ ├── no-missing-tests.test.js │ └── testExportedPaths.js └── README.md ├── src ├── filters │ ├── stripDescriptions.js │ ├── __tests__ │ │ ├── fixtures │ │ │ ├── enable-disable.patch │ │ │ ├── comment-lines-no-comment-end.patch │ │ │ ├── comment-lines-comment-end.patch │ │ │ ├── double-comment.patch │ │ │ └── new-file-complex.patch │ │ ├── _esc.test.js │ │ ├── __snapshots__ │ │ │ ├── stripPaths.test.js.snap │ │ │ ├── moveDirectories.test.js.snap │ │ │ └── conditionalLines.test.js.snap │ │ ├── stripExceptDirectories.test.js │ │ ├── stripDescriptions.test.js │ │ ├── stripPaths.test.js │ │ ├── moveDirectoriesReverse.test.js │ │ ├── conditionalLines.test.js │ │ ├── moveDirectories.test.js │ │ └── addTrackingData.test.js │ ├── _esc.js │ ├── addTrackingData.js │ ├── moveDirectoriesReverse.js │ ├── stripExceptDirectories.js │ ├── stripPaths.js │ ├── moveDirectories.js │ └── conditionalLines.js ├── __tests__ │ ├── fixtures │ │ ├── diffs │ │ │ ├── chmod.patch │ │ │ ├── file-new.patch │ │ │ ├── file-delete.patch │ │ │ ├── nasty.patch │ │ │ ├── file-modify-no-eol.patch │ │ │ ├── lfs-removal.patch │ │ │ ├── files-modify.patch │ │ │ ├── file-rename.patch │ │ │ ├── unicode.patch │ │ │ └── diff-in-diff.patch │ │ ├── headers │ │ │ ├── unicode.header │ │ │ ├── simple.header │ │ │ ├── multiline-subject.header │ │ │ ├── co-authored-by.header │ │ │ └── co-authored-by-multiple.header │ │ └── configs │ │ │ ├── invalid-missing-props.js │ │ │ ├── valid-minimal.js │ │ │ ├── invalid-additional-props-2.js │ │ │ ├── invalid-additional-props-1.js │ │ │ ├── valid-branches.js │ │ │ ├── invalid-misconfigured-branches.js │ │ │ └── valid-exhaustive.js │ ├── parsePatchHeader.test.js │ ├── parsePatch.test.js │ ├── __snapshots__ │ │ ├── RepoGit.renderPatch.test.js.snap │ │ ├── ShipitConfig.test.js.snap │ │ ├── parsePatchHeader.test.js.snap │ │ ├── Integration.test.js.snap │ │ └── parsePatch.test.js.snap │ ├── RepoGit.renderPatch.test.js │ ├── RepoGit.commitPatch.test.js │ ├── Integration.test.js │ ├── RepoGit.findFirstAvailableCommit.test.js │ ├── Changeset.test.js │ ├── ShipitConfig.test.js │ ├── requireAndValidateConfig.test.js │ └── RepoGit.findLastSourceCommit.test.js ├── splitHead.js ├── phases │ ├── createCleanPhase.js │ ├── createPushPhase.js │ ├── createCheckCorruptedRepoPhase.js │ ├── createClonePhase.js │ ├── createImportSyncPhase.js │ ├── createSyncPhase.js │ └── createVerifyRepoPhase.js ├── utils │ ├── createMockDiff.js │ └── createMockChangeset.js ├── parsePatchHeader.js ├── RepoGitFake.js ├── requireAndValidateConfig.js ├── parsePatch.js ├── iterateConfigs.js ├── Changeset.js ├── ShipitConfig.js └── RepoGit.js ├── .eslintrc.js ├── ConfigType.flow.js ├── bin ├── create-patch.js ├── shipit.js └── importit.js ├── package.json ├── LICENSE └── README.md /.npmignore: -------------------------------------------------------------------------------- 1 | __flowtests__ 2 | __tests__ 3 | -------------------------------------------------------------------------------- /types.flow.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | export type Phase = { 4 | (): void, 5 | +readableName: string, 6 | ... 7 | }; 8 | -------------------------------------------------------------------------------- /config/__mocks__/fs.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | const fs: any = jest.createMockFromModule('fs'); 4 | 5 | fs.existsSync = (path) => path !== '/unknown_path'; 6 | 7 | export default fs; 8 | -------------------------------------------------------------------------------- /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/__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 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | /* eslint-disable no-unused-vars */ 4 | const OFF = 0; 5 | const WARN = 1; 6 | const ERROR = 2; 7 | /* eslint-enable no-unused-vars */ 8 | 9 | module.exports = { 10 | rules: { 11 | 'no-console': OFF, 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /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/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/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/__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/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/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 '@adeira/relay' (0.1.0) 5 | 6 | Formerly known as '@mrtnzlml/relay' 7 | 8 | -------------------------------------------------------------------------------- /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__/parsePatchHeader.test.js: -------------------------------------------------------------------------------- 1 | // @flow strict-local 2 | 3 | import path from 'path'; 4 | import { generateTestsFromFixtures } from '@adeira/fixtures-tester'; 5 | 6 | import parsePatchHeader from '../parsePatchHeader'; 7 | 8 | generateTestsFromFixtures(path.join(__dirname, 'fixtures', 'headers'), parsePatchHeader); 9 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /config/js.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | import type { ConfigType } from '../ConfigType.flow'; 4 | 5 | module.exports = ({ 6 | getStaticConfig() { 7 | return { 8 | repository: 'git@github.com:adeira/js.git', 9 | }; 10 | }, 11 | getPathMappings() { 12 | return new Map([['src/js/', '']]); 13 | }, 14 | }: ConfigType); 15 | -------------------------------------------------------------------------------- /config/icons.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | import type { ConfigType } from '../ConfigType.flow'; 4 | 5 | module.exports = ({ 6 | getStaticConfig() { 7 | return { 8 | repository: 'git@github.com:adeira/icons.git', 9 | }; 10 | }, 11 | getPathMappings() { 12 | return new Map([['src/icons/', '']]); 13 | }, 14 | }: ConfigType); 15 | -------------------------------------------------------------------------------- /config/relay.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | import type { ConfigType } from '../ConfigType.flow'; 4 | 5 | module.exports = ({ 6 | getStaticConfig() { 7 | return { 8 | repository: 'git@github.com:adeira/relay.git', 9 | }; 10 | }, 11 | getPathMappings() { 12 | return new Map([['src/relay/', '']]); 13 | }, 14 | }: ConfigType); 15 | -------------------------------------------------------------------------------- /src/__tests__/fixtures/headers/co-authored-by.header: -------------------------------------------------------------------------------- 1 | From 160feaf6d2637b22216987d1f95b96d585a838a6 Mon Sep 17 00:00:00 2001 2 | From: =?UTF-8?q?Martin=20Zl=C3=A1mal?= 3 | Date: Thu, 15 Apr 2021 13:18:49 -0500 4 | Subject: [PATCH] SX Design: add Norwegian language 5 | 6 | Co-authored-by: Trond Bergquist 7 | 8 | -------------------------------------------------------------------------------- /src/__tests__/fixtures/configs/invalid-missing-props.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | import type { ConfigType } from '../../../../ConfigType.flow'; 4 | 5 | // $FlowExpectedError[prop-missing] 6 | module.exports = ({ 7 | // getStaticConfig missing 8 | getPathMappings() { 9 | return new Map([['src/apps/example-relay/', '']]); 10 | }, 11 | }: ConfigType); 12 | -------------------------------------------------------------------------------- /config/monorepo-shipit.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | import type { ConfigType } from '../ConfigType.flow'; 4 | 5 | module.exports = ({ 6 | getStaticConfig() { 7 | return { 8 | repository: 'git@github.com:adeira/shipit.git', 9 | }; 10 | }, 11 | getPathMappings() { 12 | return new Map([['src/monorepo-shipit/', '']]); 13 | }, 14 | }: ConfigType); 15 | -------------------------------------------------------------------------------- /config/fixtures-tester.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | import type { ConfigType } from '../ConfigType.flow'; 4 | 5 | module.exports = ({ 6 | getStaticConfig() { 7 | return { 8 | repository: 'git@github.com:adeira/fixtures-tester.git', 9 | }; 10 | }, 11 | getPathMappings() { 12 | return new Map([['src/fixtures-tester/', '']]); 13 | }, 14 | }: ConfigType); 15 | -------------------------------------------------------------------------------- /config/monorepo-utils.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | import type { ConfigType } from '../ConfigType.flow'; 4 | 5 | module.exports = ({ 6 | getStaticConfig() { 7 | return { 8 | repository: 'git@github.com:adeira/monorepo-utils.git', 9 | }; 10 | }, 11 | getPathMappings() { 12 | return new Map([['src/monorepo-utils/', '']]); 13 | }, 14 | }: ConfigType); 15 | -------------------------------------------------------------------------------- /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/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 | -------------------------------------------------------------------------------- /config/babel-preset-adeira.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | import type { ConfigType } from '../ConfigType.flow'; 4 | 5 | module.exports = ({ 6 | getStaticConfig() { 7 | return { 8 | repository: 'git@github.com:adeira/babel-preset-adeira.git', 9 | }; 10 | }, 11 | getPathMappings() { 12 | return new Map([['src/babel-preset-adeira/', '']]); 13 | }, 14 | }: ConfigType); 15 | -------------------------------------------------------------------------------- /config/eslint-config-adeira.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | import type { ConfigType } from '../ConfigType.flow'; 4 | 5 | module.exports = ({ 6 | getStaticConfig() { 7 | return { 8 | repository: 'git@github.com:adeira/eslint-config-adeira.git', 9 | }; 10 | }, 11 | getPathMappings() { 12 | return new Map([['src/eslint-config-adeira/', '']]); 13 | }, 14 | }: ConfigType); 15 | -------------------------------------------------------------------------------- /config/eslint-fixtures-tester.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | import type { ConfigType } from '../ConfigType.flow'; 4 | 5 | module.exports = ({ 6 | getStaticConfig() { 7 | return { 8 | repository: 'git@github.com:adeira/eslint-fixtures-tester.git', 9 | }; 10 | }, 11 | getPathMappings() { 12 | return new Map([['src/eslint-fixtures-tester/', '']]); 13 | }, 14 | }: ConfigType); 15 | -------------------------------------------------------------------------------- /config/monorepo-npm-publisher.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | import type { ConfigType } from '../ConfigType.flow'; 4 | 5 | module.exports = ({ 6 | getStaticConfig() { 7 | return { 8 | repository: 'git@github.com:adeira/monorepo-npm-publisher.git', 9 | }; 10 | }, 11 | getPathMappings() { 12 | return new Map([['src/monorepo-npm-publisher/', '']]); 13 | }, 14 | }: ConfigType); 15 | -------------------------------------------------------------------------------- /src/phases/createCleanPhase.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import RepoGit from '../RepoGit'; 4 | import type { Phase } from '../../types.flow'; 5 | 6 | export default function createCleanPhase(repoPath: string): Phase { 7 | const phase = function () { 8 | const repo = new RepoGit(repoPath); 9 | repo.clean(); 10 | }; 11 | 12 | phase.readableName = 'Clean repository'; 13 | return phase; 14 | } 15 | -------------------------------------------------------------------------------- /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/__tests__/fixtures/headers/co-authored-by-multiple.header: -------------------------------------------------------------------------------- 1 | From 160feaf6d2637b22216987d1f95b96d585a838a6 Mon Sep 17 00:00:00 2001 2 | From: =?UTF-8?q?Martin=20Zl=C3=A1mal?= 3 | Date: Thu, 15 Apr 2021 13:18:49 -0500 4 | Subject: [PATCH] SX Design: add Norwegian language 5 | 6 | Co-authored-by: Trond Bergquist 7 | Co-authored-by: Patricia Bergquist 8 | 9 | -------------------------------------------------------------------------------- /src/filters/__tests__/_esc.test.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | import _esc from '../_esc'; 4 | 5 | const ESCAPED = '\\^\\$\\.\\*\\+\\?\\(\\)\\[\\]\\{\\}\\|\\\\'; 6 | const UNESCAPED = '^$.*+?()[]{}|\\'; 7 | 8 | it('should escape values', function () { 9 | expect(_esc(UNESCAPED + UNESCAPED)).toStrictEqual(ESCAPED + ESCAPED); 10 | }); 11 | 12 | it('should handle strings with nothing to escape', function () { 13 | expect(_esc('abc')).toBe('abc'); 14 | }); 15 | -------------------------------------------------------------------------------- /src/filters/_esc.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | /** 4 | * Escapes the `RegExp` special characters "^", "$", "\", ".", "*", "+", 5 | * "?", "(", ")", "[", "]", "{", "}", and "|" in `string`. 6 | * 7 | * @see https://github.com/lodash/lodash/blob/e0029485ab4d97adea0cb34292afb6700309cf16/escapeRegExp.js 8 | */ 9 | export default function _esc(s: string): string { 10 | // RegExp.escape substitute 11 | return s.replace(/[\\^$.*+?()[\]{}|]/g, '\\$&'); 12 | } 13 | -------------------------------------------------------------------------------- /ConfigType.flow.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | import Changeset from './src/Changeset'; 4 | 5 | export type ConfigType = { 6 | +customShipitFilter?: (Changeset) => Changeset, 7 | +customImportitFilter?: (Changeset) => Changeset, 8 | +getStaticConfig: () => { 9 | +repository: string, 10 | }, 11 | +getPathMappings: () => Map, 12 | +getStrippedFiles?: () => Set, 13 | +getBranchConfig?: () => { 14 | +source: string, 15 | +destination: string, 16 | }, 17 | }; 18 | -------------------------------------------------------------------------------- /src/phases/createPushPhase.js: -------------------------------------------------------------------------------- 1 | // @flow strict-local 2 | 3 | import RepoGit from '../RepoGit'; 4 | import ShipitConfig from '../ShipitConfig'; 5 | import type { Phase } from '../../types.flow'; 6 | 7 | export default function createPushPhase(config: ShipitConfig): Phase { 8 | const phase = function () { 9 | const repo = new RepoGit(config.destinationPath); 10 | repo.push(config.getDestinationBranch()); 11 | }; 12 | 13 | phase.readableName = 'Push new changes'; 14 | return phase; 15 | } 16 | -------------------------------------------------------------------------------- /src/__tests__/fixtures/diffs/lfs-removal.patch: -------------------------------------------------------------------------------- 1 | diff --git a/.yarn/cache/@adeira-js-0.9.0.tgz b/.yarn/cache/@adeira-js-0.9.0.tgz 2 | deleted file mode 100644 3 | index f2572f9c9d2f2ab4f2a61695a09ee50430f8ccbe..0000000000000000000000000000000000000000 4 | --- a/.yarn/cache/@adeira-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/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/utils/createMockDiff.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | type Diff = { 4 | +path: string, 5 | +body: string, 6 | }; 7 | 8 | export default function createMockDiff(filename: string): Diff { 9 | return { 10 | path: filename, 11 | body: 12 | 'new file mode 100644\n' + 13 | 'index 0000000000000000000000000000000000000000..1111111111111111111111111111111111111111\n' + 14 | '--- /dev/null\n' + 15 | `+++ b/${filename}\n` + 16 | '@@ -0,0 +1 @@\n' + 17 | `+fake content`, 18 | }; 19 | } 20 | -------------------------------------------------------------------------------- /config/__tests__/fixtures-tester.test.js: -------------------------------------------------------------------------------- 1 | // @flow strict-local 2 | 3 | import path from 'path'; 4 | 5 | import testExportedPaths from './testExportedPaths'; 6 | 7 | testExportedPaths(path.join(__dirname, '..', 'fixtures-tester.js'), [ 8 | ['src/fixtures-tester/src/index.js', 'src/index.js'], 9 | ['src/fixtures-tester/package.json', 'package.json'], 10 | 11 | // invalid cases: 12 | ['src/packages/monorepo/outsideScope.js', undefined], // correctly deleted 13 | ['package.json', undefined], // correctly deleted 14 | ]); 15 | -------------------------------------------------------------------------------- /config/__tests__/eslint-fixtures-tester.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-fixtures-tester.js'), [ 8 | ['src/eslint-fixtures-tester/src/index.js', 'src/index.js'], 9 | ['src/eslint-fixtures-tester/package.json', 'package.json'], 10 | 11 | // invalid cases: 12 | ['src/packages/monorepo/outsideScope.js', undefined], // correctly deleted 13 | ['package.json', undefined], // correctly deleted 14 | ]); 15 | -------------------------------------------------------------------------------- /config/__tests__/monorepo-npm-publisher.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-npm-publisher.js'), [ 8 | ['src/monorepo-npm-publisher/package.json', 'package.json'], 9 | ['src/monorepo-npm-publisher/src/index.js', 'src/index.js'], 10 | 11 | // invalid cases: 12 | ['src/packages/monorepo/outsideScope.js', undefined], // correctly deleted 13 | ['package.json', undefined], // correctly deleted 14 | ]); 15 | -------------------------------------------------------------------------------- /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/configs/valid-minimal.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | import path from 'path'; 4 | 5 | import type { ConfigType } from '../../../../ConfigType.flow'; 6 | 7 | module.exports = ({ 8 | getStaticConfig() { 9 | return { 10 | repository: 'git@github.com/adeira/relay-example.git', 11 | }; 12 | }, 13 | getPathMappings() { 14 | const ossRoot = 'src/apps/example-relay/'; 15 | return new Map([ 16 | [path.join(ossRoot, '__github__', '.flowconfig'), '.flowconfig'], 17 | [ossRoot, ''], 18 | ]); 19 | }, 20 | }: ConfigType); 21 | -------------------------------------------------------------------------------- /src/__tests__/fixtures/configs/invalid-additional-props-2.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | import type { ConfigType } from '../../../../ConfigType.flow'; 4 | 5 | // $FlowExpectedError[prop-missing] 6 | module.exports = ({ 7 | getStrippedFiles() { 8 | return new Set([/__github__/]); 9 | }, 10 | getStaticConfig() { 11 | return { 12 | repository: 'git@github.com/adeira/relay-example.git', 13 | }; 14 | }, 15 | defaultPathMappings() { 16 | // this configuration is not supported and should be removed (should be 'getPathMappings') 17 | }, 18 | }: ConfigType); 19 | -------------------------------------------------------------------------------- /src/__tests__/fixtures/configs/invalid-additional-props-1.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | import type { ConfigType } from '../../../../ConfigType.flow'; 4 | 5 | // $FlowExpectedError[prop-missing] 6 | module.exports = ({ 7 | defaultStrippedFiles() { 8 | // this configuration is not supported and should be removed (should be 'getStrippedFiles') 9 | }, 10 | getStaticConfig() { 11 | return { 12 | repository: 'git@github.com/adeira/relay-example.git', 13 | }; 14 | }, 15 | getPathMappings() { 16 | return new Map([['src/apps/example-relay/', '']]); 17 | }, 18 | }: ConfigType); 19 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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/monorepo-utils/src/__tests__/glob.test.js', 'src/__tests__/glob.test.js'], 9 | ['src/monorepo-utils/src/index.js', 'src/index.js'], 10 | ['src/monorepo-utils/package.json', 'package.json'], 11 | 12 | // invalid cases: 13 | ['src/packages/monorepo/outsideScope.js', undefined], // correctly deleted 14 | ['package.json', undefined], // correctly deleted 15 | ]); 16 | -------------------------------------------------------------------------------- /config/__tests__/js.test.js: -------------------------------------------------------------------------------- 1 | // @flow strict-local 2 | 3 | import path from 'path'; 4 | 5 | import testExportedPaths from './testExportedPaths'; 6 | 7 | testExportedPaths(path.join(__dirname, '..', 'js.js'), [ 8 | ['src/js/src/__tests__/invariant.test.js', 'src/__tests__/invariant.test.js'], 9 | ['src/js/src/invariant.js', 'src/invariant.js'], 10 | ['src/js/src/index.js', 'src/index.js'], 11 | ['src/js/package.json', 'package.json'], 12 | 13 | // invalid cases: 14 | ['src/packages/monorepo/outsideScope.js', undefined], // correctly deleted 15 | ['package.json', undefined], // correctly deleted 16 | ]); 17 | -------------------------------------------------------------------------------- /config/__tests__/eslint-config-adeira.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-adeira.js'), [ 8 | ['src/eslint-config-adeira/package.json', 'package.json'], 9 | ['src/eslint-config-adeira/ourRules.js', 'ourRules.js'], 10 | ['src/eslint-config-adeira/__tests__/ourRules.test.js', '__tests__/ourRules.test.js'], 11 | 12 | // invalid cases: 13 | ['src/packages/monorepo/outsideScope.js', undefined], // correctly deleted 14 | ['package.json', undefined], // correctly deleted 15 | ]); 16 | -------------------------------------------------------------------------------- /config/__tests__/babel-preset-adeira.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-adeira.js'), [ 8 | ['src/babel-preset-adeira/src/__tests__/configs.test.js', 'src/__tests__/configs.test.js'], 9 | ['src/babel-preset-adeira/src/index.js', 'src/index.js'], 10 | ['src/babel-preset-adeira/package.json', 'package.json'], 11 | 12 | // invalid cases: 13 | ['src/packages/monorepo/outsideScope.js', undefined], // correctly deleted 14 | ['package.json', undefined], // correctly deleted 15 | ]); 16 | -------------------------------------------------------------------------------- /config/__tests__/relay.test.js: -------------------------------------------------------------------------------- 1 | // @flow strict-local 2 | 3 | import path from 'path'; 4 | 5 | import testExportedPaths from './testExportedPaths'; 6 | 7 | testExportedPaths(path.join(__dirname, '..', 'relay.js'), [ 8 | ['src/relay/src/__flowtests__/QueryRenderer.js', 'src/__flowtests__/QueryRenderer.js'], 9 | ['src/relay/src/__tests__/QueryRenderer.test.js', 'src/__tests__/QueryRenderer.test.js'], 10 | ['src/relay/src/index.js', 'src/index.js'], 11 | ['src/relay/package.json', 'package.json'], 12 | 13 | // invalid cases: 14 | ['src/packages/monorepo/outsideScope.js', undefined], // correctly deleted 15 | ['package.json', undefined], // correctly deleted 16 | ]); 17 | -------------------------------------------------------------------------------- /src/__tests__/parsePatch.test.js: -------------------------------------------------------------------------------- 1 | // @flow strict-local 2 | 3 | import path from 'path'; 4 | import { generateTestsFromFixtures } from '@adeira/fixtures-tester'; 5 | 6 | import parsePatch from '../parsePatch'; 7 | 8 | generateTestsFromFixtures(path.join(__dirname, 'fixtures', 'diffs'), operation); 9 | 10 | function operation(input: string) { 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 | -------------------------------------------------------------------------------- /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/monorepo-shipit/package.json', 'package.json'], 9 | ['src/monorepo-shipit/src/accounts.js', 'src/accounts.js'], 10 | ['src/monorepo-shipit/config/monorepo-shipit.js', 'config/monorepo-shipit.js'], 11 | ['src/monorepo-shipit/bin/shipit.js', 'bin/shipit.js'], 12 | 13 | // invalid cases: 14 | ['src/packages/monorepo/outsideScope.js', undefined], // correctly deleted 15 | ['package.json', undefined], // correctly deleted 16 | ]); 17 | -------------------------------------------------------------------------------- /bin/create-patch.js: -------------------------------------------------------------------------------- 1 | // @flow strict-local 2 | 3 | import { findMonorepoRoot } from '@adeira/monorepo-utils'; 4 | 5 | import RepoGit from '../src/RepoGit'; 6 | 7 | // yarn monorepo-babel-node src/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 | console.log('~~~ HEADER ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~'); 16 | console.log(header); 17 | console.log('~~~ PATCH ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~'); 18 | console.log(patch); 19 | -------------------------------------------------------------------------------- /config/__tests__/icons.test.js: -------------------------------------------------------------------------------- 1 | // @flow strict-local 2 | 3 | import path from 'path'; 4 | 5 | import testExportedPaths from './testExportedPaths'; 6 | 7 | testExportedPaths(path.join(__dirname, '..', 'icons.js'), [ 8 | ['src/icons/__generated__/__meta.js', '__generated__/__meta.js'], 9 | ['src/icons/__generated__/Backward.js', '__generated__/Backward.js'], 10 | ['src/icons/original/backward.svg', 'original/backward.svg'], 11 | ['src/icons/svg2jsx/index.js', 'svg2jsx/index.js'], 12 | ['src/icons/package.json', 'package.json'], 13 | 14 | // invalid cases: 15 | ['src/packages/monorepo/outsideScope.js', undefined], // correctly deleted 16 | ['package.json', undefined], // correctly deleted 17 | ]); 18 | -------------------------------------------------------------------------------- /src/__tests__/fixtures/configs/valid-branches.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | import path from 'path'; 4 | 5 | import type { ConfigType } from '../../../../ConfigType.flow'; 6 | 7 | module.exports = ({ 8 | getBranchConfig() { 9 | return { 10 | source: 'source_branch', 11 | destination: 'destination_branch', 12 | }; 13 | }, 14 | getStaticConfig() { 15 | return { 16 | repository: 'git@github.com/adeira/relay-example.git', 17 | }; 18 | }, 19 | getPathMappings() { 20 | const ossRoot = 'src/apps/example-relay/'; 21 | return new Map([ 22 | [path.join(ossRoot, '__github__', '.flowconfig'), '.flowconfig'], 23 | [ossRoot, ''], 24 | ]); 25 | }, 26 | }: ConfigType); 27 | -------------------------------------------------------------------------------- /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 tracking = `adeira-source-id: ${revision}`; 8 | 9 | let newDescription = `${changeset.getDescription()}\n\n${tracking}`; 10 | 11 | // Co-authored-by must be the absolute last thing in the message 12 | const coAuthorLines = changeset.getCoAuthorLines(); 13 | if (coAuthorLines.length > 0) { 14 | newDescription += `\n\n${coAuthorLines.join('\n')}`; 15 | } 16 | 17 | return changeset 18 | .withDebugMessage('ADD TRACKING DATA: "%s"', tracking) 19 | .withDescription(newDescription.trim()); 20 | } 21 | -------------------------------------------------------------------------------- /src/phases/createCheckCorruptedRepoPhase.js: -------------------------------------------------------------------------------- 1 | // @flow strict-local 2 | 3 | import { invariant } from '@adeira/js'; 4 | 5 | import RepoGit from '../RepoGit'; 6 | import type { Phase } from '../../types.flow'; 7 | 8 | export default function createCheckCorruptedRepoPhase(repoPath: string): Phase { 9 | const phase = function () { 10 | const repo = new RepoGit(repoPath); 11 | 12 | // We should eventually nuke the repo and clone it again. But we do not 13 | // store the repos in CI yet so it's not necessary. Also, be careful not 14 | // to nuke monorepo in CI. 15 | invariant(repo.isCorrupted() === false, `Repo located in '${repoPath}' is corrupted.`); 16 | }; 17 | 18 | phase.readableName = 'Check if repository is corrupted'; 19 | return phase; 20 | } 21 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 '@adeira/monorepo-utils'; 6 | 7 | const configFilenames = globSync('/**/*.js', { 8 | root: path.join(__dirname, '..'), 9 | ignore: [ 10 | '**/node_modules/**', 11 | '**/__[a-z]*__/**', // ignore __tests__, __mocks__, ... 12 | ], 13 | }); 14 | 15 | test.each(configFilenames)('config %s has a test file', (configFilename) => { 16 | const testFilename = configFilename.replace( 17 | /^(?.+?)(?[^/]+)\.js$/, 18 | `$1__tests__${path.sep}$2.test.js`, 19 | ); 20 | 21 | expect({ 22 | isOK: fs.existsSync(testFilename), 23 | testFilename, 24 | }).toEqual({ 25 | isOK: true, 26 | testFilename, 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /src/__tests__/fixtures/configs/invalid-misconfigured-branches.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | import path from 'path'; 4 | 5 | import type { ConfigType } from '../../../../ConfigType.flow'; 6 | 7 | module.exports = ({ 8 | // $FlowExpectedError[prop-missing] 9 | getBranchConfig() { 10 | // $FlowExpectedError[prop-missing] 11 | return { 12 | // should be 'source' and 'destination' 13 | what_is_this: 'source_branch', 14 | }; 15 | }, 16 | getStaticConfig() { 17 | return { 18 | repository: 'git@github.com/adeira/relay-example.git', 19 | }; 20 | }, 21 | getPathMappings() { 22 | const ossRoot = 'src/apps/example-relay/'; 23 | return new Map([ 24 | [path.join(ossRoot, '__github__', '.flowconfig'), '.flowconfig'], 25 | [ossRoot, ''], 26 | ]); 27 | }, 28 | }: ConfigType); 29 | -------------------------------------------------------------------------------- /src/filters/__tests__/__snapshots__/stripPaths.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Multiple patterns 1`] = ` 4 | [ 5 | "STRIP FILE: "foo" matches pattern "/^foo$/"", 6 | "STRIP FILE: "bar" matches pattern "/^bar$/"", 7 | ] 8 | `; 9 | 10 | exports[`No change 1`] = `[]`; 11 | 12 | exports[`Remove directory 1`] = ` 13 | [ 14 | "STRIP FILE: "herp/derp" matches pattern "/^herp\\//"", 15 | "STRIP FILE: "herp/derp-derp" matches pattern "/^herp\\//"", 16 | ] 17 | `; 18 | 19 | exports[`Remove file 1`] = ` 20 | [ 21 | "STRIP FILE: "herp/derp" matches pattern "/(?:^|\\/)derp(?:\\/|$)/"", 22 | "STRIP FILE: "derp" matches pattern "/(?:^|\\/)derp(?:\\/|$)/"", 23 | ] 24 | `; 25 | 26 | exports[`Remove top level file 1`] = ` 27 | [ 28 | "STRIP FILE: "bar" matches pattern "/^bar$/"", 29 | ] 30 | `; 31 | -------------------------------------------------------------------------------- /src/filters/stripExceptDirectories.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | import path from 'path'; 4 | 5 | import Changeset, { type Diff } 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) => 16 | roots.add(rawRoot.endsWith(path.sep) ? rawRoot : rawRoot + path.sep), 17 | ); 18 | const diffs = new Set(); 19 | for (const diff of changeset.getDiffs()) { 20 | const path = diff.path; 21 | for (const root of roots) { 22 | if (path.startsWith(root)) { 23 | diffs.add(diff); 24 | break; 25 | } 26 | } 27 | } 28 | return changeset.withDiffs(diffs); 29 | } 30 | -------------------------------------------------------------------------------- /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/__tests__/fixtures/configs/valid-exhaustive.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | import path from 'path'; 4 | 5 | import type { ConfigType } from '../../../../ConfigType.flow'; 6 | 7 | module.exports = ({ 8 | customImportitFilter: (changeset) => changeset, 9 | customShipitFilter: (changeset) => changeset, 10 | getStrippedFiles() { 11 | return new Set([/asdasd/]); 12 | }, 13 | getBranchConfig() { 14 | return { 15 | source: 'origin/main', 16 | destination: 'main', 17 | }; 18 | }, 19 | getStaticConfig() { 20 | return { 21 | repository: 'git@github.com/adeira/relay-example.git', 22 | }; 23 | }, 24 | getPathMappings() { 25 | const ossRoot = 'src/apps/example-relay/'; 26 | return new Map([ 27 | [path.join(ossRoot, '__github__', '.flowconfig'), '.flowconfig'], 28 | [ossRoot, ''], 29 | ]); 30 | }, 31 | }: ConfigType); 32 | -------------------------------------------------------------------------------- /src/utils/createMockChangeset.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | import Changeset, { type Diff } from '../Changeset'; 4 | import createMockDiff from './createMockDiff'; 5 | 6 | /** 7 | * This changeset contains valid but fake data and should be used only in tests. 8 | */ 9 | export default function createMockChangeset( 10 | numberOfDiffs: number = 2, 11 | basePath: string = '', 12 | ): Changeset { 13 | const diffs = new Set(); 14 | 15 | for (let i = 1; i <= numberOfDiffs; i++) { 16 | const filename = `${basePath}fakeFile_${i}.txt`; 17 | diffs.add(createMockDiff(filename)); 18 | } 19 | 20 | return new Changeset() 21 | .withID('1234567890') 22 | .withSubject('Test subject') 23 | .withDescription('Test description') 24 | .withAuthor('John Doe ') 25 | .withTimestamp('Mon, 16 July 2019 10:55:04 -0100') 26 | .withDiffs(diffs); 27 | } 28 | -------------------------------------------------------------------------------- /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/phases/createClonePhase.js: -------------------------------------------------------------------------------- 1 | // @flow strict-local 2 | 3 | import path from 'path'; 4 | import { sprintf } from '@adeira/js'; 5 | import { ShellCommand } from '@adeira/shell-command'; 6 | 7 | import RepoGit from '../RepoGit'; 8 | import type { Phase } from '../../types.flow'; 9 | 10 | export default function createClonePhase(exportedRepoURL: string, exportedRepoPath: string): Phase { 11 | const phase = function () { 12 | // from destination path `/x/y/z` to: 13 | const dirname = path.dirname(exportedRepoPath); // `/x/y` 14 | const basename = path.basename(exportedRepoPath); // `z` 15 | 16 | // TODO: make it Git agnostic 17 | 18 | new ShellCommand(dirname, 'git', 'clone', exportedRepoURL, basename).runSynchronously(); 19 | 20 | const exportedRepo = new RepoGit(exportedRepoPath); 21 | exportedRepo.configure(); 22 | }; 23 | 24 | phase.readableName = sprintf('Clone and configure %s', exportedRepoURL); 25 | return phase; 26 | } 27 | -------------------------------------------------------------------------------- /src/filters/__tests__/fixtures/new-file-complex.patch: -------------------------------------------------------------------------------- 1 | diff --git a/src/ya-comiste-react-native/ios/YaComiste/Images.xcassets/Logo (no background).imageset/Contents.json b/src/ya-comiste-react-native/ios/YaComiste/Images.xcassets/Logo (no background).imageset/Contents.json 2 | new file mode 100644 3 | index 0000000000000000000000000000000000000000..fb7887c2e2ec937f47e9a58a22ffc8fd705379ff 4 | --- /dev/null 5 | +++ b/src/ya-comiste-react-native/ios/YaComiste/Images.xcassets/Logo (no background).imageset/Contents.json 6 | @@ -0,0 +1,21 @@ 7 | +{ 8 | + "images" : [ 9 | + { 10 | + "idiom" : "universal", 11 | + "scale" : "1x" 12 | + }, 13 | + { 14 | + "idiom" : "universal", 15 | + "scale" : "2x" 16 | + }, 17 | + { 18 | + "filename" : "100.png", 19 | + "idiom" : "universal", 20 | + "scale" : "3x" 21 | + } 22 | + ], 23 | + "info" : { 24 | + "author" : "xcode", 25 | + "version" : 1 26 | + } 27 | +} 28 | -- 29 | 2.9.3 30 | 31 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@adeira/monorepo-shipit", 3 | "description": "Monorepo → many repos (Git) exporter.", 4 | "homepage": "https://github.com/adeira/monorepo-shipit", 5 | "bugs": "https://github.com/adeira/universe/issues", 6 | "repository": { 7 | "type": "git", 8 | "url": "git@github.com:adeira/universe.git", 9 | "directory": "src/monorepo-shipit" 10 | }, 11 | "license": "MIT", 12 | "version": "0.4.0", 13 | "type": "commonjs", 14 | "bin": { 15 | "monorepo-importit": "bin/importit.js", 16 | "monorepo-shipit": "bin/shipit.js" 17 | }, 18 | "sideEffects": false, 19 | "dependencies": { 20 | "@adeira/js": "^2.1.1", 21 | "@adeira/monorepo-utils": "^0.12.0", 22 | "@adeira/shell-command": "^0.1.0", 23 | "@babel/runtime": "^7.28.3", 24 | "chalk": "^4.1.2", 25 | "commander": "^12.1.0", 26 | "fast-levenshtein": "^3.0.0" 27 | }, 28 | "devDependencies": { 29 | "@adeira/fixtures-tester": "^1.1.1" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /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 | "coAuthorLines": [], 16 | "debugMessages": [], 17 | "description": "This description should be stripped.", 18 | "subject": "Commit subject", 19 | } 20 | `); 21 | 22 | expect(stripDescriptions(changeset)).toMatchInlineSnapshot(` 23 | Changeset { 24 | "author": "John Doe", 25 | "coAuthorLines": [], 26 | "debugMessages": [], 27 | "description": "", 28 | "subject": "Commit subject", 29 | } 30 | `); 31 | }); 32 | -------------------------------------------------------------------------------- /src/filters/__tests__/stripPaths.test.js: -------------------------------------------------------------------------------- 1 | // @flow 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 | 18 | const newChangeset = stripPaths(changeset, stripPatterns); 19 | const diffs = newChangeset.getDiffs(); 20 | expect([...diffs].map((diff) => diff.path)).toEqual(expectedFiles); 21 | expect(newChangeset.debugMessages).toMatchSnapshot(); 22 | }); 23 | -------------------------------------------------------------------------------- /src/filters/__tests__/__snapshots__/moveDirectories.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`handles complex filenames requiring regexp escaping correctly 1`] = ` 4 | Changeset { 5 | "coAuthorLines": [], 6 | "debugMessages": [], 7 | "description": "MOCKED_HEADER", 8 | "diffs": Set { 9 | { 10 | "body": "new file mode 100644 11 | index 0000000000000000000000000000000000000000..fb7887c2e2ec937f47e9a58a22ffc8fd705379ff 12 | --- /dev/null 13 | +++ b/react-native/ios/YaComiste/Images.xcassets/Logo (no background).imageset/Contents.json 14 | @@ -0,0 +1,21 @@ 15 | +{ 16 | + "images" : [ 17 | + { 18 | + "idiom" : "universal", 19 | + "scale" : "1x" 20 | + }, 21 | + { 22 | + "idiom" : "universal", 23 | + "scale" : "2x" 24 | + }, 25 | + { 26 | + "filename" : "100.png", 27 | + "idiom" : "universal", 28 | + "scale" : "3x" 29 | + } 30 | + ], 31 | + "info" : { 32 | + "author" : "xcode", 33 | + "version" : 1 34 | + } 35 | +} 36 | ", 37 | "path": "react-native/ios/YaComiste/Images.xcassets/Logo (no background).imageset/Contents.json", 38 | }, 39 | }, 40 | } 41 | `; 42 | -------------------------------------------------------------------------------- /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/filters/stripPaths.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | import Changeset, { type Diff } from '../Changeset'; 4 | 5 | function matchesAnyPattern(path: string, stripPatterns: Set): RegExp | null { 6 | for (const stripPattern of stripPatterns) { 7 | if (stripPattern.test(path)) { 8 | return stripPattern; 9 | } 10 | } 11 | return null; 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 | 22 | let newChangeset = changeset; 23 | const diffs = new Set(); 24 | for (const diff of changeset.getDiffs()) { 25 | const path = diff.path; 26 | const match = matchesAnyPattern(path, stripPatterns); 27 | if (match !== null) { 28 | // stripping because matching pattern was found 29 | newChangeset = newChangeset.withDebugMessage( 30 | 'STRIP FILE: "%s" matches pattern "%s"', 31 | path, 32 | match.toString(), 33 | ); 34 | continue; 35 | } 36 | diffs.add(diff); 37 | } 38 | 39 | return newChangeset.withDiffs(diffs); 40 | } 41 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019-present, Adeira 4 | Copyright (c) 2018-present, Kiwi.com 5 | Copyright (c) 2013-present, Facebook, Inc. 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | -------------------------------------------------------------------------------- /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 | // eslint-disable-next-line jest/no-export 8 | export default function testExportedPaths( 9 | configPath: string, 10 | mapping: $ReadOnlyArray< 11 | [ 12 | string, 13 | string | void, // void describes deleted file 14 | ], 15 | >, 16 | ) { 17 | const config = requireAndValidateConfig(configPath); 18 | 19 | test.each(mapping)('mapping: %s -> %s', (input, output) => { 20 | const defaultFilter = new ShipitConfig( 21 | 'mocked repo path', 22 | 'mocked repo URL', 23 | config.getPathMappings(), 24 | config.getStrippedFiles ? config.getStrippedFiles() : new Set(), 25 | ).getDefaultShipitFilter(); 26 | 27 | const inputChangeset = new Changeset().withDiffs(new Set([{ path: input, body: 'mocked' }])); 28 | 29 | const outputDataset = defaultFilter(inputChangeset); 30 | 31 | if (output === undefined) { 32 | expect(...outputDataset.getDiffs()).toBeUndefined(); 33 | } else { 34 | expect(...outputDataset.getDiffs()).toEqual({ 35 | body: 'mocked', 36 | path: output, 37 | }); 38 | } 39 | }); 40 | } 41 | -------------------------------------------------------------------------------- /config/README.md: -------------------------------------------------------------------------------- 1 | _These instructions are mainly for `adeira/universe` maintainers._ 2 | 3 | This is the place where you can setup or change Shipit export configs. Refer to the main README or other configuration files to know how to do it. 4 | 5 | # Creating a new Shipit config 6 | 7 | There are additional manual steps necessary when creating a new config: 8 | 9 | 1. Create a new repository: https://github.com/organizations/adeira/repositories/new 10 | 1. Add a good description, simple template could be: 11 | 12 | ```text 13 | [READONLY] TKTK This repository is automatically exported from https://github.com/adeira/universe via Shipit 14 | ``` 15 | 16 | Where `TKTK` is an actual repository description. For example, this is a description of [SX](https://github.com/adeira/sx): 17 | 18 | ```text 19 | [READONLY] 🐝 Atomic CSS-in-JS (not only) for Next.js applications. This repository is automatically exported from https://github.com/adeira/universe via Shipit 20 | ``` 21 | 22 | This will not only help us to promote `adeira/universe` but it will also be clear to external contributors what's going on. 23 | 24 | 1. Invite `adeira/bots` to the repository with `write` access (https://github.com/adeira/eslint-config-adeira/settings/access) - without this, Shipit won't be able to export the repository! Do not invite anyone else (not even `adeira/devs`) - it should not be necessary. 25 | 1. Profit! 26 | -------------------------------------------------------------------------------- /src/filters/moveDirectories.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | import _esc from './_esc'; 4 | import Changeset, { type Diff } from '../Changeset'; 5 | 6 | /** 7 | * Apply patches to a different directory in the destination repository. 8 | */ 9 | export default function moveDirectories( 10 | changeset: Changeset, 11 | mapping: Map, 12 | ): Changeset { 13 | const rewriteCallback = (oldPath: string) => { 14 | let newPath = oldPath; 15 | for (const [src, dest] of mapping.entries()) { 16 | let matchFound = false; 17 | if (new RegExp(`^${_esc(src)}`).test(newPath)) { 18 | matchFound = true; 19 | } 20 | newPath = newPath.replace(new RegExp(`^${_esc(src)}`), dest); 21 | if (matchFound) { 22 | break; // only first match in the map 23 | } 24 | } 25 | return newPath; 26 | }; 27 | 28 | const diffs = new Set(); 29 | for (const diff of changeset.getDiffs()) { 30 | const oldPath = diff.path; 31 | const newPath = rewriteCallback(oldPath); 32 | if (oldPath === newPath) { 33 | diffs.add(diff); 34 | continue; 35 | } 36 | 37 | let body = diff.body; 38 | body = body.replace(new RegExp(`^--- a/${_esc(oldPath)}`, 'm'), `--- a/${newPath}`); 39 | body = body.replace(new RegExp(`^\\+\\+\\+ b/${_esc(oldPath)}`, 'm'), `+++ b/${newPath}`); 40 | 41 | diffs.add({ 42 | path: newPath, 43 | body, 44 | }); 45 | } 46 | 47 | return changeset.withDiffs(diffs); 48 | } 49 | -------------------------------------------------------------------------------- /src/filters/conditionalLines.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | import Changeset, { type Diff } from '../Changeset'; 4 | import _esc from './_esc'; 5 | 6 | function process(changeset: Changeset, pattern: RegExp, replacement: string) { 7 | const diffs = new Set(); 8 | for (const diff of changeset.getDiffs()) { 9 | const newDiff = { 10 | ...diff, 11 | body: diff.body 12 | .split('\n') 13 | .map((line) => line.replace(pattern, replacement)) 14 | .join('\n'), 15 | }; 16 | diffs.add(newDiff); 17 | } 18 | return changeset.withDiffs(diffs); 19 | } 20 | 21 | export function commentLines( 22 | changeset: Changeset, 23 | marker: string = '@x-oss-disable', 24 | commentStart: string = '//', 25 | commentEnd: null | string = null, 26 | ): Changeset { 27 | const ending = commentEnd === null ? '' : ` ${commentEnd}`; 28 | const pattern = new RegExp( 29 | `^([-+ ]\\s*)(\\S.*) ${_esc(commentStart)} ${_esc(marker)}${_esc(ending)}$`, 30 | ); 31 | return process(changeset, pattern, `$1${commentStart} ${marker}: $2${ending}`); 32 | } 33 | 34 | export function uncommentLines( 35 | changeset: Changeset, 36 | marker: string = '@x-oss-disable', 37 | commentStart: string = '//', 38 | commentEnd: null | string = null, 39 | ): Changeset { 40 | const ending = commentEnd === null ? '' : ` ${commentEnd}`; 41 | const pattern = new RegExp( 42 | `^([-+ ]\\s*)${_esc(commentStart)} ${_esc(marker)}: (.+)${_esc(ending)}$`, 43 | ); 44 | return process(changeset, pattern, `$1$2 ${commentStart} ${marker}${ending}`); 45 | } 46 | -------------------------------------------------------------------------------- /src/__tests__/RepoGit.renderPatch.test.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import RepoGit from '../RepoGitFake'; 4 | import Changeset, { type Diff } from '../Changeset'; 5 | 6 | function createChangeset(diffs: null | Set) { 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/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 | 9 | const description = []; 10 | const coAuthorLines = []; 11 | 12 | // Co-authored-by must be the absolute last thing in the message so we separate it here from the 13 | // description and compose it later correctly (to add the `adeira-source-id` label correctly). 14 | for (const line of rawBody.trim().split('\n')) { 15 | if (line.startsWith('Co-authored-by:')) { 16 | coAuthorLines.push(line); 17 | } else { 18 | description.push(line); 19 | } 20 | } 21 | 22 | let changeset = new Changeset() 23 | .withDescription(description.join('\n')) 24 | .withCoAuthorLines(coAuthorLines); 25 | 26 | const envelope = rawEnvelope.replace(/(?:\n\t|\n )/, ' '); 27 | for (const line of envelope.split('\n')) { 28 | if (!line.includes(':')) { 29 | continue; 30 | } 31 | const [key, rawValue] = splitHead(line, ':'); 32 | const value = rawValue.trim(); 33 | switch (key.trim().toLowerCase()) { 34 | case 'from': 35 | changeset = changeset.withAuthor(value); 36 | break; 37 | case 'subject': 38 | changeset = changeset.withSubject(value.replace(/^\[PATCH] /, '')); 39 | break; 40 | case 'date': 41 | changeset = changeset.withTimestamp(value); 42 | break; 43 | } 44 | } 45 | return changeset; 46 | } 47 | -------------------------------------------------------------------------------- /src/__tests__/RepoGit.commitPatch.test.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import RepoGitFake from '../RepoGitFake'; 4 | import createMockChangeset from '../utils/createMockChangeset'; 5 | 6 | it('can commit empty changeset', () => { 7 | const repo = new RepoGitFake(); 8 | const changeset = createMockChangeset(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 = createMockChangeset(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 the author timestamp the same as commit timestamp', () => { 31 | const repo = new RepoGitFake(); 32 | const changeset = createMockChangeset(1); 33 | repo.commitPatch(changeset); 34 | 35 | expect(repo.printTimestamps()).toMatchInlineSnapshot(` 36 | "AUTHOR:Tue, 16 Jul 2019 10:55:04 -0100 37 | COMMIT:Tue, 16 Jul 2019 10:55:04 -0100 38 | 39 | fakeFile_1.txt | 1 + 40 | 1 file changed, 1 insertion(+)" 41 | `); 42 | }); 43 | 44 | it('commits changeset with multiple diffs correctly', () => { 45 | const repo = new RepoGitFake(); 46 | const changeset = createMockChangeset(3); 47 | repo.commitPatch(changeset); 48 | 49 | expect(repo.printFakeRepoHistory()).toMatchInlineSnapshot(` 50 | "SUBJ: Test subject 51 | DESC: Test description 52 | 53 | fakeFile_1.txt | 1 + 54 | fakeFile_2.txt | 1 + 55 | fakeFile_3.txt | 1 + 56 | 3 files changed, 3 insertions(+)" 57 | `); 58 | }); 59 | -------------------------------------------------------------------------------- /src/__tests__/Integration.test.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { findMonorepoRoot } from '@adeira/monorepo-utils'; 4 | 5 | import RepoGit from '../RepoGit'; 6 | import ShipitConfig from '../ShipitConfig'; 7 | 8 | // This is a high level integration test which does approximately the same as Shipit with small 9 | // exceptions (for example, we do not try to apply the changeset because we would need to whole 10 | // exported project history). It uses real commits from `adeira/universe` so you can run: 11 | // 12 | // ``` 13 | // git show cc7b3818e06f95c2732f75f165a2b98c6eeab135 14 | // ``` 15 | // 16 | // This test snapshots various states throughout the export process so you can observe how are the 17 | // filters being applies and how are the changesets being rendered. 18 | test.each([ 19 | // TODO: add more commits as you go 20 | ['cc7b3818e06f95c2732f75f165a2b98c6eeab135', new Map([['src/eslint-config-adeira/', '']])], 21 | ])( 22 | 'creates correct changeset from commit %s, filters it and renders it as expected', 23 | (commitHash, directoryMapping) => { 24 | const universeRoot = findMonorepoRoot(); 25 | const universeRepo = new RepoGit(universeRoot); 26 | 27 | const changeset = universeRepo.getChangesetFromID(commitHash); 28 | 29 | // First, check that we can construct the changeset as expected: 30 | expect(changeset).toMatchSnapshot('1 - parsed changeset without filters'); 31 | 32 | const shipitConfig = new ShipitConfig( 33 | universeRoot, // from repo URI 34 | 'mocked.git', // to repo URI 35 | directoryMapping, 36 | new Set(), // stripped files 37 | ); 38 | const defaultFilter = shipitConfig.getDefaultShipitFilter(); 39 | const filteredChangeset = defaultFilter(changeset); 40 | 41 | // Then we check that the default filters were applied to the changeset as expected: 42 | expect(filteredChangeset).toMatchSnapshot('2 - parsed changeset WITH applied filters'); 43 | 44 | // And finally, we verify that the filtered changeset is being rendered as expected: 45 | expect(universeRepo.renderPatch(filteredChangeset)).toMatchSnapshot('3 - rendered changeset'); 46 | }, 47 | ); 48 | -------------------------------------------------------------------------------- /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 '@adeira/shell-command'; 7 | 8 | import RepoGit from './RepoGit'; 9 | 10 | /* $FlowFixMe[incompatible-extend] This comment suppresses an error when 11 | * upgrading Flow to version 0.203.0. To see the error delete this comment 12 | * and run Flow. */ 13 | export default class RepoGitFake extends RepoGit { 14 | #testRepoPath: string; 15 | 16 | constructor( 17 | testRepoPath: string = fs.mkdtempSync(path.join(os.tmpdir(), 'adeira-shipit-tests-')), 18 | ) { 19 | new ShellCommand(testRepoPath, 'git', 'init').runSynchronously(); 20 | for (const [key, value] of Object.entries({ 21 | 'user.email': 'shipit-tests@adeira.dev', 22 | 'user.name': 'adeira-shipit-tests', 23 | })) { 24 | new ShellCommand( 25 | testRepoPath, 26 | 'git', 27 | 'config', 28 | key, 29 | // $FlowIssue[incompatible-call]: https://github.com/facebook/flow/issues/2174 30 | value, 31 | ).runSynchronously(); 32 | } 33 | super(testRepoPath); 34 | this.#testRepoPath = testRepoPath; 35 | } 36 | 37 | push: (destinationBranch: string) => void = () => {}; 38 | 39 | configure: () => void = () => {}; 40 | 41 | checkoutBranch: (branchName: string) => void = () => {}; 42 | 43 | clean: () => void = () => {}; 44 | 45 | /* $FlowFixMe[incompatible-extend] This comment suppresses an error when 46 | * upgrading Flow to version 0.186.0. To see the error delete this comment 47 | * and run Flow. */ 48 | export = (): void => {}; 49 | 50 | getFakeRepoPath(): string { 51 | return this.#testRepoPath; 52 | } 53 | 54 | printTimestamps(): string { 55 | return this._gitCommand('log', '--stat', '--pretty=format:AUTHOR:%aD%nCOMMIT:%cD%n') 56 | .runSynchronously() 57 | .getStdout() 58 | .trim(); 59 | } 60 | 61 | printFakeRepoHistory(): string { 62 | return this._gitCommand('log', '--stat', '--pretty=format:SUBJ: %s%nDESC: %b') 63 | .runSynchronously() 64 | .getStdout() 65 | .trim(); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/requireAndValidateConfig.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import { invariant } from '@adeira/js'; 4 | import levenshtein from 'fast-levenshtein'; 5 | 6 | import type { ConfigType } from '../ConfigType.flow'; 7 | 8 | function suggest(name: string, alternativeNames: Array): string { 9 | return alternativeNames.sort((firstEl, secondEl) => { 10 | const firstScore = levenshtein.get(name, firstEl); 11 | const secondScore = levenshtein.get(name, secondEl); 12 | return firstScore - secondScore; 13 | })[0]; 14 | } 15 | 16 | function validateObjectKeys( 17 | object: { [string]: mixed, ... }, 18 | allowedFields: Map, 19 | ): void | empty { 20 | for (const key of Object.keys(object)) { 21 | invariant( 22 | allowedFields.has(key), 23 | "Your config contains field '%s' but this is not allowed key. Did you mean '%s' instead?", 24 | key, 25 | suggest(key, [...allowedFields.keys()]), 26 | ); 27 | } 28 | 29 | for (const [fieldName, isRequired] of allowedFields) { 30 | if (isRequired) { 31 | invariant( 32 | object[fieldName] !== undefined, 33 | "Configuration field '%s' is required but it's missing.", 34 | fieldName, 35 | ); 36 | } 37 | } 38 | } 39 | 40 | export default function requireAndValidateConfig(configFile: string): ConfigType { 41 | const config = require(configFile); 42 | const allowedFields = new Map([ 43 | // filed name => is required 44 | ['customShipitFilter', false], 45 | ['customImportitFilter', false], 46 | ['getBranchConfig', false], 47 | ['getStaticConfig', true], 48 | ['getPathMappings', true], 49 | ['getStrippedFiles', false], 50 | ]); 51 | 52 | // TODO: consider Ajv but with good error messages! 53 | validateObjectKeys(config, allowedFields); 54 | 55 | if (config.getBranchConfig) { 56 | validateObjectKeys( 57 | config.getBranchConfig(), 58 | new Map([ 59 | ['source', true], 60 | ['destination', true], 61 | ]), 62 | ); 63 | } 64 | 65 | if (config.getStaticConfig) { 66 | validateObjectKeys(config.getStaticConfig(), new Map([['repository', true]])); 67 | } 68 | 69 | return config; 70 | } 71 | -------------------------------------------------------------------------------- /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('@adeira/shell-command', () => { 11 | return { 12 | ShellCommand: class { 13 | stdout: $FlowFixMe = null; 14 | constructor(path: string, git: any, noPager: any, ...commandArray: Array) { 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(): $FlowFixMe { 33 | return this.stdout; 34 | } 35 | 36 | runSynchronously(): $FlowFixMe { 37 | return this; 38 | } 39 | 40 | setEnvironmentVariables(): $FlowFixMe { 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/filters/__tests__/moveDirectoriesReverse.test.js: -------------------------------------------------------------------------------- 1 | // @flow 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)).toThrow( 61 | 'It is not possible to reverse mapping with duplicate destinations.', 62 | ); 63 | }); 64 | -------------------------------------------------------------------------------- /src/phases/createImportSyncPhase.js: -------------------------------------------------------------------------------- 1 | // @flow strict-local 2 | 3 | import { ShellCommand } from '@adeira/shell-command'; 4 | 5 | import RepoGit, { type DestinationRepo, type SourceRepo } from '../RepoGit'; 6 | import Changeset from '../Changeset'; 7 | import ShipitConfig from '../ShipitConfig'; 8 | import type { Phase } from '../../types.flow'; 9 | 10 | export default function createImportSyncPhase( 11 | config: ShipitConfig, 12 | packageName: string, 13 | pullRequestNumber: string, 14 | ): Phase { 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 | ).runSynchronously(); 25 | 26 | // 'git rev-parse FETCH_HEAD' to get actual hash 27 | const mergeBase = new ShellCommand( 28 | config.destinationPath, 29 | 'git', 30 | 'merge-base', 31 | 'FETCH_HEAD', 32 | config.getDestinationBranch(), 33 | ) 34 | .runSynchronously() 35 | .getStdout() 36 | .trim(); 37 | 38 | const changesets = new Set(); 39 | const exportedRepo: SourceRepo = new RepoGit(config.destinationPath); 40 | const descendantsPath = exportedRepo.findDescendantsPath(mergeBase, 'FETCH_HEAD', new Set([])); 41 | if (descendantsPath !== null) { 42 | descendantsPath.forEach((revision) => { 43 | const changeset = exportedRepo.getChangesetFromID(revision); 44 | const filter = config.getDefaultImportitFilter(); 45 | changesets.add(filter(changeset)); 46 | }); 47 | } 48 | return changesets; 49 | } 50 | 51 | /* $FlowFixMe[prop-missing] This comment suppresses an error when upgrading 52 | * Flow to version 0.205.0. To see the error delete this comment and run 53 | * Flow. */ 54 | return function () { 55 | const monorepo: DestinationRepo = new RepoGit(config.sourcePath); 56 | const changesets = getFilteredChangesets(); 57 | 58 | const branchName = `shipit-import-github-${packageName}-pr-${pullRequestNumber}`; 59 | monorepo.checkoutBranch(branchName); 60 | 61 | changesets.forEach((changeset) => { 62 | if (changeset.isValid()) { 63 | monorepo.commitPatch(changeset); 64 | } 65 | }); 66 | }; 67 | } 68 | -------------------------------------------------------------------------------- /bin/shipit.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // @flow 4 | 5 | import chalk from 'chalk'; 6 | import { program as commander } from 'commander'; 7 | 8 | import iterateConfigs from '../src/iterateConfigs'; 9 | import createClonePhase from '../src/phases/createClonePhase'; 10 | import createCheckCorruptedRepoPhase from '../src/phases/createCheckCorruptedRepoPhase'; 11 | import createCleanPhase from '../src/phases/createCleanPhase'; 12 | import createSyncPhase from '../src/phases/createSyncPhase'; 13 | import createVerifyRepoPhase from '../src/phases/createVerifyRepoPhase'; 14 | import createPushPhase from '../src/phases/createPushPhase'; 15 | import type { Phase } from '../types.flow'; 16 | 17 | // yarn monorepo-babel-node src/monorepo-shipit/bin/shipit.js --help 18 | // yarn monorepo-babel-node src/monorepo-shipit/bin/shipit.js --committer-name=A --committer-email=B 19 | 20 | type ShipitCLIType = { 21 | +configFilter: string, 22 | +configDir: string, 23 | +committerName: string, 24 | +committerEmail: string, 25 | }; 26 | 27 | commander 28 | .version(require('./../package.json').version) 29 | .description('Export a monorepo to multiple git repositories') 30 | .requiredOption('--config-filter ', 'Glob pattern to filter config files', '/*.js') 31 | .requiredOption('--config-dir ', 'Path to the directory with config files', './.shipit') 32 | .requiredOption('--committer-name ', 'Name of the committer') 33 | .requiredOption('--committer-email ', 'Email of the committer'); 34 | 35 | commander.parse(); 36 | const options: ShipitCLIType = commander.opts(); 37 | 38 | process.env.SHIPIT_COMMITTER_EMAIL = options.committerEmail; 39 | process.env.SHIPIT_COMMITTER_NAME = options.committerName; 40 | 41 | iterateConfigs(options, (config) => { 42 | new Set([ 43 | createClonePhase(config.exportedRepoURL, config.destinationPath), 44 | createCheckCorruptedRepoPhase(config.destinationPath), 45 | createCleanPhase(config.destinationPath), 46 | createSyncPhase(config), 47 | createVerifyRepoPhase(config), 48 | createPushPhase(config), 49 | ]).forEach((phase) => { 50 | console.log(`${chalk.dim('Starting phase:')} %s`, phase.readableName); 51 | phase(); 52 | console.log(`${chalk.dim('Finished phase:')} %s`, phase.readableName); 53 | }); 54 | console.log(''); // just to add a new line between each config 55 | }); 56 | -------------------------------------------------------------------------------- /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/phases/createSyncPhase.js: -------------------------------------------------------------------------------- 1 | // @flow strict-local 2 | 3 | import RepoGit, { type DestinationRepo, type SourceRepo } from '../RepoGit'; 4 | import Changeset from '../Changeset'; 5 | import ShipitConfig from '../ShipitConfig'; 6 | import type { Phase } from '../../types.flow'; 7 | 8 | export default function createSyncPhase(config: ShipitConfig): Phase { 9 | function _getSourceRepo(): SourceRepo { 10 | return new RepoGit(config.sourcePath); 11 | } 12 | 13 | function _getDestinationRepo(): DestinationRepo { 14 | return new RepoGit(config.destinationPath); 15 | } 16 | 17 | function getSourceChangesets(): Set { 18 | const destinationRepo = _getDestinationRepo(); 19 | const sourceRepo = _getSourceRepo(); 20 | let initialRevision = destinationRepo.findLastSourceCommit(config.getDestinationRoots()); 21 | let isNewRepo = false; 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 | isNewRepo = true; 27 | } 28 | 29 | const sourceChangesets = new Set(); 30 | const descendantsPath = sourceRepo.findDescendantsPath( 31 | initialRevision, 32 | config.getSourceBranch(), 33 | config.getSourceRoots(), 34 | isNewRepo, 35 | ); 36 | if (descendantsPath !== null) { 37 | descendantsPath.forEach((revision) => { 38 | sourceChangesets.add(sourceRepo.getChangesetFromID(revision)); 39 | }); 40 | } 41 | return sourceChangesets; 42 | } 43 | 44 | function getFilteredChangesets(): Set { 45 | const filteredChangesets = new Set(); 46 | getSourceChangesets().forEach((changeset) => { 47 | const filter = config.getDefaultShipitFilter(); 48 | filteredChangesets.add(filter(changeset)); 49 | }); 50 | return filteredChangesets; 51 | } 52 | 53 | const phase = function () { 54 | const destinationRepo = _getDestinationRepo(); 55 | const changesets = getFilteredChangesets(); 56 | 57 | destinationRepo.checkoutBranch(config.getDestinationBranch()); 58 | 59 | changesets.forEach((changeset) => { 60 | changeset.dumpDebugMessages(); 61 | if (changeset.isValid()) { 62 | destinationRepo.commitPatch(changeset); 63 | } 64 | }); 65 | }; 66 | 67 | phase.readableName = 'Synchronize repository'; 68 | return phase; 69 | } 70 | -------------------------------------------------------------------------------- /src/iterateConfigs.js: -------------------------------------------------------------------------------- 1 | // @flow strict-local 2 | 3 | import path from 'path'; 4 | import { findMonorepoRoot, globSync } from '@adeira/monorepo-utils'; 5 | 6 | import requireAndValidateConfig from './requireAndValidateConfig'; 7 | import ShipitConfig from './ShipitConfig'; 8 | 9 | type IterateConfig = { 10 | +configFilter: string, 11 | +configDir: string, 12 | ... 13 | }; 14 | 15 | function iterateConfigsInPath( 16 | options: IterateConfig, 17 | rootPath: string, 18 | callback: (ShipitConfig) => void, 19 | ): void { 20 | const configFiles = globSync(options.configFilter, { 21 | root: rootPath, 22 | ignore: [ 23 | '**/node_modules/**', 24 | '**/__[a-z]*__/**', // ignore __tests__, __mocks__, ... 25 | ], 26 | }); 27 | 28 | const monorepoPath = findMonorepoRoot(); 29 | const throwedErrors = new Map(); 30 | 31 | configFiles.forEach((configFile) => { 32 | const config = requireAndValidateConfig(configFile); 33 | const staticConfig = config.getStaticConfig(); 34 | const branches = config.getBranchConfig 35 | ? config.getBranchConfig() 36 | : { 37 | source: undefined, 38 | destination: undefined, 39 | }; 40 | 41 | const cfg = new ShipitConfig( 42 | monorepoPath, 43 | staticConfig.repository, 44 | config.getPathMappings(), 45 | config.getStrippedFiles ? config.getStrippedFiles() : new Set(), 46 | branches.source, 47 | branches.destination, 48 | config.customShipitFilter, 49 | config.customImportitFilter, 50 | ); 51 | 52 | // We collect all the errors but we do not stop the iteration. These errors 53 | // are being displayed at the end of the process so that projects which are 54 | // OK can be exported successfully (and not being affected by irrelevant 55 | // failures). 56 | try { 57 | callback(cfg); 58 | } catch (error) { 59 | throwedErrors.set(cfg.exportedRepoURL, error); 60 | } 61 | }); 62 | 63 | if (throwedErrors.size > 0) { 64 | throwedErrors.forEach((error, repoURL) => { 65 | console.error(repoURL, error.name); 66 | console.error(error.stack); 67 | }); 68 | process.exitCode = 1; 69 | } else { 70 | process.exitCode = 0; 71 | } 72 | } 73 | 74 | export default function iterateConfigs( 75 | options: IterateConfig, 76 | callback: (ShipitConfig) => void, 77 | ): void { 78 | iterateConfigsInPath(options, path.join(process.cwd(), options.configDir), callback); 79 | } 80 | -------------------------------------------------------------------------------- /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 | .withDebugMessage('DEBUG %s', 'yadada') 14 | .withDiffs( 15 | new Set([ 16 | { path: 'aaa', body: 'AAA' }, 17 | { path: 'bbb', body: 'BBB' }, 18 | ]), 19 | ); 20 | 21 | // everything in the original changeset should be empty 22 | expect(originalChangeset).toMatchInlineSnapshot(` 23 | Changeset { 24 | "coAuthorLines": [], 25 | "debugMessages": [], 26 | } 27 | `); 28 | 29 | // this should have new values 30 | expect(modifiedChangeset1).toMatchInlineSnapshot(` 31 | Changeset { 32 | "author": "John Doe", 33 | "coAuthorLines": [], 34 | "debugMessages": [ 35 | "DEBUG yadada", 36 | ], 37 | "description": "new description", 38 | "diffs": Set { 39 | { 40 | "body": "AAA", 41 | "path": "aaa", 42 | }, 43 | { 44 | "body": "BBB", 45 | "path": "bbb", 46 | }, 47 | }, 48 | "id": "2f0f6ca5039506a1ebc5651a0b7e2b617e28544c", 49 | "subject": "Subject 1", 50 | "timestamp": "Mon, 4 Feb 2019 13:29:04 -0600", 51 | } 52 | `); 53 | 54 | const modifiedChangeset2 = modifiedChangeset1 55 | .withDescription('even newer description') 56 | .withDebugMessage('DEBUG %s', 'should be appended') 57 | .withDiffs(new Set([{ path: 'ccc', body: 'CCC' }])); 58 | 59 | // should be similar to modified changeset 1 but with some changed values 60 | expect(modifiedChangeset2).toMatchInlineSnapshot(` 61 | Changeset { 62 | "author": "John Doe", 63 | "coAuthorLines": [], 64 | "debugMessages": [ 65 | "DEBUG yadada", 66 | "DEBUG should be appended", 67 | ], 68 | "description": "even newer description", 69 | "diffs": Set { 70 | { 71 | "body": "CCC", 72 | "path": "ccc", 73 | }, 74 | }, 75 | "id": "2f0f6ca5039506a1ebc5651a0b7e2b617e28544c", 76 | "subject": "Subject 1", 77 | "timestamp": "Mon, 4 Feb 2019 13:29:04 -0600", 78 | } 79 | `); 80 | }); 81 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/ShipitConfig.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`creates and runs the default filters 1`] = ` 4 | Changeset { 5 | "author": "John Doe ", 6 | "coAuthorLines": [], 7 | "debugMessages": [ 8 | "ADD TRACKING DATA: "adeira-source-id: 1234567890"", 9 | ], 10 | "description": "Test description 11 | 12 | adeira-source-id: 1234567890", 13 | "diffs": Set { 14 | { 15 | "body": "new file mode 100644 16 | index 0000000000000000000000000000000000000000..1111111111111111111111111111111111111111 17 | --- /dev/null 18 | +++ b//destination_path/fakeFile_1.txt 19 | @@ -0,0 +1 @@ 20 | +fake content", 21 | "path": "/destination_path/fakeFile_1.txt", 22 | }, 23 | { 24 | "body": "new file mode 100644 25 | index 0000000000000000000000000000000000000000..1111111111111111111111111111111111111111 26 | --- /dev/null 27 | +++ b//destination_path/fakeFile_2.txt 28 | @@ -0,0 +1 @@ 29 | +fake content", 30 | "path": "/destination_path/fakeFile_2.txt", 31 | }, 32 | }, 33 | "id": "1234567890", 34 | "subject": "Test subject", 35 | "timestamp": "Mon, 16 July 2019 10:55:04 -0100", 36 | } 37 | `; 38 | 39 | exports[`creates and runs the default filters with Co-authored-by 1`] = ` 40 | Changeset { 41 | "author": "John Doe ", 42 | "coAuthorLines": [ 43 | "Co-authored-by: Trond Bergquist ", 44 | "Co-authored-by: Patricia Bergquist ", 45 | ], 46 | "debugMessages": [ 47 | "ADD TRACKING DATA: "adeira-source-id: 1234567890"", 48 | ], 49 | "description": "Test description 50 | 51 | adeira-source-id: 1234567890 52 | 53 | Co-authored-by: Trond Bergquist 54 | Co-authored-by: Patricia Bergquist ", 55 | "diffs": Set { 56 | { 57 | "body": "new file mode 100644 58 | index 0000000000000000000000000000000000000000..1111111111111111111111111111111111111111 59 | --- /dev/null 60 | +++ b//destination_path/fakeFile_1.txt 61 | @@ -0,0 +1 @@ 62 | +fake content", 63 | "path": "/destination_path/fakeFile_1.txt", 64 | }, 65 | { 66 | "body": "new file mode 100644 67 | index 0000000000000000000000000000000000000000..1111111111111111111111111111111111111111 68 | --- /dev/null 69 | +++ b//destination_path/fakeFile_2.txt 70 | @@ -0,0 +1 @@ 71 | +fake content", 72 | "path": "/destination_path/fakeFile_2.txt", 73 | }, 74 | }, 75 | "id": "1234567890", 76 | "subject": "Test subject", 77 | "timestamp": "Mon, 16 July 2019 10:55:04 -0100", 78 | } 79 | `; 80 | -------------------------------------------------------------------------------- /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__/moveDirectories.test.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | import fs from 'fs'; 4 | import path from 'path'; 5 | 6 | import Changeset from '../../Changeset'; 7 | import moveDirectories from '../moveDirectories'; 8 | import RepoGitFake from '../../RepoGitFake'; 9 | 10 | test.each([ 11 | [ 12 | 'first takes precedence (first is more specific)', 13 | new Map([ 14 | // from => to 15 | ['foo/public_tld/', ''], 16 | ['foo/', ''], 17 | ]), 18 | ['foo/orig_root_file', 'foo/public_tld/public_root_file'], 19 | ['orig_root_file', 'public_root_file'], 20 | ], 21 | [ 22 | // this mapping doesn't make sense given the behavior, just using it to check that order matters 23 | 'first takes precedence (second is more specific)', 24 | new Map([ 25 | ['foo/', ''], 26 | ['foo/public_tld/', ''], 27 | ]), 28 | ['foo/orig_root_file', 'foo/public_tld/public_root_file'], 29 | ['orig_root_file', 'public_tld/public_root_file'], 30 | ], 31 | [ 32 | 'only one rule applied', 33 | new Map([ 34 | ['foo/', ''], 35 | ['bar/', 'project_bar/'], 36 | ]), 37 | ['foo/bar/part of project foo', 'bar/part of project bar'], 38 | [ 39 | 'bar/part of project foo', // this shouldn't turn into 'project_bar/part of project foo' 40 | 'project_bar/part of project bar', 41 | ], 42 | ], 43 | [ 44 | 'missing trailing slashes', 45 | new Map([ 46 | ['foo', 'bar'], 47 | ['xyz/', 'aaa'], 48 | ]), 49 | ['foo/file', 'foo_baz/file', 'xyz/file'], 50 | [ 51 | 'bar/file', 52 | 'bar_baz/file', 53 | 'aaafile', // this can be a gotcha for someone 54 | ], 55 | ], 56 | [ 57 | 'path with special regex characters', 58 | new Map([['Logo (no background).imageset/', 'logo_no_background_imageset/']]), 59 | ['Logo (no background).imageset/Contents.json'], 60 | ['logo_no_background_imageset/Contents.json'], 61 | ], 62 | ])('%s', (testName, mapping, inputPaths, expected) => { 63 | const changeset = new Changeset().withDiffs( 64 | new Set(inputPaths.map((path) => ({ path, body: 'placeholder' }))), 65 | ); 66 | const diffs = moveDirectories(changeset, mapping).getDiffs(); 67 | expect([...diffs].map((diff) => diff.path)).toEqual(expected); 68 | }); 69 | 70 | it('handles complex filenames requiring regexp escaping correctly', () => { 71 | const repo = new RepoGitFake(); 72 | const patch = fs.readFileSync(path.join(__dirname, 'fixtures', 'new-file-complex.patch'), 'utf8'); 73 | const changeset = repo.getChangesetFromExportedPatch('MOCKED_HEADER', patch); 74 | 75 | expect( 76 | moveDirectories( 77 | changeset, 78 | new Map([ 79 | ['src/ya-comiste-react-native/', 'react-native/'], 80 | ['src/ya-comiste-rust/', 'rust/'], 81 | ]), 82 | ), 83 | ).toMatchSnapshot(); 84 | }); 85 | -------------------------------------------------------------------------------- /src/filters/__tests__/addTrackingData.test.js: -------------------------------------------------------------------------------- 1 | // @flow strict-local 2 | 3 | import addTrackingData from '../addTrackingData'; 4 | import Changeset from '../../Changeset'; 5 | 6 | it('adds tracking data', () => { 7 | const changeset = new Changeset() 8 | .withID('MOCK_COMMIT_ID') 9 | .withAuthor('John Doe') 10 | .withSubject('Commit subject') 11 | .withDescription('Commit description'); 12 | 13 | expect(changeset).toMatchInlineSnapshot(` 14 | Changeset { 15 | "author": "John Doe", 16 | "coAuthorLines": [], 17 | "debugMessages": [], 18 | "description": "Commit description", 19 | "id": "MOCK_COMMIT_ID", 20 | "subject": "Commit subject", 21 | } 22 | `); 23 | 24 | expect(addTrackingData(changeset)).toMatchInlineSnapshot(` 25 | Changeset { 26 | "author": "John Doe", 27 | "coAuthorLines": [], 28 | "debugMessages": [ 29 | "ADD TRACKING DATA: "adeira-source-id: MOCK_COMMIT_ID"", 30 | ], 31 | "description": "Commit description 32 | 33 | adeira-source-id: MOCK_COMMIT_ID", 34 | "id": "MOCK_COMMIT_ID", 35 | "subject": "Commit subject", 36 | } 37 | `); 38 | }); 39 | 40 | it('adds tracking data with Co-authored-by line', () => { 41 | const changeset = new Changeset() 42 | .withID('MOCK_COMMIT_ID') 43 | .withAuthor('John Doe') 44 | .withSubject('Commit subject') 45 | .withDescription('Commit description') 46 | .withCoAuthorLines([ 47 | 'Co-authored-by: Trond Bergquist ', 48 | 'Co-authored-by: Patricia Bergquist ', 49 | ]); 50 | 51 | expect(changeset).toMatchInlineSnapshot(` 52 | Changeset { 53 | "author": "John Doe", 54 | "coAuthorLines": [ 55 | "Co-authored-by: Trond Bergquist ", 56 | "Co-authored-by: Patricia Bergquist ", 57 | ], 58 | "debugMessages": [], 59 | "description": "Commit description", 60 | "id": "MOCK_COMMIT_ID", 61 | "subject": "Commit subject", 62 | } 63 | `); 64 | 65 | expect(addTrackingData(changeset)).toMatchInlineSnapshot(` 66 | Changeset { 67 | "author": "John Doe", 68 | "coAuthorLines": [ 69 | "Co-authored-by: Trond Bergquist ", 70 | "Co-authored-by: Patricia Bergquist ", 71 | ], 72 | "debugMessages": [ 73 | "ADD TRACKING DATA: "adeira-source-id: MOCK_COMMIT_ID"", 74 | ], 75 | "description": "Commit description 76 | 77 | adeira-source-id: MOCK_COMMIT_ID 78 | 79 | Co-authored-by: Trond Bergquist 80 | Co-authored-by: Patricia Bergquist ", 81 | "id": "MOCK_COMMIT_ID", 82 | "subject": "Commit subject", 83 | } 84 | `); 85 | }); 86 | -------------------------------------------------------------------------------- /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 | { 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 | { 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 | { 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 | { 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/__tests__/ShipitConfig.test.js: -------------------------------------------------------------------------------- 1 | // @flow strict-local 2 | 3 | import Config from '../ShipitConfig'; 4 | import createMockChangeset from '../utils/createMockChangeset'; 5 | 6 | jest.mock('fs'); 7 | 8 | it('returns set of paths when trying to access monorepo roots', () => { 9 | expect( 10 | new Config( 11 | 'fake monorepo path', 12 | 'fake exported repo URL', 13 | new Map([['/known_path', '/destination_path']]), 14 | new Set([/mocked/]), 15 | ).getSourceRoots(), 16 | ).toEqual(new Set(['/known_path'])); 17 | }); 18 | 19 | it('returns empty set when trying to get roots of the exported repo', () => { 20 | expect( 21 | new Config( 22 | 'fake monorepo path', 23 | 'fake exported repo URL', 24 | new Map([['/known_path', '/destination_path']]), 25 | new Set([/mocked/]), 26 | ).getDestinationRoots(), 27 | ).toEqual( 28 | new Set(), // empty set expected 29 | ); 30 | }); 31 | 32 | it('creates and runs the default filters', () => { 33 | const defaultFilter = new Config( 34 | 'fake monorepo path', 35 | 'fake exported repo URL', 36 | new Map([['/known_path', '/destination_path']]), 37 | new Set([/mocked/]), 38 | ).getDefaultShipitFilter(); 39 | 40 | expect(defaultFilter(createMockChangeset(2, '/known_path/'))).toMatchSnapshot(); 41 | }); 42 | 43 | it('creates and runs the default filters with Co-authored-by', () => { 44 | const defaultFilter = new Config( 45 | 'fake monorepo path', 46 | 'fake exported repo URL', 47 | new Map([['/known_path', '/destination_path']]), 48 | new Set([/mocked/]), 49 | ).getDefaultShipitFilter(); 50 | 51 | const changeset = createMockChangeset(2, '/known_path/').withCoAuthorLines([ 52 | 'Co-authored-by: Trond Bergquist ', 53 | 'Co-authored-by: Patricia Bergquist ', 54 | ]); 55 | expect(defaultFilter(changeset)).toMatchSnapshot(); 56 | }); 57 | 58 | it('should use custom shipit filter', () => { 59 | const defaultFilter = new Config( 60 | 'fake monorepo path', 61 | 'fake exported repo URL', 62 | new Map([['/known_path', '/destination_path']]), 63 | new Set([/mocked/]), 64 | 'origin/master', 65 | 'master', 66 | (changeset) => { 67 | return changeset.withDescription('Overridden description'); 68 | }, 69 | ).getDefaultShipitFilter(); 70 | 71 | const changeset = createMockChangeset(2, '/known_path/').withCoAuthorLines([ 72 | 'Co-authored-by: Trond Bergquist ', 73 | 'Co-authored-by: Patricia Bergquist ', 74 | ]); 75 | 76 | expect(defaultFilter(changeset).description).toMatchInlineSnapshot(` 77 | "Overridden description 78 | 79 | adeira-source-id: 1234567890 80 | 81 | Co-authored-by: Trond Bergquist 82 | Co-authored-by: Patricia Bergquist " 83 | `); 84 | }); 85 | -------------------------------------------------------------------------------- /src/__tests__/requireAndValidateConfig.test.js: -------------------------------------------------------------------------------- 1 | // @flow strict-local 2 | 3 | import path from 'path'; 4 | 5 | import requireAndValidate from '../requireAndValidateConfig'; 6 | 7 | it('returns minimal valid config correctly', () => { 8 | const config = requireAndValidate( 9 | path.join(__dirname, 'fixtures', 'configs', 'valid-minimal.js'), 10 | ); 11 | expect(config).toMatchInlineSnapshot(` 12 | { 13 | "getPathMappings": [Function], 14 | "getStaticConfig": [Function], 15 | } 16 | `); 17 | expect(config.getPathMappings()).toMatchInlineSnapshot(` 18 | Map { 19 | "src/apps/example-relay/__github__/.flowconfig" => ".flowconfig", 20 | "src/apps/example-relay/" => "", 21 | } 22 | `); 23 | expect(config.getStaticConfig()).toMatchInlineSnapshot(` 24 | { 25 | "repository": "git@github.com/adeira/relay-example.git", 26 | } 27 | `); 28 | }); 29 | 30 | it('should return valid exhaustive config', () => { 31 | expect(() => { 32 | requireAndValidate(path.join(__dirname, 'fixtures', 'configs', 'valid-exhaustive.js')); 33 | }).not.toThrow(); 34 | }); 35 | 36 | it('returns valid config with branches correctly', () => { 37 | const config = requireAndValidate( 38 | path.join(__dirname, 'fixtures', 'configs', 'valid-branches.js'), 39 | ); 40 | expect(config).toMatchInlineSnapshot(` 41 | { 42 | "getBranchConfig": [Function], 43 | "getPathMappings": [Function], 44 | "getStaticConfig": [Function], 45 | } 46 | `); 47 | expect(config.getPathMappings()).toMatchInlineSnapshot(` 48 | Map { 49 | "src/apps/example-relay/__github__/.flowconfig" => ".flowconfig", 50 | "src/apps/example-relay/" => "", 51 | } 52 | `); 53 | expect(config.getStaticConfig()).toMatchInlineSnapshot(` 54 | { 55 | "repository": "git@github.com/adeira/relay-example.git", 56 | } 57 | `); 58 | }); 59 | 60 | it('fails when config contains unsupported fields', () => { 61 | expect(() => 62 | requireAndValidate( 63 | path.join(__dirname, 'fixtures', 'configs', 'invalid-additional-props-1.js'), 64 | ), 65 | ).toThrow( 66 | "Your config contains field 'defaultStrippedFiles' but this is not allowed key. Did you mean 'getStrippedFiles' instead?", 67 | ); 68 | expect(() => 69 | requireAndValidate( 70 | path.join(__dirname, 'fixtures', 'configs', 'invalid-additional-props-2.js'), 71 | ), 72 | ).toThrow( 73 | "Your config contains field 'defaultPathMappings' but this is not allowed key. Did you mean 'getPathMappings' instead?", 74 | ); 75 | }); 76 | 77 | it("fails when branch config doesn't have valid keys", () => { 78 | expect(() => 79 | requireAndValidate( 80 | path.join(__dirname, 'fixtures', 'configs', 'invalid-misconfigured-branches.js'), 81 | ), 82 | ).toThrow( 83 | "Your config contains field 'what_is_this' but this is not allowed key. Did you mean 'destination' instead?", 84 | ); 85 | }); 86 | 87 | it('fails when config does not contain all the required props', () => { 88 | expect(() => 89 | requireAndValidate(path.join(__dirname, 'fixtures', 'configs/invalid-missing-props.js')), 90 | ).toThrow("Configuration field 'getStaticConfig' is required but it's missing."); 91 | }); 92 | -------------------------------------------------------------------------------- /src/Changeset.js: -------------------------------------------------------------------------------- 1 | // @flow strict 2 | 3 | import { sprintf } from '@adeira/js'; 4 | 5 | export type Diff = { 6 | +path: string, 7 | +body: string, 8 | }; 9 | 10 | opaque type ChangesetData = { 11 | +id: string, 12 | +timestamp: string, 13 | +author: string, 14 | +subject: string, 15 | +description: string, 16 | +diffs: Set, 17 | +coAuthorLines: $ReadOnlyArray, 18 | +debugMessages: $ReadOnlyArray, 19 | }; 20 | 21 | export default class Changeset { 22 | declare id: string; 23 | declare timestamp: string; 24 | declare author: string; 25 | declare subject: string; 26 | declare description: string; 27 | declare diffs: Set; 28 | 29 | coAuthorLines: $ReadOnlyArray = []; 30 | debugMessages: $ReadOnlyArray = []; 31 | 32 | isValid(): boolean { 33 | return this.diffs.size > 0; 34 | } 35 | 36 | getID(): string { 37 | return this.id; 38 | } 39 | 40 | withID(id: string): Changeset { 41 | return this.__clone({ id }); 42 | } 43 | 44 | getTimestamp(): string { 45 | return this.timestamp; 46 | } 47 | 48 | withTimestamp(timestamp: string): Changeset { 49 | return this.__clone({ timestamp }); 50 | } 51 | 52 | getAuthor(): string { 53 | return this.author; 54 | } 55 | 56 | withAuthor(author: string): Changeset { 57 | return this.__clone({ author }); 58 | } 59 | 60 | getCoAuthorLines(): $ReadOnlyArray { 61 | return this.coAuthorLines; 62 | } 63 | 64 | withCoAuthorLines(coAuthorLines: $ReadOnlyArray): Changeset { 65 | return this.__clone({ coAuthorLines }); 66 | } 67 | 68 | getSubject(): string { 69 | return this.subject; 70 | } 71 | 72 | withSubject(subject: string): Changeset { 73 | return this.__clone({ subject }); 74 | } 75 | 76 | getDescription(): string { 77 | return this.description; 78 | } 79 | 80 | withDescription(description: string): Changeset { 81 | return this.__clone({ description }); 82 | } 83 | 84 | getCommitMessage(): string { 85 | return `${this.getSubject()}\n\n${this.getDescription()}`; 86 | } 87 | 88 | getDiffs(): Set { 89 | return this.diffs ?? new Set(); 90 | } 91 | 92 | withDiffs(diffs: Set): Changeset { 93 | return this.__clone({ diffs }); 94 | } 95 | 96 | withDebugMessage(string: string, ...args: $ReadOnlyArray): Changeset { 97 | const messages = this.debugMessages; 98 | return this.__clone({ 99 | debugMessages: messages.concat(sprintf(string, ...args)), 100 | }); 101 | } 102 | 103 | dumpDebugMessages(): void { 104 | console.log(sprintf(' DEBUG %s (%s)', this.getID(), this.getSubject())); 105 | for (const debugMessage of this.debugMessages) { 106 | console.log(' %s', debugMessage); 107 | } 108 | } 109 | 110 | __clone(newProps: { [$Keys]: $Values, ... }): Changeset { 111 | /* $FlowFixMe[prop-missing] This comment suppresses an error when upgrading 112 | * Flow to version 0.184.0. To see the error delete this comment and run 113 | * Flow. */ 114 | return Object.assign(Object.create(Object.getPrototypeOf(this)), this, newProps); 115 | } 116 | } 117 | -------------------------------------------------------------------------------- /bin/importit.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | // @flow 4 | 5 | import { invariant } from '@adeira/js'; 6 | import { program as commander } from 'commander'; 7 | 8 | import iterateConfigs from '../src/iterateConfigs'; 9 | import createClonePhase from '../src/phases/createClonePhase'; 10 | import createCheckCorruptedRepoPhase from '../src/phases/createCheckCorruptedRepoPhase'; 11 | import createCleanPhase from '../src/phases/createCleanPhase'; 12 | import createImportSyncPhase from '../src/phases/createImportSyncPhase'; 13 | import type { Phase } from '../types.flow'; 14 | 15 | // TODO: check we can actually import this package (whether we have config for it) 16 | // yarn monorepo-babel-node src/monorepo-shipit/bin/importit.js --help 17 | // yarn monorepo-babel-node src/monorepo-shipit/bin/importit.js --committer-name=A --committer-email=B --pull-request=https://github.com/adeira/universe/pull/1 18 | 19 | type ImportitCLIType = { 20 | +configFilter: string, 21 | +configDir: string, 22 | +committerName: string, 23 | +committerEmail: string, 24 | +pullRequest: string, 25 | }; 26 | 27 | commander 28 | .version(require('./../package.json').version) 29 | .description('Import pull request into your monorepo.') 30 | .requiredOption('--config-filter ', 'Glob pattern to filter config files', '/*.js') 31 | .requiredOption('--config-dir ', 'Path to the directory with config files', './.shipit') 32 | .requiredOption('--committer-name ', 'Name of the committer') 33 | .requiredOption('--committer-email ', 'Email of the committer') 34 | .requiredOption('--pull-request ', 'URL of the pull request to import'); 35 | 36 | commander.parse(); 37 | const options: ImportitCLIType = commander.opts(); 38 | 39 | function parseGitHubPRUrl(url: string): { +packageName: string, +prNumber: string } { 40 | const urlPattern = 41 | /^https:\/\/github\.com\/(?[A-Za-z0-9_.-]+)\/(?[A-Za-z0-9_.-]+)\/pull\/(?\d+)$/; 42 | 43 | const match = url.match(urlPattern); 44 | 45 | if (match) { 46 | invariant(match.groups?.org != null, 'Invalid GitHub PR URL (cannot determine org)'); 47 | invariant(match.groups.repo != null, 'Invalid GitHub PR URL (cannot determine repo)'); 48 | invariant(match.groups.prNumber != null, 'Invalid GitHub PR URL (cannot determine PR number)'); 49 | 50 | return { 51 | packageName: `${match.groups.org}/${match.groups.repo}`, 52 | prNumber: match.groups.prNumber, 53 | }; 54 | } 55 | throw new Error( 56 | 'Invalid GitHub PR URL. We currently support imports only from GitHub.com - please open an issue to add a support for additional providers.', 57 | ); 58 | } 59 | 60 | process.env.SHIPIT_COMMITTER_EMAIL = options.committerEmail; 61 | process.env.SHIPIT_COMMITTER_NAME = options.committerName; 62 | 63 | const parsedURL = parseGitHubPRUrl(options.pullRequest); 64 | const repoURL = `git@github.com:${parsedURL.packageName}.git`; 65 | 66 | iterateConfigs(options, (config) => { 67 | if (config.exportedRepoURL === repoURL) { 68 | new Set([ 69 | createClonePhase(config.exportedRepoURL, config.destinationPath), 70 | createCheckCorruptedRepoPhase(config.destinationPath), 71 | createCleanPhase(config.destinationPath), 72 | createImportSyncPhase(config, parsedURL.packageName, parsedURL.prNumber), 73 | ]).forEach((phase) => phase()); 74 | } 75 | }); 76 | -------------------------------------------------------------------------------- /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 '@adeira/shell-command'; 7 | 8 | import RepoGit from '../RepoGit'; 9 | import ShipitConfig from '../ShipitConfig'; 10 | import type { Phase } from '../../types.flow'; 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): Phase { 25 | function createNewEmptyRepo(path: string) { 26 | new ShellCommand(path, 'git', 'init').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(), 'adeira-shipit-verify-dirty-')); 34 | } 35 | 36 | function getFilteredExportedRepoPath() { 37 | return fs.mkdtempSync(path.join(os.tmpdir(), 'adeira-shipit-verify-filtered-')); 38 | } 39 | 40 | const monorepoPath = config.sourcePath; 41 | 42 | const phase = 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').runSynchronously(); 50 | 51 | new ShellCommand( 52 | dirtyExportedRepoPath, 53 | 'git', 54 | 'commit', 55 | '-m', 56 | 'Initial filtered commit', 57 | ).runSynchronously(); 58 | 59 | const dirtyChangeset = dirtyExportedRepo.getChangesetFromID('HEAD'); 60 | const filter = config.getDefaultShipitFilter(); 61 | const filteredChangeset = filter(dirtyChangeset).withSubject('Initial filtered commit'); 62 | 63 | const filteredRepoPath = getFilteredExportedRepoPath(); 64 | const filteredRepo = createNewEmptyRepo(filteredRepoPath); 65 | filteredRepo.commitPatch(filteredChangeset); 66 | 67 | new ShellCommand( 68 | filteredRepoPath, 69 | 'git', 70 | 'remote', 71 | 'add', 72 | 'shipit_destination', 73 | config.destinationPath, // notice we don't use URL here but locally updated repo instead 74 | ).runSynchronously(); 75 | 76 | new ShellCommand(filteredRepoPath, 'git', 'fetch', 'shipit_destination').runSynchronously(); 77 | 78 | const diffStats = new ShellCommand( 79 | filteredRepoPath, 80 | 'git', 81 | '--no-pager', 82 | 'diff', 83 | '--stat', 84 | 'HEAD', 85 | `shipit_destination/${config.getDestinationBranch()}`, 86 | ) 87 | .runSynchronously() 88 | .getStdout() 89 | .trim(); 90 | 91 | if (diffStats !== '') { 92 | const diff = new ShellCommand( 93 | filteredRepoPath, 94 | 'git', 95 | 'diff', 96 | '--full-index', 97 | '--binary', 98 | '--no-color', 99 | `shipit_destination/${config.getDestinationBranch()}`, 100 | 'HEAD', 101 | ) 102 | .runSynchronously() 103 | .getStdout(); 104 | console.error(diff); 105 | throw new Error('❌ Repository is out of SYNC!'); 106 | } 107 | }; 108 | 109 | phase.readableName = 'Verify integrity of the repository'; 110 | return phase; 111 | } 112 | -------------------------------------------------------------------------------- /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: string) { 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 Co-authored-by', () => { 33 | const fakeCommitID = generateCommitID(); 34 | const description = addTrackingData( 35 | new Changeset() 36 | .withID(fakeCommitID) 37 | .withCoAuthorLines(['Co-authored-by: Trond Bergquist ']), 38 | ).getDescription(); 39 | const repo = createGITRepoWithCommit(description); 40 | expect(repo.findLastSourceCommit(new Set())).toBe(fakeCommitID); 41 | }); 42 | 43 | it('can find last source commit with multiple markers', () => { 44 | const fakeCommitID1 = generateCommitID(); 45 | const fakeCommitID2 = generateCommitID(); 46 | const description1 = addTrackingData(new Changeset().withID(fakeCommitID1)).getDescription(); 47 | const description2 = addTrackingData(new Changeset().withID(fakeCommitID2)).getDescription(); 48 | const repo = createGITRepoWithCommit(`${description1}\n\n${description2}`); 49 | expect(repo.findLastSourceCommit(new Set())).toBe(fakeCommitID2); 50 | }); 51 | 52 | it('can find last source commit with trailing whitespace', () => { 53 | const fakeCommitID = generateCommitID(); 54 | const description = addTrackingData(new Changeset().withID(fakeCommitID)).getDescription(); 55 | const repo = createGITRepoWithCommit(`${description} `); 56 | expect(repo.findLastSourceCommit(new Set())).toBe(fakeCommitID); 57 | }); 58 | 59 | it('can find last source commit without whitespaces', () => { 60 | const fakeCommitID = generateCommitID(); 61 | const description = `adeira-source-id:${fakeCommitID}`; 62 | const repo = createGITRepoWithCommit(`${description} `); 63 | expect(repo.findLastSourceCommit(new Set())).toBe(fakeCommitID); 64 | }); 65 | 66 | it('should return first commit in list', () => { 67 | const repo = new RepoGitFake(); 68 | const firstCommit = repo.commitPatch( 69 | new Changeset() 70 | .withSubject('test subject') 71 | .withDescription('initial-commit') 72 | .withAuthor('John Doe ') 73 | .withTimestamp('Mon, 4 Feb 2019 13:29:04 -0600'), 74 | ); 75 | 76 | const secondCommit = repo.commitPatch( 77 | new Changeset() 78 | .withSubject('test subject') 79 | .withDescription('second-commit') 80 | .withAuthor('John Doe ') 81 | .withTimestamp('Mon, 5 Feb 2019 13:29:04 -0600'), 82 | ); 83 | 84 | const descendants = repo.findDescendantsPath(firstCommit, 'master', new Set([])); 85 | 86 | // It never brings back the first commit (base revision)! 87 | // This means any repository that has a first commit _with content_ has its shipit fail. 88 | expect(descendants).toEqual([secondCommit]); 89 | 90 | const descendantsFromFirst = repo.findDescendantsPath(firstCommit, 'master', new Set([]), true); 91 | 92 | // By forcing it we can have it picked up. 93 | expect(descendantsFromFirst).toEqual([firstCommit, secondCommit]); 94 | }); 95 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/parsePatchHeader.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`matches expected output: co-authored-by.header: 45178502b2cdd06958b837cb5b8a0cf8 1`] = ` 4 | ~~~~~~~~~~ INPUT ~~~~~~~~~~ 5 | From 160feaf6d2637b22216987d1f95b96d585a838a6 Mon Sep 17 00:00:00 2001 6 | From: =?UTF-8?q?Martin=20Zl=C3=A1mal?= 7 | Date: Thu, 15 Apr 2021 13:18:49 -0500 8 | Subject: [PATCH] SX Design: add Norwegian language 9 | 10 | Co-authored-by: Trond Bergquist 11 | 12 | 13 | ~~~~~~~~~~ OUTPUT ~~~~~~~~~~ 14 | { 15 | "coAuthorLines": [ 16 | "Co-authored-by: Trond Bergquist " 17 | ], 18 | "debugMessages": [], 19 | "description": "", 20 | "author": "=?UTF-8?q?Martin=20Zl=C3=A1mal?= ", 21 | "timestamp": "Thu, 15 Apr 2021 13:18:49 -0500", 22 | "subject": "SX Design: add Norwegian language" 23 | } 24 | `; 25 | 26 | exports[`matches expected output: co-authored-by-multiple.header: 45178502b2cdd06958b837cb5b8a0cf8 1`] = ` 27 | ~~~~~~~~~~ INPUT ~~~~~~~~~~ 28 | From 160feaf6d2637b22216987d1f95b96d585a838a6 Mon Sep 17 00:00:00 2001 29 | From: =?UTF-8?q?Martin=20Zl=C3=A1mal?= 30 | Date: Thu, 15 Apr 2021 13:18:49 -0500 31 | Subject: [PATCH] SX Design: add Norwegian language 32 | 33 | Co-authored-by: Trond Bergquist 34 | Co-authored-by: Patricia Bergquist 35 | 36 | 37 | ~~~~~~~~~~ OUTPUT ~~~~~~~~~~ 38 | { 39 | "coAuthorLines": [ 40 | "Co-authored-by: Trond Bergquist ", 41 | "Co-authored-by: Patricia Bergquist " 42 | ], 43 | "debugMessages": [], 44 | "description": "", 45 | "author": "=?UTF-8?q?Martin=20Zl=C3=A1mal?= ", 46 | "timestamp": "Thu, 15 Apr 2021 13:18:49 -0500", 47 | "subject": "SX Design: add Norwegian language" 48 | } 49 | `; 50 | 51 | exports[`matches expected output: multiline-subject.header: 45178502b2cdd06958b837cb5b8a0cf8 1`] = ` 52 | ~~~~~~~~~~ INPUT ~~~~~~~~~~ 53 | From 93ee65de02e77c52eb9d677b859554b40253bafa Mon Sep 17 00:00:00 2001 54 | From: =?UTF-8?q?Martin=20Zl=C3=A1mal?= 55 | Date: Wed, 10 Apr 2019 16:50:47 -0500 56 | Subject: [PATCH] Docs: add Relay and GraphQL related videos from our internal 57 | JS Saturday 58 | 59 | 60 | ~~~~~~~~~~ OUTPUT ~~~~~~~~~~ 61 | { 62 | "coAuthorLines": [], 63 | "debugMessages": [], 64 | "description": "", 65 | "author": "=?UTF-8?q?Martin=20Zl=C3=A1mal?= ", 66 | "timestamp": "Wed, 10 Apr 2019 16:50:47 -0500", 67 | "subject": "Docs: add Relay and GraphQL related videos from our internal JS Saturday" 68 | } 69 | `; 70 | 71 | exports[`matches expected output: simple.header: 45178502b2cdd06958b837cb5b8a0cf8 1`] = ` 72 | ~~~~~~~~~~ INPUT ~~~~~~~~~~ 73 | From bf0a8783cf416f796659e60fe735bba98e676e67 Mon Sep 17 00:00:00 2001 74 | From: Automator 75 | Date: Mon, 4 Feb 2019 13:29:04 -0600 76 | Subject: [PATCH] Import and publish '@adeira/relay' (0.1.0) 77 | 78 | Formerly known as '@mrtnzlml/relay' 79 | 80 | 81 | ~~~~~~~~~~ OUTPUT ~~~~~~~~~~ 82 | { 83 | "coAuthorLines": [], 84 | "debugMessages": [], 85 | "description": "Formerly known as '@mrtnzlml/relay'", 86 | "author": "Automator ", 87 | "timestamp": "Mon, 4 Feb 2019 13:29:04 -0600", 88 | "subject": "Import and publish '@adeira/relay' (0.1.0)" 89 | } 90 | `; 91 | 92 | exports[`matches expected output: unicode.header: 45178502b2cdd06958b837cb5b8a0cf8 1`] = ` 93 | ~~~~~~~~~~ INPUT ~~~~~~~~~~ 94 | From 0000000000000000000000000000000000000000 Mon Sep 17 00:00:00 2001 95 | From: =?UTF-8?q?Martin=20Zl=C3=A1mal?= 96 | Date: Wed, 10 Apr 2019 16:50:47 -0500 97 | Subject: [PATCH] Let's party 🍺 🍻 🍷 🍸😀 😬 😁 😂 😃 😄 😅 98 | 99 | 100 | ~~~~~~~~~~ OUTPUT ~~~~~~~~~~ 101 | { 102 | "coAuthorLines": [], 103 | "debugMessages": [], 104 | "description": "", 105 | "author": "=?UTF-8?q?Martin=20Zl=C3=A1mal?= ", 106 | "timestamp": "Wed, 10 Apr 2019 16:50:47 -0500", 107 | "subject": "Let's party 🍺 🍻 🍷 🍸😀 😬 😁 😂 😃 😄 😅" 108 | } 109 | `; 110 | -------------------------------------------------------------------------------- /src/ShipitConfig.js: -------------------------------------------------------------------------------- 1 | // @flow 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 | customShipitFilter: ChangesetFilter; 25 | customImportitFilter: ChangesetFilter; 26 | 27 | #sourceBranch: string; 28 | #destinationBranch: string; 29 | 30 | constructor( 31 | sourcePath: string, 32 | exportedRepoURL: string, 33 | directoryMapping: Map, 34 | strippedFiles: Set, 35 | sourceBranch: string = 'origin/master', // our GitLab CI doesn't have master branch 36 | destinationBranch: string = 'master', 37 | customShipitFilter: ChangesetFilter = (changeset) => changeset, 38 | customImportitFilter: ChangesetFilter = (changeset) => changeset, 39 | ) { 40 | this.sourcePath = sourcePath; 41 | // This is currently not configurable. We could (should) eventually keep 42 | // the temp directory, cache it and just update it. 43 | this.destinationPath = fs.mkdtempSync(path.join(os.tmpdir(), 'adeira-shipit-')); 44 | this.exportedRepoURL = exportedRepoURL; 45 | this.directoryMapping = directoryMapping; 46 | this.strippedFiles = strippedFiles; 47 | this.customShipitFilter = customShipitFilter; 48 | this.customImportitFilter = customImportitFilter; 49 | this.#sourceBranch = sourceBranch; 50 | this.#destinationBranch = destinationBranch; 51 | } 52 | 53 | getSourceRoots(): Set { 54 | const roots = new Set(); 55 | for (const root of this.directoryMapping.keys()) { 56 | warning(fs.existsSync(root) === true, `Directory mapping uses non-existent root: ${root}`); 57 | roots.add(root); 58 | } 59 | return roots; 60 | } 61 | 62 | getDestinationRoots(): Set { 63 | // In out cases root is always "". However, if we'd like to export our 64 | // workspaces to another monorepo then the root would change to something 65 | // like "my-oss-project/" 66 | return new Set(); 67 | } 68 | 69 | /** 70 | * Shipit by default maps directory to match OSS version and strips everything 71 | * else so we don't publish something outside of the roots scope. 72 | */ 73 | getDefaultShipitFilter(): ChangesetFilter { 74 | return (changeset: Changeset) => { 75 | const ch0 = this.customShipitFilter(changeset); 76 | const ch1 = addTrackingData(ch0); 77 | const ch2 = stripExceptDirectories(ch1, this.getSourceRoots()); 78 | const ch3 = moveDirectories(ch2, this.directoryMapping); 79 | const ch4 = stripPaths(ch3, this.strippedFiles); 80 | 81 | // First we comment out lines marked with `@x-shipit-disable`. 82 | const ch5 = commentLines(ch4, '@x-shipit-disable', '//', null); 83 | // Then we uncomment lines marked with `@x-shipit-enable`. 84 | return uncommentLines(ch5, '@x-shipit-enable', '//', null); 85 | }; 86 | } 87 | 88 | /** 89 | * Importit reverses the directory mapping and strip some predefined files. 90 | * It should be in the reversed order from Shipit filters. 91 | * Please note: there are usually less filters when importing the project (not 1:1 with Shipit). 92 | */ 93 | getDefaultImportitFilter(): ChangesetFilter { 94 | return (changeset: Changeset) => { 95 | const ch0 = this.customImportitFilter(changeset); 96 | // Comment out lines which are only for OSS. 97 | const ch1 = commentLines(ch0, '@x-shipit-enable', '//', null); 98 | // Uncomment private code which is disabled in OSS. 99 | const ch2 = uncommentLines(ch1, '@x-shipit-disable', '//', null); 100 | 101 | const ch3 = stripPaths(ch2, this.strippedFiles); 102 | return moveDirectoriesReverse(ch3, this.directoryMapping); 103 | }; 104 | } 105 | 106 | getSourceBranch(): string { 107 | return this.#sourceBranch; 108 | } 109 | 110 | getDestinationBranch(): string { 111 | return this.#destinationBranch; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/Integration.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`creates correct changeset from commit cc7b3818e06f95c2732f75f165a2b98c6eeab135, filters it and renders it as expected: 1 - parsed changeset without filters 1`] = ` 4 | Changeset { 5 | "author": ""dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>", 6 | "coAuthorLines": [], 7 | "debugMessages": [], 8 | "description": "Bumps [prettier](https://github.com/prettier/prettier) from 2.3.0 to 2.3.1. 9 | - [Release notes](https://github.com/prettier/prettier/releases) 10 | - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) 11 | - [Commits](https://github.com/prettier/prettier/compare/2.3.0...2.3.1) 12 | 13 | --- 14 | updated-dependencies: 15 | - dependency-name: prettier 16 | dependency-type: direct:production 17 | update-type: version-update:semver-patch 18 | ... 19 | 20 | Signed-off-by: dependabot[bot] ", 21 | "diffs": Set { 22 | { 23 | "body": "index 33c6b476374a38f54e7aa3a92d4f9aed68c34624..dd10ce35969c86ef2c5bd8c59e58bb1fea865a58 100644 24 | --- a/src/eslint-config-adeira/package.json 25 | +++ b/src/eslint-config-adeira/package.json 26 | @@ -26,7 +26,7 @@ 27 | "eslint-plugin-react-native": "^3.11.0", 28 | "eslint-plugin-relay": "^1.8.2", 29 | "eslint-plugin-sx": "^0.11.0", 30 | - "prettier": "^2.3.0" 31 | + "prettier": "^2.3.1" 32 | }, 33 | "devDependencies": { 34 | "deep-diff": "^1.0.2", 35 | ", 36 | "path": "src/eslint-config-adeira/package.json", 37 | }, 38 | { 39 | "body": "index dd99feca88cc2c4f0561b8b2274d547f4194251a..3dfbae6752edf8d4390293bc3b3c8edb7b26bf56 100644 40 | --- a/src/sx-jest-snapshot-serializer/package.json 41 | +++ b/src/sx-jest-snapshot-serializer/package.json 42 | @@ -11,7 +11,7 @@ 43 | "dependencies": { 44 | "@adeira/sx": "^0.25.0", 45 | "@babel/runtime": "^7.14.0", 46 | - "prettier": "^2.3.0", 47 | + "prettier": "^2.3.1", 48 | "pretty-format": "^27.0.2" 49 | }, 50 | "devDependencies": { 51 | ", 52 | "path": "src/sx-jest-snapshot-serializer/package.json", 53 | }, 54 | { 55 | "body": "index d486c00d03f695c9ff320d25b0a2523ed6330a6a..3a2c4845ffd37e77e97e71af73c97b4913ca6a1a 100644 56 | --- a/src/sx/package.json 57 | +++ b/src/sx/package.json 58 | @@ -19,7 +19,7 @@ 59 | "fast-levenshtein": "^3.0.0", 60 | "json-stable-stringify": "^1.0.1", 61 | "mdn-data": "^2.0.19", 62 | - "prettier": "^2.3.0", 63 | + "prettier": "^2.3.1", 64 | "stylis": "^4.0.10" 65 | }, 66 | "peerDependencies": { 67 | ", 68 | "path": "src/sx/package.json", 69 | }, 70 | { 71 | "body": "index 63ed36a5e5c003c175d9d91a2786b8c1746d11cb..ad33f54ed4ad0afbe5a4561502bfdda0823ef574 100644 72 | --- a/yarn.lock 73 | +++ b/yarn.lock 74 | @@ -14086,10 +14086,10 @@ prettier-linter-helpers@^1.0.0: 75 | dependencies: 76 | fast-diff "^1.1.2" 77 | 78 | -prettier@^2.0.1, prettier@^2.3.0: 79 | - version "2.3.0" 80 | - resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.3.0.tgz#b6a5bf1284026ae640f17f7ff5658a7567fc0d18" 81 | - integrity sha512-kXtO4s0Lz/DW/IJ9QdWhAf7/NmPWQXkFr/r/WkR3vyI+0v8amTDxiaQSLzs8NBlytfLWX/7uQUMIW677yLKl4w== 82 | +prettier@^2.0.1, prettier@^2.3.1: 83 | + version "2.3.1" 84 | + resolved "https://registry.yarnpkg.com/prettier/-/prettier-2.3.1.tgz#76903c3f8c4449bc9ac597acefa24dc5ad4cbea6" 85 | + integrity sha512-p+vNbgpLjif/+D+DwAZAbndtRrR0md0MwfmOVN9N+2RgyACMT+7tfaRnT+WDPkqnuVwleyuBIG2XBxKDme3hPA== 86 | 87 | prettier@~2.2.1: 88 | version "2.2.1" 89 | ", 90 | "path": "yarn.lock", 91 | }, 92 | }, 93 | "id": "cc7b3818e06f95c2732f75f165a2b98c6eeab135", 94 | "subject": "Bump prettier from 2.3.0 to 2.3.1", 95 | "timestamp": "Mon, 7 Jun 2021 05:12:25 +0000", 96 | } 97 | `; 98 | 99 | exports[`creates correct changeset from commit cc7b3818e06f95c2732f75f165a2b98c6eeab135, filters it and renders it as expected: 2 - parsed changeset WITH applied filters 1`] = ` 100 | Changeset { 101 | "author": ""dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com>", 102 | "coAuthorLines": [], 103 | "debugMessages": [ 104 | "ADD TRACKING DATA: "adeira-source-id: cc7b3818e06f95c2732f75f165a2b98c6eeab135"", 105 | ], 106 | "description": "Bumps [prettier](https://github.com/prettier/prettier) from 2.3.0 to 2.3.1. 107 | - [Release notes](https://github.com/prettier/prettier/releases) 108 | - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) 109 | - [Commits](https://github.com/prettier/prettier/compare/2.3.0...2.3.1) 110 | 111 | --- 112 | updated-dependencies: 113 | - dependency-name: prettier 114 | dependency-type: direct:production 115 | update-type: version-update:semver-patch 116 | ... 117 | 118 | Signed-off-by: dependabot[bot] 119 | 120 | adeira-source-id: cc7b3818e06f95c2732f75f165a2b98c6eeab135", 121 | "diffs": Set { 122 | { 123 | "body": "index 33c6b476374a38f54e7aa3a92d4f9aed68c34624..dd10ce35969c86ef2c5bd8c59e58bb1fea865a58 100644 124 | --- a/package.json 125 | +++ b/package.json 126 | @@ -26,7 +26,7 @@ 127 | "eslint-plugin-react-native": "^3.11.0", 128 | "eslint-plugin-relay": "^1.8.2", 129 | "eslint-plugin-sx": "^0.11.0", 130 | - "prettier": "^2.3.0" 131 | + "prettier": "^2.3.1" 132 | }, 133 | "devDependencies": { 134 | "deep-diff": "^1.0.2", 135 | ", 136 | "path": "package.json", 137 | }, 138 | }, 139 | "id": "cc7b3818e06f95c2732f75f165a2b98c6eeab135", 140 | "subject": "Bump prettier from 2.3.0 to 2.3.1", 141 | "timestamp": "Mon, 7 Jun 2021 05:12:25 +0000", 142 | } 143 | `; 144 | 145 | exports[`creates correct changeset from commit cc7b3818e06f95c2732f75f165a2b98c6eeab135, filters it and renders it as expected: 3 - rendered changeset 1`] = ` 146 | "From cc7b3818e06f95c2732f75f165a2b98c6eeab135 Mon Sep 17 00:00:00 2001 147 | From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> 148 | Date: Mon, 7 Jun 2021 05:12:25 +0000 149 | Subject: [PATCH] Bump prettier from 2.3.0 to 2.3.1 150 | 151 | Bumps [prettier](https://github.com/prettier/prettier) from 2.3.0 to 2.3.1. 152 | - [Release notes](https://github.com/prettier/prettier/releases) 153 | - [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md) 154 | - [Commits](https://github.com/prettier/prettier/compare/2.3.0...2.3.1) 155 | 156 | --- 157 | updated-dependencies: 158 | - dependency-name: prettier 159 | dependency-type: direct:production 160 | update-type: version-update:semver-patch 161 | ... 162 | 163 | Signed-off-by: dependabot[bot] 164 | 165 | adeira-source-id: cc7b3818e06f95c2732f75f165a2b98c6eeab135 166 | 167 | diff --git a/package.json b/package.json 168 | index 33c6b476374a38f54e7aa3a92d4f9aed68c34624..dd10ce35969c86ef2c5bd8c59e58bb1fea865a58 100644 169 | --- a/package.json 170 | +++ b/package.json 171 | @@ -26,7 +26,7 @@ 172 | "eslint-plugin-react-native": "^3.11.0", 173 | "eslint-plugin-relay": "^1.8.2", 174 | "eslint-plugin-sx": "^0.11.0", 175 | - "prettier": "^2.3.0" 176 | + "prettier": "^2.3.1" 177 | }, 178 | "devDependencies": { 179 | "deep-diff": "^1.0.2", 180 | 181 | -- 182 | 2.21.0 183 | " 184 | `; 185 | -------------------------------------------------------------------------------- /src/__tests__/__snapshots__/parsePatch.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`matches expected output: chmod.patch: 7e86460a4c66b47098780aa649d174c8 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: 7e86460a4c66b47098780aa649d174c8 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: 7e86460a4c66b47098780aa649d174c8 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: 7e86460a4c66b47098780aa649d174c8 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: 7e86460a4c66b47098780aa649d174c8 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: 7e86460a4c66b47098780aa649d174c8 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: 7e86460a4c66b47098780aa649d174c8 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: 7e86460a4c66b47098780aa649d174c8 1`] = ` 268 | ~~~~~~~~~~ INPUT ~~~~~~~~~~ 269 | diff --git a/.yarn/cache/@adeira-js-0.9.0.tgz b/.yarn/cache/@adeira-js-0.9.0.tgz 270 | deleted file mode 100644 271 | index f2572f9c9d2f2ab4f2a61695a09ee50430f8ccbe..0000000000000000000000000000000000000000 272 | --- a/.yarn/cache/@adeira-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/@adeira-js-0.9.0.tgz b/.yarn/cache/@adeira-js-0.9.0.tgz 286 | deleted file mode 100644 287 | index f2572f9c9d2f2ab4f2a61695a09ee50430f8ccbe..0000000000000000000000000000000000000000 288 | --- a/.yarn/cache/@adeira-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: 7e86460a4c66b47098780aa649d174c8 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: 7e86460a4c66b47098780aa649d174c8 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/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, ShellCommandResult, type EnvironmentVariables } from '@adeira/shell-command'; 7 | 8 | import parsePatch from './parsePatch'; 9 | import parsePatchHeader from './parsePatchHeader'; 10 | import splitHead from './splitHead'; 11 | import Changeset, { type Diff } from './Changeset'; 12 | 13 | /** 14 | * This is our monorepo part - source of exports. 15 | */ 16 | export interface SourceRepo { 17 | findFirstAvailableCommit(): string; 18 | findDescendantsPath( 19 | baseRevision: string, 20 | headRevision: string, 21 | roots: Set, 22 | includeBaseRevision?: boolean, 23 | ): null | $ReadOnlyArray; 24 | getChangesetFromID(revision: string): Changeset; 25 | getNativePatchFromID(revision: string): string; 26 | getNativeHeaderFromIDWithPatch(revision: string, patch: string): string; 27 | getChangesetFromExportedPatch(header: string, patch: string): Changeset; 28 | } 29 | 30 | /** 31 | * Exported repository containing `adeira-source-id` handles. 32 | */ 33 | export interface DestinationRepo { 34 | findLastSourceCommit(roots: Set): null | string; 35 | renderPatch(changeset: Changeset): string; 36 | commitPatch(changeset: Changeset): string; 37 | checkoutBranch(branchName: string): void; 38 | push(branch: string): void; 39 | } 40 | 41 | interface AnyRepo { 42 | // Will most probably always return '4b825dc642cb6eb9a060e54bf8d69288fbee4904'. What? https://stackoverflow.com/q/9765453/3135248 43 | getEmptyTreeHash(): string; 44 | } 45 | 46 | export default class RepoGit implements AnyRepo, SourceRepo, DestinationRepo { 47 | #localPath: string; 48 | 49 | constructor(localPath: string) { 50 | invariant(fs.existsSync(path.join(localPath, '.git')), '%s is not a GIT repo.', localPath); 51 | this.#localPath = localPath; 52 | } 53 | 54 | _gitCommand: (...args: $ReadOnlyArray) => ShellCommand = (...args) => { 55 | const environmentVariables: EnvironmentVariables = { 56 | // https://git-scm.com/docs/git#_environment_variables 57 | GIT_CONFIG_NOSYSTEM: '1', 58 | GIT_TERMINAL_PROMPT: '0', 59 | }; 60 | 61 | if (process.env.PATH != null) { 62 | // Since we are overwriting the envs we need to set PATH explicitly in order to access Git 63 | // from Homebrew in case on macOS (for example). 64 | environmentVariables.PATH = process.env.PATH; 65 | } 66 | 67 | return new ShellCommand(this.#localPath, 'git', '--no-pager', ...args).setEnvironmentVariables( 68 | environmentVariables, 69 | ); 70 | }; 71 | 72 | push: (destinationBranch: string) => void = (destinationBranch) => { 73 | this._gitCommand('push', 'origin', destinationBranch).runSynchronously(); 74 | }; 75 | 76 | configure: () => void = () => { 77 | for (const [key, value] of Object.entries({ 78 | 'user.email': process.env.SHIPIT_COMMITTER_EMAIL, 79 | 'user.name': process.env.SHIPIT_COMMITTER_NAME, 80 | })) { 81 | // $FlowIssue[incompatible-call]: https://github.com/facebook/flow/issues/2174 82 | this._gitCommand('config', key, value).runSynchronously(); 83 | } 84 | }; 85 | 86 | // https://git-scm.com/docs/git-checkout 87 | checkoutBranch: (branchName: string) => void = (branchName) => { 88 | this._gitCommand( 89 | 'checkout', 90 | '-B', // create (or switch to) a new branch 91 | branchName, 92 | ).runSynchronously(); 93 | }; 94 | 95 | clean: () => void = () => { 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 | ).runSynchronously(); 103 | }; 104 | 105 | isCorrupted: () => boolean = () => { 106 | const exitCode = this._gitCommand('fsck', '--strict') 107 | .setNoExceptions() 108 | .runSynchronously() 109 | .getExitCode(); 110 | return exitCode !== 0; 111 | }; 112 | 113 | findLastSourceCommit: (roots: Set) => null | string = (roots) => { 114 | const log = this._gitCommand( 115 | 'log', 116 | '-1', 117 | '--grep', 118 | '^adeira-source-id: \\?[a-z0-9]\\+\\s*$', 119 | ...roots, 120 | ) 121 | .setNoExceptions() // empty repo fails with: "your current branch 'master' does not have any commits yet" 122 | .runSynchronously() 123 | .getStdout() 124 | .trim(); 125 | const regex = /adeira-source-id: ?(?[a-z0-9]+)$/gm; 126 | let lastCommit = null; 127 | let match; 128 | while ((match = regex.exec(log)) !== null) { 129 | lastCommit = match?.groups?.commit; 130 | } 131 | return lastCommit ?? null; 132 | }; 133 | 134 | // https://stackoverflow.com/a/5189296/3135248 135 | findFirstAvailableCommit: () => string = () => { 136 | // Please note, the following command may return multiple roots. For example, 137 | // `git` repository has 6 roots (and we should take the last one). 138 | const rawOutput = this._gitCommand('rev-list', '--max-parents=0', 'HEAD') 139 | .runSynchronously() 140 | .getStdout(); 141 | const rootRevisions = rawOutput.trim().split('\n'); 142 | return rootRevisions[rootRevisions.length - 1]; 143 | }; 144 | 145 | getNativePatchFromID: (revision: string) => string = (revision) => { 146 | return this._gitCommand( 147 | 'format-patch', 148 | '--no-renames', 149 | '--no-stat', 150 | '--stdout', 151 | '--full-index', 152 | '--format=', // contain nothing but the code changes 153 | '-1', 154 | revision, 155 | ) 156 | .runSynchronously() 157 | .getStdout(); 158 | }; 159 | 160 | getNativeHeaderFromIDWithPatch: (revision: string, patch: string) => string = ( 161 | revision, 162 | patch, 163 | ) => { 164 | const fullPatch = this._gitCommand( 165 | 'format-patch', 166 | '--no-renames', 167 | '--no-stat', 168 | '--stdout', 169 | '--full-index', 170 | '-1', 171 | revision, 172 | ) 173 | .runSynchronously() 174 | .getStdout(); 175 | if (patch.length === 0) { 176 | // this is an empty commit, so everything is the header 177 | return fullPatch; 178 | } 179 | return fullPatch.replace(patch, ''); 180 | }; 181 | 182 | getChangesetFromID: (revision: string) => Changeset = (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 = (header, patch) => { 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) => null | { +body: string, +path: string } = (hunk) => { 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 | includeBaseRevision?: boolean, 218 | ) => null | $ReadOnlyArray = (baseRevision, headRevision, roots, includeBaseRevision) => { 219 | const log = this._gitCommand( 220 | 'log', 221 | '--reverse', 222 | '--ancestry-path', 223 | '--no-merges', 224 | '--pretty=tformat:%H', 225 | `${baseRevision}..${headRevision}`, 226 | '--', // separates paths from revisions (so you can use non-existent paths) 227 | ...roots, 228 | ) 229 | .runSynchronously() 230 | .getStdout(); 231 | const trimmedLog = log.trim(); 232 | if (trimmedLog === '') { 233 | return null; 234 | } 235 | 236 | const descendants = trimmedLog.split('\n'); 237 | 238 | if (includeBaseRevision === true) { 239 | descendants.unshift(baseRevision); 240 | } 241 | 242 | return descendants; 243 | }; 244 | 245 | commitPatch: (changeset: Changeset) => string = (changeset) => { 246 | if (changeset.getDiffs().size === 0) { 247 | // This is an empty commit, which `git am` does not handle properly. 248 | this._gitCommand( 249 | 'commit', 250 | '--allow-empty', 251 | '--author', 252 | changeset.getAuthor(), 253 | '--date', 254 | changeset.getTimestamp(), 255 | '--message', 256 | changeset.getCommitMessage(), 257 | ).runSynchronously(); 258 | } else { 259 | const diff = this.renderPatch(changeset); 260 | try { 261 | this._gitCommand('am', '--keep-non-patch', '--keep-cr', '--committer-date-is-author-date') 262 | .setStdin(diff) 263 | .runSynchronously(); 264 | } catch (error) { 265 | this._gitCommand('am', '--show-current-patch').setOutputToScreen().runSynchronously(); 266 | this._gitCommand('am', '--abort').setOutputToScreen().runSynchronously(); 267 | throw error; 268 | } 269 | } 270 | // git rev-parse --verify HEAD 271 | // git --no-pager log -1 --pretty=format:%H 272 | return this._gitCommand('rev-parse', '--verify', 'HEAD').runSynchronously().getStdout().trim(); 273 | }; 274 | 275 | /** 276 | * Renders changeset to be later used with `git am` command. 277 | */ 278 | renderPatch: (changeset: Changeset) => string = (changeset) => { 279 | let renderedDiffs = ''; 280 | const diffs = changeset.getDiffs(); 281 | invariant(diffs.size > 0, 'It is not possible to render empty commit.'); // https://stackoverflow.com/a/34692447 282 | 283 | for (const diff of diffs) { 284 | const path = diff.path; 285 | renderedDiffs += `diff --git a/${path} b/${path}\n${diff.body}`; 286 | } 287 | 288 | // Insert a space before patterns that will make `git am` think that a line in the commit 289 | // message is the start of a patch, which is an artifact of the way `git am` tries to tell 290 | // where the message ends and the diffs begin. This fix is a hack; a better fix might be to 291 | // use `git apply` and `git commit` directly instead of `git am`. It's inspired by the same 292 | // fix in `facebook/fbshipit` code. 293 | // 294 | // See: https://git-scm.com/docs/git-am/2.32.0#_discussion 295 | // See: https://github.com/git/git/blob/ebf3c04b262aa27fbb97f8a0156c2347fecafafb/mailinfo.c#L649-L683 296 | // See: https://github.com/facebook/fbshipit/blob/bd0df15c3c18a6645da7a765789ab60c5ffc3a45/src/shipit/repo/ShipItRepoGIT.php#L236-L240 297 | const commitMessage = changeset 298 | .getCommitMessage() 299 | .replace(/^(?diff -|Index: |---(?:\s\S|\s*$))/m, ' $1'); 300 | 301 | // Mon Sep 17 is a magic date used by format-patch to distinguish from real mailboxes 302 | // see: https://git-scm.com/docs/git-format-patch 303 | return `From ${changeset.getID()} Mon Sep 17 00:00:00 2001 304 | From: ${changeset.getAuthor()} 305 | Date: ${changeset.getTimestamp()} 306 | Subject: [PATCH] ${commitMessage} 307 | 308 | ${renderedDiffs} 309 | -- 310 | 2.21.0 311 | `; 312 | }; 313 | 314 | /** 315 | * This function exports specified roots from the monorepo. It takes a 316 | * snapshot of HEAD revision and exports it to the destination path. 317 | * Please note: this export is unfiltered. 318 | */ 319 | export: (exportedRepoPath: string, roots: Set) => ShellCommandResult = ( 320 | exportedRepoPath, 321 | roots, 322 | ) => { 323 | const archivePath = path.join(exportedRepoPath, 'archive.tar.gz'); 324 | this._gitCommand( 325 | 'archive', 326 | '--format=tar', 327 | `--output=${archivePath}`, 328 | 'HEAD', // TODO 329 | ...roots, 330 | ).runSynchronously(); 331 | // Previously, we used only STDIN but that didn't work for some binary files like images for some reason. 332 | // So now we create an actual archive and use this instead. 333 | return new ShellCommand(exportedRepoPath, 'tar', '-xvf', archivePath).runSynchronously(); 334 | }; 335 | 336 | getEmptyTreeHash(): string { 337 | return this._gitCommand('hash-object', '-t', 'tree', '/dev/null') 338 | .runSynchronously() 339 | .getStdout() 340 | .trim(); 341 | } 342 | } 343 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Adeira/Shipit takes care of exporting projects from large Git monorepos into smaller independent Git repositories. Typical use-case is exporting parts of a private monorepo into open-sourced repositories on GitHub. It also supports importing of pull requests back into the monorepo. 2 | 3 | Adeira/Shipit consists of two main parts: _Shipit_ for exporting and _Importit_ for PRs importing. 4 | 5 | Real-world users: 6 | 7 | - https://github.com/adeira 8 | - https://github.com/try-triplex/triplex 9 | 10 | # Shipit part 11 | 12 | Shipit part is responsible for exporting code from a monorepo to somewhere else. 13 | 14 | ## Usage 15 | 16 | ``` 17 | npx --package @adeira/monorepo-shipit monorepo-shipit --help 18 | ``` 19 | 20 | ## How it works 21 | 22 | First, Shipit tries to extract relevant commits of each project we want to export. Each commit is converted into so called _changeset_ which is an immutable structure representing one commit and doesn't depend on Git or any other VCS. Each changeset can contain many diffs describing changes in each individual file. 23 | 24 | ```text 25 | ↗ diff 26 | ↗ commit_1 → changeset → diff 27 | Git history → commit_2 → changeset ⠇ 28 | ↘ commit_3 → changeset 29 | ⠇ ⠇ 30 | ``` 31 | 32 | Paths in the original monorepo might be very different from the exported version. Therefore, we apply some filters to hide non-relevant or secret files and to adjust paths in the exported repo version. These modified changesets are then pushed to individual GitHub repositories. 33 | 34 | ```text 35 | .-----------------------------------. 36 | | | 37 | | adeira/universe monorepo | 38 | | | 39 | `-----------------------------------` 40 | v v v 41 | .-----------. .-----------. .-----------. 42 | | Changeset | | Changeset | | Changeset | 43 | `-----------` `-----------` `-----------` 44 | v v v 45 | .-----------------------------------. 46 | | Filters and Modifiers | 47 | `-----------------------------------` 48 | v v v 49 | .-----------. .-----------. .-----------. 50 | | Changeset | | Changeset | | Changeset | 51 | `-----------` `-----------` `-----------` 52 | | | v 53 | | | .----------. 54 | | | | Git repo | <------. 55 | | v `----------` | .--------------------. 56 | | .----------. | | | 57 | | | Git repo | <---------------------+----> | GitHub | 58 | v `----------` | | | 59 | .----------. | `--------------------` 60 | | Git repo | <------------------------------------` 61 | `----------` 62 | ``` 63 | 64 | One of the filters modifies commit descriptions and adds `adeira-source-id` signature which helps us to identify which changes we pushed last time, so Shipit can just amend latest changes next time. These filters work with the parsed changesets which gives you an incredible flexibility: you can for example completely remove some lines from the exported code. 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 exported repository then just add a new filter and manually change the code in the exported repository. 65 | 66 | ## Configuration 67 | 68 | Real-world examples: https://github.com/adeira/universe/tree/master/src/monorepo-shipit/config 69 | 70 | Each project has its own configuration stored in `.shipit` directory (configurable via `--config-dir` option). If you want it to work with another project then you have to create a new configuration (with configuration tests), for example: 71 | 72 | ```js 73 | module.exports = { 74 | getStaticConfig() { 75 | return { 76 | repository: 'git@github.com/adeira/relay-example.git', // see: https://git-scm.com/docs/git-clone/#_git_urls 77 | }; 78 | }, 79 | getPathMappings(): Map { 80 | return new Map([ 81 | ['src/example-relay/__github__/.circleci', '.circleci'], 82 | ['src/example-relay/__github__/.flowconfig', '.flowconfig'], 83 | ['src/example-relay/', ''], 84 | ]); 85 | }, 86 | getStrippedFiles(): Set { 87 | // this method is optional 88 | return new Set([/__github__/]); 89 | }, 90 | }; 91 | ``` 92 | 93 | Here are all supported config options and their interface: 94 | 95 | ```js 96 | export type ConfigType = { 97 | +customShipitFilter?: (Changeset) => Changeset, 98 | +customImportitFilter?: (Changeset) => Changeset, 99 | +getStaticConfig: () => { 100 | +repository: string, 101 | }, 102 | +getPathMappings: () => Map, 103 | +getStrippedFiles?: () => Set, 104 | +getBranchConfig?: () => { 105 | +source: string, 106 | +destination: string, 107 | }, 108 | }; 109 | ``` 110 | 111 | Read more about available filters and how to use them below. 112 | 113 | ## Filters 114 | 115 | There are various filters applied on exported changesets to make it work properly. Currently, we apply these filters in _exactly_ this order: 116 | 117 | 1. `addTrackingData` - adds `adeira-source-id` into the commit description which helps us identify the latest synchronized changes 118 | 2. `stripExceptDirectories` - makes sure we publish only files relevant to the workspace that is being exported 119 | 3. `moveDirectories` - makes sure that we export correct paths (our projects are located in for example `src/packages/fetch` but we want to have these files in the root on GitHub rather than the original monorepo path), uses `getPathMappings` configuration (see below) 120 | 4. `stripPaths` - removes unwanted files based on `getStrippedFiles` configuration 121 | 5. `commentLines` - comments out lines marked with `@x-shipit-disable` (see below) 122 | 6. `commentLines` - uncomment lines marked with `@x-shipit-enable` (see below) 123 | 124 | Order of these filters is significant and has one important implication: it's not possible to "replace" file with different version for OSS. For example, you **cannot** do this: 125 | 126 | ```js 127 | module.exports = { 128 | getPathMappings() { 129 | return new Map([ 130 | ['src/example/__github__/.babelrc.js', '.babelrc.js'], 131 | ['src/example/', ''], 132 | ]); 133 | }, 134 | getStrippedFiles() { 135 | return new Set([ 136 | /^\.babelrc\.js$/, // replaced by the one in __github__ 137 | /__github__/, 138 | ]); 139 | }, 140 | }; 141 | ``` 142 | 143 | It's because the Babel config file is first moved from `__github__` to the repository root, and later it's stripped (see filters 3 and 4). It would not work even if we'd change order of the filters (`__github__` would be first stripped and later there is no Babel config to move). 144 | 145 | _Are you interested in having this improved? Let us know._ 146 | 147 | ### Filter `moveDirectories` 148 | 149 | This filter maps our internal directories to exported directories and vice versa. Typical minimalistic mapping looks like this: 150 | 151 | ```js 152 | new Map([ 153 | // from, to 154 | ['src/fetch/', ''], 155 | ]); 156 | ``` 157 | 158 | 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: 159 | 160 | ```js 161 | new Map([ 162 | ['src/fetch/__github__/', ''], // trailing slash is significant 163 | ['src/fetch/', ''], 164 | ]); 165 | ``` 166 | 167 | 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](#importit-part-_unstable_)). 168 | 169 | 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): 170 | 171 | ```js 172 | new Map([['src/packages/fetch/', 'packages/fetch/']]); 173 | ``` 174 | 175 | ### Filter of conditional comments (`commentLines`) 176 | 177 | 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): 178 | 179 | ```js 180 | someFunctionCallWithDifferentOSSRepresentation( 181 | // @x-oss-enable: true, 182 | false, // @x-oss-disable 183 | ); 184 | ``` 185 | 186 | The code above is written by our programmer. Shipit then automatically turns this code into the following code when exporting: 187 | 188 | ```js 189 | someFunctionCallWithDifferentOSSRepresentation( 190 | true, // @x-oss-enable 191 | // @x-oss-disable: false, 192 | ); 193 | ``` 194 | 195 | Please note: this is just an example, currently we support only `// @x-shipit-enable` and `// @x-shipit-disable` in this exact format. 196 | 197 | ## Renaming project roots 198 | 199 | 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. 200 | 201 | ```js 202 | module.exports = { 203 | getPathMappings(): Map { 204 | return new Map([ 205 | ['src/path-old/', ''], 206 | ['src/path-new/', ''], // add a new root here, keep the old one as well 207 | ]); 208 | }, 209 | }; 210 | ``` 211 | 212 | To deal with this you have to approach the roots renaming carefully. Our current best attempt is to do it in two steps: 213 | 214 | 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 `adeira-source-id`. 215 | 2. Delete the original root from the config when the previous step succeeds. You should be good to go. 216 | 217 | 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 `adeira-source-id` so that Shipit can catch up. 218 | 219 | ## Linear history 220 | 221 | 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): 222 | 223 | ```text 224 | * 225 | ---1----2----4----7 226 | \ \ 227 | 3----5----6----8--- 228 | * 229 | ``` 230 | 231 | 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. 232 | 233 | For this reason Shipit requires linear Git history only (it works with reversed ancestry path without merge commits). 234 | 235 | # Importit part _(unstable)_ 236 | 237 | **Only imports from GitHub are currently supported. Help us to improve this part.** 238 | 239 | ## Usage 240 | 241 | ``` 242 | npx --package @adeira/monorepo-shipit monorepo-importit --help 243 | ``` 244 | 245 | ## How it works 246 | 247 | This is how you'd import a pull request #1 from `adeira/js` GitHub repository into your local branch (to be later merged into your monorepo): 248 | 249 | ```text 250 | npx --package @adeira/monorepo-shipit monorepo-importit --committer-name=A --committer-email=B --pull-request=https://github.com/adeira/js/pull/1 251 | ``` 252 | 253 | Technically, _Importit_ part works just like _Shipit_ except in the opposite direction and from pull requests: 254 | 255 | ```text 256 | .-----------------------------------. 257 | | | 258 | | adeira/universe monorepo | 259 | | | 260 | `-----------------------------------` 261 | ^ ^ ^ 262 | .-----------. .-----------. .-----------. 263 | | Changeset | | Changeset | | Changeset | 264 | `-----------` `-----------` `-----------` 265 | ^ ^ ^ 266 | .-----------------------------------. 267 | | Filters and Modifiers | 268 | `-----------------------------------` 269 | ^ ^ ^ 270 | .-----------. .-----------. .-----------. 271 | | Changeset | | Changeset | | Changeset | 272 | `-----------` `-----------` `-----------` 273 | ^ ^ ^ 274 | | | .---------------. 275 | | | | GH repo PR #1 | 276 | | | `---------------` 277 | | .----------------. 278 | | | GH repo PR #21 | 279 | | `----------------` 280 | .----------------. 281 | | GH repo PR #42 | 282 | `----------------` 283 | ``` 284 | 285 | ## Filters 286 | 287 | 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. 288 | 289 | # Main differences from facebook/fbshipit 290 | 291 | - our version is tailored for our needs and infra, not Facebook ones 292 | - our version doesn't support [Mercurial](https://www.mercurial-scm.org/) and it's written in JS (not in Hack) 293 | - our version doesn't support [Git Submodules](https://git-scm.com/book/en/v2/Git-Tools-Submodules) 294 | - we _do not_ sync internal LFS storage with GitHub LFS (currently unused) 295 | - we currently cannot do this in one commit: 296 | - changed Shipit config: https://github.com/facebook/fbshipit/commit/939949dc1369295c910772c6e8eccbbef2a2db7f 297 | - effect in Relay repo: https://github.com/facebook/relay/commit/13b6436e406398065507efb9df2eae61cdc14dd9 298 | 299 | # Prior art 300 | 301 | - https://github.com/facebook/fbshipit 👍 302 | - https://git-scm.com/docs/git-filter-branch 😏 303 | - https://github.com/splitsh/lite 👎 304 | --------------------------------------------------------------------------------