Skip to content

Commit 879cd8b

Browse files
QuantGeekDevclaude
andcommitted
feat: skip git init in existing repos and add unit tests for create .
Skip `git init` when scaffolding into a directory that already has a .git directory. Add 15 unit tests covering scaffolding, name derivation, conflict detection, and git init behavior. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 300d89e commit 879cd8b

File tree

2 files changed

+205
-58
lines changed

2 files changed

+205
-58
lines changed

src/cli/project/create.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { createRequire } from 'module';
22
import { spawnSync } from 'child_process';
3+
import { existsSync } from 'fs';
34
import { mkdir, readdir, writeFile } from 'fs/promises';
45
import { basename, join } from 'path';
56
import prompts from 'prompts';
@@ -350,14 +351,16 @@ OAUTH_ISSUER=https://auth.example.com
350351

351352
process.chdir(projectDir);
352353

353-
console.log('Initializing git repository...');
354-
const gitInit = spawnSync('git', ['init'], {
355-
stdio: 'inherit',
356-
shell: true,
357-
});
354+
if (!isCurrentDir || !existsSync(join(projectDir, '.git'))) {
355+
console.log('Initializing git repository...');
356+
const gitInit = spawnSync('git', ['init'], {
357+
stdio: 'inherit',
358+
shell: true,
359+
});
358360

359-
if (gitInit.status !== 0) {
360-
throw new Error('Failed to initialize git repository');
361+
if (gitInit.status !== 0) {
362+
throw new Error('Failed to initialize git repository');
363+
}
361364
}
362365

363366
if (shouldInstall) {
Lines changed: 195 additions & 51 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
1-
import { describe, it, expect, beforeEach, afterEach } from '@jest/globals';
2-
import { mkdtempSync, rmSync, existsSync, readFileSync } from 'fs';
1+
import { describe, it, expect, beforeEach, afterEach, jest } from '@jest/globals';
2+
import { mkdtempSync, mkdirSync, rmSync, existsSync, readFileSync, writeFileSync } from 'fs';
33
import { join } from 'path';
44
import { tmpdir } from 'os';
5+
import { spawnSync } from 'child_process';
56

6-
// Import createProject directly for unit testing
77
import { createProject } from '../../src/cli/project/create.js';
88

99
describe('mcp create . (current directory)', () => {
@@ -13,7 +13,6 @@ describe('mcp create . (current directory)', () => {
1313

1414
beforeEach(() => {
1515
tempDir = mkdtempSync(join(tmpdir(), 'mcp-test-'));
16-
process.chdir(tempDir);
1716
});
1817

1918
afterEach(() => {
@@ -22,60 +21,205 @@ describe('mcp create . (current directory)', () => {
2221
rmSync(tempDir, { recursive: true, force: true });
2322
});
2423

25-
it('should scaffold project in the current directory when name is "."', async () => {
26-
// Create a specifically named subdirectory to control the derived name
27-
const projectDir = join(tempDir, 'my-test-server');
28-
const { mkdirSync } = await import('fs');
29-
mkdirSync(projectDir);
30-
process.chdir(projectDir);
31-
32-
await createProject('.', { install: false, example: true });
33-
34-
// Verify files were created in the current directory (not a subdirectory)
35-
expect(existsSync(join(projectDir, 'package.json'))).toBe(true);
36-
expect(existsSync(join(projectDir, 'tsconfig.json'))).toBe(true);
37-
expect(existsSync(join(projectDir, 'src', 'index.ts'))).toBe(true);
38-
expect(existsSync(join(projectDir, 'src', 'tools', 'ExampleTool.ts'))).toBe(true);
39-
expect(existsSync(join(projectDir, '.gitignore'))).toBe(true);
40-
expect(existsSync(join(projectDir, 'README.md'))).toBe(true);
41-
42-
// No subdirectory should have been created
43-
expect(existsSync(join(projectDir, '.'))).toBe(true);
44-
45-
// Verify project name was derived from directory name
46-
const pkg = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf-8'));
47-
expect(pkg.name).toBe('my-test-server');
48-
expect(pkg.description).toBe('my-test-server MCP server');
49-
expect(pkg.bin['my-test-server']).toBe('./dist/index.js');
24+
function mockProcessExit(): { getExitCode: () => number | undefined } {
25+
let exitCode: number | undefined;
26+
process.exit = ((code?: number) => {
27+
exitCode = code;
28+
throw new Error('process.exit called');
29+
}) as never;
30+
return { getExitCode: () => exitCode };
31+
}
32+
33+
describe('scaffolding', () => {
34+
it('should create all project files in the current directory', async () => {
35+
const projectDir = join(tempDir, 'my-test-server');
36+
mkdirSync(projectDir);
37+
process.chdir(projectDir);
38+
39+
await createProject('.', { install: false, example: true });
40+
41+
expect(existsSync(join(projectDir, 'package.json'))).toBe(true);
42+
expect(existsSync(join(projectDir, 'tsconfig.json'))).toBe(true);
43+
expect(existsSync(join(projectDir, 'src', 'index.ts'))).toBe(true);
44+
expect(existsSync(join(projectDir, 'src', 'tools', 'ExampleTool.ts'))).toBe(true);
45+
expect(existsSync(join(projectDir, '.gitignore'))).toBe(true);
46+
expect(existsSync(join(projectDir, 'README.md'))).toBe(true);
47+
});
48+
49+
it('should not create a subdirectory', async () => {
50+
const projectDir = join(tempDir, 'test-server');
51+
mkdirSync(projectDir);
52+
process.chdir(projectDir);
53+
54+
await createProject('.', { install: false, example: false });
55+
56+
// No nested subdirectory should exist with the project name
57+
expect(existsSync(join(projectDir, 'test-server'))).toBe(false);
58+
});
59+
60+
it('should skip example tool when --no-example is used', async () => {
61+
const projectDir = join(tempDir, 'no-example');
62+
mkdirSync(projectDir);
63+
process.chdir(projectDir);
64+
65+
await createProject('.', { install: false, example: false });
66+
67+
expect(existsSync(join(projectDir, 'src', 'tools', 'ExampleTool.ts'))).toBe(false);
68+
expect(existsSync(join(projectDir, 'src', 'index.ts'))).toBe(true);
69+
});
70+
71+
it('should generate HTTP transport config when --http is used', async () => {
72+
const projectDir = join(tempDir, 'http-server');
73+
mkdirSync(projectDir);
74+
process.chdir(projectDir);
75+
76+
await createProject('.', { install: false, http: true, port: 3000 });
77+
78+
const indexTs = readFileSync(join(projectDir, 'src', 'index.ts'), 'utf-8');
79+
expect(indexTs).toContain('http-stream');
80+
expect(indexTs).toContain('3000');
81+
});
5082
});
5183

52-
it('should derive a valid npm name from directory with uppercase/special chars', async () => {
53-
const projectDir = join(tempDir, 'My_Cool.Server');
54-
const { mkdirSync } = await import('fs');
55-
mkdirSync(projectDir);
56-
process.chdir(projectDir);
84+
describe('project name derivation', () => {
85+
it('should derive name from directory name', async () => {
86+
const projectDir = join(tempDir, 'my-test-server');
87+
mkdirSync(projectDir);
88+
process.chdir(projectDir);
89+
90+
await createProject('.', { install: false, example: false });
91+
92+
const pkg = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf-8'));
93+
expect(pkg.name).toBe('my-test-server');
94+
expect(pkg.description).toBe('my-test-server MCP server');
95+
expect(pkg.bin['my-test-server']).toBe('./dist/index.js');
96+
});
97+
98+
it('should lowercase uppercase directory names', async () => {
99+
const projectDir = join(tempDir, 'MyServer');
100+
mkdirSync(projectDir);
101+
process.chdir(projectDir);
102+
103+
await createProject('.', { install: false, example: false });
104+
105+
const pkg = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf-8'));
106+
expect(pkg.name).toBe('myserver');
107+
});
108+
109+
it('should replace special characters with hyphens', async () => {
110+
const projectDir = join(tempDir, 'My_Cool.Server');
111+
mkdirSync(projectDir);
112+
process.chdir(projectDir);
113+
114+
await createProject('.', { install: false, example: false });
115+
116+
const pkg = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf-8'));
117+
expect(pkg.name).toBe('my-cool-server');
118+
});
119+
120+
it('should collapse consecutive hyphens', async () => {
121+
const projectDir = join(tempDir, 'my___server');
122+
mkdirSync(projectDir);
123+
process.chdir(projectDir);
124+
125+
await createProject('.', { install: false, example: false });
57126

58-
await createProject('.', { install: false, example: false });
127+
const pkg = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf-8'));
128+
expect(pkg.name).toBe('my-server');
129+
});
59130

60-
const pkg = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf-8'));
61-
// Uppercase → lowercase, underscores/dots → hyphens
62-
expect(pkg.name).toBe('my-cool-server');
131+
it('should strip leading and trailing hyphens', async () => {
132+
const projectDir = join(tempDir, '-my-server-');
133+
mkdirSync(projectDir);
134+
process.chdir(projectDir);
135+
136+
await createProject('.', { install: false, example: false });
137+
138+
const pkg = JSON.parse(readFileSync(join(projectDir, 'package.json'), 'utf-8'));
139+
expect(pkg.name).toBe('my-server');
140+
});
63141
});
64142

65-
it('should reject when current directory has conflicting files', async () => {
66-
const projectDir = join(tempDir, 'existing-project');
67-
const { mkdirSync, writeFileSync } = await import('fs');
68-
mkdirSync(projectDir);
69-
writeFileSync(join(projectDir, 'package.json'), '{}');
70-
process.chdir(projectDir);
143+
describe('conflict detection', () => {
144+
it('should reject when package.json exists', async () => {
145+
const projectDir = join(tempDir, 'has-pkg');
146+
mkdirSync(projectDir);
147+
writeFileSync(join(projectDir, 'package.json'), '{}');
148+
process.chdir(projectDir);
71149

72-
let exitCode: number | undefined;
73-
process.exit = ((code?: number) => {
74-
exitCode = code;
75-
throw new Error('process.exit called');
76-
}) as never;
150+
const { getExitCode } = mockProcessExit();
151+
152+
await expect(createProject('.', { install: false })).rejects.toThrow('process.exit called');
153+
expect(getExitCode()).toBe(1);
154+
});
155+
156+
it('should reject when tsconfig.json exists', async () => {
157+
const projectDir = join(tempDir, 'has-tsconfig');
158+
mkdirSync(projectDir);
159+
writeFileSync(join(projectDir, 'tsconfig.json'), '{}');
160+
process.chdir(projectDir);
161+
162+
const { getExitCode } = mockProcessExit();
163+
164+
await expect(createProject('.', { install: false })).rejects.toThrow('process.exit called');
165+
expect(getExitCode()).toBe(1);
166+
});
167+
168+
it('should reject when src directory exists', async () => {
169+
const projectDir = join(tempDir, 'has-src');
170+
mkdirSync(projectDir);
171+
mkdirSync(join(projectDir, 'src'));
172+
process.chdir(projectDir);
173+
174+
const { getExitCode } = mockProcessExit();
175+
176+
await expect(createProject('.', { install: false })).rejects.toThrow('process.exit called');
177+
expect(getExitCode()).toBe(1);
178+
});
179+
180+
it('should allow directories with other non-conflicting files', async () => {
181+
const projectDir = join(tempDir, 'has-readme');
182+
mkdirSync(projectDir);
183+
writeFileSync(join(projectDir, 'notes.txt'), 'hello');
184+
process.chdir(projectDir);
185+
186+
await createProject('.', { install: false, example: false });
187+
188+
expect(existsSync(join(projectDir, 'package.json'))).toBe(true);
189+
});
190+
});
191+
192+
describe('git init behavior', () => {
193+
it('should skip git init when .git already exists', async () => {
194+
const projectDir = join(tempDir, 'git-exists');
195+
mkdirSync(projectDir);
196+
process.chdir(projectDir);
197+
198+
// Initialize a git repo before running create
199+
spawnSync('git', ['init'], { cwd: projectDir, stdio: 'ignore' });
200+
expect(existsSync(join(projectDir, '.git'))).toBe(true);
201+
202+
// Capture console output to verify git init was skipped
203+
const logs: string[] = [];
204+
const originalLog = console.log;
205+
console.log = (...args: unknown[]) => logs.push(args.join(' '));
206+
207+
await createProject('.', { install: false, example: false });
208+
209+
console.log = originalLog;
210+
211+
expect(logs.some((l) => l.includes('Initializing git'))).toBe(false);
212+
expect(existsSync(join(projectDir, 'package.json'))).toBe(true);
213+
});
214+
215+
it('should run git init when no .git exists', async () => {
216+
const projectDir = join(tempDir, 'no-git');
217+
mkdirSync(projectDir);
218+
process.chdir(projectDir);
219+
220+
await createProject('.', { install: false, example: false });
77221

78-
await expect(createProject('.', { install: false })).rejects.toThrow('process.exit called');
79-
expect(exitCode).toBe(1);
222+
expect(existsSync(join(projectDir, '.git'))).toBe(true);
223+
});
80224
});
81225
});

0 commit comments

Comments
 (0)