-
Notifications
You must be signed in to change notification settings - Fork 53
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(ng-dev): add simple migration command for master branch rename
See details in: https://docs.google.com/document/d/1nqb94eSIcGuPC0M9Rv7-IeqOiJKzsMnuistHZNeUmAg/edit#
- Loading branch information
1 parent
e75a146
commit 87e5ec9
Showing
9 changed files
with
384 additions
and
38 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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.', | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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}`; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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); | ||
} |
Oops, something went wrong.