UNPKG

backport

Version:

A CLI tool that automates the process of backporting commits

469 lines 21.4 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; }; 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