Merge pull request #13585 from leonardomaier/init-git-repo-on-create-app

Initializes git repository when creating an app
This commit is contained in:
Patrik Oldsberg
2022-09-29 13:25:24 +02:00
committed by GitHub
10 changed files with 254 additions and 11 deletions
+5
View File
@@ -0,0 +1,5 @@
---
'@backstage/create-app': patch
---
Initializes a git repository when creating an app using @packages/create-app
+5
View File
@@ -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:
+5
View File
@@ -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:
+2
View File
@@ -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",
+17 -1
View File
@@ -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();
});
});
+19 -2
View File
@@ -28,8 +28,12 @@ import {
createTemporaryAppFolderTask,
moveAppTask,
templatingTask,
initGitRepository,
readGitConfig,
} from './lib/tasks';
const DEFAULT_BRANCH = 'master';
export default async (opts: OptionValues): Promise<void> => {
/* eslint-disable-next-line no-restricted-syntax */
const paths = findPaths(__dirname);
@@ -73,6 +77,8 @@ export default async (opts: OptionValues): Promise<void> => {
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<void> => {
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<void> => {
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);
+116 -7
View File
@@ -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),
);
});
});
});
+82
View File
@@ -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<GitConfig> {
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"');
});
}
@@ -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",
+2
View File
@@ -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