feat(core): introduce nx import (#26847)
## Current Behavior <!-- This is the behavior we have today --> Importing other projects/ repositories into an Nx workspace is a natural part of the Nx adoption story. However, there is no easy built in way of handling this while maintaining `git` history. ## Expected Behavior <!-- This is the behavior we should expect with the changes in this PR --> `nx import` is a new command which allows teams to merge code from other repositories into a Nx workspace. https://asciinema.org/a/oQiA9qOvA2z85AQvVJ5QRVTp1 ## Related Issue(s) <!-- Please link the issue being fixed so it gets closed when this is merged. --> Fixes #
This commit is contained in:
parent
903c4607d9
commit
c72ba9b504
89
e2e/nx/src/import.test.ts
Normal file
89
e2e/nx/src/import.test.ts
Normal file
@ -0,0 +1,89 @@
|
||||
import {
|
||||
checkFilesExist,
|
||||
cleanupProject,
|
||||
getSelectedPackageManager,
|
||||
newProject,
|
||||
runCLI,
|
||||
updateJson,
|
||||
updateFile,
|
||||
e2eCwd,
|
||||
} from '@nx/e2e/utils';
|
||||
import { mkdirSync, rmdirSync } from 'fs';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { join } from 'path';
|
||||
|
||||
describe('Nx Import', () => {
|
||||
let proj: string;
|
||||
const tempImportE2ERoot = join(e2eCwd, 'nx-import');
|
||||
beforeAll(() => {
|
||||
proj = newProject({
|
||||
packages: ['@nx/js'],
|
||||
unsetProjectNameAndRootFormat: false,
|
||||
});
|
||||
|
||||
if (getSelectedPackageManager() === 'pnpm') {
|
||||
updateFile(
|
||||
'pnpm-workspace.yaml',
|
||||
`packages:
|
||||
- 'projects/*'
|
||||
`
|
||||
);
|
||||
} else {
|
||||
updateJson('package.json', (json) => {
|
||||
json.workspaces = ['projects/*'];
|
||||
return json;
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
rmdirSync(join(tempImportE2ERoot));
|
||||
} catch {}
|
||||
});
|
||||
afterAll(() => cleanupProject());
|
||||
|
||||
it('should be able to import a vite app', () => {
|
||||
mkdirSync(join(tempImportE2ERoot), { recursive: true });
|
||||
const tempViteProjectName = 'created-vite-app';
|
||||
execSync(
|
||||
`npx create-vite@latest ${tempViteProjectName} --template react-ts`,
|
||||
{
|
||||
cwd: tempImportE2ERoot,
|
||||
}
|
||||
);
|
||||
const tempViteProjectPath = join(tempImportE2ERoot, tempViteProjectName);
|
||||
execSync(`git init`, {
|
||||
cwd: tempViteProjectPath,
|
||||
});
|
||||
execSync(`git add .`, {
|
||||
cwd: tempViteProjectPath,
|
||||
});
|
||||
execSync(`git commit -am "initial commit"`, {
|
||||
cwd: tempViteProjectPath,
|
||||
});
|
||||
execSync(`git checkout -b main`, {
|
||||
cwd: tempViteProjectPath,
|
||||
});
|
||||
|
||||
const remote = tempViteProjectPath;
|
||||
const ref = 'main';
|
||||
const source = '.';
|
||||
const directory = 'projects/vite-app';
|
||||
|
||||
runCLI(
|
||||
`import ${remote} ${directory} --ref ${ref} --source ${source} --no-interactive`,
|
||||
{
|
||||
verbose: true,
|
||||
}
|
||||
);
|
||||
|
||||
checkFilesExist(
|
||||
'projects/vite-app/.gitignore',
|
||||
'projects/vite-app/package.json',
|
||||
'projects/vite-app/index.html',
|
||||
'projects/vite-app/vite.config.ts',
|
||||
'projects/vite-app/src/main.tsx',
|
||||
'projects/vite-app/src/App.tsx'
|
||||
);
|
||||
runCLI(`vite:build created-vite-app`);
|
||||
});
|
||||
});
|
||||
48
packages/nx/src/command-line/import/command-object.ts
Normal file
48
packages/nx/src/command-line/import/command-object.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { CommandModule } from 'yargs';
|
||||
import { linkToNxDevAndExamples } from '../yargs-utils/documentation';
|
||||
import { withVerbose } from '../yargs-utils/shared-options';
|
||||
import { handleErrors } from '../../utils/params';
|
||||
|
||||
export const yargsImportCommand: CommandModule = {
|
||||
command: 'import [sourceRemoteUrl] [destination]',
|
||||
describe: false,
|
||||
builder: (yargs) =>
|
||||
linkToNxDevAndExamples(
|
||||
withVerbose(
|
||||
yargs
|
||||
.positional('sourceRemoteUrl', {
|
||||
type: 'string',
|
||||
description: 'The remote URL of the source to import',
|
||||
})
|
||||
.positional('destination', {
|
||||
type: 'string',
|
||||
description:
|
||||
'The directory in the current workspace to import into',
|
||||
})
|
||||
.option('source', {
|
||||
type: 'string',
|
||||
description:
|
||||
'The directory in the source repository to import from',
|
||||
})
|
||||
.option('ref', {
|
||||
type: 'string',
|
||||
description: 'The branch from the source repository to import',
|
||||
})
|
||||
.option('interactive', {
|
||||
type: 'boolean',
|
||||
description: 'Interactive mode',
|
||||
default: true,
|
||||
})
|
||||
),
|
||||
'import'
|
||||
),
|
||||
handler: async (args) => {
|
||||
const exitCode = await handleErrors(
|
||||
(args.verbose as boolean) ?? process.env.NX_VERBOSE_LOGGING === 'true',
|
||||
async () => {
|
||||
return (await import('./import')).importHandler(args as any);
|
||||
}
|
||||
);
|
||||
process.exit(exitCode);
|
||||
},
|
||||
};
|
||||
276
packages/nx/src/command-line/import/import.ts
Normal file
276
packages/nx/src/command-line/import/import.ts
Normal file
@ -0,0 +1,276 @@
|
||||
import { join, relative, resolve } from 'path';
|
||||
import { cloneFromUpstream, GitRepository } from '../../utils/git-utils';
|
||||
import { stat, mkdir, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'tmp';
|
||||
import { prompt } from 'enquirer';
|
||||
import { output } from '../../utils/output';
|
||||
import * as createSpinner from 'ora';
|
||||
import { detectPlugins, installPlugins } from '../init/init-v2';
|
||||
import { readNxJson } from '../../config/nx-json';
|
||||
import { workspaceRoot } from '../../utils/workspace-root';
|
||||
import {
|
||||
detectPackageManager,
|
||||
getPackageManagerCommand,
|
||||
} from '../../utils/package-manager';
|
||||
import { resetWorkspaceContext } from '../../utils/workspace-context';
|
||||
import { runInstall } from '../init/implementation/utils';
|
||||
import { getBaseRef } from '../../utils/command-line-utils';
|
||||
import { prepareSourceRepo } from './utils/prepare-source-repo';
|
||||
import { mergeRemoteSource } from './utils/merge-remote-source';
|
||||
import {
|
||||
getPackagesInPackageManagerWorkspace,
|
||||
needsInstall,
|
||||
} from './utils/needs-install';
|
||||
|
||||
const importRemoteName = '__tmp_nx_import__';
|
||||
|
||||
export interface ImportOptions {
|
||||
/**
|
||||
* The remote URL of the repository to import
|
||||
*/
|
||||
sourceRemoteUrl: string;
|
||||
/**
|
||||
* The branch or reference to import
|
||||
*/
|
||||
ref: string;
|
||||
/**
|
||||
* The directory in the source repo to import
|
||||
*/
|
||||
source: string;
|
||||
/**
|
||||
* The directory in the destination repo to import into
|
||||
*/
|
||||
destination: string;
|
||||
|
||||
verbose: boolean;
|
||||
interactive: boolean;
|
||||
}
|
||||
|
||||
export async function importHandler(options: ImportOptions) {
|
||||
let { sourceRemoteUrl, ref, source, destination } = options;
|
||||
|
||||
output.log({
|
||||
title:
|
||||
'Nx will walk you through the process of importing code from another repository into this workspace:',
|
||||
bodyLines: [
|
||||
`1. Nx will clone the other repository into a temporary directory`,
|
||||
`2. Code to be imported will be moved to the same directory it will be imported into on a temporary branch`,
|
||||
`3. The code will be merged into the current branch in this workspace`,
|
||||
`4. Nx will recommend plugins to integrate tools used in the imported code with Nx`,
|
||||
`5. The code will be successfully imported into this workspace`,
|
||||
'',
|
||||
`Git history will be preserved during this process`,
|
||||
],
|
||||
});
|
||||
|
||||
const tempImportDirectory = join(tmpdir, 'nx-import');
|
||||
|
||||
if (!sourceRemoteUrl) {
|
||||
sourceRemoteUrl = (
|
||||
await prompt<{ sourceRemoteUrl: string }>([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'sourceRemoteUrl',
|
||||
message:
|
||||
'What is the URL of the repository you want to import? (This can be a local git repository or a git remote URL)',
|
||||
required: true,
|
||||
},
|
||||
])
|
||||
).sourceRemoteUrl;
|
||||
}
|
||||
|
||||
try {
|
||||
const maybeLocalDirectory = await stat(sourceRemoteUrl);
|
||||
if (maybeLocalDirectory.isDirectory()) {
|
||||
sourceRemoteUrl = resolve(sourceRemoteUrl);
|
||||
}
|
||||
} catch (e) {
|
||||
// It's a remote url
|
||||
}
|
||||
|
||||
const sourceRepoPath = join(tempImportDirectory, 'repo');
|
||||
const spinner = createSpinner(
|
||||
`Cloning ${sourceRemoteUrl} into a temporary directory: ${sourceRepoPath}`
|
||||
).start();
|
||||
try {
|
||||
await rm(tempImportDirectory, { recursive: true });
|
||||
} catch {}
|
||||
await mkdir(tempImportDirectory, { recursive: true });
|
||||
|
||||
let sourceGitClient: GitRepository;
|
||||
try {
|
||||
sourceGitClient = await cloneFromUpstream(sourceRemoteUrl, sourceRepoPath, {
|
||||
originName: importRemoteName,
|
||||
});
|
||||
} catch (e) {
|
||||
spinner.fail(`Failed to clone ${sourceRemoteUrl} into ${sourceRepoPath}`);
|
||||
let errorMessage = `Failed to clone ${sourceRemoteUrl} into ${sourceRepoPath}. Please double check the remote and try again.\n${e.message}`;
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
spinner.succeed(`Cloned into ${sourceRepoPath}`);
|
||||
|
||||
if (!ref) {
|
||||
const branchChoices = await sourceGitClient.listBranches();
|
||||
ref = (
|
||||
await prompt<{ ref: string }>([
|
||||
{
|
||||
type: 'autocomplete',
|
||||
name: 'ref',
|
||||
message: `Which branch do you want to import?`,
|
||||
choices: branchChoices,
|
||||
/**
|
||||
* Limit the number of choices so that it fits on screen
|
||||
*/
|
||||
limit: process.stdout.rows - 3,
|
||||
required: true,
|
||||
} as any,
|
||||
])
|
||||
).ref;
|
||||
}
|
||||
|
||||
if (!source) {
|
||||
source = (
|
||||
await prompt<{ source: string }>([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'source',
|
||||
message: `Which directory do you want to import into this workspace? (leave blank to import the entire repository)`,
|
||||
},
|
||||
])
|
||||
).source;
|
||||
}
|
||||
|
||||
if (!destination) {
|
||||
destination = (
|
||||
await prompt<{ destination: string }>([
|
||||
{
|
||||
type: 'input',
|
||||
name: 'destination',
|
||||
message: 'Where in this workspace should the code be imported into?',
|
||||
required: true,
|
||||
},
|
||||
])
|
||||
).destination;
|
||||
}
|
||||
|
||||
const absSource = join(sourceRepoPath, source);
|
||||
const absDestination = join(process.cwd(), destination);
|
||||
|
||||
try {
|
||||
await stat(absSource);
|
||||
} catch (e) {
|
||||
throw new Error(
|
||||
`The source directory ${source} does not exist in ${sourceRemoteUrl}. Please double check to make sure it exists.`
|
||||
);
|
||||
}
|
||||
|
||||
const destinationGitClient = new GitRepository(process.cwd());
|
||||
await assertDestinationEmpty(destinationGitClient, absDestination);
|
||||
|
||||
const tempImportBranch = getTempImportBranch(ref);
|
||||
|
||||
const packageManager = detectPackageManager(workspaceRoot);
|
||||
|
||||
const originalPackageWorkspaces = await getPackagesInPackageManagerWorkspace(
|
||||
packageManager
|
||||
);
|
||||
|
||||
const relativeDestination = relative(
|
||||
destinationGitClient.root,
|
||||
absDestination
|
||||
);
|
||||
await prepareSourceRepo(
|
||||
sourceGitClient,
|
||||
ref,
|
||||
source,
|
||||
relativeDestination,
|
||||
tempImportBranch,
|
||||
sourceRemoteUrl,
|
||||
importRemoteName
|
||||
);
|
||||
|
||||
await createTemporaryRemote(
|
||||
destinationGitClient,
|
||||
join(sourceRepoPath, '.git'),
|
||||
importRemoteName
|
||||
);
|
||||
|
||||
await mergeRemoteSource(
|
||||
destinationGitClient,
|
||||
sourceRemoteUrl,
|
||||
tempImportBranch,
|
||||
destination,
|
||||
importRemoteName,
|
||||
ref
|
||||
);
|
||||
|
||||
spinner.start('Cleaning up temporary files and remotes');
|
||||
await rm(tempImportDirectory, { recursive: true });
|
||||
await destinationGitClient.deleteGitRemote(importRemoteName);
|
||||
spinner.succeed('Cleaned up temporary files and remotes');
|
||||
|
||||
const pmc = getPackageManagerCommand();
|
||||
const nxJson = readNxJson(workspaceRoot);
|
||||
|
||||
resetWorkspaceContext();
|
||||
|
||||
const { plugins, updatePackageScripts } = await detectPlugins(
|
||||
nxJson,
|
||||
options.interactive
|
||||
);
|
||||
|
||||
if (plugins.length > 0) {
|
||||
output.log({ title: 'Installing Plugins' });
|
||||
installPlugins(workspaceRoot, plugins, pmc, updatePackageScripts);
|
||||
|
||||
await destinationGitClient.amendCommit();
|
||||
} else if (await needsInstall(packageManager, originalPackageWorkspaces)) {
|
||||
output.log({
|
||||
title: 'Installing dependencies for imported code',
|
||||
});
|
||||
|
||||
runInstall(workspaceRoot, getPackageManagerCommand(packageManager));
|
||||
|
||||
await destinationGitClient.amendCommit();
|
||||
}
|
||||
|
||||
console.log(await destinationGitClient.showStat());
|
||||
|
||||
output.log({
|
||||
title: `Merging these changes into ${getBaseRef(nxJson)}`,
|
||||
bodyLines: [
|
||||
`MERGE these changes when merging these changes.`,
|
||||
`Do NOT squash and do NOT rebase these changes when merging these changes.`,
|
||||
`If you would like to UNDO these changes, run "git reset HEAD~1 --hard"`,
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
async function assertDestinationEmpty(
|
||||
gitClient: GitRepository,
|
||||
absDestination: string
|
||||
) {
|
||||
const files = await gitClient.getGitFiles(absDestination);
|
||||
if (files.length > 0) {
|
||||
throw new Error(
|
||||
`Destination directory ${absDestination} is not empty. Please make sure it is empty before importing into it.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function getTempImportBranch(sourceBranch: string) {
|
||||
return `__nx_tmp_import__/${sourceBranch}`;
|
||||
}
|
||||
|
||||
async function createTemporaryRemote(
|
||||
destinationGitClient: GitRepository,
|
||||
sourceRemoteUrl: string,
|
||||
remoteName: string
|
||||
) {
|
||||
try {
|
||||
await destinationGitClient.deleteGitRemote(remoteName);
|
||||
} catch {}
|
||||
await destinationGitClient.addGitRemote(remoteName, sourceRemoteUrl);
|
||||
await destinationGitClient.fetch(remoteName);
|
||||
}
|
||||
@ -0,0 +1,32 @@
|
||||
import { GitRepository } from '../../../utils/git-utils';
|
||||
import * as createSpinner from 'ora';
|
||||
|
||||
export async function mergeRemoteSource(
|
||||
destinationGitClient: GitRepository,
|
||||
sourceRemoteUrl: string,
|
||||
tempBranch: string,
|
||||
destination: string,
|
||||
remoteName: string,
|
||||
branchName: string
|
||||
) {
|
||||
const spinner = createSpinner();
|
||||
spinner.start(
|
||||
`Merging ${branchName} from ${sourceRemoteUrl} into ${destination}`
|
||||
);
|
||||
|
||||
spinner.start(`Fetching ${tempBranch} from ${remoteName}`);
|
||||
await destinationGitClient.fetch(remoteName, tempBranch);
|
||||
spinner.succeed(`Fetched ${tempBranch} from ${remoteName}`);
|
||||
|
||||
spinner.start(
|
||||
`Merging files and git history from ${branchName} from ${sourceRemoteUrl} into ${destination}`
|
||||
);
|
||||
await destinationGitClient.mergeUnrelatedHistories(
|
||||
`${remoteName}/${tempBranch}`,
|
||||
`feat(repo): merge ${branchName} from ${sourceRemoteUrl}`
|
||||
);
|
||||
|
||||
spinner.succeed(
|
||||
`Merged files and git history from ${branchName} from ${sourceRemoteUrl} into ${destination}`
|
||||
);
|
||||
}
|
||||
44
packages/nx/src/command-line/import/utils/needs-install.ts
Normal file
44
packages/nx/src/command-line/import/utils/needs-install.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import {
|
||||
isWorkspacesEnabled,
|
||||
PackageManager,
|
||||
} from '../../../utils/package-manager';
|
||||
import { workspaceRoot } from '../../../utils/workspace-root';
|
||||
import { getGlobPatternsFromPackageManagerWorkspaces } from '../../../plugins/package-json';
|
||||
import { globWithWorkspaceContext } from '../../../utils/workspace-context';
|
||||
|
||||
export async function getPackagesInPackageManagerWorkspace(
|
||||
packageManager: PackageManager
|
||||
) {
|
||||
if (!isWorkspacesEnabled(packageManager, workspaceRoot)) {
|
||||
return new Set<string>();
|
||||
}
|
||||
const patterns = getGlobPatternsFromPackageManagerWorkspaces(workspaceRoot);
|
||||
return new Set(await globWithWorkspaceContext(workspaceRoot, patterns));
|
||||
}
|
||||
|
||||
export async function needsInstall(
|
||||
packageManager: PackageManager,
|
||||
originalPackagesInPackageManagerWorkspaces: Set<string>
|
||||
) {
|
||||
if (!isWorkspacesEnabled(packageManager, workspaceRoot)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const updatedPackagesInPackageManagerWorkspaces =
|
||||
await getPackagesInPackageManagerWorkspace(packageManager);
|
||||
|
||||
if (
|
||||
updatedPackagesInPackageManagerWorkspaces.size !==
|
||||
originalPackagesInPackageManagerWorkspaces.size
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (const pkg of updatedPackagesInPackageManagerWorkspaces) {
|
||||
if (!originalPackagesInPackageManagerWorkspaces.has(pkg)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
133
packages/nx/src/command-line/import/utils/prepare-source-repo.ts
Normal file
133
packages/nx/src/command-line/import/utils/prepare-source-repo.ts
Normal file
@ -0,0 +1,133 @@
|
||||
import * as createSpinner from 'ora';
|
||||
import { basename, dirname, join, relative } from 'path';
|
||||
import { copyFile, mkdir, rm } from 'node:fs/promises';
|
||||
import { GitRepository } from '../../../utils/git-utils';
|
||||
|
||||
export async function prepareSourceRepo(
|
||||
gitClient: GitRepository,
|
||||
ref: string,
|
||||
source: string,
|
||||
relativeDestination: string,
|
||||
tempImportBranch: string,
|
||||
sourceRemoteUrl: string,
|
||||
originName: string
|
||||
) {
|
||||
const spinner = createSpinner().start(
|
||||
`Fetching ${ref} from ${sourceRemoteUrl}`
|
||||
);
|
||||
await gitClient.addFetchRemote(originName, ref);
|
||||
await gitClient.fetch(originName, ref);
|
||||
spinner.succeed(`Fetched ${ref} from ${sourceRemoteUrl}`);
|
||||
spinner.start(
|
||||
`Checking out a temporary branch, ${tempImportBranch} based on ${ref}`
|
||||
);
|
||||
await gitClient.checkout(tempImportBranch, {
|
||||
new: true,
|
||||
base: `${originName}/${ref}`,
|
||||
});
|
||||
spinner.succeed(`Created a ${tempImportBranch} branch based on ${ref}`);
|
||||
const relativeSourceDir = relative(
|
||||
gitClient.root,
|
||||
join(gitClient.root, source)
|
||||
);
|
||||
|
||||
const destinationInSource = join(gitClient.root, relativeDestination);
|
||||
spinner.start(`Moving files and git history to ${destinationInSource}`);
|
||||
if (relativeSourceDir === '') {
|
||||
const files = await gitClient.getGitFiles('.');
|
||||
try {
|
||||
await rm(destinationInSource, {
|
||||
recursive: true,
|
||||
});
|
||||
} catch {}
|
||||
await mkdir(destinationInSource, { recursive: true });
|
||||
const gitignores = new Set<string>();
|
||||
for (const file of files) {
|
||||
if (basename(file) === '.gitignore') {
|
||||
gitignores.add(file);
|
||||
continue;
|
||||
}
|
||||
spinner.start(
|
||||
`Moving files and git history to ${destinationInSource}: ${file}`
|
||||
);
|
||||
|
||||
const newPath = join(destinationInSource, file);
|
||||
|
||||
await mkdir(dirname(newPath), { recursive: true });
|
||||
try {
|
||||
await gitClient.move(file, newPath);
|
||||
} catch {
|
||||
await wait(100);
|
||||
await gitClient.move(file, newPath);
|
||||
}
|
||||
}
|
||||
|
||||
await gitClient.commit(
|
||||
`chore(repo): move ${source} to ${relativeDestination} to prepare to be imported`
|
||||
);
|
||||
|
||||
for (const gitignore of gitignores) {
|
||||
await gitClient.move(gitignore, join(destinationInSource, gitignore));
|
||||
}
|
||||
await gitClient.amendCommit();
|
||||
for (const gitignore of gitignores) {
|
||||
await copyFile(
|
||||
join(destinationInSource, gitignore),
|
||||
join(gitClient.root, gitignore)
|
||||
);
|
||||
}
|
||||
} else {
|
||||
let needsSquash = false;
|
||||
const needsMove = destinationInSource !== join(gitClient.root, source);
|
||||
if (needsMove) {
|
||||
try {
|
||||
await rm(destinationInSource, {
|
||||
recursive: true,
|
||||
});
|
||||
await gitClient.commit('chore(repo): prepare for import');
|
||||
needsSquash = true;
|
||||
} catch {}
|
||||
|
||||
await mkdir(destinationInSource, { recursive: true });
|
||||
}
|
||||
|
||||
const files = await gitClient.getGitFiles('.');
|
||||
for (const file of files) {
|
||||
if (file === '.gitignore') {
|
||||
continue;
|
||||
}
|
||||
spinner.start(
|
||||
`Moving files and git history to ${destinationInSource}: ${file}`
|
||||
);
|
||||
|
||||
if (!relative(source, file).startsWith('..')) {
|
||||
if (needsMove) {
|
||||
const newPath = join(destinationInSource, file);
|
||||
|
||||
await mkdir(dirname(newPath), { recursive: true });
|
||||
try {
|
||||
await gitClient.move(file, newPath);
|
||||
} catch {
|
||||
await wait(100);
|
||||
await gitClient.move(file, newPath);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
await rm(join(gitClient.root, file), {
|
||||
recursive: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
await gitClient.commit('chore(repo): prepare for import 2');
|
||||
if (needsSquash) {
|
||||
await gitClient.squashLastTwoCommits();
|
||||
}
|
||||
}
|
||||
spinner.succeed(
|
||||
`${sourceRemoteUrl} has been prepared to be imported into this workspace on a temporary branch: ${tempImportBranch} in ${gitClient.root}`
|
||||
);
|
||||
}
|
||||
|
||||
function wait(ms: number) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
@ -2,7 +2,10 @@ import { existsSync } from 'fs';
|
||||
import { PackageJson } from '../../utils/package-json';
|
||||
import { prerelease } from 'semver';
|
||||
import { output } from '../../utils/output';
|
||||
import { getPackageManagerCommand } from '../../utils/package-manager';
|
||||
import {
|
||||
getPackageManagerCommand,
|
||||
PackageManagerCommands,
|
||||
} from '../../utils/package-manager';
|
||||
import { generateDotNxSetup } from './implementation/dot-nx/add-nx-scripts';
|
||||
import { runNxSync } from '../../utils/child-process';
|
||||
import { readJsonFile } from '../../utils/fileutils';
|
||||
@ -23,6 +26,8 @@ import { globWithWorkspaceContext } from '../../utils/workspace-context';
|
||||
import { connectExistingRepoToNxCloudPrompt } from '../connect/connect-to-nx-cloud';
|
||||
import { addNxToNpmRepo } from './implementation/add-nx-to-npm-repo';
|
||||
import { addNxToMonorepo } from './implementation/add-nx-to-monorepo';
|
||||
import { NxJsonConfiguration, readNxJson } from '../../config/nx-json';
|
||||
import { getPackageNameFromImportPath } from '../../utils/get-package-name-from-import-path';
|
||||
|
||||
export interface InitArgs {
|
||||
interactive: boolean;
|
||||
@ -31,6 +36,34 @@ export interface InitArgs {
|
||||
integrated?: boolean; // For Angular projects only
|
||||
}
|
||||
|
||||
export function installPlugins(
|
||||
repoRoot: string,
|
||||
plugins: string[],
|
||||
pmc: PackageManagerCommands,
|
||||
updatePackageScripts: boolean
|
||||
) {
|
||||
if (plugins.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
addDepsToPackageJson(repoRoot, plugins);
|
||||
|
||||
runInstall(repoRoot, pmc);
|
||||
|
||||
output.log({ title: '🔨 Configuring plugins' });
|
||||
for (const plugin of plugins) {
|
||||
execSync(
|
||||
`${pmc.exec} nx g ${plugin}:init --keepExistingVersions ${
|
||||
updatePackageScripts ? '--updatePackageScripts' : ''
|
||||
} --no-interactive`,
|
||||
{
|
||||
stdio: [0, 1, 2],
|
||||
cwd: repoRoot,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function initHandler(options: InitArgs): Promise<void> {
|
||||
process.env.NX_RUNNING_NX_INIT = 'true';
|
||||
const version =
|
||||
@ -50,7 +83,8 @@ export async function initHandler(options: InitArgs): Promise<void> {
|
||||
);
|
||||
}
|
||||
generateDotNxSetup(version);
|
||||
const { plugins } = await detectPlugins();
|
||||
const nxJson = readNxJson(process.cwd());
|
||||
const { plugins } = await detectPlugins(nxJson, options.interactive);
|
||||
plugins.forEach((plugin) => {
|
||||
runNxSync(`add ${plugin}`, {
|
||||
stdio: 'inherit',
|
||||
@ -75,10 +109,6 @@ export async function initHandler(options: InitArgs): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
output.log({ title: '🧐 Checking dependencies' });
|
||||
|
||||
const { plugins, updatePackageScripts } = await detectPlugins();
|
||||
|
||||
const packageJson: PackageJson = readJsonFile('package.json');
|
||||
if (isMonorepo(packageJson)) {
|
||||
await addNxToMonorepo({
|
||||
@ -104,26 +134,18 @@ export async function initHandler(options: InitArgs): Promise<void> {
|
||||
createNxJsonFile(repoRoot, [], [], {});
|
||||
updateGitIgnore(repoRoot);
|
||||
|
||||
addDepsToPackageJson(repoRoot, plugins);
|
||||
const nxJson = readNxJson(repoRoot);
|
||||
|
||||
output.log({ title: '🧐 Checking dependencies' });
|
||||
|
||||
const { plugins, updatePackageScripts } = await detectPlugins(
|
||||
nxJson,
|
||||
options.interactive
|
||||
);
|
||||
|
||||
output.log({ title: '📦 Installing Nx' });
|
||||
|
||||
runInstall(repoRoot, pmc);
|
||||
|
||||
if (plugins.length > 0) {
|
||||
output.log({ title: '🔨 Configuring plugins' });
|
||||
for (const plugin of plugins) {
|
||||
execSync(
|
||||
`${pmc.exec} nx g ${plugin}:init --keepExistingVersions ${
|
||||
updatePackageScripts ? '--updatePackageScripts' : ''
|
||||
} --no-interactive`,
|
||||
{
|
||||
stdio: [0, 1, 2],
|
||||
cwd: repoRoot,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
installPlugins(repoRoot, plugins, pmc, updatePackageScripts);
|
||||
|
||||
if (useNxCloud) {
|
||||
output.log({ title: '🛠️ Setting up Nx Cloud' });
|
||||
@ -157,7 +179,10 @@ const npmPackageToPluginMap: Record<string, string> = {
|
||||
'@remix-run/dev': '@nx/remix',
|
||||
};
|
||||
|
||||
async function detectPlugins(): Promise<{
|
||||
export async function detectPlugins(
|
||||
nxJson: NxJsonConfiguration,
|
||||
interactive: boolean
|
||||
): Promise<{
|
||||
plugins: string[];
|
||||
updatePackageScripts: boolean;
|
||||
}> {
|
||||
@ -165,6 +190,13 @@ async function detectPlugins(): Promise<{
|
||||
await globWithWorkspaceContext(process.cwd(), ['**/*/package.json'])
|
||||
);
|
||||
|
||||
const currentPlugins = new Set(
|
||||
(nxJson.plugins ?? []).map((p) => {
|
||||
const plugin = typeof p === 'string' ? p : p.plugin;
|
||||
return getPackageNameFromImportPath(plugin);
|
||||
})
|
||||
);
|
||||
|
||||
const detectedPlugins = new Set<string>();
|
||||
for (const file of files) {
|
||||
if (!existsSync(file)) continue;
|
||||
@ -192,6 +224,13 @@ async function detectPlugins(): Promise<{
|
||||
detectedPlugins.add('@nx/gradle');
|
||||
}
|
||||
|
||||
// Remove existing plugins
|
||||
for (const plugin of detectedPlugins) {
|
||||
if (currentPlugins.has(plugin)) {
|
||||
detectedPlugins.delete(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
const plugins = Array.from(detectedPlugins);
|
||||
|
||||
if (plugins.length === 0) {
|
||||
@ -201,6 +240,20 @@ async function detectPlugins(): Promise<{
|
||||
};
|
||||
}
|
||||
|
||||
if (!interactive) {
|
||||
output.log({
|
||||
title: `Recommended Plugins:`,
|
||||
bodyLines: [
|
||||
`Adding these Nx plugins to integrate with the tools used in your workspace:`,
|
||||
...plugins.map((p) => `- ${p}`),
|
||||
],
|
||||
});
|
||||
return {
|
||||
plugins,
|
||||
updatePackageScripts: true,
|
||||
};
|
||||
}
|
||||
|
||||
output.log({
|
||||
title: `Recommended Plugins:`,
|
||||
bodyLines: [
|
||||
|
||||
@ -20,6 +20,7 @@ import {
|
||||
yargsFormatWriteCommand,
|
||||
} from './format/command-object';
|
||||
import { yargsGenerateCommand } from './generate/command-object';
|
||||
import { yargsImportCommand } from './import/command-object';
|
||||
import { yargsInitCommand } from './init/command-object';
|
||||
import { yargsListCommand } from './list/command-object';
|
||||
import {
|
||||
@ -74,6 +75,7 @@ export const commandsObject = yargs
|
||||
.command(yargsFormatCheckCommand)
|
||||
.command(yargsFormatWriteCommand)
|
||||
.command(yargsGenerateCommand)
|
||||
.command(yargsImportCommand)
|
||||
.command(yargsInitCommand)
|
||||
.command(yargsInternalMigrateCommand)
|
||||
.command(yargsListCommand)
|
||||
|
||||
@ -124,7 +124,7 @@ export function withConfiguration(yargs: Argv) {
|
||||
});
|
||||
}
|
||||
|
||||
export function withVerbose(yargs: Argv) {
|
||||
export function withVerbose<T>(yargs: Argv<T>) {
|
||||
return yargs
|
||||
.option('verbose', {
|
||||
describe:
|
||||
|
||||
37
packages/nx/src/utils/__snapshots__/git-utils.spec.ts.snap
Normal file
37
packages/nx/src/utils/__snapshots__/git-utils.spec.ts.snap
Normal file
@ -0,0 +1,37 @@
|
||||
// Jest Snapshot v1, https://goo.gl/fbAQLP
|
||||
|
||||
exports[`git utils tests updateRebaseFile should squash the last 2 commits 1`] = `
|
||||
"pick 6a642190 chore(repo): hi
|
||||
pick 022528d9 chore(repo): prepare for import
|
||||
fixup 84ef7741 feat(repo): complete import of git@github.com:FrozenPandaz/created-vite-app.git
|
||||
|
||||
# Rebase 3441f39e..84ef7741 onto 3441f39e (3 commands)
|
||||
#
|
||||
# Commands:
|
||||
# p, pick <commit> = use commit
|
||||
# r, reword <commit> = use commit, but edit the commit message
|
||||
# e, edit <commit> = use commit, but stop for amending
|
||||
# s, squash <commit> = use commit, but meld into previous commit
|
||||
# f, fixup [-C | -c] <commit> = like "squash" but keep only the previous
|
||||
# commit's log message, unless -C is used, in which case
|
||||
# keep only this commit's message; -c is same as -C but
|
||||
# opens the editor
|
||||
# x, exec <command> = run command (the rest of the line) using shell
|
||||
# b, break = stop here (continue rebase later with 'git rebase --continue')
|
||||
# d, drop <commit> = remove commit
|
||||
# l, label <label> = label current HEAD with a name
|
||||
# t, reset <label> = reset HEAD to a label
|
||||
# m, merge [-C <commit> | -c <commit>] <label> [# <oneline>]
|
||||
# create a merge commit using the original merge commit's
|
||||
# message (or the oneline, if no original merge commit was
|
||||
# specified); use -c <commit> to reword the commit message
|
||||
# u, update-ref <ref> = track a placeholder for the <ref> to be updated
|
||||
# to this position in the new commits. The <ref> is
|
||||
# updated at the end of the rebase
|
||||
#
|
||||
# These lines can be re-ordered; they are executed from top to bottom.
|
||||
#
|
||||
# If you remove a line here THAT COMMIT WILL BE LOST.
|
||||
#
|
||||
# However, if you remove everything, the rebase will be aborted."
|
||||
`;
|
||||
@ -56,6 +56,10 @@ export function createOverrides(__overrides_unparsed__: string[] = []) {
|
||||
return overrides;
|
||||
}
|
||||
|
||||
export function getBaseRef(nxJson: NxJsonConfiguration) {
|
||||
return nxJson.defaultBase ?? nxJson.affected?.defaultBase ?? 'main';
|
||||
}
|
||||
|
||||
export function splitArgsIntoNxArgsAndOverrides(
|
||||
args: { [k: string]: any },
|
||||
mode: 'run-one' | 'run-many' | 'affected' | 'print-affected',
|
||||
@ -143,8 +147,7 @@ export function splitArgsIntoNxArgsAndOverrides(
|
||||
}
|
||||
|
||||
if (!nxArgs.base) {
|
||||
nxArgs.base =
|
||||
nxJson.defaultBase ?? nxJson.affected?.defaultBase ?? 'main';
|
||||
nxArgs.base = getBaseRef(nxJson);
|
||||
|
||||
// No user-provided arguments to set the affected criteria, so inform the user of the defaults being used
|
||||
if (
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import {
|
||||
extractUserAndRepoFromGitHubUrl,
|
||||
getGithubSlugOrNull,
|
||||
updateRebaseFile,
|
||||
} from './git-utils';
|
||||
import { execSync } from 'child_process';
|
||||
|
||||
@ -220,4 +221,48 @@ describe('git utils tests', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateRebaseFile', () => {
|
||||
let rebaseFileContents;
|
||||
|
||||
beforeEach(() => {
|
||||
rebaseFileContents = `pick 6a642190 chore(repo): hi
|
||||
pick 022528d9 chore(repo): prepare for import
|
||||
pick 84ef7741 feat(repo): complete import of git@github.com:FrozenPandaz/created-vite-app.git
|
||||
|
||||
# Rebase 3441f39e..84ef7741 onto 3441f39e (3 commands)
|
||||
#
|
||||
# Commands:
|
||||
# p, pick <commit> = use commit
|
||||
# r, reword <commit> = use commit, but edit the commit message
|
||||
# e, edit <commit> = use commit, but stop for amending
|
||||
# s, squash <commit> = use commit, but meld into previous commit
|
||||
# f, fixup [-C | -c] <commit> = like "squash" but keep only the previous
|
||||
# commit's log message, unless -C is used, in which case
|
||||
# keep only this commit's message; -c is same as -C but
|
||||
# opens the editor
|
||||
# x, exec <command> = run command (the rest of the line) using shell
|
||||
# b, break = stop here (continue rebase later with 'git rebase --continue')
|
||||
# d, drop <commit> = remove commit
|
||||
# l, label <label> = label current HEAD with a name
|
||||
# t, reset <label> = reset HEAD to a label
|
||||
# m, merge [-C <commit> | -c <commit>] <label> [# <oneline>]
|
||||
# create a merge commit using the original merge commit's
|
||||
# message (or the oneline, if no original merge commit was
|
||||
# specified); use -c <commit> to reword the commit message
|
||||
# u, update-ref <ref> = track a placeholder for the <ref> to be updated
|
||||
# to this position in the new commits. The <ref> is
|
||||
# updated at the end of the rebase
|
||||
#
|
||||
# These lines can be re-ordered; they are executed from top to bottom.
|
||||
#
|
||||
# If you remove a line here THAT COMMIT WILL BE LOST.
|
||||
#
|
||||
# However, if you remove everything, the rebase will be aborted.`;
|
||||
});
|
||||
|
||||
it('should squash the last 2 commits', () => {
|
||||
expect(updateRebaseFile(rebaseFileContents)).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,5 +1,162 @@
|
||||
import { execSync } from 'child_process';
|
||||
import { exec, ExecOptions, execSync, ExecSyncOptions } from 'child_process';
|
||||
import { logger } from '../devkit-exports';
|
||||
import { dirname, join } from 'path';
|
||||
|
||||
const SQUASH_EDITOR = join(__dirname, 'squash.js');
|
||||
|
||||
function execAsync(command: string, execOptions: ExecOptions) {
|
||||
return new Promise<string>((res, rej) => {
|
||||
exec(command, execOptions, (err, stdout, stderr) => {
|
||||
if (err) {
|
||||
return rej(err);
|
||||
}
|
||||
res(stdout);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function cloneFromUpstream(
|
||||
url: string,
|
||||
destination: string,
|
||||
{ originName } = { originName: 'origin' }
|
||||
) {
|
||||
await execAsync(
|
||||
`git clone ${url} ${destination} --depth 1 --origin ${originName}`,
|
||||
{
|
||||
cwd: dirname(destination),
|
||||
}
|
||||
);
|
||||
|
||||
return new GitRepository(destination);
|
||||
}
|
||||
|
||||
export class GitRepository {
|
||||
public root = this.getGitRootPath(this.directory);
|
||||
constructor(private directory: string) {}
|
||||
|
||||
getGitRootPath(cwd: string) {
|
||||
return execSync('git rev-parse --show-toplevel', {
|
||||
cwd,
|
||||
})
|
||||
.toString()
|
||||
.trim();
|
||||
}
|
||||
|
||||
addFetchRemote(remoteName: string, branch: string) {
|
||||
return this.execAsync(
|
||||
`git config --add remote.${remoteName}.fetch "+refs/heads/${branch}:refs/remotes/${remoteName}/${branch}"`
|
||||
);
|
||||
}
|
||||
|
||||
private execAsync(command: string) {
|
||||
return execAsync(command, {
|
||||
cwd: this.root,
|
||||
});
|
||||
}
|
||||
|
||||
async showStat() {
|
||||
return await this.execAsync(`git show --stat`);
|
||||
}
|
||||
|
||||
async listBranches() {
|
||||
return (await this.execAsync(`git ls-remote --heads --quiet`))
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((s) =>
|
||||
s
|
||||
.trim()
|
||||
.substring(s.indexOf('\t') + 1)
|
||||
.replace('refs/heads/', '')
|
||||
);
|
||||
}
|
||||
|
||||
async getGitFiles(path: string) {
|
||||
return (await this.execAsync(`git ls-files ${path}`))
|
||||
.trim()
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
async reset(ref: string) {
|
||||
return this.execAsync(`git reset ${ref} --hard`);
|
||||
}
|
||||
|
||||
async squashLastTwoCommits() {
|
||||
return this.execAsync(
|
||||
`git -c core.editor="node ${SQUASH_EDITOR}" rebase --interactive --no-autosquash HEAD~2`
|
||||
);
|
||||
}
|
||||
|
||||
async mergeUnrelatedHistories(ref: string, message: string) {
|
||||
return this.execAsync(
|
||||
`git merge ${ref} -X ours --allow-unrelated-histories -m "${message}"`
|
||||
);
|
||||
}
|
||||
async fetch(remote: string, ref?: string) {
|
||||
return this.execAsync(`git fetch ${remote}${ref ? ` ${ref}` : ''}`);
|
||||
}
|
||||
|
||||
async checkout(
|
||||
branch: string,
|
||||
opts: {
|
||||
new: boolean;
|
||||
base: string;
|
||||
}
|
||||
) {
|
||||
return this.execAsync(
|
||||
`git checkout ${opts.new ? '-b ' : ' '}${branch}${
|
||||
opts.base ? ' ' + opts.base : ''
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
async move(path: string, destination: string) {
|
||||
return this.execAsync(`git mv ${path} ${destination}`);
|
||||
}
|
||||
|
||||
async push(ref: string, remoteName: string) {
|
||||
return this.execAsync(`git push -u -f ${remoteName} ${ref}`);
|
||||
}
|
||||
|
||||
async commit(message: string) {
|
||||
return this.execAsync(`git commit -am "${message}"`);
|
||||
}
|
||||
async amendCommit() {
|
||||
return this.execAsync(`git commit --amend -a --no-edit`);
|
||||
}
|
||||
|
||||
deleteGitRemote(name: string) {
|
||||
return this.execAsync(`git remote rm ${name}`);
|
||||
}
|
||||
|
||||
deleteBranch(branch: string) {
|
||||
return this.execAsync(`git branch -D ${branch}`);
|
||||
}
|
||||
|
||||
addGitRemote(name: string, url: string) {
|
||||
return this.execAsync(`git remote add ${name} ${url}`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This is used by the squash editor script to update the rebase file.
|
||||
*/
|
||||
export function updateRebaseFile(contents: string): string {
|
||||
const lines = contents.split('\n');
|
||||
const lastCommitIndex = lines.findIndex((line) => line === '') - 1;
|
||||
|
||||
lines[lastCommitIndex] = lines[lastCommitIndex].replace('pick', 'fixup');
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function fetchGitRemote(
|
||||
name: string,
|
||||
branch: string,
|
||||
execOptions: ExecSyncOptions
|
||||
) {
|
||||
return execSync(`git fetch ${name} ${branch} --depth 1`, execOptions);
|
||||
}
|
||||
|
||||
export function getGithubSlugOrNull(): string | null {
|
||||
try {
|
||||
|
||||
@ -61,7 +61,7 @@ export function isWorkspacesEnabled(
|
||||
return existsSync(join(root, 'pnpm-workspace.yaml'));
|
||||
}
|
||||
|
||||
// yarn and pnpm both use the same 'workspaces' property in package.json
|
||||
// yarn and npm both use the same 'workspaces' property in package.json
|
||||
const packageJson: PackageJson = readPackageJson();
|
||||
return !!packageJson?.workspaces;
|
||||
}
|
||||
|
||||
14
packages/nx/src/utils/squash.ts
Normal file
14
packages/nx/src/utils/squash.ts
Normal file
@ -0,0 +1,14 @@
|
||||
import { readFileSync, writeFileSync } from 'fs';
|
||||
import { updateRebaseFile } from './git-utils';
|
||||
|
||||
// This script is used as an editor for git rebase -i
|
||||
|
||||
// This is the file which git creates. When this script exits, the updates should be written to this file.
|
||||
const filePath = process.argv[2];
|
||||
|
||||
// Change the second commit from pick to fixup
|
||||
const contents = readFileSync(filePath).toString();
|
||||
const newContents = updateRebaseFile(contents);
|
||||
|
||||
// Write the updated contents back to the file
|
||||
writeFileSync(filePath, newContents);
|
||||
Loading…
x
Reference in New Issue
Block a user