Skip to content

Commit 89178f5

Browse files
committed
feat: add Git repository check and automatic .gitignore append
1 parent 60d55e4 commit 89178f5

6 files changed

Lines changed: 355 additions & 3 deletions

File tree

package-lock.json

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/commands/doctor.test.ts

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,11 @@ function healthyDeps(credentialsPath: string, extra: Partial<DoctorDeps> = {}):
5050
credentialsPath,
5151
cwd: '/project',
5252
nodeVersion: '22.9.0',
53-
existsSync: () => true, // skill landing file present
53+
existsSync: () => true, // skill landing file present, and git/gitignore present
54+
readFileSync: (p: string) => {
55+
if (p.endsWith('.gitignore')) return '.testsprite/';
56+
return '';
57+
},
5458
fetchImpl: makeFetch(OK_ME),
5559
...extra,
5660
};
@@ -195,6 +199,30 @@ describe('runDoctor — warnings do not fail', () => {
195199
expect(out).toContain('Verify skill');
196200
});
197201

202+
it('missing Git repository is a warning, not a failure', async () => {
203+
writeProfile('default', { apiKey: 'sk-abc' }, { path: credentialsPath });
204+
const { capture, deps } = makeCapture();
205+
const report = await runDoctor(
206+
{ profile: 'default', output: 'text', debug: false },
207+
{ ...healthyDeps(credentialsPath, { existsSync: (p) => !p.endsWith('.git') }), ...deps },
208+
);
209+
expect(report.failures).toBe(0);
210+
const out = capture.stdout.join('\n');
211+
expect(out).toContain('[WARN] Git repository');
212+
});
213+
214+
it('missing .gitignore or .testsprite/ not ignored is a warning, not a failure', async () => {
215+
writeProfile('default', { apiKey: 'sk-abc' }, { path: credentialsPath });
216+
const { capture, deps } = makeCapture();
217+
const report = await runDoctor(
218+
{ profile: 'default', output: 'text', debug: false },
219+
{ ...healthyDeps(credentialsPath, { readFileSync: () => 'node_modules/' }), ...deps },
220+
);
221+
expect(report.failures).toBe(0);
222+
const out = capture.stdout.join('\n');
223+
expect(out).toContain('[WARN] Gitignore safety');
224+
});
225+
198226
it('--dry-run skips connectivity and never calls fetch, missing key is a warning', async () => {
199227
const fetchImpl = vi.fn(async () => {
200228
throw new Error('fetch must not be called under --dry-run');
@@ -208,6 +236,10 @@ describe('runDoctor — warnings do not fail', () => {
208236
cwd: '/project',
209237
nodeVersion: '22.9.0',
210238
existsSync: () => true,
239+
readFileSync: (p: string) => {
240+
if (p.endsWith('.gitignore')) return '.testsprite/';
241+
return '';
242+
},
211243
fetchImpl,
212244
...deps,
213245
},

src/commands/doctor.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import { GLOBAL_OPTS_HINT, Output, type OutputMode } from '../lib/output.js';
2828
import { isVerifySkillInstalled } from '../lib/skill-nudge.js';
2929
import { VERSION } from '../version.js';
3030
import { MIN_SUPPORTED_NODE_MAJOR, shouldRejectNodeVersion } from '../version-guard.js';
31+
import { isInsideGitRepo, checkTestspriteIgnored } from '../lib/git-utils.js';
3132

3233
export type DoctorStatus = 'ok' | 'warn' | 'fail';
3334

@@ -85,6 +86,7 @@ export async function runDoctor(opts: CommonOptions, deps: DoctorDeps = {}): Pro
8586
const checks: DoctorCheck[] = [
8687
{ name: 'CLI version', status: 'ok', detail: VERSION },
8788
checkNodeVersion(nodeVersion),
89+
checkGitRepo(cwd, deps),
8890
{ name: 'Profile', status: 'ok', detail: config.profile },
8991
endpointCheck,
9092
checkCredentials(hasKey, config.profile, opts.dryRun ?? false),
@@ -93,6 +95,7 @@ export async function runDoctor(opts: CommonOptions, deps: DoctorDeps = {}): Pro
9395
endpointOk: endpointCheck.status === 'ok',
9496
}),
9597
checkSkill(cwd, deps),
98+
await checkGitignoreSafety(cwd, deps),
9699
];
97100

98101
const failures = checks.filter(check => check.status === 'fail').length;
@@ -171,6 +174,33 @@ function checkSkill(cwd: string, deps: DoctorDeps): DoctorCheck {
171174
};
172175
}
173176

177+
function checkGitRepo(cwd: string, deps: DoctorDeps): DoctorCheck {
178+
const isGit = isInsideGitRepo(cwd, {
179+
existsSync: deps.existsSync,
180+
});
181+
return {
182+
name: 'Git repository',
183+
status: isGit ? 'ok' : 'warn',
184+
detail: isGit
185+
? 'initialized repository'
186+
: 'not a Git repository; agent skills require Git tracking',
187+
};
188+
}
189+
190+
async function checkGitignoreSafety(cwd: string, deps: DoctorDeps): Promise<DoctorCheck> {
191+
const isIgnored = await checkTestspriteIgnored(cwd, {
192+
existsSync: deps.existsSync,
193+
readFile: deps.readFileSync ? async (p) => deps.readFileSync!(p) : undefined,
194+
});
195+
return {
196+
name: 'Gitignore safety',
197+
status: isIgnored ? 'ok' : 'warn',
198+
detail: isIgnored
199+
? '.testsprite/ is ignored'
200+
: '.testsprite/ is not ignored; run setup or add to .gitignore to avoid committing artifacts',
201+
};
202+
}
203+
174204
async function checkConnectivity(
175205
opts: CommonOptions,
176206
deps: DoctorDeps,

src/commands/init.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { runInstall } from './agent.js';
2626
import { TARGETS, DEFAULT_SKILLS, type AgentTarget } from '../lib/agent-targets.js';
2727
import type { FetchImpl } from '../lib/http.js';
2828
import { readProfile } from '../lib/credentials.js';
29+
import { ensureTestspriteIgnored, checkTestspriteIgnored } from '../lib/git-utils.js';
2930

3031
/** Mirrors auth.ts's DEFAULT_API_URL (kept in sync; auth.ts owns the canonical value). */
3132
const DEFAULT_API_URL = 'https://api.testsprite.com';
@@ -243,6 +244,18 @@ export async function runInit(opts: InitOptions, deps: InitDeps = {}): Promise<v
243244
);
244245
}
245246

247+
if (!opts.noAgent) {
248+
const projectDir = opts.dir ?? deps.cwd ?? process.cwd();
249+
const gitignoreDeps = {
250+
exists: deps.fs ? async (p: string) => (await deps.fs!.lstat(p)) !== null : undefined,
251+
readFile: deps.fs ? (p: string) => deps.fs!.readFile(p) : undefined,
252+
};
253+
const isIgnored = await checkTestspriteIgnored(projectDir, gitignoreDeps);
254+
if (!isIgnored) {
255+
stderrFn('[dry-run] would ignore .testsprite/ in .gitignore');
256+
}
257+
}
258+
246259
const summary: InitSummary = {
247260
profile: opts.profile,
248261
apiUrl: resolveReportedEndpoint(opts, deps),
@@ -354,6 +367,27 @@ export async function runInit(opts: InitOptions, deps: InitDeps = {}): Promise<v
354367
}
355368
}
356369

370+
// -------------------------------------------------------------------------
371+
// Step 3.5: Ensure .testsprite/ is ignored in .gitignore
372+
// -------------------------------------------------------------------------
373+
if (!opts.noAgent) {
374+
const projectDir = opts.dir ?? deps.cwd ?? process.cwd();
375+
const gitignoreDeps = {
376+
exists: deps.fs ? async (p: string) => (await deps.fs!.lstat(p)) !== null : undefined,
377+
readFile: deps.fs ? (p: string) => deps.fs!.readFile(p) : undefined,
378+
writeFile: deps.fs ? (p: string, content: string) => deps.fs!.writeFile(p, content) : undefined,
379+
};
380+
try {
381+
const appended = await ensureTestspriteIgnored(projectDir, gitignoreDeps);
382+
if (appended) {
383+
stderrFn('[info] Added .testsprite/ to .gitignore to avoid committing artifacts');
384+
}
385+
} catch (err) {
386+
// Non-blocking warning on gitignore setup error
387+
stderrFn(`[warn] Failed to update .gitignore: ${err instanceof Error ? err.message : String(err)}`);
388+
}
389+
}
390+
357391
// -------------------------------------------------------------------------
358392
// Step 4: Summary
359393
// -------------------------------------------------------------------------

src/lib/git-utils.test.ts

Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import { describe, expect, it } from 'vitest';
2+
import { join } from 'node:path';
3+
import {
4+
isInsideGitRepo,
5+
isTestspriteIgnored,
6+
checkTestspriteIgnored,
7+
ensureTestspriteIgnored,
8+
} from './git-utils.js';
9+
10+
describe('git-utils', () => {
11+
describe('isInsideGitRepo', () => {
12+
it('returns true if .git exists in the current directory', () => {
13+
const mockExists = (p: string) => p === join('/foo/bar', '.git');
14+
expect(isInsideGitRepo('/foo/bar', { existsSync: mockExists })).toBe(true);
15+
});
16+
17+
it('returns true if .git exists in a parent directory', () => {
18+
const mockExists = (p: string) => p === join('/foo', '.git');
19+
expect(isInsideGitRepo('/foo/bar/baz', { existsSync: mockExists })).toBe(true);
20+
});
21+
22+
it('returns false if .git is not found anywhere up the tree', () => {
23+
const mockExists = () => false;
24+
expect(isInsideGitRepo('/foo/bar/baz', { existsSync: mockExists })).toBe(false);
25+
});
26+
});
27+
28+
describe('isTestspriteIgnored', () => {
29+
it('returns false for empty content', () => {
30+
expect(isTestspriteIgnored('')).toBe(false);
31+
});
32+
33+
it('returns true if .testsprite is explicitly ignored', () => {
34+
expect(isTestspriteIgnored('.testsprite')).toBe(true);
35+
expect(isTestspriteIgnored('.testsprite/')).toBe(true);
36+
expect(isTestspriteIgnored('**/.testsprite')).toBe(true);
37+
expect(isTestspriteIgnored('**/.testsprite/')).toBe(true);
38+
});
39+
40+
it('returns true if .testsprite subfolders or files are ignored', () => {
41+
expect(isTestspriteIgnored('.testsprite/*')).toBe(true);
42+
expect(isTestspriteIgnored('**/.testsprite/runs')).toBe(true);
43+
});
44+
45+
it('ignores comments and empty lines', () => {
46+
expect(isTestspriteIgnored('# .testsprite/')).toBe(false);
47+
expect(isTestspriteIgnored(' # comment\n.testsprite/')).toBe(true);
48+
});
49+
50+
it('returns false if unrelated folders are ignored', () => {
51+
expect(isTestspriteIgnored('node_modules/\ndist/')).toBe(false);
52+
});
53+
54+
it('correctly handles negation patterns (un-ignoring)', () => {
55+
expect(isTestspriteIgnored('.testsprite/\n!.testsprite/')).toBe(false);
56+
expect(isTestspriteIgnored('!.testsprite/\n.testsprite/')).toBe(true);
57+
expect(isTestspriteIgnored('**/.testsprite/\n!**/.testsprite/')).toBe(false);
58+
expect(isTestspriteIgnored('.testsprite/*\n!.testsprite/')).toBe(false);
59+
});
60+
});
61+
62+
describe('checkTestspriteIgnored', () => {
63+
it('returns false if .gitignore does not exist', async () => {
64+
const mockExists = () => false;
65+
const mockRead = async () => '';
66+
await expect(checkTestspriteIgnored('/foo', { existsSync: mockExists, readFile: mockRead })).resolves.toBe(false);
67+
});
68+
69+
it('returns true if .gitignore exists and ignores .testsprite', async () => {
70+
const mockExists = (p: string) => p === join('/foo', '.gitignore');
71+
const mockRead = async () => 'dist/\n.testsprite/';
72+
await expect(checkTestspriteIgnored('/foo', { existsSync: mockExists, readFile: mockRead })).resolves.toBe(true);
73+
});
74+
75+
it('returns false if .gitignore exists but does not ignore .testsprite', async () => {
76+
const mockExists = (p: string) => p === join('/foo', '.gitignore');
77+
const mockRead = async () => 'dist/';
78+
await expect(checkTestspriteIgnored('/foo', { existsSync: mockExists, readFile: mockRead })).resolves.toBe(false);
79+
});
80+
});
81+
82+
describe('ensureTestspriteIgnored', () => {
83+
it('creates .gitignore and appends .testsprite if file does not exist', async () => {
84+
let writtenContent = '';
85+
const mockExists = () => false;
86+
const mockRead = async () => '';
87+
const mockWrite = async (p: string, content: string) => {
88+
writtenContent = content;
89+
};
90+
91+
const result = await ensureTestspriteIgnored('/foo', {
92+
existsSync: mockExists,
93+
readFile: mockRead,
94+
writeFile: mockWrite,
95+
});
96+
97+
expect(result).toBe(true);
98+
expect(writtenContent).toContain('.testsprite/');
99+
});
100+
101+
it('appends .testsprite if .gitignore exists but does not ignore it', async () => {
102+
let writtenContent = '';
103+
const mockExists = () => true;
104+
const mockRead = async () => 'node_modules/\ndist/';
105+
const mockWrite = async (p: string, content: string) => {
106+
writtenContent = content;
107+
};
108+
109+
const result = await ensureTestspriteIgnored('/foo', {
110+
existsSync: mockExists,
111+
readFile: mockRead,
112+
writeFile: mockWrite,
113+
});
114+
115+
expect(result).toBe(true);
116+
expect(writtenContent).toBe('node_modules/\ndist/\n\n# TestSprite failure artifacts\n.testsprite/\n');
117+
});
118+
119+
it('does not modify .gitignore if .testsprite is already ignored', async () => {
120+
let calledWrite = false;
121+
const mockExists = () => true;
122+
const mockRead = async () => 'node_modules/\n.testsprite/';
123+
const mockWrite = async () => {
124+
calledWrite = true;
125+
};
126+
127+
const result = await ensureTestspriteIgnored('/foo', {
128+
existsSync: mockExists,
129+
readFile: mockRead,
130+
writeFile: mockWrite,
131+
});
132+
133+
expect(result).toBe(false);
134+
expect(calledWrite).toBe(false);
135+
});
136+
});
137+
});

0 commit comments

Comments
 (0)