Skip to content

Commit

Permalink
feat(ng-dev): add simple migration command for master branch rename
Browse files Browse the repository at this point in the history
  • Loading branch information
devversion committed Mar 27, 2022
1 parent e75a146 commit 87e5ec9
Show file tree
Hide file tree
Showing 9 changed files with 384 additions and 38 deletions.
23 changes: 14 additions & 9 deletions github-actions/slash-commands/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -54645,7 +54645,7 @@ var require_authenticated_git_client = __commonJS({
this.githubToken = githubToken;
this._githubTokenRegex = new RegExp(this.githubToken, "g");
this._cachedOauthScopes = null;
this._cachedForkRepo = null;
this._cachedForkRepositories = null;
this.github = new github_12.AuthenticatedGithubClient(this.githubToken);
}
sanitizeConsoleOutput(value) {
Expand All @@ -54671,17 +54671,22 @@ Alternatively, a new token can be created at: ${github_urls_1.GITHUB_TOKEN_GENER
return { error };
}
async getForkOfAuthenticatedUser() {
if (this._cachedForkRepo !== null) {
return this._cachedForkRepo;
const forks = await this.getForksForAuthenticatedUser();
if (forks.length === 0) {
throw Error("Unable to find fork a for currently authenticated user.");
}
return forks[0];
}
async getForksForAuthenticatedUser() {
if (this._cachedForkRepositories !== null) {
return this._cachedForkRepositories;
}
const { owner, name } = this.remoteConfig;
const result = await this.github.graphql(graphql_queries_1.findOwnedForksOfRepoQuery, { owner, name });
const forks = result.repository.forks.nodes;
if (forks.length === 0) {
throw Error(`Unable to find fork for currently authenticated user. Please ensure you created a fork of: ${owner}/${name}.`);
}
const fork = forks[0];
return this._cachedForkRepo = { owner: fork.owner.login, name: fork.name };
return this._cachedForkRepositories = result.repository.forks.nodes.map((node) => ({
owner: node.owner.login,
name: node.name
}));
}
_fetchAuthScopesForToken() {
if (this._cachedOauthScopes !== null) {
Expand Down
2 changes: 2 additions & 0 deletions ng-dev/misc/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import * as yargs from 'yargs';

import {BuildAndLinkCommandModule} from './build-and-link/cli';
import {NewMainBranchCommandModule} from './new-main-branch/cli';
import {UpdateYarnCommandModule} from './update-yarn/cli';

/** Build the parser for the misc commands. */
Expand All @@ -16,5 +17,6 @@ export function buildMiscParser(localYargs: yargs.Argv) {
.help()
.strict()
.command(BuildAndLinkCommandModule)
.command(NewMainBranchCommandModule)
.command(UpdateYarnCommandModule);
}
132 changes: 132 additions & 0 deletions ng-dev/misc/new-main-branch/cli.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/**
* @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 {assertValidGithubConfig, getConfig} from '../../utils/config';
import {error, green, info, promptConfirm, red, warn, yellow} from '../../utils/console';
import {findAvailableLocalBranchName, hasLocalBranch} from './find-local-branch';
import {getRemotesForRepo, isAngularOwnedRemote} from './remotes';

import {CommandModule} from 'yargs';
import {GitClient} from '../../utils/git/git-client';
import {promptForRemoteForkUpdate} from './remote-fork-update';

/**
* Migration command that performs local changes to account for an upstream
* branch rename from `master` to `main`. More details can be found here:
*
* https://docs.google.com/document/d/1nqb94eSIcGuPC0M9Rv7-IeqOiJKzsMnuistHZNeUmAg.
*/
async function handler() {
const git = GitClient.get();

// The command cannot operate on the local repository if there are uncommitted changes.
if (git.hasUncommittedChanges()) {
error(red('There are uncommitted changes. Unable to switch to new main branch. Aborting..'));
return;
}

const config = getConfig([assertValidGithubConfig]);
const repoSlug = `${config.github.owner}/${config.github.name}`;

if (config.github.mainBranchName !== 'main') {
error(red('Current project is not part of the default branch renaming.'));
return;
}

if (!hasLocalBranch(git, 'master')) {
error(red('Local repository does not have a local branch named `master`. Aborting..'));
return;
}

if (hasLocalBranch(git, 'main')) {
error(red('Local repository already has a branch named `main`. Aborting..'));
return;
}

const remotes = getRemotesForRepo(git);
const angularRemotes = Array.from(remotes.entries()).filter((r) => isAngularOwnedRemote(r[1]));
const angularRemoteNames = angularRemotes.map(([name]) => name);

// Usually we expect only a single remote pointing to the Angular-owned Github
// repository. If there are more, we will just assume the first one to be the primary.
const primaryRemoteName = angularRemoteNames[0];

if (angularRemoteNames.length === 0) {
warn(yellow(`Found no remote in repository that points to the \`${repoSlug}\` repository.`));
}

info('The following steps will be performed:');
if (angularRemoteNames.length) {
info(` → Remotes (${angularRemoteNames.join(`, `)}) are refreshed.`);
}
info(` → The \`main\` branch is fetched from \`${repoSlug}\`.`);
info(' → The new `main` branch is checked out.');
if (primaryRemoteName) {
info(` → The new \`main\` branch is linked to the \`${primaryRemoteName}\` remote.`);
}
info(' → The old `master` branch is deleted or renamed (you will be prompted).');
info(' → Remote references to `master` branches in the index are removed.');
info('');

if (!(await promptConfirm('Do you want to continue?'))) {
return;
}

// Refreshing Angular-owned remotes.
git.run(['fetch', ...angularRemoteNames]);

// Refresh the remote head to account for the new default branch (i.e. `main`).
for (const remoteName of angularRemoteNames) {
git.run(['remote', 'set-head', remoteName, '-a']);
}

// Fetch the `main` remote branch and store it locally as `refs/heads/main`.
git.run(['fetch', git.getRepoGitUrl(), 'main:main']);

// Checkout the new main branch so that we can potentially delete it later.
git.run(['checkout', 'main']);

// Ensure the local `main` branch has its remote set to an Angular-remote.
if (primaryRemoteName !== undefined) {
git.run(['branch', '--set-upstream-to', primaryRemoteName, 'main']);
}

// Delete the old `master` branch if desirable, or preserve it if desired.
if (
await promptConfirm('Are there changes in your local `master` branch that you want to keep?')
) {
const tmpBranch = findAvailableLocalBranchName(git, 'old-master');
git.run(['branch', '-m', 'master', tmpBranch]);

info('');
info(yellow(`Renamed local \`master\` branch to \`${tmpBranch}\`, preserving your changes.`));
} else {
git.run(['branch', '-D', 'master']);
}

// Remove all remote-tracking branches for `master` that could
// cause `git checkout master` to work due to remote tracking.
for (const [remoteName] of remotes) {
git.runGraceful(['update-ref', '-d', `refs/remotes/${remoteName}/master`]);
}

info('');
info(green('---------------------------------------------------------'));
info(green('Successfully updated the local repository to use `main`.'));
info(green('---------------------------------------------------------'));
info('');
info('');

await promptForRemoteForkUpdate();
}

export const NewMainBranchCommandModule: CommandModule = {
handler,
command: 'new-main-branch',
describe: 'Updates the local repository to account for the new GitHub main branch.',
};
27 changes: 27 additions & 0 deletions ng-dev/misc/new-main-branch/find-local-branch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/**
* @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/git-client';

/** Finds a non-reserved branch name in the repository with respect to a base name. */
export function findAvailableLocalBranchName(git: GitClient, baseName: string): string {
let currentName = baseName;
let suffixNum = 0;

while (hasLocalBranch(git, currentName)) {
suffixNum++;
currentName = `${baseName}_${suffixNum}`;
}

return currentName;
}

/** Gets whether the given branch exists locally. */
export function hasLocalBranch(git: GitClient, branchName: string): boolean {
return git.runGraceful(['rev-parse', `refs/heads/${branchName}`], {stdio: 'ignore'}).status === 0;
}
101 changes: 101 additions & 0 deletions ng-dev/misc/new-main-branch/remote-fork-update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* @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 {debug, green, promptConfirm, promptInput, warn, yellow} from '../../utils/console';

import {AuthenticatedGitClient} from '../../utils/git/authenticated-git-client';
import {GithubRepo} from '../../utils/git/github';
import {info} from 'console';

/**
* Prompts the user whether remote forks for the current repository should be
* updated from `master` to `main`.
*
* An access token for performing the Github Admin operation is demanded through
* a prompt. This is opt-in as not every contributor may want to grant the tool a
* Github access token/or some contributors may want to make the changes themselves.
*/
export async function promptForRemoteForkUpdate() {
if (
!(await promptConfirm(
'Do you want to update your fork(s) on Github to also use `main`? (recommended)',
))
) {
return;
}

info('');
info(yellow('In order to be able to update your fork automatically, the script needs'));
info(yellow('authenticated access to your GitHub account. For this, you need to enter a'));
info(yellow('GitHub access token that is temporarily stored in memory until the script exits.'));
info('');

if (!(await promptConfirm('Do you want to proceed updating your forks automatically?'))) {
return;
}

info('');
info(yellow('You can create an access token by visiting the following GitHub URL:'));
info(
yellow(
'https://github.com/settings/tokens/new?scopes=public_repo&description=ForkBranchRename',
),
);

const accessToken = await promptInput(
'Please enter a Github access token (`public_repo` scope is required)',
);

// Configure an authenticated Git client.
AuthenticatedGitClient.configure(accessToken);

const git = AuthenticatedGitClient.get();
const forks = (await git.getAllForksOfAuthenticatedUser()).map((fork) => ({
...fork,
description: getDescriptionForRepo(fork),
}));
const failedForks: string[] = [];

if (forks.length === 0) {
warn(yellow('Could not find any forks associated with the provided access token.'));
warn(yellow('You will need to manually rename the `master` branch to `main` for your fork.'));
return;
}

for (const fork of forks) {
const forkApiParams = {owner: fork.owner, repo: fork.name};

debug(`Updating fork: ${fork.description}`);

try {
await git.github.repos.renameBranch({...forkApiParams, branch: 'master', new_name: 'main'});
await git.github.repos.update({...forkApiParams, default_branch: 'main'});
debug(`Successfully updated the fork: ${fork.description}`);
} catch (e) {
debug(`An error occurred while renaming the default branch for fork: ${fork.description}`);
failedForks.push(fork.description);
}
}

if (failedForks.length > 0) {
warn(yellow('Could not update the following forks automatically:', failedForks.join(', ')));
warn(yellow('You will need to manually rename the `master` branch to `main` in the UI.'));
return;
}

info('');
info(green('---------------------------------------------------------'));
info(green('Successfully updated your fork(s) from `master` to `main`.'));
forks.forEach((fork) => info(green(`→ ${fork.description}`)));
info(green('---------------------------------------------------------'));
}

/** Gets a human-readable description for a given Github repo instance. */
function getDescriptionForRepo(repo: GithubRepo) {
return `${repo.owner}/${repo.name}`;
}
60 changes: 60 additions & 0 deletions ng-dev/misc/new-main-branch/remotes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
/**
* @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/git-client';
import {debug} from '../../utils/console';

/**
* Regular expression matching a remote verbose info line, capturing
* the remote name, remote url and type (for different mirrors).
*
* ```
* origin https://github.com/devversion/dev-infra.git (fetch)
* origin https://github.com/devversion/dev-infra.git (push)
* upstream https://github.com/angular/dev-infra.git (fetch)
* upstream https://github.com/angular/dev-infra.git (push)
* ```
*/
const remoteVerboseInfoRegex = /^([^\s]+)\s+([^\s]+)\s+\((fetch|push)\)$/;

/**
* Regular expression that matches Git remote SSH/HTTP urls which are referring
* to a repository that is owned by the Angular Github organization.
*/
const angularOrganizationRemoteUrl = /github.com[:/]angular\//;

/** Type describing extracted remotes, mapping remote name to its URL. */
export type Remotes = Map<string, string>;

/**
* Gets all remotes for the repository associated with the given Git client.
*
* Assumes that both `fetch` and `push` mirrors of a remote have the same URL.
*/
export function getRemotesForRepo(git: GitClient): Remotes {
const remotesVerboseInfo = git.run(['remote', '--verbose']);
const remotes: Remotes = new Map();

for (const line of remotesVerboseInfo.stdout.trim().split(/\r?\n/)) {
const matches = line.match(remoteVerboseInfoRegex);

if (matches === null) {
debug('Could not parse remote info line:', line);
continue;
}

remotes.set(matches[1], matches[2]);
}

return remotes;
}

/** Gets whether the given remote URL refers to an Angular-owned repository. */
export function isAngularOwnedRemote(url: string) {
return angularOrganizationRemoteUrl.test(url);
}
Loading

0 comments on commit 87e5ec9

Please sign in to comment.