diff --git a/.changeset/silly-rules-join.md b/.changeset/silly-rules-join.md new file mode 100644 index 0000000000..88d3898317 --- /dev/null +++ b/.changeset/silly-rules-join.md @@ -0,0 +1,5 @@ +--- +'@backstage/create-app': patch +--- + +Initializes a git repository when creating an app using @packages/create-app diff --git a/.github/workflows/verify_e2e-linux.yml b/.github/workflows/verify_e2e-linux.yml index 0eedfd9d6b..466656d6f7 100644 --- a/.github/workflows/verify_e2e-linux.yml +++ b/.github/workflows/verify_e2e-linux.yml @@ -41,6 +41,11 @@ jobs: steps: - uses: actions/checkout@v3 + - name: Configure Git + run: | + git config --global user.email noreply@backstage.io + git config --global user.name 'GitHub e2e user' + - name: use node.js ${{ matrix.node-version }} uses: actions/setup-node@v3 with: diff --git a/.github/workflows/verify_e2e-windows.yml b/.github/workflows/verify_e2e-windows.yml index 3bc41a9eeb..4e8025f7d5 100644 --- a/.github/workflows/verify_e2e-windows.yml +++ b/.github/workflows/verify_e2e-windows.yml @@ -39,6 +39,11 @@ jobs: - uses: actions/checkout@v3 + - name: Configure Git + run: | + git config --global user.email noreply@backstage.io + git config --global user.name 'GitHub e2e user' + - name: use node.js ${{ matrix.node-version }} uses: actions/setup-node@v3 with: diff --git a/packages/create-app/package.json b/packages/create-app/package.json index 0af58bc911..48305e266c 100644 --- a/packages/create-app/package.json +++ b/packages/create-app/package.json @@ -34,6 +34,7 @@ "dependencies": { "@backstage/cli-common": "workspace:^", "chalk": "^4.0.0", + "command-exists": "^1.2.9", "commander": "^9.1.0", "fs-extra": "10.1.0", "handlebars": "^4.7.3", @@ -43,6 +44,7 @@ }, "devDependencies": { "@backstage/cli": "workspace:^", + "@types/command-exists": "^1.2.0", "@types/fs-extra": "^9.0.1", "@types/inquirer": "^8.1.3", "@types/node": "^16.11.26", diff --git a/packages/create-app/src/createApp.test.ts b/packages/create-app/src/createApp.test.ts index 8e7b3bae7d..ec8d15ead1 100644 --- a/packages/create-app/src/createApp.test.ts +++ b/packages/create-app/src/createApp.test.ts @@ -32,6 +32,8 @@ const promptMock = jest.spyOn(inquirer, 'prompt'); const checkPathExistsMock = jest.spyOn(tasks, 'checkPathExistsTask'); const templatingMock = jest.spyOn(tasks, 'templatingTask'); const checkAppExistsMock = jest.spyOn(tasks, 'checkAppExistsTask'); +const initGitRepositoryMock = jest.spyOn(tasks, 'initGitRepository'); +const readGitConfig = jest.spyOn(tasks, 'readGitConfig'); const createTemporaryAppFolderMock = jest.spyOn( tasks, 'createTemporaryAppFolderTask', @@ -47,7 +49,7 @@ describe('command entrypoint', () => { }); }); - afterAll(() => { + afterEach(() => { mockFs.restore(); }); @@ -56,6 +58,11 @@ describe('command entrypoint', () => { name: 'MyApp', dbType: 'PostgreSQL', }); + readGitConfig.mockResolvedValue({ + name: 'git-user', + email: 'git-email', + defaultBranch: 'git-default-branch', + }); }); afterEach(() => { @@ -67,6 +74,7 @@ describe('command entrypoint', () => { await createApp(cmd); expect(checkAppExistsMock).toHaveBeenCalled(); expect(createTemporaryAppFolderMock).toHaveBeenCalled(); + expect(initGitRepositoryMock).toHaveBeenCalled(); expect(templatingMock).toHaveBeenCalled(); expect(moveAppMock).toHaveBeenCalled(); expect(buildAppMock).toHaveBeenCalled(); @@ -76,6 +84,7 @@ describe('command entrypoint', () => { const cmd = { path: 'myDirectory' } as unknown as Command; await createApp(cmd); expect(checkPathExistsMock).toHaveBeenCalled(); + expect(initGitRepositoryMock).toHaveBeenCalled(); expect(templatingMock).toHaveBeenCalled(); expect(buildAppMock).toHaveBeenCalled(); }); @@ -85,4 +94,11 @@ describe('command entrypoint', () => { await createApp(cmd); expect(buildAppMock).not.toHaveBeenCalled(); }); + + it('should not call `initGitRepository` when `gitConfig` is undefined', async () => { + const cmd = {} as unknown as Command; + readGitConfig.mockResolvedValue({}); + await createApp(cmd); + expect(initGitRepositoryMock).not.toHaveBeenCalled(); + }); }); diff --git a/packages/create-app/src/createApp.ts b/packages/create-app/src/createApp.ts index 7cf589e5f8..182e043565 100644 --- a/packages/create-app/src/createApp.ts +++ b/packages/create-app/src/createApp.ts @@ -28,8 +28,12 @@ import { createTemporaryAppFolderTask, moveAppTask, templatingTask, + initGitRepository, + readGitConfig, } from './lib/tasks'; +const DEFAULT_BRANCH = 'master'; + export default async (opts: OptionValues): Promise => { /* eslint-disable-next-line no-restricted-syntax */ const paths = findPaths(__dirname); @@ -73,6 +77,8 @@ export default async (opts: OptionValues): Promise => { Task.log('Creating the app...'); try { + const gitConfig = await readGitConfig(); + if (opts.path) { // Template directly to specified path @@ -80,7 +86,10 @@ export default async (opts: OptionValues): Promise => { await checkPathExistsTask(appDir); Task.section('Preparing files'); - await templatingTask(templateDir, opts.path, answers); + await templatingTask(templateDir, opts.path, { + ...answers, + defaultBranch: gitConfig?.defaultBranch ?? DEFAULT_BRANCH, + }); } else { // Template to temporary location, and then move files @@ -91,12 +100,20 @@ export default async (opts: OptionValues): Promise => { await createTemporaryAppFolderTask(tempDir); Task.section('Preparing files'); - await templatingTask(templateDir, tempDir, answers); + await templatingTask(templateDir, tempDir, { + ...answers, + defaultBranch: gitConfig?.defaultBranch ?? DEFAULT_BRANCH, + }); Task.section('Moving to final location'); await moveAppTask(tempDir, appDir, answers.name); } + if (gitConfig?.name && gitConfig?.email) { + Task.section('Initializing git repository'); + await initGitRepository(appDir); + } + if (!opts.skipInstall) { Task.section('Building the app'); await buildAppTask(appDir); diff --git a/packages/create-app/src/lib/tasks.test.ts b/packages/create-app/src/lib/tasks.test.ts index decc89fbd4..b918fc0180 100644 --- a/packages/create-app/src/lib/tasks.test.ts +++ b/packages/create-app/src/lib/tasks.test.ts @@ -17,7 +17,8 @@ import fs from 'fs-extra'; import mockFs from 'mock-fs'; import child_process from 'child_process'; -import path from 'path'; +import path, { resolve as resolvePath } from 'path'; +import os from 'os'; import { Task, buildAppTask, @@ -26,8 +27,12 @@ import { createTemporaryAppFolderTask, moveAppTask, templatingTask, + initGitRepository, + readGitConfig, } from './tasks'; +const commandExists = jest.fn(); + jest.spyOn(Task, 'log').mockReturnValue(undefined); jest.spyOn(Task, 'error').mockReturnValue(undefined); jest.spyOn(Task, 'section').mockReturnValue(undefined); @@ -36,6 +41,12 @@ jest .mockImplementation((_a, _b, taskFunc) => taskFunc()); jest.mock('child_process'); +jest.mock( + 'command-exists', + () => + (...args: any[]) => + commandExists(...args), +); // By mocking this the filesystem mocks won't mess with reading all of the package.jsons jest.mock('./versions', () => ({ @@ -87,6 +98,14 @@ jest.mock('./versions', () => ({ })); describe('tasks', () => { + const mockExec = child_process.exec as unknown as jest.MockedFunction< + ( + command: string, + options: any, + callback: (error: null, stdout: any, stderr: any) => void, + ) => void + >; + beforeEach(() => { mockFs({ 'projects/my-module.ts': '', @@ -100,6 +119,7 @@ describe('tasks', () => { }); afterEach(() => { + mockExec.mockRestore(); mockFs.restore(); }); @@ -174,12 +194,6 @@ describe('tasks', () => { describe('buildAppTask', () => { it('should change to `appDir` and run `yarn install` and `yarn tsc`', async () => { const mockChdir = jest.spyOn(process, 'chdir'); - const mockExec = child_process.exec as unknown as jest.MockedFunction< - ( - command: string, - callback: (error: null, stdout: string, stderr: string) => void, - ) => void - >; // requires callback implementation to support `promisify` wrapper // https://stackoverflow.com/a/60579617/10044859 @@ -273,4 +287,99 @@ describe('tasks', () => { ).toContain('sqlite3"'); }); }); + + describe('readGitConfig', () => { + const tmpDir = resolvePath(os.tmpdir(), 'git-temp-dir'); + + it('should return git config if git package is installed and git credentials are set', async () => { + mockExec.mockImplementation((_command, _options, callback) => { + callback(null, { stdout: 'main' }, 'standard error'); + }); + + commandExists.mockResolvedValue(true); + + const gitConfig = await readGitConfig(); + + expect(gitConfig).toBeTruthy(); + expect(gitConfig).toEqual({ + name: 'main', + email: 'main', + defaultBranch: 'main', + }); + expect(mockExec).toHaveBeenCalledWith( + 'git config user.name', + { cwd: tmpDir }, + expect.any(Function), + ); + expect(mockExec).toHaveBeenCalledWith( + 'git config user.email', + { cwd: tmpDir }, + expect.any(Function), + ); + expect(mockExec).toHaveBeenCalledWith( + 'git init', + { cwd: tmpDir }, + expect.any(Function), + ); + expect(mockExec).toHaveBeenCalledWith( + 'git commit --allow-empty -m "Initial commit"', + { cwd: tmpDir }, + expect.any(Function), + ); + }); + + it('should return false if git package is not installed', async () => { + commandExists.mockResolvedValue(false); + + const gitConfig = await readGitConfig(); + + expect(gitConfig).toEqual({}); + }); + + it('should return false if git package is installed but git credentials are not set', async () => { + mockExec.mockImplementation((_command, _options, callback) => { + callback(null, { stdout: null }, 'standard error'); + }); + + commandExists.mockResolvedValue(true); + + const gitConfig = await readGitConfig(); + + expect(gitConfig).toEqual({}); + expect(mockExec).toHaveBeenCalledWith( + 'git config user.name', + { cwd: tmpDir }, + expect.any(Function), + ); + expect(mockExec).toHaveBeenCalledWith( + 'git config user.email', + { cwd: tmpDir }, + expect.any(Function), + ); + }); + }); + + describe('initGitRepository', () => { + it('should initialize a git repository at the given path', async () => { + const destinationDir = 'tmp/mockApp/'; + + mockExec.mockImplementation((_command, callback) => { + callback(null, { stdout: 'main' }, 'standard error'); + }); + + await initGitRepository(destinationDir); + + expect(mockExec).toHaveBeenCalledTimes(2); + expect(mockExec).toHaveBeenNthCalledWith( + 1, + 'git init', + expect.any(Function), + ); + expect(mockExec).toHaveBeenNthCalledWith( + 2, + 'git commit --allow-empty -m "Initial commit"', + expect.any(Function), + ); + }); + }); }); diff --git a/packages/create-app/src/lib/tasks.ts b/packages/create-app/src/lib/tasks.ts index 1650432969..1e30f9d33a 100644 --- a/packages/create-app/src/lib/tasks.ts +++ b/packages/create-app/src/lib/tasks.ts @@ -28,10 +28,18 @@ import { import { exec as execCb } from 'child_process'; import { packageVersions } from './versions'; import { promisify } from 'util'; +import commandExists from 'command-exists'; +import os from 'os'; const TASK_NAME_MAX_LENGTH = 14; const exec = promisify(execCb); +export type GitConfig = { + name?: string; + email?: string; + defaultBranch?: string; +}; + export class Task { static log(name: string = '') { process.stdout.write(`${chalk.green(name)}\n`); @@ -236,3 +244,77 @@ export async function moveAppTask( }); }); } + +/** + * Read git configs by creating a temp folder and initializing a repo + * + * @throws if `exec` fails + */ +export async function readGitConfig(): Promise { + const tempDir = resolvePath(os.tmpdir(), 'git-temp-dir'); + + const runCmd = (cmd: string) => + exec(cmd, { cwd: tempDir }).catch(error => { + process.stdout.write(error.stderr); + process.stdout.write(error.stdout); + throw new Error(`Could not execute command ${chalk.cyan(cmd)}`); + }); + + const isGitAvailable = await commandExists('git').catch(() => false); + + if (!isGitAvailable) return {}; + + try { + await fs.mkdir(tempDir); + + const [gitUsername, gitEmail] = await Promise.all([ + runCmd('git config user.name'), + runCmd('git config user.email'), + ]); + + const gitCredentials = Boolean( + gitUsername.stdout?.trim() && gitEmail.stdout?.trim(), + ); + + if (!gitCredentials) return {}; + + await runCmd('git init'); + await runCmd('git commit --allow-empty -m "Initial commit"'); + + const gitDefaultBranch = await runCmd( + 'git branch --format="%(refname:short)"', + ); + + return { + name: gitUsername.stdout?.trim(), + email: gitEmail.stdout?.trim(), + defaultBranch: gitDefaultBranch.stdout?.trim(), + }; + } catch (error) { + throw new Error(`Failed to read git config, ${error}`); + } finally { + await fs.rm(tempDir, { recursive: true }); + } +} + +/** + * Initializes a git repository in the destination folder + * + * @param dir - source path to initialize git repository in + * @throws if `exec` fails + */ +export async function initGitRepository(dir: string) { + const runCmd = (cmd: string) => + exec(cmd).catch(error => { + process.stdout.write(error.stderr); + process.stdout.write(error.stdout); + throw new Error(`Could not execute command ${chalk.cyan(cmd)}`); + }); + + await Task.forItem('init', 'git repository', async () => { + process.chdir(dir); + + await runCmd('git init'); + await runCmd('git commit --allow-empty -m "Initial commit"'); + }); +} diff --git a/packages/create-app/templates/default-app/package.json.hbs b/packages/create-app/templates/default-app/package.json.hbs index dd4d937db0..c5810d8880 100644 --- a/packages/create-app/templates/default-app/package.json.hbs +++ b/packages/create-app/templates/default-app/package.json.hbs @@ -16,7 +16,7 @@ "clean": "backstage-cli repo clean", "test": "backstage-cli repo test", "test:all": "backstage-cli repo test --coverage", - "lint": "backstage-cli repo lint --since origin/master", + "lint": "backstage-cli repo lint --since origin/{{defaultBranch}}", "lint:all": "backstage-cli repo lint", "prettier:check": "prettier --check .", "create-plugin": "backstage-cli create-plugin --scope internal", diff --git a/yarn.lock b/yarn.lock index 38bb547bc4..b573c9102b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3637,11 +3637,13 @@ __metadata: dependencies: "@backstage/cli": "workspace:^" "@backstage/cli-common": "workspace:^" + "@types/command-exists": ^1.2.0 "@types/fs-extra": ^9.0.1 "@types/inquirer": ^8.1.3 "@types/node": ^16.11.26 "@types/recursive-readdir": ^2.2.0 chalk: ^4.0.0 + command-exists: ^1.2.9 commander: ^9.1.0 fs-extra: 10.1.0 handlebars: ^4.7.3