diff --git a/README.md b/README.md index 2bc265c..31d24db 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,24 @@ create({ }); ``` +Use `onGitResolved` to run project-specific setup after template files are +copied and Git initialization has been resolved: + +```ts +create({ + onGitResolved: async ({ distFolder, gitEnabled, isGitRoot }) => { + if (gitEnabled && isGitRoot) { + // Add files that should only exist at the Git repository root. + } + }, + // ...other options +}); +``` + +The callback runs for both enabled and disabled Git initialization. `isGitRoot` +indicates whether the generated project directory is the root of its Git +worktree. + ### 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 fc98b9e..e758777 100644 --- a/src/index.ts +++ b/src/index.ts @@ -137,6 +137,18 @@ export type Argv = { 'template-version'?: string; }; +export type GitContext = { + /** Whether Git initialization is enabled for the generated project. */ + gitEnabled: boolean; + /** Whether the generated project is the root of its Git worktree. */ + isGitRoot: boolean; +}; + +export type GitResolvedContext = GitContext & { + templateName: string; + distFolder: string; +}; + export type BuiltinToolName = 'eslint' | 'rslint' | 'biome' | 'prettier'; export const BUILTIN_TOOLS: BuiltinToolName[] = [ @@ -566,31 +578,40 @@ async function runSkillCommand(skills: ExtraSkill[], cwd: string) { installationTaskLog.success(`Installed ${skillNoun} ${skillLabel}`); } -function initGit(cwd: string) { +function detectGitRoot(cwd: string): boolean | null { try { - const repositoryCheck = xSync( + const result = xSync( 'git', - ['rev-parse', '--is-inside-work-tree'], - { - nodeOptions: { cwd }, - }, + ['rev-parse', '--is-inside-work-tree', '--show-prefix'], + { nodeOptions: { cwd } }, ); - // Reuse the current repository instead of creating a nested one. - if ( - repositoryCheck.exitCode === 0 && - repositoryCheck.stdout.trim() === 'true' - ) { - return; + if (result.exitCode !== 0) { + return null; } + const [insideWorkTree, prefix] = result.stdout.split(/\r?\n/u); + return insideWorkTree === 'true' ? prefix === '' : null; + } catch { + return null; + } +} + +function initGit(cwd: string) { + const currentIsGitRoot = detectGitRoot(cwd); + if (currentIsGitRoot !== null) { + // Reuse the current repository instead of creating a nested one. + return currentIsGitRoot; + } + + try { const result = xSync('git', ['init'], { nodeOptions: { cwd }, }); if (result.exitCode === 0) { log.success('Initialized Git repository.'); - return; + return true; } const details = result.stderr.trim(); @@ -601,6 +622,37 @@ function initGit(cwd: string) { const details = error instanceof Error ? error.message : String(error); log.warn(`Failed to initialize Git repository. ${details}`); } + + return false; +} + +async function resolveGit({ + gitEnabled, + distFolder, + templateName, + onGitResolved, +}: { + gitEnabled: boolean; + distFolder: string; + templateName: string; + onGitResolved?: (context: GitResolvedContext) => void | Promise; +}) { + let projectIsGitRoot = false; + + if (gitEnabled) { + projectIsGitRoot = initGit(distFolder); + } else if (onGitResolved) { + projectIsGitRoot = detectGitRoot(distFolder) ?? false; + } + + if (onGitResolved) { + await onGitResolved({ + templateName, + distFolder, + gitEnabled, + isGitRoot: projectIsGitRoot, + }); + } } function logNextStepsAndOutro( @@ -634,6 +686,7 @@ export async function create({ version, noteInformation, git = true, + onGitResolved, builtinTools, extraTools, extraSkills, @@ -669,6 +722,11 @@ export async function create({ * @default true */ git?: boolean; + /** + * Runs after template files are copied and the optional Git initialization + * has been resolved. + */ + onGitResolved?: (context: GitResolvedContext) => void | Promise; /** * Controls which built-in tools are available. * @@ -700,7 +758,7 @@ export async function create({ } const argv = parseArgv(processArgv); - const shouldInitGit = git && argv.git !== false; + const gitEnabled = git && argv.git !== false; if (argv.help) { logHelpMessage(name, templates, git, builtinTools, extraTools, extraSkills); @@ -781,9 +839,12 @@ export async function create({ skipFiles, }); - if (shouldInitGit) { - initGit(distFolder); - } + await resolveGit({ + gitEnabled, + distFolder, + templateName, + onGitResolved, + }); logNextStepsAndOutro(noteInformation, targetDir, packageManager); return; @@ -825,9 +886,12 @@ export async function create({ skipFiles: localSkipFiles, }); - if (shouldInitGit) { - initGit(distFolder); - } + await resolveGit({ + gitEnabled, + distFolder, + templateName, + onGitResolved, + }); const skillsByValue = new Map( (extraSkills ?? []).map((extraSkill) => [extraSkill.value, extraSkill]), diff --git a/test/git.test.ts b/test/git.test.ts index fa8540a..0b0bc67 100644 --- a/test/git.test.ts +++ b/test/git.test.ts @@ -2,7 +2,7 @@ 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'; +import { create, type GitResolvedContext } from '../src'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const fixturesDir = path.join(__dirname, 'fixtures', 'basic'); @@ -46,6 +46,7 @@ async function createProject( projectDir: string, git?: boolean, extraArgv: string[] = [], + onGitResolved?: (context: GitResolvedContext) => void | Promise, ) { await create({ name: 'test', @@ -53,6 +54,7 @@ async function createProject( templates: ['vanilla'], getTemplateName: async () => 'vanilla', git, + onGitResolved, argv: [ 'node', 'test', @@ -73,7 +75,7 @@ test('should initialize a Git repository by default', async () => { expect(mocks.xSync).toHaveBeenNthCalledWith( 1, 'git', - ['rev-parse', '--is-inside-work-tree'], + ['rev-parse', '--is-inside-work-tree', '--show-prefix'], { nodeOptions: { cwd: projectDir } }, ); expect(mocks.xSync).toHaveBeenNthCalledWith(2, 'git', ['init'], { @@ -83,21 +85,21 @@ test('should initialize a Git repository by default', async () => { test('should reuse an existing Git repository', async () => { const projectDir = path.join(testDir, 'existing'); - rs.mocked(mocks.xSync).mockReturnValue(createResult(0, 'true\n')); + rs.mocked(mocks.xSync).mockReturnValue(createResult(0, 'true\n\n')); await createProject(projectDir); expect(mocks.xSync).toHaveBeenCalledTimes(1); expect(mocks.xSync).toHaveBeenCalledWith( 'git', - ['rev-parse', '--is-inside-work-tree'], + ['rev-parse', '--is-inside-work-tree', '--show-prefix'], { nodeOptions: { cwd: projectDir } }, ); }); test('should initialize Git when the current repository is bare', async () => { const projectDir = path.join(testDir, 'bare'); - rs.mocked(mocks.xSync).mockReturnValueOnce(createResult(0, 'false\n')); + rs.mocked(mocks.xSync).mockReturnValueOnce(createResult(0, 'false\n\n')); await createProject(projectDir); @@ -133,3 +135,60 @@ test('should continue when Git initialization fails', async () => { await expect(createProject(projectDir)).resolves.toBeUndefined(); expect(mocks.xSync).toHaveBeenCalledTimes(2); }); + +test('should resolve Git after copying template files', async () => { + const projectDir = path.join(testDir, 'resolved'); + let resolvedContext: GitResolvedContext | undefined; + + rs.mocked(mocks.xSync) + .mockReturnValueOnce(createResult(128)) + .mockReturnValueOnce(createResult(0)); + + await createProject(projectDir, undefined, [], (context) => { + expect(fs.existsSync(path.join(projectDir, 'package.json'))).toBe(true); + resolvedContext = context; + }); + + expect(resolvedContext).toEqual({ + templateName: 'vanilla', + distFolder: projectDir, + gitEnabled: true, + isGitRoot: true, + }); + expect(mocks.xSync).toHaveBeenCalledTimes(2); +}); + +test('should report when the project is inside an existing repository', async () => { + const projectDir = path.join(testDir, 'nested'); + let resolvedContext: GitResolvedContext | undefined; + + rs.mocked(mocks.xSync).mockReturnValueOnce( + createResult(0, 'true\npackages/app/\n'), + ); + + await createProject(projectDir, undefined, [], (context) => { + resolvedContext = context; + }); + + expect(resolvedContext).toMatchObject({ + gitEnabled: true, + isGitRoot: false, + }); +}); + +test('should resolve the repository state when Git initialization is disabled', async () => { + const projectDir = path.join(testDir, 'resolved-disabled'); + let resolvedContext: GitResolvedContext | undefined; + + rs.mocked(mocks.xSync).mockReturnValueOnce(createResult(0, 'true\n\n')); + + await createProject(projectDir, undefined, ['--no-git'], (context) => { + resolvedContext = context; + }); + + expect(resolvedContext).toMatchObject({ + gitEnabled: false, + isGitRoot: true, + }); + expect(mocks.xSync).toHaveBeenCalledTimes(1); +});