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:
Jason Jean 2024-08-12 17:52:26 -04:00 committed by GitHub
parent 903c4607d9
commit c72ba9b504
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 962 additions and 29 deletions

89
e2e/nx/src/import.test.ts Normal file
View 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`);
});
});

View 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);
},
};

View 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);
}

View File

@ -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}`
);
}

View 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;
}

View 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));
}

View File

@ -2,7 +2,10 @@ import { existsSync } from 'fs';
import { PackageJson } from '../../utils/package-json'; import { PackageJson } from '../../utils/package-json';
import { prerelease } from 'semver'; import { prerelease } from 'semver';
import { output } from '../../utils/output'; 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 { generateDotNxSetup } from './implementation/dot-nx/add-nx-scripts';
import { runNxSync } from '../../utils/child-process'; import { runNxSync } from '../../utils/child-process';
import { readJsonFile } from '../../utils/fileutils'; import { readJsonFile } from '../../utils/fileutils';
@ -23,6 +26,8 @@ import { globWithWorkspaceContext } from '../../utils/workspace-context';
import { connectExistingRepoToNxCloudPrompt } from '../connect/connect-to-nx-cloud'; import { connectExistingRepoToNxCloudPrompt } from '../connect/connect-to-nx-cloud';
import { addNxToNpmRepo } from './implementation/add-nx-to-npm-repo'; import { addNxToNpmRepo } from './implementation/add-nx-to-npm-repo';
import { addNxToMonorepo } from './implementation/add-nx-to-monorepo'; 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 { export interface InitArgs {
interactive: boolean; interactive: boolean;
@ -31,6 +36,34 @@ export interface InitArgs {
integrated?: boolean; // For Angular projects only 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> { export async function initHandler(options: InitArgs): Promise<void> {
process.env.NX_RUNNING_NX_INIT = 'true'; process.env.NX_RUNNING_NX_INIT = 'true';
const version = const version =
@ -50,7 +83,8 @@ export async function initHandler(options: InitArgs): Promise<void> {
); );
} }
generateDotNxSetup(version); generateDotNxSetup(version);
const { plugins } = await detectPlugins(); const nxJson = readNxJson(process.cwd());
const { plugins } = await detectPlugins(nxJson, options.interactive);
plugins.forEach((plugin) => { plugins.forEach((plugin) => {
runNxSync(`add ${plugin}`, { runNxSync(`add ${plugin}`, {
stdio: 'inherit', stdio: 'inherit',
@ -75,10 +109,6 @@ export async function initHandler(options: InitArgs): Promise<void> {
return; return;
} }
output.log({ title: '🧐 Checking dependencies' });
const { plugins, updatePackageScripts } = await detectPlugins();
const packageJson: PackageJson = readJsonFile('package.json'); const packageJson: PackageJson = readJsonFile('package.json');
if (isMonorepo(packageJson)) { if (isMonorepo(packageJson)) {
await addNxToMonorepo({ await addNxToMonorepo({
@ -104,26 +134,18 @@ export async function initHandler(options: InitArgs): Promise<void> {
createNxJsonFile(repoRoot, [], [], {}); createNxJsonFile(repoRoot, [], [], {});
updateGitIgnore(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' }); output.log({ title: '📦 Installing Nx' });
runInstall(repoRoot, pmc); installPlugins(repoRoot, plugins, pmc, updatePackageScripts);
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,
}
);
}
}
if (useNxCloud) { if (useNxCloud) {
output.log({ title: '🛠️ Setting up Nx Cloud' }); output.log({ title: '🛠️ Setting up Nx Cloud' });
@ -157,7 +179,10 @@ const npmPackageToPluginMap: Record<string, string> = {
'@remix-run/dev': '@nx/remix', '@remix-run/dev': '@nx/remix',
}; };
async function detectPlugins(): Promise<{ export async function detectPlugins(
nxJson: NxJsonConfiguration,
interactive: boolean
): Promise<{
plugins: string[]; plugins: string[];
updatePackageScripts: boolean; updatePackageScripts: boolean;
}> { }> {
@ -165,6 +190,13 @@ async function detectPlugins(): Promise<{
await globWithWorkspaceContext(process.cwd(), ['**/*/package.json']) 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>(); const detectedPlugins = new Set<string>();
for (const file of files) { for (const file of files) {
if (!existsSync(file)) continue; if (!existsSync(file)) continue;
@ -192,6 +224,13 @@ async function detectPlugins(): Promise<{
detectedPlugins.add('@nx/gradle'); detectedPlugins.add('@nx/gradle');
} }
// Remove existing plugins
for (const plugin of detectedPlugins) {
if (currentPlugins.has(plugin)) {
detectedPlugins.delete(plugin);
}
}
const plugins = Array.from(detectedPlugins); const plugins = Array.from(detectedPlugins);
if (plugins.length === 0) { 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({ output.log({
title: `Recommended Plugins:`, title: `Recommended Plugins:`,
bodyLines: [ bodyLines: [

View File

@ -20,6 +20,7 @@ import {
yargsFormatWriteCommand, yargsFormatWriteCommand,
} from './format/command-object'; } from './format/command-object';
import { yargsGenerateCommand } from './generate/command-object'; import { yargsGenerateCommand } from './generate/command-object';
import { yargsImportCommand } from './import/command-object';
import { yargsInitCommand } from './init/command-object'; import { yargsInitCommand } from './init/command-object';
import { yargsListCommand } from './list/command-object'; import { yargsListCommand } from './list/command-object';
import { import {
@ -74,6 +75,7 @@ export const commandsObject = yargs
.command(yargsFormatCheckCommand) .command(yargsFormatCheckCommand)
.command(yargsFormatWriteCommand) .command(yargsFormatWriteCommand)
.command(yargsGenerateCommand) .command(yargsGenerateCommand)
.command(yargsImportCommand)
.command(yargsInitCommand) .command(yargsInitCommand)
.command(yargsInternalMigrateCommand) .command(yargsInternalMigrateCommand)
.command(yargsListCommand) .command(yargsListCommand)

View File

@ -124,7 +124,7 @@ export function withConfiguration(yargs: Argv) {
}); });
} }
export function withVerbose(yargs: Argv) { export function withVerbose<T>(yargs: Argv<T>) {
return yargs return yargs
.option('verbose', { .option('verbose', {
describe: describe:

View 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."
`;

View File

@ -56,6 +56,10 @@ export function createOverrides(__overrides_unparsed__: string[] = []) {
return overrides; return overrides;
} }
export function getBaseRef(nxJson: NxJsonConfiguration) {
return nxJson.defaultBase ?? nxJson.affected?.defaultBase ?? 'main';
}
export function splitArgsIntoNxArgsAndOverrides( export function splitArgsIntoNxArgsAndOverrides(
args: { [k: string]: any }, args: { [k: string]: any },
mode: 'run-one' | 'run-many' | 'affected' | 'print-affected', mode: 'run-one' | 'run-many' | 'affected' | 'print-affected',
@ -143,8 +147,7 @@ export function splitArgsIntoNxArgsAndOverrides(
} }
if (!nxArgs.base) { if (!nxArgs.base) {
nxArgs.base = nxArgs.base = getBaseRef(nxJson);
nxJson.defaultBase ?? nxJson.affected?.defaultBase ?? 'main';
// No user-provided arguments to set the affected criteria, so inform the user of the defaults being used // No user-provided arguments to set the affected criteria, so inform the user of the defaults being used
if ( if (

View File

@ -1,6 +1,7 @@
import { import {
extractUserAndRepoFromGitHubUrl, extractUserAndRepoFromGitHubUrl,
getGithubSlugOrNull, getGithubSlugOrNull,
updateRebaseFile,
} from './git-utils'; } from './git-utils';
import { execSync } from 'child_process'; 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();
});
});
}); });

View File

@ -1,5 +1,162 @@
import { execSync } from 'child_process'; import { exec, ExecOptions, execSync, ExecSyncOptions } from 'child_process';
import { logger } from '../devkit-exports'; 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 { export function getGithubSlugOrNull(): string | null {
try { try {

View File

@ -61,7 +61,7 @@ export function isWorkspacesEnabled(
return existsSync(join(root, 'pnpm-workspace.yaml')); 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(); const packageJson: PackageJson = readPackageJson();
return !!packageJson?.workspaces; return !!packageJson?.workspaces;
} }

View 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);