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
16 changes: 16 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
57 changes: 53 additions & 4 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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) {
Comment thread
chenjiahan marked this conversation as resolved.
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,
Expand All @@ -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) {
Expand All @@ -580,6 +613,7 @@ export async function create({
mapRslintTemplate,
version,
noteInformation,
git = true,
builtinTools,
extraTools,
extraSkills,
Expand Down Expand Up @@ -608,6 +642,13 @@ export async function create({
) => RslintTemplateName | null;
version?: Record<string, string> | 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.
*
Expand Down Expand Up @@ -719,6 +760,10 @@ export async function create({
skipFiles,
});

if (git) {
initGit(distFolder);
}

logNextStepsAndOutro(noteInformation, targetDir, packageManager);
return;
}
Expand Down Expand Up @@ -754,6 +799,10 @@ export async function create({
skipFiles,
});

if (git) {
initGit(distFolder);
}

const skillsByValue = new Map(
(extraSkills ?? []).map((extraSkill) => [extraSkill.value, extraSkill]),
);
Expand Down
104 changes: 104 additions & 0 deletions test/git.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});