backport
Version:
A CLI tool that automates the process of backporting commits
469 lines • 21.4 kB
JavaScript
;
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;
};
Object.defineProperty(exports, "__esModule", { value: true });
exports.getLocalSourceRepoPath = exports.pushBackportBranch = exports.getRepoForkOwner = exports.deleteBackportBranch = exports.createBackportBranch = exports.getUnstagedFiles = exports.getConflictingFiles = exports.commitChanges = exports.gitAddAll = exports.cherrypick = exports.getShasInMergeCommit = exports.getIsMergeCommit = exports.fetchBranch = exports.addRemote = exports.deleteRemote = exports.getIsCommitInBranch = exports.getGitProjectRootPath = exports.getRepoInfoFromGitRemotes = exports.isLocalConfigFileModified = exports.isLocalConfigFileUntracked = exports.getLocalConfigFileCommitDate = exports.cloneRepo = exports.getRemoteUrl = void 0;
const path_1 = __importStar(require("path"));
const lodash_1 = require("lodash");
const ora_1 = require("../lib/ora");
const filterEmpty_1 = require("../utils/filterEmpty");
const BackportError_1 = require("./BackportError");
const child_process_promisified_1 = require("./child-process-promisified");
const env_1 = require("./env");
const commitFormatters_1 = require("./github/commitFormatters");
const logger_1 = require("./logger");
function getRemoteUrl({ repoName, accessToken, gitHostname = 'github.com' }, repoOwner) {
return `https://x-access-token:${accessToken}@${gitHostname}/${repoOwner}/${repoName}.git`;
}
exports.getRemoteUrl = getRemoteUrl;
async function cloneRepo({ sourcePath, targetPath }, onProgress) {
logger_1.logger.info(`Cloning repo from ${sourcePath} to ${targetPath}`);
return new Promise((resolve, reject) => {
const subprocess = (0, child_process_promisified_1.spawnStream)('git', [
'clone',
sourcePath,
targetPath,
'--progress',
]);
const progress = {
fileUpdate: 0,
objectReceive: 0,
};
subprocess.on('error', (err) => reject(err));
subprocess.stderr.on('data', (data) => {
logger_1.logger.verbose(data.toString());
const [, objectReceiveProgress] = data.toString().match(/^Receiving objects:\s+(\d+)%/) || [];
if (objectReceiveProgress) {
progress.objectReceive = parseInt(objectReceiveProgress, 10);
}
const [, fileUpdateProgress] = data.toString().match(/^Updating files:\s+(\d+)%/) || [];
if (fileUpdateProgress) {
progress.objectReceive = 100;
progress.fileUpdate = parseInt(fileUpdateProgress, 10);
}
const progressSum = Math.round(progress.fileUpdate * 0.1 + progress.objectReceive * 0.9);
if (progressSum > 0) {
onProgress(progressSum);
}
});
subprocess.on('close', (code) => {
if (code === 0 || code === null) {
resolve();
}
else {
reject(new Error(`Git clone failed with exit code: ${code}`));
}
});
});
}
exports.cloneRepo = cloneRepo;
async function getLocalConfigFileCommitDate({ cwd }) {
try {
const { stdout } = await (0, child_process_promisified_1.spawnPromise)('git', ['--no-pager', 'log', '-1', '--format=%cd', '.backportrc.json'], cwd);
const timestamp = Date.parse(stdout);
if (timestamp > 0) {
return timestamp;
}
}
catch (e) {
return;
}
}
exports.getLocalConfigFileCommitDate = getLocalConfigFileCommitDate;
async function isLocalConfigFileUntracked({ cwd }) {
try {
// list untracked files
const { stdout } = await (0, child_process_promisified_1.spawnPromise)('git', ['ls-files', '.backportrc.json', '--exclude-standard', '--others'], cwd);
return !!stdout;
}
catch (e) {
return;
}
}
exports.isLocalConfigFileUntracked = isLocalConfigFileUntracked;
async function isLocalConfigFileModified({ cwd }) {
try {
const { stdout } = await (0, child_process_promisified_1.spawnPromise)('git', ['--no-pager', 'diff', 'HEAD', '--name-only', '.backportrc.json'], cwd);
return !!stdout;
}
catch (e) {
return false;
}
}
exports.isLocalConfigFileModified = isLocalConfigFileModified;
async function getRepoInfoFromGitRemotes({ cwd }) {
try {
const { stdout } = await (0, child_process_promisified_1.spawnPromise)('git', ['remote', '--verbose'], cwd);
const remotes = stdout
.split('\n')
.map((line) => {
const match = line.match(/github.com[/|:](.+?)(.git)? \((fetch|push)\)/);
return match?.[1];
})
.filter(filterEmpty_1.filterNil);
return (0, lodash_1.uniq)(remotes).map((remote) => {
const [repoOwner, repoName] = remote.split('/');
return { repoOwner, repoName };
});
}
catch (e) {
return [];
}
}
exports.getRepoInfoFromGitRemotes = getRepoInfoFromGitRemotes;
async function getGitProjectRootPath(dir) {
try {
const cwd = dir;
const { stdout } = await (0, child_process_promisified_1.spawnPromise)('git', ['rev-parse', '--show-toplevel'], cwd);
return path_1.default.normalize(stdout.trim());
}
catch (e) {
logger_1.logger.error('An error occurred while retrieving git project root', e);
return;
}
}
exports.getGitProjectRootPath = getGitProjectRootPath;
async function getIsCommitInBranch(options, commitSha) {
try {
const cwd = (0, env_1.getRepoPath)(options);
await (0, child_process_promisified_1.spawnPromise)('git', ['merge-base', '--is-ancestor', commitSha, 'HEAD'], cwd);
return true;
}
catch (e) {
logger_1.logger.warn('getIsCommitInBranch threw', e);
return false;
}
}
exports.getIsCommitInBranch = getIsCommitInBranch;
async function deleteRemote(options, remoteName) {
try {
const cwd = (0, env_1.getRepoPath)(options);
await (0, child_process_promisified_1.spawnPromise)('git', ['remote', 'rm', remoteName], cwd);
}
catch (e) {
const isSpawnError = e instanceof child_process_promisified_1.SpawnError;
// Swallow the "remote does not exist" failure.
// Since git 2.30.0, this failure is indicated by the specific exit code 2.
// In earlier versions, the exit code is 128 and only the error message can
// tell the problems apart.
if (isSpawnError &&
(e.context.code == 2 ||
(e.context.code == 128 && e.context.stderr.includes('No such remote')))) {
return;
}
// re-throw
throw e;
}
}
exports.deleteRemote = deleteRemote;
async function addRemote(options, remoteName) {
try {
const cwd = (0, env_1.getRepoPath)(options);
await (0, child_process_promisified_1.spawnPromise)('git', ['remote', 'add', remoteName, getRemoteUrl(options, remoteName)], cwd);
}
catch (e) {
// note: swallowing error
return;
}
}
exports.addRemote = addRemote;
async function fetchBranch(options, branch) {
const cwd = (0, env_1.getRepoPath)(options);
await (0, child_process_promisified_1.spawnPromise)('git', ['fetch', options.repoOwner, `${branch}:${branch}`, '--force'], cwd);
}
exports.fetchBranch = fetchBranch;
async function getIsMergeCommit(options, sha) {
const cwd = (0, env_1.getRepoPath)(options);
try {
const res = await (0, child_process_promisified_1.spawnPromise)('git', ['rev-list', '-1', '--merges', `${sha}~1..${sha}`], cwd);
return res.stdout !== '';
}
catch (e) {
const shortSha = (0, commitFormatters_1.getShortSha)(sha);
logger_1.logger.info(`Could not determine if ${shortSha} is a merge commit. Will assume it is not`, e);
return false;
}
}
exports.getIsMergeCommit = getIsMergeCommit;
async function getShasInMergeCommit(options, sha) {
try {
const cwd = (0, env_1.getRepoPath)(options);
const res = await (0, child_process_promisified_1.spawnPromise)('git', ['--no-pager', 'log', `${sha}^1..${sha}^2`, '--pretty=format:%H'], cwd);
return res.stdout.split('\n');
}
catch (e) {
const isSpawnError = e instanceof child_process_promisified_1.SpawnError;
// swallow error
if (isSpawnError && e.context.code === 128) {
return [];
}
throw e;
}
}
exports.getShasInMergeCommit = getShasInMergeCommit;
async function cherrypick({ options, sha, mergedTargetPullRequest, commitAuthor, }) {
const cmdArgs = [
`-c`,
`user.name="${commitAuthor.name}"`,
`-c`,
`user.email="${commitAuthor.email}"`,
`cherry-pick`,
...(options.mainline != undefined
? ['--mainline', `${options.mainline}`]
: []),
...(options.cherrypickRef === false ? [] : ['-x']),
...(options.signoff ? ['--signoff'] : []),
sha,
];
try {
const cwd = (0, env_1.getRepoPath)(options);
await (0, child_process_promisified_1.spawnPromise)('git', cmdArgs, cwd);
return { conflictingFiles: [], unstagedFiles: [], needsResolving: false };
}
catch (e) {
const isSpawnError = e instanceof child_process_promisified_1.SpawnError;
if (isSpawnError) {
// missing `mainline` option
if (e.message.includes('is a merge but no -m option was given')) {
throw new BackportError_1.BackportError('Cherrypick failed because the selected commit was a merge commit. Please try again by specifying the parent with the `mainline` argument:\n\n> backport --mainline\n\nor:\n\n> backport --mainline <parent-number>\n\nOr refer to the git documentation for more information: https://git-scm.com/docs/git-cherry-pick#Documentation/git-cherry-pick.txt---mainlineparent-number');
}
// commit was already backported
if (e.message.includes('The previous cherry-pick is now empty')) {
const shortSha = (0, commitFormatters_1.getShortSha)(sha);
throw new BackportError_1.BackportError(`Cherrypick failed because the selected commit (${shortSha}) is empty. ${mergedTargetPullRequest?.url
? `It looks like the commit was already backported in ${mergedTargetPullRequest.url}`
: 'Did you already backport this commit? '}`);
}
if (e.message.includes(`bad object ${sha}`)) {
throw new BackportError_1.BackportError(`Cherrypick failed because commit "${sha}" was not found`);
}
const isCherryPickError = e.context.cmdArgs.includes('cherry-pick') && e.context.code > 0;
if (isCherryPickError) {
const [conflictingFiles, unstagedFiles] = await Promise.all([
getConflictingFiles(options),
getUnstagedFiles(options),
]);
if (!(0, lodash_1.isEmpty)(conflictingFiles) || !(0, lodash_1.isEmpty)(unstagedFiles))
return { conflictingFiles, unstagedFiles, needsResolving: true };
}
}
// re-throw error if it didn't match the handled cases above
throw e;
}
}
exports.cherrypick = cherrypick;
async function gitAddAll({ options }) {
const cwd = (0, env_1.getRepoPath)(options);
return (0, child_process_promisified_1.spawnPromise)('git', ['add', '--all'], cwd);
}
exports.gitAddAll = gitAddAll;
async function gitCommit({ options, commitAuthor, message, }) {
const cwd = (0, env_1.getRepoPath)(options);
return (0, child_process_promisified_1.spawnPromise)('git', [
`-c`,
`user.name="${commitAuthor.name}"`,
`-c`,
`user.email="${commitAuthor.email}"`,
'commit',
...(message ? [`--message=${message}`] : ['--no-edit']),
...(options.noVerify ? ['--no-verify'] : []), // bypass pre-commit and commit-msg hooks
...(options.signoff ? ['--signoff'] : []),
], cwd);
}
async function commitChanges({ options, commit, commitAuthor, }) {
try {
await gitCommit({ options, commitAuthor });
}
catch (e) {
const isSpawnError = e instanceof child_process_promisified_1.SpawnError;
if (isSpawnError) {
if (e.context.stdout.includes('nothing to commit')) {
logger_1.logger.info(`Could not run "git commit". Probably because the changes were manually committed`, e);
return;
}
// manually set the commit message if the inferred commit message is empty
// this can happen if the user runs `git reset HEAD` and thereby aborts the cherrypick process
if (e.context.stderr.includes('Aborting commit due to empty commit message')) {
await gitCommit({
options,
commitAuthor,
message: commit.sourceCommit.message,
});
return;
}
}
// rethrow error if it can't be handled
throw e;
}
}
exports.commitChanges = commitChanges;
async function getConflictingFiles(options) {
const repoPath = (0, env_1.getRepoPath)(options);
try {
const cwd = repoPath;
await (0, child_process_promisified_1.spawnPromise)('git', ['--no-pager', 'diff', '--check'], cwd);
return [];
}
catch (e) {
const isSpawnError = e instanceof child_process_promisified_1.SpawnError;
const isConflictError = isSpawnError && e.context.code === 2;
if (isConflictError) {
const files = e.context.stdout
.split('\n')
.filter((line) => !!line.trim() && !line.startsWith('+') && !line.startsWith('-'))
.map((line) => {
const posSeparator = line.indexOf(':');
const filename = line.slice(0, posSeparator).trim();
return filename;
});
const uniqueFiles = (0, lodash_1.uniq)(files);
return uniqueFiles.map((file) => {
return {
absolute: (0, path_1.resolve)(repoPath, file),
relative: file,
};
});
}
// rethrow error since it's unrelated
throw e;
}
}
exports.getConflictingFiles = getConflictingFiles;
// retrieve the list of files that could not be cleanly merged
async function getUnstagedFiles(options) {
const repoPath = (0, env_1.getRepoPath)(options);
const cwd = repoPath;
const res = await (0, child_process_promisified_1.spawnPromise)('git', ['--no-pager', 'diff', '--name-only'], cwd);
const files = res.stdout
.split('\n')
.filter((file) => !!file)
.map((file) => (0, path_1.resolve)(repoPath, file));
return (0, lodash_1.uniq)(files);
}
exports.getUnstagedFiles = getUnstagedFiles;
// How the commit flows:
// ${sourceBranch} -> ${backportBranch} -> ${targetBranch}
// master -> backport/7.x/pr-1234 -> 7.x
async function createBackportBranch({ options, sourceBranch, backportBranch, targetBranch, }) {
const spinner = (0, ora_1.ora)(options.interactive, 'Pulling latest changes').start();
const cwd = (0, env_1.getRepoPath)(options);
try {
await (0, child_process_promisified_1.spawnPromise)('git', ['reset', '--hard'], cwd);
await (0, child_process_promisified_1.spawnPromise)('git', ['clean', '-d', '--force'], cwd);
// create tmp branch. This can be necessary when fetching to the currently selected branch
const tmpBranchName = '__backport_tool_tmp';
await (0, child_process_promisified_1.spawnPromise)('git', ['checkout', '-B', tmpBranchName], cwd);
// fetch target branch
await fetchBranch(options, targetBranch);
// checkout backport branch and point it to target branch
await (0, child_process_promisified_1.spawnPromise)('git', [
'checkout',
'-B',
backportBranch,
`${options.repoOwner}/${targetBranch}`,
'--no-track',
], cwd);
// delete tmp branch (if it still exists)
try {
await (0, child_process_promisified_1.spawnPromise)('git', ['branch', '-D', tmpBranchName], cwd);
}
catch (e) {
// swallow error
}
// fetch commits for source branch
await fetchBranch(options, sourceBranch);
}
catch (e) {
spinner.fail();
if (e instanceof child_process_promisified_1.SpawnError) {
const invalidRemoteRef = e.context.stderr
.toLowerCase()
.match(/couldn't find remote ref (.*)/)?.[1];
const invalidCommit = e.context.stderr
.toLowerCase()
.match(/'(.+) is not a commit and a branch .+ cannot be created from it/)?.[1];
const invalidBranch = invalidRemoteRef || invalidCommit;
if (invalidBranch) {
throw new BackportError_1.BackportError(`The branch "${invalidBranch}" is invalid or doesn't exist`);
}
const invalidRefSpec = e.context.stderr
.toLowerCase()
.match(/invalid refspec (.*)/)?.[1];
if (invalidRefSpec) {
throw new BackportError_1.BackportError(`The remote "${invalidRefSpec}" is invalid or doesn't exist`);
}
}
throw e;
}
spinner.succeed();
}
exports.createBackportBranch = createBackportBranch;
async function deleteBackportBranch({ options, backportBranch, }) {
const spinner = (0, ora_1.ora)(options.interactive).start();
const cwd = (0, env_1.getRepoPath)(options);
await (0, child_process_promisified_1.spawnPromise)('git', ['reset', '--hard'], cwd);
await (0, child_process_promisified_1.spawnPromise)('git', ['checkout', options.sourceBranch], cwd);
await (0, child_process_promisified_1.spawnPromise)('git', ['branch', '--delete', '--force', backportBranch], cwd);
spinner.stop();
}
exports.deleteBackportBranch = deleteBackportBranch;
/*
* Returns the repo owner of the forked repo or the source repo
*/
function getRepoForkOwner(options) {
return options.fork ? options.repoForkOwner : options.repoOwner;
}
exports.getRepoForkOwner = getRepoForkOwner;
async function pushBackportBranch({ options, backportBranch, }) {
const repoForkOwner = getRepoForkOwner(options);
const spinner = (0, ora_1.ora)(options.interactive, `Pushing branch "${repoForkOwner}:${backportBranch}"`).start();
try {
const cwd = (0, env_1.getRepoPath)(options);
const res = await (0, child_process_promisified_1.spawnPromise)('git', ['push', repoForkOwner, `${backportBranch}:${backportBranch}`, '--force'], cwd);
spinner.succeed();
return res;
}
catch (e) {
spinner.fail();
if (e instanceof child_process_promisified_1.SpawnError &&
e.context.stderr.toLowerCase().includes(`repository not found`)) {
throw new BackportError_1.BackportError(`Error pushing to https://github.com/${repoForkOwner}/${options.repoName}. Repository does not exist. Either fork the repository (https://github.com/${options.repoOwner}/${options.repoName}) or disable fork mode via "--no-fork".\nRead more about fork mode in the docs: https://github.com/sorenlouv/backport/blob/main/docs/config-file-options.md#fork`);
}
if (e instanceof child_process_promisified_1.SpawnError &&
e.context.stderr.includes(`could not read Username for`)) {
throw new BackportError_1.BackportError(`Invalid credentials: ${e.message}`);
}
throw e;
}
}
exports.pushBackportBranch = pushBackportBranch;
// retrieve path to local repo (cwd) if it matches `repoName` / `repoOwner`
async function getLocalSourceRepoPath(options) {
const remotes = await getRepoInfoFromGitRemotes({ cwd: options.cwd });
const hasMatchingGitRemote = remotes.some((remote) => remote.repoName === options.repoName &&
remote.repoOwner === options.repoOwner);
return hasMatchingGitRemote ? getGitProjectRootPath(options.cwd) : undefined;
}
exports.getLocalSourceRepoPath = getLocalSourceRepoPath;
//# sourceMappingURL=git.js.map