Paul Gschwendtner cb2ab26296 feat(dev-infra): merge script should link to original commit when cherry-picking with API strategy (#37889)
The merge script uses `git cherry-pick` for both the API merge strategy
and the autosquash strategy. It uses cherry-pick to push commits to
different target branches (e.g. into the `10.0.x` branch).

Those commits never point to the commits that landed in the primary
Github branch though. For the autosquash strategy the pull request number
is always included, so there is a way to go back to the source. On the other
hand though, for commits cherry-picked in the API merge strategy, the
pull request number might not always be included (due to Github's
implementation of the rebase merge method).

e.g.
27f52711c0

For those cases we'd want to link the cherry-picked commits to the
original commits so that the corresponding PR is easier to track
down. This is not needed for the autosquash strategy (as outlined
before), but it would have been good for consistency. Unfortunately
though this would rather complicate the strategy as the autosquash
strategy cherry-picks directly from the PR head, so the SHAs that
are used in the primary branch are not known.

PR Close #37889
2020-07-07 12:16:21 -07:00

141 lines
6.0 KiB
TypeScript

/**
* @license
* Copyright Google LLC All Rights Reserved.
*
* Use of this source code is governed by an MIT-style license that can be
* found in the LICENSE file at https://angular.io/license
*/
import {GitClient} from '../../../utils/git';
import {PullRequestFailure} from '../failures';
import {PullRequest} from '../pull-request';
/**
* Name of a temporary branch that contains the head of a currently-processed PR. Note
* that a branch name should be used that most likely does not conflict with other local
* development branches.
*/
export const TEMP_PR_HEAD_BRANCH = 'merge_pr_head';
/**
* Base class for merge strategies. A merge strategy accepts a pull request and
* merges it into the determined target branches.
*/
export abstract class MergeStrategy {
constructor(protected git: GitClient) {}
/**
* Prepares a merge of the given pull request. The strategy by default will
* fetch all target branches and the pull request into local temporary branches.
*/
async prepare(pullRequest: PullRequest) {
this.fetchTargetBranches(
pullRequest.targetBranches, `pull/${pullRequest.prNumber}/head:${TEMP_PR_HEAD_BRANCH}`);
}
/**
* Performs the merge of the given pull request. This needs to be implemented
* by individual merge strategies.
*/
abstract merge(pullRequest: PullRequest): Promise<null|PullRequestFailure>;
/** Cleans up the pull request merge. e.g. deleting temporary local branches. */
async cleanup(pullRequest: PullRequest) {
// Delete all temporary target branches.
pullRequest.targetBranches.forEach(
branchName => this.git.run(['branch', '-D', this.getLocalTargetBranchName(branchName)]));
// Delete temporary branch for the pull request head.
this.git.run(['branch', '-D', TEMP_PR_HEAD_BRANCH]);
}
/** Gets the revision range for all commits in the given pull request. */
protected getPullRequestRevisionRange(pullRequest: PullRequest): string {
return `${this.getPullRequestBaseRevision(pullRequest)}..${TEMP_PR_HEAD_BRANCH}`;
}
/** Gets the base revision of a pull request. i.e. the commit the PR is based on. */
protected getPullRequestBaseRevision(pullRequest: PullRequest): string {
return `${TEMP_PR_HEAD_BRANCH}~${pullRequest.commitCount}`;
}
/** Gets a deterministic local branch name for a given branch. */
protected getLocalTargetBranchName(targetBranch: string): string {
return `merge_pr_target_${targetBranch.replace(/\//g, '_')}`;
}
/**
* Cherry-picks the given revision range into the specified target branches.
* @returns A list of branches for which the revisions could not be cherry-picked into.
*/
protected cherryPickIntoTargetBranches(revisionRange: string, targetBranches: string[], options: {
dryRun?: boolean,
linkToOriginalCommits?: boolean,
} = {}) {
const cherryPickArgs = [revisionRange];
const failedBranches: string[] = [];
if (options.dryRun) {
// https://git-scm.com/docs/git-cherry-pick#Documentation/git-cherry-pick.txt---no-commit
// This causes `git cherry-pick` to not generate any commits. Instead, the changes are
// applied directly in the working tree. This allow us to easily discard the changes
// for dry-run purposes.
cherryPickArgs.push('--no-commit');
}
if (options.linkToOriginalCommits) {
// We add `-x` when cherry-picking as that will allow us to easily jump to original
// commits for cherry-picked commits. With that flag set, Git will automatically append
// the original SHA/revision to the commit message. e.g. `(cherry picked from commit <..>)`.
// https://git-scm.com/docs/git-cherry-pick#Documentation/git-cherry-pick.txt--x.
cherryPickArgs.push('-x');
}
// Cherry-pick the refspec into all determined target branches.
for (const branchName of targetBranches) {
const localTargetBranch = this.getLocalTargetBranchName(branchName);
// Checkout the local target branch.
this.git.run(['checkout', localTargetBranch]);
// Cherry-pick the refspec into the target branch.
if (this.git.runGraceful(['cherry-pick', ...cherryPickArgs]).status !== 0) {
// Abort the failed cherry-pick. We do this because Git persists the failed
// cherry-pick state globally in the repository. This could prevent future
// pull request merges as a Git thinks a cherry-pick is still in progress.
this.git.runGraceful(['cherry-pick', '--abort']);
failedBranches.push(branchName);
}
// If we run with dry run mode, we reset the local target branch so that all dry-run
// cherry-pick changes are discard. Changes are applied to the working tree and index.
if (options.dryRun) {
this.git.run(['reset', '--hard', 'HEAD']);
}
}
return failedBranches;
}
/**
* Fetches the given target branches. Also accepts a list of additional refspecs that
* should be fetched. This is helpful as multiple slow fetches could be avoided.
*/
protected fetchTargetBranches(names: string[], ...extraRefspecs: string[]) {
const fetchRefspecs = names.map(targetBranch => {
const localTargetBranch = this.getLocalTargetBranchName(targetBranch);
return `refs/heads/${targetBranch}:${localTargetBranch}`;
});
// Fetch all target branches with a single command. We don't want to fetch them
// individually as that could cause an unnecessary slow-down.
this.git.run(['fetch', '-f', this.git.repoGitUrl, ...fetchRefspecs, ...extraRefspecs]);
}
/** Pushes the given target branches upstream. */
protected pushTargetBranchesUpstream(names: string[]) {
const pushRefspecs = names.map(targetBranch => {
const localTargetBranch = this.getLocalTargetBranchName(targetBranch);
return `${localTargetBranch}:refs/heads/${targetBranch}`;
});
// Push all target branches with a single command if we don't run in dry-run mode.
// We don't want to push them individually as that could cause an unnecessary slow-down.
this.git.run(['push', this.git.repoGitUrl, ...pushRefspecs]);
}
}