backport
Version:
A CLI tool that automates the process of backporting commits
614 lines (613 loc) • 26.6 kB
JavaScript
"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 promises_1 = require("fs/promises");
const promises_2 = __importDefault(require("fs/promises"));
const path_1 = __importDefault(require("path"));
const make_dir_1 = __importDefault(require("make-dir"));
const childProcessHelper_1 = require("../test/childProcessHelper");
const getDevAccessToken_1 = require("../test/private/getDevAccessToken");
const sandbox_1 = require("../test/sandbox");
const childProcess = __importStar(require("./child-process-promisified"));
const git_1 = require("./git");
const commitFormatters_1 = require("./github/commitFormatters");
jest.unmock('del');
jest.unmock('make-dir');
const commitAuthor = { name: 'Soren L', email: 'soren@mail.dk' };
const accessToken = (0, getDevAccessToken_1.getDevAccessToken)();
describe('git.private', () => {
describe('getIsCommitInBranch', () => {
let firstSha;
let secondSha;
const cwd = (0, sandbox_1.getSandboxPath)({
filename: __filename,
specname: 'getIsCommitInBranch',
});
beforeEach(async () => {
await (0, sandbox_1.resetSandbox)(cwd);
// create and commit first file
await gitInit(cwd);
firstSha = await createAndCommitFile({
filename: 'foo.md',
content: 'My first file',
cwd,
});
// create 7.x branch (but stay on `main` branch)
await childProcess.spawnPromise('git', ['branch', '7.x'], cwd);
// create and commit second file
secondSha = await createAndCommitFile({
filename: 'bar.md',
content: 'My second file',
cwd,
});
// checkout 7.x
await childProcess.spawnPromise('git', ['checkout', '7.x'], cwd);
});
it('should contain the first commit', async () => {
const isFirstCommitInBranch = await (0, git_1.getIsCommitInBranch)({ dir: cwd }, firstSha);
expect(isFirstCommitInBranch).toEqual(true);
});
it('should not contain the second commit', async () => {
const isSecondCommitInBranch = await (0, git_1.getIsCommitInBranch)({ dir: cwd }, secondSha);
expect(isSecondCommitInBranch).toEqual(false);
});
it('should not contain a random commit', async () => {
const isSecondCommitInBranch = await (0, git_1.getIsCommitInBranch)({ dir: cwd }, 'abcdefg');
expect(isSecondCommitInBranch).toEqual(false);
});
});
describe('deleteRemote', () => {
const cwd = (0, sandbox_1.getSandboxPath)({
filename: __filename,
specname: 'deleteRemote',
});
it('should handle when deleting a remote that does not exist', async () => {
const options = {
repoName: 'kibana',
repoOwner: 'elastic',
dir: cwd,
};
await (0, sandbox_1.resetSandbox)(cwd);
await gitInit(cwd);
const res = await (0, git_1.deleteRemote)(options, 'my-remote-foo');
expect(res).toBe(undefined);
});
});
describe('createBackportBranch', () => {
const cwd = (0, sandbox_1.getSandboxPath)({
filename: __filename,
specname: 'createBackportBranch',
});
beforeEach(async () => {
await (0, sandbox_1.resetSandbox)(cwd);
await gitClone('https://github.com/backport-org/repo-with-conflicts.git', cwd);
});
it('creates the backport branch when `targetBranch` exists', async () => {
await (0, git_1.createBackportBranch)({
options: {
repoOwner: 'origin',
dir: cwd,
},
sourceBranch: 'main',
targetBranch: '7.x',
backportBranch: 'my-backport-branch',
});
expect(await getCurrentBranchName(cwd)).toEqual('my-backport-branch');
});
it('throws a handled error when `targetBranch` does not exist', async () => {
await expect(async () => {
await (0, git_1.createBackportBranch)({
options: {
repoOwner: 'origin',
dir: cwd,
},
sourceBranch: 'main',
targetBranch: 'foo',
backportBranch: 'my-backport-branch',
});
}).rejects.toThrow('The branch "foo" is invalid or doesn\'t exist');
});
});
describe('pushBackportBranch', () => {
const cwd = (0, sandbox_1.getSandboxPath)({
filename: __filename,
specname: 'pushBackportBranch',
});
beforeEach(async () => {
await (0, sandbox_1.resetSandbox)(cwd);
await gitClone('https://github.com/backport-org/repo-with-conflicts.git', cwd);
await (0, git_1.createBackportBranch)({
options: {
repoOwner: 'origin',
dir: cwd,
},
sourceBranch: 'main',
targetBranch: '7.x',
backportBranch: 'my-backport-branch',
});
await (0, childProcessHelper_1.exec)(`git remote add sorenlouv https://x-access-token:${accessToken}@github.com/sorenlouv/repo-with-conflicts.git`, { cwd });
});
it('throws error when repo does not exist', async () => {
await expect(async () => {
await (0, git_1.pushBackportBranch)({
options: {
repoOwner: 'sorenlouv',
repoName: 'repo-with-conflicts',
dir: cwd,
},
backportBranch: 'my-backport-branch',
});
}).rejects.toThrowErrorMatchingInlineSnapshot(`
"Error pushing to https://github.com/sorenlouv/repo-with-conflicts. Repository does not exist. Either fork the repository (https://github.com/sorenlouv/repo-with-conflicts) 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"
`);
});
});
describe('cherrypick', () => {
let firstSha;
let secondSha;
let fourthSha;
let cwd;
const sandboxPath = (0, sandbox_1.getSandboxPath)({
filename: __filename,
specname: 'cherrypick',
});
beforeEach(async () => {
await (0, sandbox_1.resetSandbox)(sandboxPath);
cwd = sandboxPath;
// create and commit first file
await gitInit(cwd);
firstSha = await createAndCommitFile({
filename: 'foo.md',
content: 'Creating first file',
cwd,
});
// create 7.x branch (but stay on `main` branch)
await childProcess.spawnPromise('git', ['branch', '7.x'], cwd);
// create and commit second file
secondSha = await createAndCommitFile({
filename: 'bar.md',
content: 'Creating second file\nHello',
cwd,
});
// edit first file
await createAndCommitFile({
filename: 'foo.md',
content: 'Changing first file',
cwd,
});
// edit first file
fourthSha = await createAndCommitFile({
filename: 'foo.md',
content: 'Some more changes to the first file',
cwd,
});
// checkout 7.x
await childProcess.spawnPromise('git', ['checkout', '7.x'], cwd);
});
it('should not cherrypick commit that already exists', async () => {
const shortSha = (0, commitFormatters_1.getShortSha)(firstSha);
await expect(() => (0, git_1.cherrypick)({
options: { dir: cwd },
sha: firstSha,
commitAuthor: { name: 'Soren L', email: 'soren@mail.dk' },
})).rejects.toThrow(`Cherrypick failed because the selected commit (${shortSha}) is empty. Did you already backport this commit?`);
});
it('should cherrypick commit cleanly', async () => {
const res = await (0, git_1.cherrypick)({
options: {
cherrypickRef: false,
dir: cwd,
},
sha: secondSha,
commitAuthor,
});
expect(res).toEqual({
conflictingFiles: [],
needsResolving: false,
unstagedFiles: [],
});
const message = await getMostRecentCommitMessage(cwd);
expect(message).toEqual(`Update bar.md`);
});
it('should cherrypick commit cleanly and append "(cherry picked from commit...)"', async () => {
const res = await (0, git_1.cherrypick)({
options: {
cherrypickRef: true,
dir: cwd,
},
sha: secondSha,
commitAuthor,
});
expect(res).toEqual({
conflictingFiles: [],
needsResolving: false,
unstagedFiles: [],
});
const message = await getMostRecentCommitMessage(cwd);
expect(message).toEqual(`Update bar.md\n\n(cherry picked from commit ${secondSha})`);
});
it('should cherrypick commit with conflicts', async () => {
const res = await (0, git_1.cherrypick)({
options: { dir: cwd },
sha: fourthSha,
commitAuthor,
});
expect(res).toEqual({
needsResolving: true,
conflictingFiles: [
{ absolute: `${sandboxPath}/foo.md`, relative: 'foo.md' },
],
unstagedFiles: [`${sandboxPath}/foo.md`],
});
});
});
describe('commitChanges', () => {
const cwd = (0, sandbox_1.getSandboxPath)({
filename: __filename,
specname: 'commitChanges',
});
beforeEach(async () => {
await (0, sandbox_1.resetSandbox)(cwd);
});
it('should return without error if user already committed manually (and there therefore is nothing to commit)', async () => {
const options = { dir: cwd };
const commit = { sourceCommit: { message: 'my message' } };
await gitInit(cwd);
await createAndCommitFile({
content: 'foo',
filename: 'my-file-1.txt',
cwd,
});
expect(async () => {
return await (0, git_1.commitChanges)({ commit, commitAuthor, options });
}).not.toThrow();
const message = await getMostRecentCommitMessage(cwd);
expect(message).toBe('Update my-file-1.txt');
});
it('should return without error if user aborts the cherrypick process', async () => {
const options = { dir: cwd };
const commit = {
sourceCommit: { message: 'my fallback commit message' },
};
await gitInit(cwd);
await createAndStageFile({
content: 'foo',
filename: 'my-file-2.txt',
cwd,
});
await (0, git_1.commitChanges)({ commit, commitAuthor, options });
const message = await getMostRecentCommitMessage(cwd);
expect(message).toBe('my fallback commit message');
});
it('should commit cherypicked changes after conflicts have been resolved', async () => {
const options = { dir: cwd };
const commit = {
sourceCommit: { message: 'my fallback commit message' },
};
await gitClone('https://github.com/backport-org/repo-with-conflicts.git', cwd);
await (0, childProcessHelper_1.exec)('git checkout 7.x', { cwd });
// cherry-pick file
try {
await childProcess.spawnPromise('git', ['cherry-pick', '3a0934d1f646e4a50571cb4b137ad2b08d2e7b18'], cwd);
}
catch (e) {
// swallow
}
// disregard conflicts and stage all files
await (0, childProcessHelper_1.exec)('git add -A', { cwd });
await (0, git_1.commitChanges)({ commit, commitAuthor, options });
const message = await getMostRecentCommitMessage(cwd);
expect(message).toMatchInlineSnapshot(`
"Add 🇩🇰 (#12)
# Conflicts:
# la-liga.md"
`);
});
});
describe('cloneRepo', () => {
const sandboxPath = (0, sandbox_1.getSandboxPath)({
filename: __filename,
specname: 'cloneRepo',
});
const sourceRepo = `${sandboxPath}/source-repo`;
const backportRepo = `${sandboxPath}/backport-repo`;
beforeEach(async () => {
await (0, sandbox_1.resetSandbox)(sandboxPath);
});
it('clones the repo', async () => {
await (0, make_dir_1.default)(sourceRepo);
await gitInit(sourceRepo);
await childProcess.spawnPromise('git', ['remote', 'add', 'origin', 'git@github.com:elastic/kibana.git'], sourceRepo);
await createAndCommitFile({
filename: 'my-file.txt',
content: 'Hello!',
cwd: sourceRepo,
});
// file should not exist before clone
await expect(() => (0, promises_1.access)(`${backportRepo}/my-file.txt`)).rejects.toThrow();
await (0, git_1.cloneRepo)({ sourcePath: sourceRepo, targetPath: backportRepo }, () => null);
//file should exist after clone
await expect(() => (0, promises_1.access)(`${backportRepo}/my-file.txt`)).not.toThrow();
});
it('clones a remote repo and continously updates the progress', async () => {
const onProgressSpy = jest.fn();
await (0, git_1.cloneRepo)({
sourcePath: 'https://github.com/backport-org/backport-e2e.git',
targetPath: backportRepo,
}, onProgressSpy);
expect(onProgressSpy).toHaveBeenCalledWith(expect.any(Number));
});
it('fails to clone repo because sourcePath is incorrect', async () => {
await expect(() => (0, git_1.cloneRepo)({
sourcePath: `${sandboxPath}/source-repo-incorrect`,
targetPath: backportRepo,
}, () => null)).rejects.toThrow('Git clone failed with exit code: 128');
});
});
describe('getLocalConfigFileCommitDate', () => {
let cwd;
beforeEach(async () => {
cwd = (0, sandbox_1.getSandboxPath)({
filename: __filename,
specname: 'getLocalConfigFileCommitDate',
});
await (0, sandbox_1.resetSandbox)(cwd);
await gitInit(cwd);
});
it('get the commit date for project config', async () => {
const timeBefore = Math.floor(Date.now() / 1000) * 1000; // round to nearest second
await createAndCommitFile({
filename: '.backportrc.json',
content: 'foo',
cwd,
});
const timeAfter = Math.ceil(Date.now() / 1000) * 1000; // round to nearest second
const commitedDate = await (0, git_1.getLocalConfigFileCommitDate)({ cwd });
expect(commitedDate).toBeGreaterThanOrEqual(timeBefore);
expect(commitedDate).toBeLessThanOrEqual(timeAfter);
});
});
describe('isLocalConfigFileUntracked', () => {
let cwd;
beforeEach(async () => {
cwd = (0, sandbox_1.getSandboxPath)({
filename: __filename,
specname: 'isLocalConfigFileUntracked',
});
await (0, sandbox_1.resetSandbox)(cwd);
await gitInit(cwd);
});
it('is not untracked when committed', async () => {
await createAndCommitFile({
filename: '.backportrc.json',
content: 'foo',
cwd,
});
const isUntracked = await (0, git_1.isLocalConfigFileUntracked)({ cwd });
expect(isUntracked).toBe(false);
});
it('is not untracked when staged', async () => {
await createAndStageFile({
filename: '.backportrc.json',
content: 'foo',
cwd,
});
const isUntracked = await (0, git_1.isLocalConfigFileUntracked)({ cwd });
expect(isUntracked).toBe(false);
});
it('is untracked when neither staged nor committed', async () => {
await promises_2.default.writeFile(path_1.default.join(cwd, '.backportrc.json'), 'foo');
const isUntracked = await (0, git_1.isLocalConfigFileUntracked)({ cwd });
expect(isUntracked).toBe(true);
});
});
describe('getLocalSourceRepoPath', () => {
let cwd;
beforeEach(async () => {
cwd = (0, sandbox_1.getSandboxPath)({
filename: __filename,
specname: 'getLocalSourceRepoPath',
});
await (0, sandbox_1.resetSandbox)(cwd);
await gitInit(cwd);
await childProcess.spawnPromise('git', ['remote', 'add', 'sorenlouv', 'git@github.com:sorenlouv/kibana.git'], cwd);
await childProcess.spawnPromise('git', ['remote', 'add', 'elastic', 'git@github.com:elastic/kibana.git'], cwd);
});
it('returns local source repo, when one remote matches', async () => {
const options = {
repoName: 'kibana',
repoOwner: 'elastic',
cwd,
githubApiBaseUrlV4: 'http://localhost/graphql', // required to mock the response
};
expect(await (0, git_1.getLocalSourceRepoPath)(options)).toBe(cwd);
});
it('returns undefined when no remotes match', async () => {
const options = {
repoName: 'kibana',
repoOwner: 'not-a-match',
cwd,
githubApiBaseUrlV4: 'http://localhost/graphql', // required to mock the response
};
expect(await (0, git_1.getLocalSourceRepoPath)(options)).toBe(undefined);
});
});
describe('getGitProjectRootPath', () => {
let sandboxPath;
let subDirectory;
beforeEach(async () => {
sandboxPath = (0, sandbox_1.getSandboxPath)({
filename: __filename,
specname: 'getGitProjectRootPath',
});
subDirectory = `${sandboxPath}/foo-dir`;
await (0, sandbox_1.resetSandbox)(sandboxPath);
await gitInit(sandboxPath);
(0, make_dir_1.default)(subDirectory);
});
it('returns the root dir', async () => {
const rootPath = await (0, git_1.getGitProjectRootPath)(subDirectory);
expect(rootPath).toBe(sandboxPath);
expect(rootPath).not.toBe(subDirectory);
});
});
describe('isLocalConfigFileModified', () => {
let cwd;
beforeEach(async () => {
cwd = (0, sandbox_1.getSandboxPath)({
filename: __filename,
specname: 'isLocalConfigFileModified',
});
await (0, sandbox_1.resetSandbox)(cwd);
await gitClone('https://github.com/backport-org/repo-with-project-config.git', cwd);
});
it('returns false when file is unchanged', async () => {
const isModified = await (0, git_1.isLocalConfigFileModified)({ cwd });
expect(isModified).toBe(false);
});
it('returns true when file is changed', async () => {
await promises_2.default.writeFile(path_1.default.join(cwd, '.backportrc.json'), 'foo');
const isModified = await (0, git_1.isLocalConfigFileModified)({ cwd });
expect(isModified).toBe(true);
});
});
describe('when cloning "backport-org/different-merge-strategies"', () => {
const MERGE_COMMIT_HASH_1 = 'bdc5a17f81e5f32129e27b05c742e055c650bc54';
const MERGE_COMMIT_HASH_2 = '0db7f1ac1233461563d8708511d1c14adbab46da';
const SQUASH_COMMIT_HASH = '74a76fa64b34e3ffe8f2a3f73840e1b42fd07299';
const REBASE_COMMIT_HASH = '9059ae0ca31caa2eebc035f2542842d6c2fde83b';
let cwd;
beforeAll(async () => {
cwd = (0, sandbox_1.getSandboxPath)({
filename: __filename,
specname: 'different-merge-strategies',
});
await (0, sandbox_1.resetSandbox)(cwd);
await gitClone('https://github.com/backport-org/different-merge-strategies.git', cwd);
});
describe('getIsMergeCommit', () => {
it('returns true for first merge commit', async () => {
const res = await (0, git_1.getIsMergeCommit)({ dir: cwd }, MERGE_COMMIT_HASH_1);
expect(res).toBe(true);
});
it('returns true for second merge commit', async () => {
const res = await (0, git_1.getIsMergeCommit)({ dir: cwd }, MERGE_COMMIT_HASH_2);
expect(res).toBe(true);
});
it('returns false for rebased commits', async () => {
const res = await (0, git_1.getIsMergeCommit)({ dir: cwd }, REBASE_COMMIT_HASH);
expect(res).toBe(false);
});
it('returns false for squashed commits', async () => {
const res = await (0, git_1.getIsMergeCommit)({ dir: cwd }, SQUASH_COMMIT_HASH);
expect(res).toBe(false);
});
});
describe('getShasInMergeCommit', () => {
it('returns a list of commit hashes - excluding the merge hash itself', async () => {
const shas = await (0, git_1.getShasInMergeCommit)({ dir: cwd }, MERGE_COMMIT_HASH_1);
expect(shas).not.toContain(MERGE_COMMIT_HASH_1);
expect(shas).toEqual([
'f9a760e0d9eb3ebcc64f8cb75ce885714b836496',
'7b92e29e88266004485ce0fae0260605b01df887',
'a1facf8c006fb815d6a6ecd1b2907e6e64f29576',
'b8d4bcfb0fd875be4ab0230f6db40ddf72f45378',
'6f224054db5f7772b04f23f17659070216bae84c',
'7fed54cbd1ba9cb973462670ff82ac80bf8a79f8',
'78c24d0058859e7d511d10ce91ebf279c7b58ac2',
'709ccf707d443dd8c001b3c3ae40fdf037bb43f5',
]);
});
it('returns empty for squash commits', async () => {
const shas = await (0, git_1.getShasInMergeCommit)({ dir: cwd }, SQUASH_COMMIT_HASH);
expect(shas).toEqual([]);
});
});
});
});
async function createAndCommitFile({ filename, content, cwd, }) {
await createAndStageFile({ filename, content, cwd });
await childProcess.spawnPromise('git', ['commit', `--message=Update ${filename}`], cwd);
return getCurrentSha(cwd);
}
async function createAndStageFile({ filename, content, cwd, }) {
try {
await promises_2.default.writeFile(path_1.default.join(cwd, filename), content);
await childProcess.spawnPromise('git', ['add', `${filename}`], cwd);
}
catch (e) {
// eslint-disable-next-line no-console
console.log('"createAndStageFile" threw an error', {
filename,
content,
cwd,
});
throw e;
}
}
async function getCurrentSha(cwd) {
const { stdout } = await childProcess.spawnPromise('git', ['rev-parse', 'HEAD'], cwd);
return stdout.trim();
}
async function getCurrentBranchName(cwd) {
const { stdout } = await (0, childProcessHelper_1.exec)('git rev-parse --abbrev-ref HEAD', { cwd });
return stdout.trim();
}
async function getMostRecentCommitMessage(cwd) {
try {
const { stdout } = await childProcess.spawnPromise('git', ['--no-pager', 'log', '-1', '--pretty=%B'], cwd);
return stdout.trim();
}
catch (e) {
// eslint-disable-next-line no-console
console.log('"getMostRecentCommitMessage" threw an error', cwd);
throw e;
}
}
async function gitClone(repoUrl, cwd) {
try {
return await childProcess.spawnPromise('git', ['clone', repoUrl, './'], cwd);
}
catch (e) {
// eslint-disable-next-line no-console
console.log('Git clone failed');
throw e;
}
}
async function gitInit(cwd) {
try {
await childProcess.spawnPromise('git', ['init'], cwd);
}
catch (e) {
// eslint-disable-next-line no-console
console.log('Git init failed');
throw e;
}
}
//# sourceMappingURL=git.private.test.js.map