diff --git a/README.md b/README.md index cb472dc..87c2725 100644 --- a/README.md +++ b/README.md @@ -42,6 +42,22 @@ create({ Omit the option to enable every built-in tool. Pass an array such as `['rslint', 'prettier']` to enable only those tools. +### Git Initialization + +By default, the toolkit initializes a Git repository after creating the +project. If the target directory is already inside a Git worktree, the existing +repository is reused to avoid creating a nested repository. + +Set `git` to `false` to skip Git initialization. Integrations can map their own +CLI option, such as `--not-git`, to this value: + +```ts +create({ + git: false, + // ...other options +}); +``` + ### NPM Template Support `@rstackjs/create-toolkit` supports using npm packages as templates, allowing users to create projects from custom templates published to npm. diff --git a/src/index.ts b/src/index.ts index f744e81..eba15c4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,7 +18,7 @@ import { determineAgent } from '@vercel/detect-agent'; import deepmerge from 'deepmerge'; import minimist from 'minimist'; import { color, logger } from 'rslog'; -import { x } from 'tinyexec'; +import { x, xSync } from 'tinyexec'; import { isNpmTemplate, resolveCustomTemplate } from './template-manager.js'; const __filename = fileURLToPath(import.meta.url); @@ -549,6 +549,40 @@ async function runSkillCommand(skills: ExtraSkill[], cwd: string) { installationTaskLog.success(`Installed ${skillNoun} ${skillLabel}`); } +function initGit(cwd: string) { + try { + const repositoryCheck = xSync( + 'git', + ['rev-parse', '--is-inside-work-tree'], + { + nodeOptions: { cwd }, + }, + ); + + // Reuse the current repository instead of creating a nested one. + if (repositoryCheck.exitCode === 0) { + return; + } + + const result = xSync('git', ['init'], { + nodeOptions: { cwd }, + }); + + if (result.exitCode === 0) { + log.success('Initialized Git repository.'); + return; + } + + const details = result.stderr.trim(); + log.warn( + `Failed to initialize Git repository.${details ? ` ${details}` : ''}`, + ); + } catch (error) { + const details = error instanceof Error ? error.message : String(error); + log.warn(`Failed to initialize Git repository. ${details}`); + } +} + function logNextStepsAndOutro( noteInformation: string[] | undefined, targetDir: string, @@ -558,9 +592,8 @@ function logNextStepsAndOutro( ? noteInformation : [ `1. ${color.cyan(`cd ${targetDir}`)}`, - `2. ${color.cyan('git init')} ${color.dim('(optional)')}`, - `3. ${color.cyan(`${packageManager} install`)}`, - `4. ${color.cyan(`${packageManager} run dev`)}`, + `2. ${color.cyan(`${packageManager} install`)}`, + `3. ${color.cyan(`${packageManager} run dev`)}`, ]; if (nextSteps.length) { @@ -580,6 +613,7 @@ export async function create({ mapRslintTemplate, version, noteInformation, + git = true, builtinTools, extraTools, extraSkills, @@ -608,6 +642,13 @@ export async function create({ ) => RslintTemplateName | null; version?: Record | string; noteInformation?: string[]; + /** + * Whether to initialize a Git repository when the target directory is not + * already inside one. + * + * @default true + */ + git?: boolean; /** * Controls which built-in tools are available. * @@ -719,6 +760,10 @@ export async function create({ skipFiles, }); + if (git) { + initGit(distFolder); + } + logNextStepsAndOutro(noteInformation, targetDir, packageManager); return; } @@ -754,6 +799,10 @@ export async function create({ skipFiles, }); + if (git) { + initGit(distFolder); + } + const skillsByValue = new Map( (extraSkills ?? []).map((extraSkill) => [extraSkill.value, extraSkill]), ); diff --git a/test/git.test.ts b/test/git.test.ts new file mode 100644 index 0000000..c1decdb --- /dev/null +++ b/test/git.test.ts @@ -0,0 +1,104 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { beforeEach, expect, rs, test } from '@rstest/core'; +import { create } from '../src'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const fixturesDir = path.join(__dirname, 'fixtures', 'basic'); +const testDir = path.join(fixturesDir, 'test-temp-output-git'); + +const mocks = rs.hoisted(() => ({ + x: rs.fn(), + xSync: rs.fn(), +})); + +rs.mock('tinyexec', () => ({ + x: mocks.x, + xSync: mocks.xSync, +})); + +const createResult = (exitCode: number, stderr = '') => ({ + stdout: '', + stderr, + exitCode, +}); + +beforeEach(() => { + rs.mocked(mocks.xSync).mockReset(); + rs.mocked(mocks.xSync).mockImplementation((_command, args) => + createResult(args[0] === 'rev-parse' ? 128 : 0), + ); + + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true }); + } + fs.mkdirSync(testDir, { recursive: true }); + + return () => { + if (fs.existsSync(testDir)) { + fs.rmSync(testDir, { recursive: true }); + } + }; +}); + +async function createProject(projectDir: string, git?: boolean) { + await create({ + name: 'test', + root: fixturesDir, + templates: ['vanilla'], + getTemplateName: async () => 'vanilla', + git, + argv: ['node', 'test', '--dir', projectDir, '--template', 'vanilla'], + }); +} + +test('should initialize a Git repository by default', async () => { + const projectDir = path.join(testDir, 'default'); + + await createProject(projectDir); + + expect(mocks.xSync).toHaveBeenNthCalledWith( + 1, + 'git', + ['rev-parse', '--is-inside-work-tree'], + { nodeOptions: { cwd: projectDir } }, + ); + expect(mocks.xSync).toHaveBeenNthCalledWith(2, 'git', ['init'], { + nodeOptions: { cwd: projectDir }, + }); +}); + +test('should reuse an existing Git repository', async () => { + const projectDir = path.join(testDir, 'existing'); + rs.mocked(mocks.xSync).mockReturnValue(createResult(0)); + + await createProject(projectDir); + + expect(mocks.xSync).toHaveBeenCalledTimes(1); + expect(mocks.xSync).toHaveBeenCalledWith( + 'git', + ['rev-parse', '--is-inside-work-tree'], + { nodeOptions: { cwd: projectDir } }, + ); +}); + +test('should skip Git initialization when disabled', async () => { + const projectDir = path.join(testDir, 'disabled'); + + await createProject(projectDir, false); + + expect(mocks.xSync).not.toHaveBeenCalled(); +}); + +test('should continue when Git initialization fails', async () => { + const projectDir = path.join(testDir, 'failure'); + rs.mocked(mocks.xSync).mockImplementation((_command, args) => + args[0] === 'rev-parse' + ? createResult(128) + : createResult(1, 'Git is unavailable'), + ); + + await expect(createProject(projectDir)).resolves.toBeUndefined(); + expect(mocks.xSync).toHaveBeenCalledTimes(2); +});