From 6c75bf63452eb631ddb9d631ec0590c8378c58f0 Mon Sep 17 00:00:00 2001 From: FrozenPandaz Date: Thu, 4 Jul 2024 18:11:20 -0400 Subject: [PATCH] feat(core): introduce nx import --- e2e/nx/src/import.test.ts | 89 ++++++ .../src/command-line/import/command-object.ts | 48 ++++ packages/nx/src/command-line/import/import.ts | 270 ++++++++++++++++++ .../import/utils/merge-remote-source.ts | 32 +++ .../import/utils/needs-install.ts | 44 +++ .../import/utils/prepare-source-repo.ts | 133 +++++++++ packages/nx/src/command-line/init/init-v2.ts | 101 +++++-- packages/nx/src/command-line/nx-commands.ts | 2 + .../yargs-utils/shared-options.ts | 2 +- .../__snapshots__/git-utils.spec.ts.snap | 37 +++ packages/nx/src/utils/command-line-utils.ts | 7 +- packages/nx/src/utils/git-utils.spec.ts | 45 +++ packages/nx/src/utils/git-utils.ts | 159 ++++++++++- packages/nx/src/utils/package-manager.ts | 2 +- packages/nx/src/utils/squash.ts | 14 + 15 files changed, 956 insertions(+), 29 deletions(-) create mode 100644 e2e/nx/src/import.test.ts create mode 100644 packages/nx/src/command-line/import/command-object.ts create mode 100644 packages/nx/src/command-line/import/import.ts create mode 100644 packages/nx/src/command-line/import/utils/merge-remote-source.ts create mode 100644 packages/nx/src/command-line/import/utils/needs-install.ts create mode 100644 packages/nx/src/command-line/import/utils/prepare-source-repo.ts create mode 100644 packages/nx/src/utils/__snapshots__/git-utils.spec.ts.snap create mode 100644 packages/nx/src/utils/squash.ts diff --git a/e2e/nx/src/import.test.ts b/e2e/nx/src/import.test.ts new file mode 100644 index 0000000000000..2bb5196d589d1 --- /dev/null +++ b/e2e/nx/src/import.test.ts @@ -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`); + }); +}); diff --git a/packages/nx/src/command-line/import/command-object.ts b/packages/nx/src/command-line/import/command-object.ts new file mode 100644 index 0000000000000..c3b9971b74737 --- /dev/null +++ b/packages/nx/src/command-line/import/command-object.ts @@ -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); + }, +}; diff --git a/packages/nx/src/command-line/import/import.ts b/packages/nx/src/command-line/import/import.ts new file mode 100644 index 0000000000000..962d8141f677e --- /dev/null +++ b/packages/nx/src/command-line/import/import.ts @@ -0,0 +1,270 @@ +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 }); + + const sourceGitClient = await cloneFromUpstream( + sourceRemoteUrl, + sourceRepoPath, + { originName: importRemoteName } + ); + 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); +} diff --git a/packages/nx/src/command-line/import/utils/merge-remote-source.ts b/packages/nx/src/command-line/import/utils/merge-remote-source.ts new file mode 100644 index 0000000000000..5cfc9dd12b2d1 --- /dev/null +++ b/packages/nx/src/command-line/import/utils/merge-remote-source.ts @@ -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}` + ); +} diff --git a/packages/nx/src/command-line/import/utils/needs-install.ts b/packages/nx/src/command-line/import/utils/needs-install.ts new file mode 100644 index 0000000000000..e65f3eec6c644 --- /dev/null +++ b/packages/nx/src/command-line/import/utils/needs-install.ts @@ -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(); + } + const patterns = getGlobPatternsFromPackageManagerWorkspaces(workspaceRoot); + return new Set(await globWithWorkspaceContext(workspaceRoot, patterns)); +} + +export async function needsInstall( + packageManager: PackageManager, + originalPackagesInPackageManagerWorkspaces: Set +) { + 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; +} diff --git a/packages/nx/src/command-line/import/utils/prepare-source-repo.ts b/packages/nx/src/command-line/import/utils/prepare-source-repo.ts new file mode 100644 index 0000000000000..65cd5841a4e07 --- /dev/null +++ b/packages/nx/src/command-line/import/utils/prepare-source-repo.ts @@ -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(); + 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)); +} diff --git a/packages/nx/src/command-line/init/init-v2.ts b/packages/nx/src/command-line/init/init-v2.ts index 95bcf13f0ebef..457cdd99d4445 100644 --- a/packages/nx/src/command-line/init/init-v2.ts +++ b/packages/nx/src/command-line/init/init-v2.ts @@ -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 { process.env.NX_RUNNING_NX_INIT = 'true'; const version = @@ -50,7 +83,8 @@ export async function initHandler(options: InitArgs): Promise { ); } 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 { 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 { createNxJsonFile(repoRoot, [], [], {}); updateGitIgnore(repoRoot); - addDepsToPackageJson(repoRoot, plugins); + const nxJson = readNxJson(repoRoot); - output.log({ title: '📦 Installing Nx' }); + output.log({ title: '🧐 Checking dependencies' }); - runInstall(repoRoot, pmc); + const { plugins, updatePackageScripts } = await detectPlugins( + nxJson, + options.interactive + ); - 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, - } - ); - } - } + output.log({ title: '📦 Installing Nx' }); + + installPlugins(repoRoot, plugins, pmc, updatePackageScripts); if (useNxCloud) { output.log({ title: '🛠️ Setting up Nx Cloud' }); @@ -157,7 +179,10 @@ const npmPackageToPluginMap: Record = { '@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(); 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: [ diff --git a/packages/nx/src/command-line/nx-commands.ts b/packages/nx/src/command-line/nx-commands.ts index 6ba7000c61781..78ac8a484c2c2 100644 --- a/packages/nx/src/command-line/nx-commands.ts +++ b/packages/nx/src/command-line/nx-commands.ts @@ -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 { @@ -73,6 +74,7 @@ export const commandsObject = yargs .command(yargsFormatCheckCommand) .command(yargsFormatWriteCommand) .command(yargsGenerateCommand) + .command(yargsImportCommand) .command(yargsInitCommand) .command(yargsInternalMigrateCommand) .command(yargsListCommand) diff --git a/packages/nx/src/command-line/yargs-utils/shared-options.ts b/packages/nx/src/command-line/yargs-utils/shared-options.ts index 7f92ec50fafd0..3e673ab1339b1 100644 --- a/packages/nx/src/command-line/yargs-utils/shared-options.ts +++ b/packages/nx/src/command-line/yargs-utils/shared-options.ts @@ -124,7 +124,7 @@ export function withConfiguration(yargs: Argv) { }); } -export function withVerbose(yargs: Argv) { +export function withVerbose(yargs: Argv) { return yargs .option('verbose', { describe: diff --git a/packages/nx/src/utils/__snapshots__/git-utils.spec.ts.snap b/packages/nx/src/utils/__snapshots__/git-utils.spec.ts.snap new file mode 100644 index 0000000000000..5dc1f3c4715d9 --- /dev/null +++ b/packages/nx/src/utils/__snapshots__/git-utils.spec.ts.snap @@ -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 = use commit +# r, reword = use commit, but edit the commit message +# e, edit = use commit, but stop for amending +# s, squash = use commit, but meld into previous commit +# f, fixup [-C | -c] = 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 = run command (the rest of the line) using shell +# b, break = stop here (continue rebase later with 'git rebase --continue') +# d, drop = remove commit +# l, label