[cli] Support multiple remote URLs in Git config (#8145)

Two features that handle a user's local Git config have been shipped:

- #8100 
- #7910 

Both of these features currently pull only from the user's remote origin URL. This covers 90% of cases, but there are cases in which the user has more than one remote URL, and may want to use one other than the origin URL, especially in `vc git connect`. This PR:

- Adds support for multiple remote URLs in a user's Git config
- Updates `vc git connect` to prompt the user to select a URL if they have multiple remote URLs
- Updates `createGitMeta` to send the connected Git repository url by default, origin url otherwise

### 📋 Checklist

<!--
  Please keep your PR as a Draft until the checklist is complete
-->

#### Tests

- [x] The code changed/added as part of this PR has been covered with tests
- [x] All tests pass locally with `yarn test-unit`

#### Code Review

- [ ] This PR has a concise title and thorough description useful to a reviewer
- [ ] Issue from task tracker has a link to this PR
This commit is contained in:
Matthew Stanciu
2022-07-20 00:17:53 -07:00
committed by GitHub
parent e5421c27e8
commit 0140db38fa
25 changed files with 362 additions and 89 deletions

View File

@@ -428,7 +428,7 @@ export default async (client: Client) => {
parseMeta(argv['--meta'])
);
const gitMetadata = await createGitMeta(path, output);
const gitMetadata = await createGitMeta(path, output, project);
// Merge dotenv config, `env` from vercel.json, and `--env` / `-e` arguments
const deploymentEnv = Object.assign(

View File

@@ -2,8 +2,9 @@ import chalk from 'chalk';
import { join } from 'path';
import { Org, Project } from '../../types';
import Client from '../../util/client';
import { parseGitConfig, pluckRemoteUrl } from '../../util/create-git-meta';
import { parseGitConfig, pluckRemoteUrls } from '../../util/create-git-meta';
import confirm from '../../util/input/confirm';
import list, { ListChoice } from '../../util/input/list';
import { Output } from '../../util/output';
import link from '../../util/output/link';
import { getCommandName } from '../../util/pkg-name';
@@ -64,20 +65,37 @@ export default async function connect(
);
return 1;
}
const remoteUrl = pluckRemoteUrl(gitConfig);
if (!remoteUrl) {
const remoteUrls = pluckRemoteUrls(gitConfig);
if (!remoteUrls) {
output.error(
`No remote origin URL found in your Git config. Make sure you've configured a remote repo in your local Git config. Run ${chalk.cyan(
`No remote URLs found in your Git config. Make sure you've configured a remote repo in your local Git config. Run ${chalk.cyan(
'`git remote --help`'
)} for more details.`
);
return 1;
}
output.log(`Identified Git remote "origin": ${link(remoteUrl)}`);
let remoteUrl: string;
if (Object.keys(remoteUrls).length > 1) {
output.log(`Found multiple remote URLs.`);
remoteUrl = await selectRemoteUrl(client, remoteUrls);
} else {
// If only one is found, get it — usually "origin"
remoteUrl = Object.values(remoteUrls)[0];
}
if (remoteUrl === '') {
output.log('Aborted.');
return 0;
}
output.log(`Connecting Git remote: ${link(remoteUrl)}`);
const parsedUrl = parseRepoUrl(remoteUrl);
if (!parsedUrl) {
output.error(
`Failed to parse Git repo data from the following remote URL in your Git config: ${link(
`Failed to parse Git repo data from the following remote URL: ${link(
remoteUrl
)}`
);
@@ -166,3 +184,22 @@ async function confirmRepoConnect(
}
return shouldReplaceProject;
}
async function selectRemoteUrl(
client: Client,
remoteUrls: { [key: string]: string }
): Promise<string> {
let choices: ListChoice[] = [];
for (const [urlKey, urlValue] of Object.entries(remoteUrls)) {
choices.push({
name: `${urlValue} ${chalk.gray(`(${urlKey})`)}`,
value: urlValue,
short: urlKey,
});
}
return await list(client, {
message: 'Which remote do you want to connect?',
choices,
});
}

View File

@@ -3,76 +3,42 @@ import { join } from 'path';
import ini from 'ini';
import git from 'git-last-commit';
import { exec } from 'child_process';
import { GitMetadata } from '../types';
import { GitMetadata, Project } from '../types';
import { Output } from './output';
export function isDirty(directory: string, output: Output): Promise<boolean> {
return new Promise(resolve => {
exec('git status -s', { cwd: directory }, function (err, stdout, stderr) {
let debugMessage = `Failed to determine if Git repo has been modified:`;
if (err || stderr) {
if (err) debugMessage += `\n${err}`;
if (stderr) debugMessage += `\n${stderr.trim()}`;
output.debug(debugMessage);
return resolve(false);
}
resolve(stdout.trim().length > 0);
});
});
}
function getLastCommit(directory: string): Promise<git.Commit> {
return new Promise((resolve, reject) => {
git.getLastCommit(
(err, commit) => {
if (err) return reject(err);
resolve(commit);
},
{ dst: directory }
);
});
}
export async function parseGitConfig(configPath: string, output: Output) {
try {
return ini.parse(await fs.readFile(configPath, 'utf-8'));
} catch (error) {
output.debug(`Error while parsing repo data: ${error.message}`);
}
}
export function pluckRemoteUrl(gitConfig: {
[key: string]: any;
}): string | undefined {
// Assuming "origin" is the remote url that the user would want to use
return gitConfig['remote "origin"']?.url;
}
export async function getRemoteUrl(
configPath: string,
output: Output
): Promise<string | null> {
let gitConfig = await parseGitConfig(configPath, output);
if (!gitConfig) {
return null;
}
const originUrl = pluckRemoteUrl(gitConfig);
if (originUrl) {
return originUrl;
}
return null;
}
export async function createGitMeta(
directory: string,
output: Output
output: Output,
project?: Project | null
): Promise<GitMetadata | undefined> {
const remoteUrl = await getRemoteUrl(join(directory, '.git/config'), output);
// If a Git repository is already connected via `vc git`, use that remote url
let remoteUrl;
if (project?.link) {
// in the form of org/repo
const { repo } = project.link;
const remoteUrls = await getRemoteUrls(
join(directory, '.git/config'),
output
);
if (remoteUrls) {
for (const urlValue of Object.values(remoteUrls)) {
if (urlValue.includes(repo)) {
remoteUrl = urlValue;
}
}
}
}
// If we couldn't get a remote url from the connected repo, default to the origin url
if (!remoteUrl) {
remoteUrl = await getOriginUrl(join(directory, '.git/config'), output);
}
// If we can't get the repo URL, then don't return any metadata
if (!remoteUrl) {
return;
}
const [commit, dirty] = await Promise.all([
getLastCommit(directory).catch(err => {
output.debug(
@@ -96,3 +62,97 @@ export async function createGitMeta(
dirty,
};
}
function getLastCommit(directory: string): Promise<git.Commit> {
return new Promise((resolve, reject) => {
git.getLastCommit(
(err, commit) => {
if (err) return reject(err);
resolve(commit);
},
{ dst: directory }
);
});
}
export function isDirty(directory: string, output: Output): Promise<boolean> {
return new Promise(resolve => {
exec('git status -s', { cwd: directory }, function (err, stdout, stderr) {
let debugMessage = `Failed to determine if Git repo has been modified:`;
if (err || stderr) {
if (err) debugMessage += `\n${err}`;
if (stderr) debugMessage += `\n${stderr.trim()}`;
output.debug(debugMessage);
return resolve(false);
}
resolve(stdout.trim().length > 0);
});
});
}
export async function parseGitConfig(configPath: string, output: Output) {
try {
return ini.parse(await fs.readFile(configPath, 'utf-8'));
} catch (error) {
output.debug(`Error while parsing repo data: ${error.message}`);
}
}
export function pluckRemoteUrls(gitConfig: {
[key: string]: any;
}): { [key: string]: string } | undefined {
let remoteUrls: { [key: string]: string } = {};
for (const key of Object.keys(gitConfig)) {
if (key.includes('remote')) {
// ex. remote "origin" — matches origin
const remoteName = key.match(/(?<=").*(?=")/g)?.[0];
const remoteUrl = gitConfig[key]?.url;
if (remoteName && remoteUrl) {
remoteUrls[remoteName] = remoteUrl;
}
}
}
if (Object.keys(remoteUrls).length === 0) {
return;
}
return remoteUrls;
}
export async function getRemoteUrls(
configPath: string,
output: Output
): Promise<{ [key: string]: string } | undefined> {
const config = await parseGitConfig(configPath, output);
if (!config) {
return;
}
const remoteUrls = pluckRemoteUrls(config);
return remoteUrls;
}
export function pluckOriginUrl(gitConfig: {
[key: string]: any;
}): string | undefined {
// Assuming "origin" is the remote url that the user would want to use
return gitConfig['remote "origin"']?.url;
}
export async function getOriginUrl(
configPath: string,
output: Output
): Promise<string | null> {
let gitConfig = await parseGitConfig(configPath, output);
if (!gitConfig) {
return null;
}
const originUrl = pluckOriginUrl(gitConfig);
if (originUrl) {
return originUrl;
}
return null;
}

View File

@@ -14,7 +14,7 @@ interface ListSeparator {
separator: string;
}
type ListChoice = ListEntry | ListSeparator | typeof inquirer.Separator;
export type ListChoice = ListEntry | ListSeparator | typeof inquirer.Separator;
interface ListOptions {
message: string;

View File

@@ -0,0 +1 @@
!.vercel

View File

@@ -0,0 +1,4 @@
{
"orgId": "team_dummy",
"projectId": "multiple-remotes"
}

View File

@@ -0,0 +1 @@
ref: refs/heads/master

View File

@@ -0,0 +1,13 @@
[core]
repositoryformatversion = 0
filemode = true
bare = false
logallrefupdates = true
ignorecase = true
precomposeunicode = true
[remote "origin"]
url = https://github.com/user/repo.git
fetch = +refs/heads/*:refs/remotes/origin/*
[remote "secondary"]
url = https://github.com/user/repo2.git
fetch = +refs/heads/*:refs/remotes/secondary/*

View File

@@ -0,0 +1 @@
Unnamed repository; edit this file 'description' to name the repository.

View File

@@ -0,0 +1 @@
!.vercel

View File

@@ -0,0 +1,4 @@
{
"orgId": "team_dummy",
"projectId": "connected-repo"
}

View File

@@ -0,0 +1 @@
add hi

View File

@@ -0,0 +1 @@
ref: refs/heads/master

View File

@@ -0,0 +1,13 @@
[core]
repositoryformatversion = 0
filemode = true
bare = false
logallrefupdates = true
ignorecase = true
precomposeunicode = true
[remote "origin"]
url = https://github.com/user/repo
fetch = +refs/heads/*:refs/remotes/origin/*
[remote "secondary"]
url = https://github.com/user/repo2
fetch = +refs/heads/*:refs/remotes/secondary/*

View File

@@ -0,0 +1 @@
Unnamed repository; edit this file 'description' to name the repository.

View File

@@ -0,0 +1 @@
8050816205303e5957b2909083c50677930d5b29

View File

@@ -0,0 +1 @@
hi

View File

@@ -0,0 +1 @@
ref: refs/heads/master

View File

@@ -0,0 +1,13 @@
[core]
repositoryformatversion = 0
filemode = true
bare = false
logallrefupdates = true
ignorecase = true
precomposeunicode = true
[remote "origin"]
url = https://github.com/user/repo
fetch = +refs/heads/*:refs/remotes/origin/*
[remote "secondary"]
url = https://github.com/user/repo2
fetch = +refs/heads/*:refs/remotes/secondary/*

View File

@@ -0,0 +1 @@
Unnamed repository; edit this file 'description' to name the repository.

View File

@@ -25,7 +25,7 @@ describe('git', () => {
id: 'unlinked',
name: 'unlinked',
});
client.setArgv('projects', 'connect');
client.setArgv('git', 'connect');
const gitPromise = git(client);
await expect(client.stderr).toOutput('Set up');
@@ -40,7 +40,7 @@ describe('git', () => {
client.stdin.write('y\n');
await expect(client.stderr).toOutput(
`Identified Git remote "origin": https://github.com/user/repo.git`
`Connecting Git remote: https://github.com/user/repo.git`
);
const exitCode = await gitPromise;
@@ -76,7 +76,7 @@ describe('git', () => {
id: 'no-git-config',
name: 'no-git-config',
});
client.setArgv('projects', 'connect', '--confirm');
client.setArgv('git', 'connect', '--confirm');
const exitCode = await git(client);
expect(exitCode).toEqual(1);
await expect(client.stderr).toOutput(
@@ -98,11 +98,11 @@ describe('git', () => {
id: 'no-remote-url',
name: 'no-remote-url',
});
client.setArgv('projects', 'connect', '--confirm');
client.setArgv('git', 'connect', '--confirm');
const exitCode = await git(client);
expect(exitCode).toEqual(1);
await expect(client.stderr).toOutput(
`Error! No remote origin URL found in your Git config. Make sure you've configured a remote repo in your local Git config. Run \`git remote --help\` for more details.`
`Error! No remote URLs found in your Git config. Make sure you've configured a remote repo in your local Git config. Run \`git remote --help\` for more details.`
);
} finally {
await fs.rename(join(cwd, '.git'), join(cwd, 'git'));
@@ -121,15 +121,15 @@ describe('git', () => {
id: 'bad-remote-url',
name: 'bad-remote-url',
});
client.setArgv('projects', 'connect', '--confirm');
client.setArgv('git', 'connect', '--confirm');
const exitCode = await git(client);
expect(exitCode).toEqual(1);
await expect(client.stderr).toOutput(
`Identified Git remote "origin": bababooey`
`Connecting Git remote: bababooey`
);
await expect(client.stderr).toOutput(
`Error! Failed to parse Git repo data from the following remote URL in your Git config: bababooey\n`
`Error! Failed to parse Git repo data from the following remote URL: bababooey\n`
);
} finally {
await fs.rename(join(cwd, '.git'), join(cwd, 'git'));
@@ -148,11 +148,11 @@ describe('git', () => {
id: 'new-connection',
name: 'new-connection',
});
client.setArgv('projects', 'connect', '--confirm');
client.setArgv('git', 'connect', '--confirm');
const gitPromise = git(client);
await expect(client.stderr).toOutput(
`Identified Git remote "origin": https://github.com/user/repo`
`Connecting Git remote: https://github.com/user/repo`
);
await expect(client.stderr).toOutput(
`> Connected GitHub repository user/repo!\n`
@@ -201,11 +201,11 @@ describe('git', () => {
updatedAt: 1656109539791,
};
client.setArgv('projects', 'connect', '--confirm');
client.setArgv('git', 'connect', '--confirm');
const gitPromise = git(client);
await expect(client.stderr).toOutput(
`Identified Git remote "origin": https://github.com/user2/repo2`
`Connecting Git remote: https://github.com/user2/repo2`
);
await expect(client.stderr).toOutput(
`> Connected GitHub repository user2/repo2!\n`
@@ -253,11 +253,11 @@ describe('git', () => {
createdAt: 1656109539791,
updatedAt: 1656109539791,
};
client.setArgv('projects', 'connect', '--confirm');
client.setArgv('git', 'connect', '--confirm');
const gitPromise = git(client);
await expect(client.stderr).toOutput(
`Identified Git remote "origin": https://github.com/user/repo`
`Connecting Git remote: https://github.com/user/repo`
);
await expect(client.stderr).toOutput(
`> user/repo is already connected to your project.\n`
@@ -283,11 +283,11 @@ describe('git', () => {
name: 'invalid-repo',
});
client.setArgv('projects', 'connect', '--confirm');
client.setArgv('git', 'connect', '--confirm');
const gitPromise = git(client);
await expect(client.stderr).toOutput(
`Identified Git remote "origin": https://github.com/laksfj/asdgklsadkl`
`Connecting Git remote: https://github.com/laksfj/asdgklsadkl`
);
await expect(client.stderr).toOutput(
`Failed to link laksfj/asdgklsadkl. Make sure there aren't any typos and that you have access to the repository if it's private.`
@@ -300,6 +300,56 @@ describe('git', () => {
process.chdir(originalCwd);
}
});
it('should connect the default option of multiple remotes', async () => {
const cwd = fixture('multiple-remotes');
try {
process.chdir(cwd);
await fs.rename(join(cwd, 'git'), join(cwd, '.git'));
useUser();
useTeams('team_dummy');
useProject({
...defaultProject,
id: 'multiple-remotes',
name: 'multiple-remotes',
});
client.setArgv('git', 'connect');
const gitPromise = git(client);
await expect(client.stderr).toOutput('Found multiple remote URLs.');
await expect(client.stderr).toOutput(
'Which remote do you want to connect?'
);
client.stdin.write('\r');
await expect(client.stderr).toOutput(
'Connecting Git remote: https://github.com/user/repo.git'
);
await expect(client.stderr).toOutput(
'Connected GitHub repository user/repo!'
);
const exitCode = await gitPromise;
expect(exitCode).toEqual(0);
const project: Project = await client.fetch(
`/v8/projects/multiple-remotes`
);
expect(project.link).toMatchObject({
type: 'github',
repo: 'user/repo',
repoId: 1010,
gitCredentialId: '',
sourceless: true,
createdAt: 1656109539791,
updatedAt: 1656109539791,
});
} finally {
await fs.rename(join(cwd, '.git'), join(cwd, 'git'));
process.chdir(originalCwd);
}
});
});
describe('disconnect', () => {
const originalCwd = process.cwd();

View File

@@ -4,31 +4,58 @@ import os from 'os';
import { getWriteableDirectory } from '@vercel/build-utils';
import {
createGitMeta,
getRemoteUrl,
getOriginUrl,
getRemoteUrls,
isDirty,
} from '../../../../src/util/create-git-meta';
import { client } from '../../../mocks/client';
import { parseRepoUrl } from '../../../../src/util/projects/connect-git-provider';
import { readOutputStream } from '../../../helpers/read-output-stream';
import { useUser } from '../../../mocks/user';
import { defaultProject, useProject } from '../../../mocks/project';
import { Project } from '../../../../src/types';
const fixture = (name: string) =>
join(__dirname, '../../../fixtures/unit/create-git-meta', name);
describe('getRemoteUrl', () => {
describe('getOriginUrl', () => {
it('does not provide data for no-origin', async () => {
const configPath = join(fixture('no-origin'), 'git/config');
const data = await getRemoteUrl(configPath, client.output);
const data = await getOriginUrl(configPath, client.output);
expect(data).toBeNull();
});
it('displays debug message when repo data cannot be parsed', async () => {
const dir = await getWriteableDirectory();
client.output.debugEnabled = true;
const data = await getRemoteUrl(join(dir, 'git/config'), client.output);
const data = await getOriginUrl(join(dir, 'git/config'), client.output);
expect(data).toBeNull();
await expect(client.stderr).toOutput('Error while parsing repo data');
});
});
describe('getRemoteUrls', () => {
it('does not provide data when there are no remote urls', async () => {
const configPath = join(fixture('no-origin'), 'git/config');
const data = await getRemoteUrls(configPath, client.output);
expect(data).toBeUndefined();
});
it('returns an object when multiple urls are present', async () => {
const configPath = join(fixture('multiple-remotes'), 'git/config');
const data = await getRemoteUrls(configPath, client.output);
expect(data).toMatchObject({
origin: 'https://github.com/user/repo',
secondary: 'https://github.com/user/repo2',
});
});
it('returns an object for origin url', async () => {
const configPath = join(fixture('test-github'), 'git/config');
const data = await getRemoteUrls(configPath, client.output);
expect(data).toMatchObject({
origin: 'https://github.com/user/repo.git',
});
});
});
describe('parseRepoUrl', () => {
it('should be null when a url does not match the regex', () => {
const parsedUrl = parseRepoUrl('https://examplecom/foo');
@@ -244,4 +271,45 @@ describe('createGitMeta', () => {
await fs.remove(tmpDir);
}
});
it('uses the repo url for a connected project', async () => {
const originalCwd = process.cwd();
const directory = fixture('connected-repo');
try {
process.chdir(directory);
await fs.rename(join(directory, 'git'), join(directory, '.git'));
useUser();
const project = useProject({
...defaultProject,
id: 'connected-repo',
name: 'connected-repo',
});
project.project.link = {
type: 'github',
repo: 'user/repo2',
repoId: 1010,
gitCredentialId: '',
sourceless: true,
createdAt: 1656109539791,
updatedAt: 1656109539791,
};
const data = await createGitMeta(
directory,
client.output,
project.project as Project
);
expect(data).toMatchObject({
remoteUrl: 'https://github.com/user/repo2',
commitAuthorName: 'Matthew Stanciu',
commitMessage: 'add hi',
commitRef: 'master',
commitSha: '8050816205303e5957b2909083c50677930d5b29',
dirty: true,
});
} finally {
await fs.rename(join(directory, '.git'), join(directory, 'git'));
process.chdir(originalCwd);
}
});
});