Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
104 changes: 84 additions & 20 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[] = [
Expand Down Expand Up @@ -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();
Expand All @@ -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<void>;
}) {
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(
Expand Down Expand Up @@ -634,6 +686,7 @@ export async function create({
version,
noteInformation,
git = true,
onGitResolved,
builtinTools,
extraTools,
extraSkills,
Expand Down Expand Up @@ -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<void>;
/**
* Controls which built-in tools are available.
*
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -781,9 +839,12 @@ export async function create({
skipFiles,
});

if (shouldInitGit) {
initGit(distFolder);
}
await resolveGit({
gitEnabled,
distFolder,
templateName,
onGitResolved,
});

logNextStepsAndOutro(noteInformation, targetDir, packageManager);
return;
Expand Down Expand Up @@ -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]),
Expand Down
69 changes: 64 additions & 5 deletions test/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -46,13 +46,15 @@ async function createProject(
projectDir: string,
git?: boolean,
extraArgv: string[] = [],
onGitResolved?: (context: GitResolvedContext) => void | Promise<void>,
) {
await create({
name: 'test',
root: fixturesDir,
templates: ['vanilla'],
getTemplateName: async () => 'vanilla',
git,
onGitResolved,
argv: [
'node',
'test',
Expand All @@ -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'], {
Expand All @@ -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);

Expand Down Expand Up @@ -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);
});