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
Original file line number Diff line number Diff line change
Expand Up @@ -66,4 +66,9 @@ node_modules excluded even though tarball shipped no .gitignore

```
node_modules

# dotenv environment variable files
.env
.env.*
!.env.example
```
57 changes: 35 additions & 22 deletions packages/cli/src/create/__tests__/utils.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest';

import {
deriveDefaultPackageName,
ensureGitignoreNodeModules,
ensureDefaultGitignoreEntries,
ensureGitignoreVsCodeEditorConfigs,
formatTargetDir,
getProjectDirFromPackageName,
Expand Down Expand Up @@ -124,7 +124,7 @@ describe('deriveDefaultPackageName', () => {
});
});

describe('ensureGitignoreNodeModules', () => {
describe('ensureDefaultGitignoreEntries', () => {
let projectDir: string;

beforeEach(() => {
Expand All @@ -139,54 +139,67 @@ describe('ensureGitignoreNodeModules', () => {
return fs.readFileSync(path.join(projectDir, '.gitignore'), 'utf-8');
}

it('creates a fresh `.gitignore` with `node_modules` when none exists', () => {
ensureGitignoreNodeModules(projectDir);
expect(gitignore()).toBe('node_modules\n');
const dotenvBlock = '# dotenv environment variable files\n.env\n.env.*\n!.env.example\n';

it('creates a fresh `.gitignore` with default entries when none exists', () => {
ensureDefaultGitignoreEntries(projectDir);
expect(gitignore()).toBe(`node_modules\n\n${dotenvBlock}`);
});

it('appends `node_modules` to an existing `.gitignore` that omits it', () => {
it('appends default entries to an existing `.gitignore` that omits them', () => {
fs.writeFileSync(path.join(projectDir, '.gitignore'), 'dist\n*.log\n');
ensureGitignoreNodeModules(projectDir);
expect(gitignore()).toBe('dist\n*.log\nnode_modules\n');
ensureDefaultGitignoreEntries(projectDir);
expect(gitignore()).toBe(`dist\n*.log\nnode_modules\n\n${dotenvBlock}`);
});

it('terminates the last line first when the existing file lacks a trailing newline', () => {
fs.writeFileSync(path.join(projectDir, '.gitignore'), 'dist');
ensureGitignoreNodeModules(projectDir);
expect(gitignore()).toBe('dist\nnode_modules\n');
ensureDefaultGitignoreEntries(projectDir);
expect(gitignore()).toBe(`dist\nnode_modules\n\n${dotenvBlock}`);
});

it('is a no-op when `node_modules` already appears as a standalone line', () => {
it('appends dotenv entries when `node_modules` already appears as a standalone line', () => {
const existing = '# Logs\n*.log\nnode_modules\ndist\n';
fs.writeFileSync(path.join(projectDir, '.gitignore'), existing);
ensureGitignoreNodeModules(projectDir);
expect(gitignore()).toBe(existing);
ensureDefaultGitignoreEntries(projectDir);
expect(gitignore()).toBe(`${existing}\n${dotenvBlock}`);
});

it('treats `node_modules/` (with trailing slash) as a match', () => {
const existing = 'node_modules/\ndist\n';
fs.writeFileSync(path.join(projectDir, '.gitignore'), existing);
ensureGitignoreNodeModules(projectDir);
expect(gitignore()).toBe(existing);
ensureDefaultGitignoreEntries(projectDir);
expect(gitignore()).toBe(`${existing}\n${dotenvBlock}`);
});

it('handles CRLF line endings without re-appending', () => {
const existing = 'node_modules\r\ndist\r\n';
it('handles CRLF line endings without re-appending existing defaults', () => {
const existing = `node_modules\r\ndist\r\n${dotenvBlock.replaceAll('\n', '\r\n')}`;
fs.writeFileSync(path.join(projectDir, '.gitignore'), existing);
ensureGitignoreNodeModules(projectDir);
ensureDefaultGitignoreEntries(projectDir);
expect(gitignore()).toBe(existing);
});

it('does not consider a `node_modules/sub` subpath as already excluded', () => {
fs.writeFileSync(path.join(projectDir, '.gitignore'), 'node_modules/sub\n');
ensureGitignoreNodeModules(projectDir);
expect(gitignore()).toBe('node_modules/sub\nnode_modules\n');
ensureDefaultGitignoreEntries(projectDir);
expect(gitignore()).toBe(`node_modules/sub\nnode_modules\n\n${dotenvBlock}`);
});

it('does not match `!node_modules` (an explicit un-ignore override)', () => {
fs.writeFileSync(path.join(projectDir, '.gitignore'), '!node_modules\n');
ensureGitignoreNodeModules(projectDir);
expect(gitignore()).toBe('!node_modules\nnode_modules\n');
ensureDefaultGitignoreEntries(projectDir);
expect(gitignore()).toBe(`!node_modules\nnode_modules\n\n${dotenvBlock}`);
});

it('adds only missing dotenv entries', () => {
fs.writeFileSync(
path.join(projectDir, '.gitignore'),
'# dotenv environment variable files\n.env\nnode_modules\n',
);
ensureDefaultGitignoreEntries(projectDir);
expect(gitignore()).toBe(
'# dotenv environment variable files\n.env\nnode_modules\n.env.*\n!.env.example\n',
);
});
});

Expand Down
12 changes: 8 additions & 4 deletions packages/cli/src/create/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ import {
import { BuiltinTemplate, TemplateType } from './templates/types.ts';
import {
deriveDefaultPackageName,
ensureGitignoreNodeModules,
ensureDefaultGitignoreEntries,
ensureGitignoreVsCodeEditorConfigs,
formatTargetDir,
normalizeEditorOption,
Expand Down Expand Up @@ -1073,7 +1073,7 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h
if (!compactOutput) {
prompts.log.success('Git repository initialized');
}
ensureGitignoreNodeModules(fullPath);
ensureDefaultGitignoreEntries(fullPath);
} else {
prompts.log.warn('Failed to initialize git repository');
if (gitResult.stderr) {
Expand Down Expand Up @@ -1111,7 +1111,9 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h
rewriteMonorepo(workspaceInfo, undefined, compactOutput);
if (shouldSetupGit) {
updateCreateProgress('Initializing git repository');
await initGitRepository(fullPath);
if (await initGitRepository(fullPath)) {
ensureDefaultGitignoreEntries(fullPath);
}
}
if (bundled?.monorepo) {
// Wire `create.defaultTemplate: '<scope>'` into the new workspace's
Expand Down Expand Up @@ -1434,7 +1436,9 @@ Use \`vp create --list\` to list all available templates, or run \`vp create --h
}
if (shouldSetupGit) {
updateCreateProgress('Initializing git repository');
await initGitRepository(fullPath);
if (await initGitRepository(fullPath)) {
ensureDefaultGitignoreEntries(fullPath);
}
}
if (shouldSetupHooks) {
installGitHooks(fullPath, compactOutput, undefined, workspaceInfo.packageManager);
Expand Down
66 changes: 47 additions & 19 deletions packages/cli/src/create/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,27 +161,67 @@ export function renameFiles(projectDir: string): void {
}
}

const DOTENV_GITIGNORE_LINES = [
'# dotenv environment variable files',
'.env',
'.env.*',
'!.env.example',
] as const;

/**
* Make sure the scaffolded project's `.gitignore` excludes `node_modules`.
* Make sure the scaffolded project's `.gitignore` excludes default generated
* project artifacts.
*
* Called right after `git init` so even bundled `@org` templates (which
* may ship without a `.gitignore`) don't end up tracking installed
* dependencies on the user's first commit. No-op when an existing
* `.gitignore` already lists `node_modules`.
* may ship without a `.gitignore`) don't end up tracking dependencies or
* local environment files on the user's first commit.
*/
export function ensureGitignoreNodeModules(projectDir: string): void {
export function ensureDefaultGitignoreEntries(projectDir: string): void {
const gitignorePath = path.join(projectDir, '.gitignore');
let content = '';
try {
content = fs.readFileSync(gitignorePath, 'utf-8');
} catch {
// No existing .gitignore — we'll write a fresh one below.
}
if (/^\s*node_modules\/?\s*$/m.test(content)) {

const lines: string[] = [];
if (!hasNodeModulesGitignoreLine(content)) {
lines.push('node_modules');
}

const missingDotenvLines = DOTENV_GITIGNORE_LINES.filter(
(line) => !hasGitignoreLine(content, line),
);
if (missingDotenvLines.length > 0) {
const startsDotenvSection = missingDotenvLines[0] === DOTENV_GITIGNORE_LINES[0];
if (lines.length > 0 || (startsDotenvSection && content.trim() !== '')) {
lines.push('');
}
lines.push(...missingDotenvLines);
}

appendGitignoreLines(gitignorePath, content, lines);
}

function hasNodeModulesGitignoreLine(content: string): boolean {
return /^\s*node_modules\/?\s*$/m.test(content);
}

function hasGitignoreLine(content: string, line: string): boolean {
return content.split(/\r?\n/).some((entry) => entry.trim() === line);
}

function appendGitignoreLines(
gitignorePath: string,
content: string,
lines: readonly string[],
): void {
if (lines.length === 0) {
return;
}
const prefix = content === '' || content.endsWith('\n') ? '' : '\n';
fs.appendFileSync(gitignorePath, `${prefix}node_modules\n`);
fs.appendFileSync(gitignorePath, `${prefix}${lines.join('\n')}\n`);
}

const VSCODE_SETTINGS_PATH = '.vscode/settings.json';
Expand Down Expand Up @@ -218,18 +258,6 @@ function appendGitignoreVsCodeEditorConfigsBlock(gitignorePath: string, content:
appendGitignoreLines(gitignorePath, content, VSCODE_CONFIG_UNIGNORE_BLOCK);
}

function appendGitignoreLines(
gitignorePath: string,
content: string,
lines: readonly string[],
): void {
if (lines.length === 0) {
return;
}
const prefix = content === '' || content.endsWith('\n') ? '' : '\n';
fs.appendFileSync(gitignorePath, `${prefix}${lines.join('\n')}\n`);
}

export function formatDisplayTargetDir(targetDir: string) {
const normalized = targetDir.split(path.sep).join('/');
if (normalized === '' || normalized === '.') {
Expand Down
5 changes: 5 additions & 0 deletions packages/cli/templates/monorepo/_gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ dist
dist-ssr
*.local

# dotenv environment variable files
.env
.env.*
!.env.example

# Editor directories and files
.vscode/*
!.vscode/settings.json
Expand Down
Loading