UNPKG

backport

Version:

A CLI tool that automates the process of backporting commits

673 lines (669 loc) 28.2 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k); __setModuleDefault(result, mod); return result; }; var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); const os_1 = __importDefault(require("os")); const childProcess = __importStar(require("./child-process-promisified")); const git_1 = require("./git"); beforeEach(() => { jest.spyOn(os_1.default, 'homedir').mockReturnValue('/myHomeDir'); }); afterEach(() => { jest.restoreAllMocks(); }); const commitAuthor = { name: 'Soren L', email: 'soren@mail.dk' }; describe('getUnstagedFiles', () => { it('should split lines and remove empty', async () => { jest.spyOn(childProcess, 'spawnPromise').mockResolvedValueOnce({ stdout: 'conflicting-file.txt\nconflicting-file2.txt\n', stderr: '', code: 0, cmdArgs: [], }); const options = { repoOwner: 'elastic', repoName: 'kibana', }; await expect(await (0, git_1.getUnstagedFiles)(options)).toEqual([ '/myHomeDir/.backport/repositories/elastic/kibana/conflicting-file.txt', '/myHomeDir/.backport/repositories/elastic/kibana/conflicting-file2.txt', ]); }); it('should not error on empty', async () => { jest.spyOn(childProcess, 'spawnPromise').mockResolvedValueOnce({ stdout: '', stderr: '', code: 0, cmdArgs: [], }); const options = { repoOwner: 'elastic', repoName: 'kibana', }; await expect(await (0, git_1.getUnstagedFiles)(options)).toEqual([]); }); }); describe('getLocalConfigFileCommitDate', () => { it('returns a timestamp the file exists', async () => { const res = { stdout: 'Wed Dec 16 10:10:39 2020 -0800\n', stderr: '', code: 0, cmdArgs: [], }; jest.spyOn(childProcess, 'spawnPromise').mockResolvedValue(res); expect(await (0, git_1.getLocalConfigFileCommitDate)({ cwd: 'foo/bar' })).toEqual(1608142239000); }); it('returns empty when file does not exists', async () => { const res = { stdout: '', stderr: '', code: 0, cmdArgs: [] }; jest.spyOn(childProcess, 'spawnPromise').mockResolvedValue(res); expect(await (0, git_1.getLocalConfigFileCommitDate)({ cwd: 'foo/bar' })).toEqual(undefined); }); it('handles errors', async () => { const err = { killed: false, code: 128, signal: null, cmd: 'any command...', stdout: '', stderr: 'any error', }; jest.spyOn(childProcess, 'spawnPromise').mockRejectedValueOnce(err); expect(await (0, git_1.getLocalConfigFileCommitDate)({ cwd: 'foo/bar' })).toEqual(undefined); }); }); describe('isLocalConfigFileUntracked', () => { it('returns "false" if file does not exist', async () => { const res = { stdout: '', stderr: '', code: 0, cmdArgs: [] }; jest.spyOn(childProcess, 'spawnPromise').mockResolvedValue(res); expect(await (0, git_1.isLocalConfigFileUntracked)({ cwd: 'foo/bar' })).toEqual(false); }); it('returns "true" if file is untracked', async () => { const res = { stdout: '.backportrc.json\n', stderr: '', code: 0, cmdArgs: [], }; jest.spyOn(childProcess, 'spawnPromise').mockResolvedValue(res); expect(await (0, git_1.isLocalConfigFileUntracked)({ cwd: 'foo/bar' })).toEqual(true); }); it('handles errors', async () => { const err = { killed: false, code: 128, signal: null, cmd: 'any command...', stdout: '', stderr: 'any error', }; jest.spyOn(childProcess, 'spawnPromise').mockRejectedValueOnce(err); expect(await (0, git_1.isLocalConfigFileUntracked)({ cwd: 'foo/bar' })).toEqual(undefined); }); }); describe('isLocalConfigFileModified', () => { it('returns "false" if file does not exist', async () => { const res = { stdout: '', stderr: '', code: 0, cmdArgs: [] }; jest.spyOn(childProcess, 'spawnPromise').mockResolvedValue(res); expect(await (0, git_1.isLocalConfigFileModified)({ cwd: 'foo/bar' })).toEqual(false); }); it('returns "false" if file is untracked', async () => { const res = { stdout: '', stderr: '', code: 0, cmdArgs: [] }; jest.spyOn(childProcess, 'spawnPromise').mockResolvedValue(res); expect(await (0, git_1.isLocalConfigFileModified)({ cwd: 'foo/bar' })).toEqual(false); }); it('returns "true" if file is staged', async () => { const res = { stdout: '.backportrc.json\n', stderr: '', code: 0, cmdArgs: [], }; jest.spyOn(childProcess, 'spawnPromise').mockResolvedValue(res); expect(await (0, git_1.isLocalConfigFileModified)({ cwd: 'foo/bar' })).toEqual(true); }); it('handles errors', async () => { const err = { killed: false, code: 128, signal: null, cmd: 'any command...', stdout: '', stderr: 'any error', }; jest.spyOn(childProcess, 'spawnPromise').mockRejectedValueOnce(err); expect(await (0, git_1.isLocalConfigFileModified)({ cwd: 'foo/bar' })).toBe(false); }); }); describe('getRepoInfoFromGitRemotes', () => { it('returns repoName and repoOwner ssh remotes', async () => { const res = { stdout: 'john.doe\tgit@github.com:john.doe/kibana (fetch)\n' + 'john.doe\tgit@github.com:john.doe/kibana (push)\n' + 'elastic\tgit@github.com:elastic/kibana.git (fetch)\n' + 'elastic\tgit@github.com:elastic/kibana.git (push)\n' + 'peter\tgit@github.com:peter/kibana (fetch)\n' + 'peter\tgit@github.com:peter/kibana (push)\n' + 'sorenlouv\tgit@github.com:sorenlouv/kibana.git (fetch)\n' + 'sorenlouv\tgit@github.com:sorenlouv/kibana.git (push)\n', stderr: '', code: 0, cmdArgs: [], }; jest.spyOn(childProcess, 'spawnPromise').mockResolvedValue(res); expect(await (0, git_1.getRepoInfoFromGitRemotes)({ cwd: 'foo/bar' })).toEqual([ { repoName: 'kibana', repoOwner: 'john.doe' }, { repoName: 'kibana', repoOwner: 'elastic' }, { repoName: 'kibana', repoOwner: 'peter' }, { repoName: 'kibana', repoOwner: 'sorenlouv' }, ]); }); it('returns repoName and repoOwner https remotes', async () => { jest.spyOn(childProcess, 'spawnPromise').mockResolvedValue({ stdout: 'origin\thttps://github.com/shay/elasticsearch.git (fetch)\n' + 'origin\thttps://github.com/shay/elasticsearch.git (push)\n', stderr: '', code: 0, cmdArgs: [], }); expect(await (0, git_1.getRepoInfoFromGitRemotes)({ cwd: 'foo/bar' })).toEqual([ { repoName: 'elasticsearch', repoOwner: 'shay' }, ]); }); it('returns undefined when no remotes exist', async () => { const res = { stdout: '', stderr: '', code: 0, cmdArgs: [] }; jest.spyOn(childProcess, 'spawnPromise').mockResolvedValue(res); expect(await (0, git_1.getRepoInfoFromGitRemotes)({ cwd: 'foo/bar' })).toEqual([]); }); it('handles errors', async () => { const err = { killed: false, code: 128, signal: null, cmd: 'git ls-files .backportrc.js* --exclude-standard --others', stdout: '', stderr: 'fatal: not a git repository (or any of the parent directories): .git\n', }; jest.spyOn(childProcess, 'spawnPromise').mockRejectedValueOnce(err); expect(await (0, git_1.getRepoInfoFromGitRemotes)({ cwd: 'foo/bar' })).toEqual([]); }); }); describe('getConflictingFiles', () => { it('should split by linebreak and remove empty and duplicate items', async () => { const err = new childProcess.SpawnError({ code: 2, cmdArgs: ['--no-pager', 'diff', '--check'], stdout: 'conflicting-file.txt:1: leftover conflict marker\nconflicting-file.txt:3: leftover conflict marker\nconflicting-file.txt:5: leftover conflict marker\n', stderr: '', }); jest.spyOn(childProcess, 'spawnPromise').mockRejectedValueOnce(err); const options = { repoOwner: 'elastic', repoName: 'kibana', }; expect(await (0, git_1.getConflictingFiles)(options)).toEqual([ { absolute: '/myHomeDir/.backport/repositories/elastic/kibana/conflicting-file.txt', relative: 'conflicting-file.txt', }, ]); }); }); describe('createBackportBranch', () => { const options = { repoOwner: 'elastic', repoName: 'kibana', }; const sourceBranch = 'main'; const targetBranch = '4.x'; const backportBranch = 'backport/4.x/commit-72f94e76'; it(`should handle "couldn't find remote ref" error`, async () => { expect.assertions(1); const err = new childProcess.SpawnError({ code: 128, cmdArgs: [], stdout: 'HEAD is now at 72f94e7 Create "conflicting-file.txt" in master\n', stderr: "fatal: couldn't find remote ref 4.x\n", }); jest.spyOn(childProcess, 'spawnPromise').mockRejectedValueOnce(err); await expect((0, git_1.createBackportBranch)({ options, sourceBranch, targetBranch, backportBranch, })).rejects.toThrowErrorMatchingInlineSnapshot(`"The branch "4.x" is invalid or doesn't exist"`); }); it('should throw "Invalid refspec" error', async () => { expect.assertions(1); const err = new childProcess.SpawnError({ code: 128, cmdArgs: [], stdout: '', stderr: "fatal: Invalid refspec 'https://github.com/elastic/kibana.git'\n", }); jest.spyOn(childProcess, 'spawnPromise').mockRejectedValueOnce(err); await expect((0, git_1.createBackportBranch)({ options, sourceBranch, targetBranch, backportBranch, })).rejects.toThrowErrorMatchingInlineSnapshot(`"The remote "'https://github.com/elastic/kibana.git'" is invalid or doesn't exist"`); }); it('should throw "is not a commit" error', async () => { expect.assertions(1); const err = new childProcess.SpawnError({ code: 128, cmdArgs: [], stdout: '', stderr: "fatal: 'origin/remote-branch-name' is not a commit and a branch 'local-branch-name' cannot be created from it", }); jest.spyOn(childProcess, 'spawnPromise').mockRejectedValueOnce(err); await expect((0, git_1.createBackportBranch)({ options, sourceBranch, targetBranch, backportBranch, })).rejects.toThrowErrorMatchingInlineSnapshot(`"The branch "origin/remote-branch-name'" is invalid or doesn't exist"`); }); it('should rethrow normal error', async () => { expect.assertions(1); const err = new Error('just a normal error'); jest.spyOn(childProcess, 'spawnPromise').mockRejectedValueOnce(err); expect.assertions(1); await expect((0, git_1.createBackportBranch)({ options, sourceBranch, targetBranch, backportBranch, })).rejects.toThrowErrorMatchingInlineSnapshot(`"just a normal error"`); }); }); describe('deleteRemote', () => { const remoteName = 'my-remote'; const options = { repoOwner: 'elastic', repoName: 'kibana', }; it('should swallow "no such remote" error on git before 2.30.0', async () => { const err = new childProcess.SpawnError({ code: 128, cmdArgs: [], stdout: '', stderr: "fatal: No such remote: 'my-remote'\n", }); jest.spyOn(childProcess, 'spawnPromise').mockRejectedValueOnce(err); await expect(await (0, git_1.deleteRemote)(options, remoteName)).toBe(undefined); }); it('should swallow "no such remote" error on git 2.30.0 or later', async () => { const err = new childProcess.SpawnError({ code: 2, cmdArgs: [], stdout: '', stderr: "fatal: No such remote: 'my-remote'\n", }); jest.spyOn(childProcess, 'spawnPromise').mockRejectedValueOnce(err); await expect(await (0, git_1.deleteRemote)(options, remoteName)).toBe(undefined); }); it('should swallow "no such remote" error on git 2.30.0+, even if it is not in English', async () => { const err = new childProcess.SpawnError({ code: 2, // returned only by git 2.30.0 or later, earlier versions returned 128 cmdArgs: [], stdout: '', stderr: "Fehler: Remote-Repository nicht gefunden: 'my-remote'\n", }); jest.spyOn(childProcess, 'spawnPromise').mockRejectedValueOnce(err); await expect(await (0, git_1.deleteRemote)(options, remoteName)).toBe(undefined); }); it('should rethrow normal error', async () => { const err = new Error('just a normal error'); jest.spyOn(childProcess, 'spawnPromise').mockRejectedValueOnce(err); expect.assertions(1); await expect((0, git_1.deleteRemote)(options, remoteName)).rejects.toThrowErrorMatchingInlineSnapshot(`"just a normal error"`); }); }); describe('cherrypick', () => { const options = { repoOwner: 'elastic', repoName: 'kibana', }; it('should return `needsResolving: false` when no errors are encountered', async () => { jest .spyOn(childProcess, 'spawnPromise') .mockResolvedValueOnce({ stderr: '', stdout: '', code: 0, cmdArgs: [] }); // mock `git cherrypick` expect(await (0, git_1.cherrypick)({ options, sha: 'abcd', commitAuthor, })).toEqual({ conflictingFiles: [], unstagedFiles: [], needsResolving: false, }); }); it('should use mainline option when specified', async () => { const spawnSpy = jest .spyOn(childProcess, 'spawnPromise') .mockResolvedValueOnce({ stderr: '', stdout: '', code: 0, cmdArgs: [] }); // mock `git cherrypick` await (0, git_1.cherrypick)({ options: { ...options, mainline: 1 }, sha: 'abcd', commitAuthor, }); const args = spawnSpy.mock.calls[0]; expect(args).toEqual([ 'git', [ '-c', 'user.name="Soren L"', '-c', 'user.email="soren@mail.dk"', 'cherry-pick', '--mainline', '1', '-x', 'abcd', ], '/myHomeDir/.backport/repositories/elastic/kibana', ]); }); it('should use signoff option when specified', async () => { const spawnSpy = jest .spyOn(childProcess, 'spawnPromise') .mockResolvedValueOnce({ stderr: '', stdout: '', code: 0, cmdArgs: [] }); // mock `git cherrypick` await (0, git_1.cherrypick)({ options: { ...options, signoff: true }, sha: 'abcd', commitAuthor, }); const args = spawnSpy.mock.calls[0]; expect(args).toEqual([ 'git', [ '-c', 'user.name="Soren L"', '-c', 'user.email="soren@mail.dk"', 'cherry-pick', '-x', '--signoff', 'abcd', ], '/myHomeDir/.backport/repositories/elastic/kibana', ]); }); it('should return `needsResolving: true` upon cherrypick error', async () => { jest .spyOn(childProcess, 'spawnPromise') // mock `git cherrypick` .mockRejectedValueOnce(new childProcess.SpawnError({ code: 128, cmdArgs: ['cherry-pick', '-x', 'abcd'], stdout: '', stderr: '', })) // mock getConflictingFiles .mockRejectedValueOnce(new childProcess.SpawnError({ code: 2, cmdArgs: ['--no-pager', 'diff', '--check'], stdout: 'conflicting-file.txt:1: leftover conflict marker\nconflicting-file.txt:3: leftover conflict marker\nconflicting-file.txt:5: leftover conflict marker\n', stderr: '', })) // mock getUnstagedFiles .mockResolvedValueOnce({ stdout: '', stderr: '', code: 0, cmdArgs: [] }); expect(await (0, git_1.cherrypick)({ options, sha: 'abcd', commitAuthor, })).toEqual({ conflictingFiles: [ { absolute: '/myHomeDir/.backport/repositories/elastic/kibana/conflicting-file.txt', relative: 'conflicting-file.txt', }, ], needsResolving: true, unstagedFiles: [], }); }); it('should let the user know about the "--mainline" argument when cherry-picking a merge commit without specifying it', async () => { jest .spyOn(childProcess, 'spawnPromise') // mock cherry pick command .mockRejectedValueOnce(new childProcess.SpawnError({ code: 128, cmdArgs: ['cherry-pick', '381c7b604110257437a289b1f1742685eb8d79c5'], stdout: '', stderr: 'error: commit 381c7b604110257437a289b1f1742685eb8d79c5 is a merge but no -m option was given.\nfatal: cherry-pick failed\n', })); await expect((0, git_1.cherrypick)({ options, sha: 'abcd', commitAuthor, })).rejects .toThrow(`Cherrypick failed because the selected commit was a merge commit. Please try again by specifying the parent with the \`mainline\` argument: > backport --mainline or: > backport --mainline <parent-number> Or refer to the git documentation for more information: https://git-scm.com/docs/git-cherry-pick#Documentation/git-cherry-pick.txt---mainlineparent-number`); }); it('should gracefully handle empty commits', async () => { jest .spyOn(childProcess, 'spawnPromise') // mock cherry pick command .mockRejectedValueOnce(new childProcess.SpawnError({ code: 1, cmdArgs: ['cherry-pick', 'fe6b13b83cc010f722548cd5a0a8c2d5341a20dd'], stdout: 'On branch backport/7.x/pr-58692\nYou are currently cherry-picking commit fe6b13b83cc.\n\nnothing to commit, working tree clean\n', stderr: "The previous cherry-pick is now empty, possibly due to conflict resolution.\nIf you wish to commit it anyway, use:\n\n git commit --allow-empty\n\nOtherwise, please use 'git cherry-pick --skip'\n", })); await expect((0, git_1.cherrypick)({ options, sha: 'abcd', commitAuthor, })).rejects.toThrow(`Cherrypick failed because the selected commit (abcd) is empty. Did you already backport this commit?`); }); it('should re-throw non-cherrypick errors', async () => { jest .spyOn(childProcess, 'spawnPromise') // mock cherry pick command .mockRejectedValueOnce(new Error('non-cherrypick error')) // getConflictingFiles .mockResolvedValueOnce({ stdout: '', stderr: '', code: 0, cmdArgs: [] }) // getUnstagedFiles .mockResolvedValueOnce({ stdout: '', stderr: '', code: 0, cmdArgs: [] }); await expect((0, git_1.cherrypick)({ options, sha: 'abcd', commitAuthor, })).rejects.toThrowErrorMatchingInlineSnapshot(`"non-cherrypick error"`); }); }); describe('commitChanges', () => { const options = { repoOwner: 'elastic', repoName: 'kibana', }; const commit = { author: { email: 'soren.louv@elastic.co', name: 'Søren Louv-Jansen' }, suggestedTargetBranches: [], sourceCommit: { branchLabelMapping: {}, message: 'The original commit message', committedDate: '2020', sha: 'abc', }, sourceBranch: 'master', targetPullRequestStates: [], }; it('should return when changes committed successfully', async () => { jest .spyOn(childProcess, 'spawnPromise') .mockResolvedValueOnce({ stderr: '', stdout: '', code: 0, cmdArgs: [] }); await expect(await (0, git_1.commitChanges)({ commit, commitAuthor, options })).toBe(undefined); }); it('should swallow error if changes have already been committed manaully', async () => { const err = new childProcess.SpawnError({ code: 1, cmdArgs: ['commit', '--no-edit'], stdout: 'On branch backport/7.x/commit-913afb3b\nnothing to commit, working tree clean\n', stderr: '', }); jest.spyOn(childProcess, 'spawnPromise').mockRejectedValueOnce(err); await expect(await (0, git_1.commitChanges)({ commit, commitAuthor, options })).toBe(undefined); }); describe('when commit fails due to empty message', () => { let spy; let res; beforeEach(async () => { const err = new childProcess.SpawnError({ code: 1, cmdArgs: ['commit', '--no-edit', '--no-verify'], stdout: '', stderr: 'Aborting commit due to empty commit message.\n', }); spy = jest .spyOn(childProcess, 'spawnPromise') .mockRejectedValueOnce(err) .mockResolvedValueOnce({ stderr: '', stdout: '', code: 0, cmdArgs: [], }); res = await (0, git_1.commitChanges)({ commit, commitAuthor, options }); }); it('should manually set the commit message', () => { expect(spy).toHaveBeenCalledTimes(2); expect(spy.mock.calls[0]).toEqual([ 'git', [ '-c', 'user.name="Soren L"', '-c', 'user.email="soren@mail.dk"', 'commit', '--no-edit', ], expect.any(String), ]); expect(spy.mock.calls[1]).toEqual([ 'git', [ '-c', 'user.name="Soren L"', '-c', 'user.email="soren@mail.dk"', 'commit', '--message=The original commit message', ], expect.any(String), ]); }); it('should handle the error and resolve successfully', async () => { await expect(res).toBe(undefined); }); }); it('should re-throw other errors', async () => { const err = new Error('another error'); jest.spyOn(childProcess, 'spawnPromise').mockRejectedValueOnce(err); expect.assertions(1); await expect((0, git_1.commitChanges)({ commit, commitAuthor, options })).rejects.toThrowErrorMatchingInlineSnapshot(`"another error"`); }); }); describe('addRemote', () => { const options = { accessToken: 'myAccessToken', repoOwner: 'elastic', repoName: 'kibana', gitHostname: 'github.com', }; it('add correct origin remote', async () => { const spy = jest .spyOn(childProcess, 'spawnPromise') .mockResolvedValueOnce({ stderr: '', stdout: '', code: 0, cmdArgs: [] }); await (0, git_1.addRemote)(options, 'elastic'); return expect(spy).toHaveBeenCalledWith('git', [ 'remote', 'add', 'elastic', 'https://x-access-token:myAccessToken@github.com/elastic/kibana.git', ], '/myHomeDir/.backport/repositories/elastic/kibana'); }); it('add correct user remote', async () => { const spy = jest .spyOn(childProcess, 'spawnPromise') .mockResolvedValueOnce({ stderr: '', stdout: '', code: 0, cmdArgs: [] }); await (0, git_1.addRemote)(options, 'sorenlouv'); return expect(spy).toHaveBeenCalledWith('git', [ 'remote', 'add', 'sorenlouv', 'https://x-access-token:myAccessToken@github.com/sorenlouv/kibana.git', ], '/myHomeDir/.backport/repositories/elastic/kibana'); }); it('allows custom github url', async () => { const spy = jest .spyOn(childProcess, 'spawnPromise') .mockResolvedValueOnce({ stderr: '', stdout: '', code: 0, cmdArgs: [] }); await (0, git_1.addRemote)({ ...options, gitHostname: 'github.my-company.com' }, 'sorenlouv'); return expect(spy).toHaveBeenCalledWith('git', [ 'remote', 'add', 'sorenlouv', 'https://x-access-token:myAccessToken@github.my-company.com/sorenlouv/kibana.git', ], '/myHomeDir/.backport/repositories/elastic/kibana'); }); }); describe('pushBackportBranch', () => { const options = { authenticatedUsername: 'sqren_authenticated', fork: true, repoForkOwner: 'the_fork_owner', repoName: 'kibana', repoOwner: 'elastic', }; const backportBranch = 'backport/7.x/pr-2'; it('should handle missing fork error', async () => { jest.spyOn(childProcess, 'spawnPromise').mockRejectedValueOnce(new childProcess.SpawnError({ code: 128, cmdArgs: [ 'push', 'sorenlouv', 'backport/7.x/pr-2:backport/7.x/pr-2', '--force', ], stdout: '', stderr: "remote: Repository not found.\nfatal: repository 'https://github.com/sorenlouv/kibana.git/' not found\n", })); await expect((0, git_1.pushBackportBranch)({ options, backportBranch })).rejects .toThrowErrorMatchingInlineSnapshot(` "Error pushing to https://github.com/the_fork_owner/kibana. Repository does not exist. Either fork the repository (https://github.com/elastic/kibana) or disable fork mode via "--no-fork". Read more about fork mode in the docs: https://github.com/sorenlouv/backport/blob/main/docs/config-file-options.md#fork" `); }); }); //# sourceMappingURL=git.test.js.map