From 89178f54a96665ad207ad34e9fee14e17181f717 Mon Sep 17 00:00:00 2001 From: sandman-sh Date: Fri, 17 Jul 2026 02:55:43 +0530 Subject: [PATCH 1/2] feat: add Git repository check and automatic .gitignore append --- package-lock.json | 4 +- src/commands/doctor.test.ts | 34 ++++++++- src/commands/doctor.ts | 30 ++++++++ src/commands/init.ts | 34 +++++++++ src/lib/git-utils.test.ts | 137 ++++++++++++++++++++++++++++++++++++ src/lib/git-utils.ts | 119 +++++++++++++++++++++++++++++++ 6 files changed, 355 insertions(+), 3 deletions(-) create mode 100644 src/lib/git-utils.test.ts create mode 100644 src/lib/git-utils.ts diff --git a/package-lock.json b/package-lock.json index b254e0d..4461016 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@testsprite/testsprite-cli", - "version": "0.2.0", + "version": "0.3.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@testsprite/testsprite-cli", - "version": "0.2.0", + "version": "0.3.0", "license": "Apache-2.0", "dependencies": { "commander": "^12.1.0", diff --git a/src/commands/doctor.test.ts b/src/commands/doctor.test.ts index 230398a..d16e391 100644 --- a/src/commands/doctor.test.ts +++ b/src/commands/doctor.test.ts @@ -50,7 +50,11 @@ function healthyDeps(credentialsPath: string, extra: Partial = {}): credentialsPath, cwd: '/project', nodeVersion: '22.9.0', - existsSync: () => true, // skill landing file present + existsSync: () => true, // skill landing file present, and git/gitignore present + readFileSync: (p: string) => { + if (p.endsWith('.gitignore')) return '.testsprite/'; + return ''; + }, fetchImpl: makeFetch(OK_ME), ...extra, }; @@ -195,6 +199,30 @@ describe('runDoctor — warnings do not fail', () => { expect(out).toContain('Verify skill'); }); + it('missing Git repository is a warning, not a failure', async () => { + writeProfile('default', { apiKey: 'sk-abc' }, { path: credentialsPath }); + const { capture, deps } = makeCapture(); + const report = await runDoctor( + { profile: 'default', output: 'text', debug: false }, + { ...healthyDeps(credentialsPath, { existsSync: (p) => !p.endsWith('.git') }), ...deps }, + ); + expect(report.failures).toBe(0); + const out = capture.stdout.join('\n'); + expect(out).toContain('[WARN] Git repository'); + }); + + it('missing .gitignore or .testsprite/ not ignored is a warning, not a failure', async () => { + writeProfile('default', { apiKey: 'sk-abc' }, { path: credentialsPath }); + const { capture, deps } = makeCapture(); + const report = await runDoctor( + { profile: 'default', output: 'text', debug: false }, + { ...healthyDeps(credentialsPath, { readFileSync: () => 'node_modules/' }), ...deps }, + ); + expect(report.failures).toBe(0); + const out = capture.stdout.join('\n'); + expect(out).toContain('[WARN] Gitignore safety'); + }); + it('--dry-run skips connectivity and never calls fetch, missing key is a warning', async () => { const fetchImpl = vi.fn(async () => { throw new Error('fetch must not be called under --dry-run'); @@ -208,6 +236,10 @@ describe('runDoctor — warnings do not fail', () => { cwd: '/project', nodeVersion: '22.9.0', existsSync: () => true, + readFileSync: (p: string) => { + if (p.endsWith('.gitignore')) return '.testsprite/'; + return ''; + }, fetchImpl, ...deps, }, diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index 2fa0c57..1a433c8 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -28,6 +28,7 @@ import { GLOBAL_OPTS_HINT, Output, type OutputMode } from '../lib/output.js'; import { isVerifySkillInstalled } from '../lib/skill-nudge.js'; import { VERSION } from '../version.js'; import { MIN_SUPPORTED_NODE_MAJOR, shouldRejectNodeVersion } from '../version-guard.js'; +import { isInsideGitRepo, checkTestspriteIgnored } from '../lib/git-utils.js'; export type DoctorStatus = 'ok' | 'warn' | 'fail'; @@ -85,6 +86,7 @@ export async function runDoctor(opts: CommonOptions, deps: DoctorDeps = {}): Pro const checks: DoctorCheck[] = [ { name: 'CLI version', status: 'ok', detail: VERSION }, checkNodeVersion(nodeVersion), + checkGitRepo(cwd, deps), { name: 'Profile', status: 'ok', detail: config.profile }, endpointCheck, checkCredentials(hasKey, config.profile, opts.dryRun ?? false), @@ -93,6 +95,7 @@ export async function runDoctor(opts: CommonOptions, deps: DoctorDeps = {}): Pro endpointOk: endpointCheck.status === 'ok', }), checkSkill(cwd, deps), + await checkGitignoreSafety(cwd, deps), ]; const failures = checks.filter(check => check.status === 'fail').length; @@ -171,6 +174,33 @@ function checkSkill(cwd: string, deps: DoctorDeps): DoctorCheck { }; } +function checkGitRepo(cwd: string, deps: DoctorDeps): DoctorCheck { + const isGit = isInsideGitRepo(cwd, { + existsSync: deps.existsSync, + }); + return { + name: 'Git repository', + status: isGit ? 'ok' : 'warn', + detail: isGit + ? 'initialized repository' + : 'not a Git repository; agent skills require Git tracking', + }; +} + +async function checkGitignoreSafety(cwd: string, deps: DoctorDeps): Promise { + const isIgnored = await checkTestspriteIgnored(cwd, { + existsSync: deps.existsSync, + readFile: deps.readFileSync ? async (p) => deps.readFileSync!(p) : undefined, + }); + return { + name: 'Gitignore safety', + status: isIgnored ? 'ok' : 'warn', + detail: isIgnored + ? '.testsprite/ is ignored' + : '.testsprite/ is not ignored; run setup or add to .gitignore to avoid committing artifacts', + }; +} + async function checkConnectivity( opts: CommonOptions, deps: DoctorDeps, diff --git a/src/commands/init.ts b/src/commands/init.ts index c2d5678..9554b65 100644 --- a/src/commands/init.ts +++ b/src/commands/init.ts @@ -26,6 +26,7 @@ import { runInstall } from './agent.js'; import { TARGETS, DEFAULT_SKILLS, type AgentTarget } from '../lib/agent-targets.js'; import type { FetchImpl } from '../lib/http.js'; import { readProfile } from '../lib/credentials.js'; +import { ensureTestspriteIgnored, checkTestspriteIgnored } from '../lib/git-utils.js'; /** Mirrors auth.ts's DEFAULT_API_URL (kept in sync; auth.ts owns the canonical value). */ const DEFAULT_API_URL = 'https://api.testsprite.com'; @@ -243,6 +244,18 @@ export async function runInit(opts: InitOptions, deps: InitDeps = {}): Promise (await deps.fs!.lstat(p)) !== null : undefined, + readFile: deps.fs ? (p: string) => deps.fs!.readFile(p) : undefined, + }; + const isIgnored = await checkTestspriteIgnored(projectDir, gitignoreDeps); + if (!isIgnored) { + stderrFn('[dry-run] would ignore .testsprite/ in .gitignore'); + } + } + const summary: InitSummary = { profile: opts.profile, apiUrl: resolveReportedEndpoint(opts, deps), @@ -354,6 +367,27 @@ export async function runInit(opts: InitOptions, deps: InitDeps = {}): Promise (await deps.fs!.lstat(p)) !== null : undefined, + readFile: deps.fs ? (p: string) => deps.fs!.readFile(p) : undefined, + writeFile: deps.fs ? (p: string, content: string) => deps.fs!.writeFile(p, content) : undefined, + }; + try { + const appended = await ensureTestspriteIgnored(projectDir, gitignoreDeps); + if (appended) { + stderrFn('[info] Added .testsprite/ to .gitignore to avoid committing artifacts'); + } + } catch (err) { + // Non-blocking warning on gitignore setup error + stderrFn(`[warn] Failed to update .gitignore: ${err instanceof Error ? err.message : String(err)}`); + } + } + // ------------------------------------------------------------------------- // Step 4: Summary // ------------------------------------------------------------------------- diff --git a/src/lib/git-utils.test.ts b/src/lib/git-utils.test.ts new file mode 100644 index 0000000..4fcdfe9 --- /dev/null +++ b/src/lib/git-utils.test.ts @@ -0,0 +1,137 @@ +import { describe, expect, it } from 'vitest'; +import { join } from 'node:path'; +import { + isInsideGitRepo, + isTestspriteIgnored, + checkTestspriteIgnored, + ensureTestspriteIgnored, +} from './git-utils.js'; + +describe('git-utils', () => { + describe('isInsideGitRepo', () => { + it('returns true if .git exists in the current directory', () => { + const mockExists = (p: string) => p === join('/foo/bar', '.git'); + expect(isInsideGitRepo('/foo/bar', { existsSync: mockExists })).toBe(true); + }); + + it('returns true if .git exists in a parent directory', () => { + const mockExists = (p: string) => p === join('/foo', '.git'); + expect(isInsideGitRepo('/foo/bar/baz', { existsSync: mockExists })).toBe(true); + }); + + it('returns false if .git is not found anywhere up the tree', () => { + const mockExists = () => false; + expect(isInsideGitRepo('/foo/bar/baz', { existsSync: mockExists })).toBe(false); + }); + }); + + describe('isTestspriteIgnored', () => { + it('returns false for empty content', () => { + expect(isTestspriteIgnored('')).toBe(false); + }); + + it('returns true if .testsprite is explicitly ignored', () => { + expect(isTestspriteIgnored('.testsprite')).toBe(true); + expect(isTestspriteIgnored('.testsprite/')).toBe(true); + expect(isTestspriteIgnored('**/.testsprite')).toBe(true); + expect(isTestspriteIgnored('**/.testsprite/')).toBe(true); + }); + + it('returns true if .testsprite subfolders or files are ignored', () => { + expect(isTestspriteIgnored('.testsprite/*')).toBe(true); + expect(isTestspriteIgnored('**/.testsprite/runs')).toBe(true); + }); + + it('ignores comments and empty lines', () => { + expect(isTestspriteIgnored('# .testsprite/')).toBe(false); + expect(isTestspriteIgnored(' # comment\n.testsprite/')).toBe(true); + }); + + it('returns false if unrelated folders are ignored', () => { + expect(isTestspriteIgnored('node_modules/\ndist/')).toBe(false); + }); + + it('correctly handles negation patterns (un-ignoring)', () => { + expect(isTestspriteIgnored('.testsprite/\n!.testsprite/')).toBe(false); + expect(isTestspriteIgnored('!.testsprite/\n.testsprite/')).toBe(true); + expect(isTestspriteIgnored('**/.testsprite/\n!**/.testsprite/')).toBe(false); + expect(isTestspriteIgnored('.testsprite/*\n!.testsprite/')).toBe(false); + }); + }); + + describe('checkTestspriteIgnored', () => { + it('returns false if .gitignore does not exist', async () => { + const mockExists = () => false; + const mockRead = async () => ''; + await expect(checkTestspriteIgnored('/foo', { existsSync: mockExists, readFile: mockRead })).resolves.toBe(false); + }); + + it('returns true if .gitignore exists and ignores .testsprite', async () => { + const mockExists = (p: string) => p === join('/foo', '.gitignore'); + const mockRead = async () => 'dist/\n.testsprite/'; + await expect(checkTestspriteIgnored('/foo', { existsSync: mockExists, readFile: mockRead })).resolves.toBe(true); + }); + + it('returns false if .gitignore exists but does not ignore .testsprite', async () => { + const mockExists = (p: string) => p === join('/foo', '.gitignore'); + const mockRead = async () => 'dist/'; + await expect(checkTestspriteIgnored('/foo', { existsSync: mockExists, readFile: mockRead })).resolves.toBe(false); + }); + }); + + describe('ensureTestspriteIgnored', () => { + it('creates .gitignore and appends .testsprite if file does not exist', async () => { + let writtenContent = ''; + const mockExists = () => false; + const mockRead = async () => ''; + const mockWrite = async (p: string, content: string) => { + writtenContent = content; + }; + + const result = await ensureTestspriteIgnored('/foo', { + existsSync: mockExists, + readFile: mockRead, + writeFile: mockWrite, + }); + + expect(result).toBe(true); + expect(writtenContent).toContain('.testsprite/'); + }); + + it('appends .testsprite if .gitignore exists but does not ignore it', async () => { + let writtenContent = ''; + const mockExists = () => true; + const mockRead = async () => 'node_modules/\ndist/'; + const mockWrite = async (p: string, content: string) => { + writtenContent = content; + }; + + const result = await ensureTestspriteIgnored('/foo', { + existsSync: mockExists, + readFile: mockRead, + writeFile: mockWrite, + }); + + expect(result).toBe(true); + expect(writtenContent).toBe('node_modules/\ndist/\n\n# TestSprite failure artifacts\n.testsprite/\n'); + }); + + it('does not modify .gitignore if .testsprite is already ignored', async () => { + let calledWrite = false; + const mockExists = () => true; + const mockRead = async () => 'node_modules/\n.testsprite/'; + const mockWrite = async () => { + calledWrite = true; + }; + + const result = await ensureTestspriteIgnored('/foo', { + existsSync: mockExists, + readFile: mockRead, + writeFile: mockWrite, + }); + + expect(result).toBe(false); + expect(calledWrite).toBe(false); + }); + }); +}); diff --git a/src/lib/git-utils.ts b/src/lib/git-utils.ts new file mode 100644 index 0000000..34a6147 --- /dev/null +++ b/src/lib/git-utils.ts @@ -0,0 +1,119 @@ +import { existsSync } from 'node:fs'; +import fs from 'node:fs/promises'; +import { dirname, join } from 'node:path'; + +export interface GitUtilsDeps { + existsSync?: (p: string) => boolean; + exists?: (p: string) => Promise; + readFile?: (p: string) => Promise; + writeFile?: (p: string, content: string) => Promise; +} + +/** + * Checks if the specified directory is inside a Git repository by recursively walking + * up the directory tree to check for a `.git` folder or file. + */ +export function isInsideGitRepo(dir: string, deps: GitUtilsDeps = {}): boolean { + const exists = deps.existsSync ?? existsSync; + + try { + let current = dir; + while (true) { + const gitDir = join(current, '.git'); + if (exists(gitDir)) { + return true; + } + const parent = dirname(current); + if (parent === current) { + break; + } + current = parent; + } + } catch { + // Best-effort: if the filesystem check fails, treat as not a git repo. + } + return false; +} + +/** + * Checks if `.testsprite` is ignored given the raw content of a `.gitignore` file. + */ +export function isTestspriteIgnored(content: string): boolean { + const lines = content.split(/\r?\n/); + let ignored = false; + for (const line of lines) { + const cleaned = line.replace(/#.*$/, '').trim(); + if (!cleaned) continue; + + // A negation pattern like `!.testsprite/` un-ignores the directory. + const isNegation = cleaned.startsWith('!'); + const pattern = isNegation ? cleaned.slice(1) : cleaned; + + if ( + pattern === '.testsprite' || + pattern === '.testsprite/' || + pattern === '**/.testsprite' || + pattern === '**/.testsprite/' || + pattern.startsWith('.testsprite/') || + pattern.startsWith('**/.testsprite/') + ) { + ignored = !isNegation; + } + } + return ignored; +} + +/** + * Returns true if a `.gitignore` exists in the target directory and ignores `.testsprite/`. + */ +export async function checkTestspriteIgnored(dir: string, deps: GitUtilsDeps = {}): Promise { + const exists = deps.exists ?? (async (p) => (deps.existsSync ?? existsSync)(p)); + const read = deps.readFile ?? ((p) => fs.readFile(p, 'utf8')); + + const gitignorePath = join(dir, '.gitignore'); + if (!(await exists(gitignorePath))) { + return false; + } + + try { + const content = await read(gitignorePath); + return isTestspriteIgnored(content); + } catch { + return false; + } +} + +/** + * Ensures that `.testsprite/` is ignored in the target directory's `.gitignore`. + * If `.gitignore` doesn't exist, it will be created. If `.testsprite` is already + * ignored, no changes are made. + * + * Returns true if the file was modified or created, false if it was already ignored. + */ +export async function ensureTestspriteIgnored(dir: string, deps: GitUtilsDeps = {}): Promise { + const exists = deps.exists ?? (async (p) => (deps.existsSync ?? existsSync)(p)); + const read = deps.readFile ?? ((p) => fs.readFile(p, 'utf8')); + const write = deps.writeFile ?? ((p, content) => fs.writeFile(p, content, 'utf8')); + + const gitignorePath = join(dir, '.gitignore'); + let content = ''; + if (await exists(gitignorePath)) { + try { + content = await read(gitignorePath); + } catch { + content = ''; + } + } + + if (isTestspriteIgnored(content)) { + return false; // already ignored, no changes made + } + + // Ensure file ends with newline before appending + const separator = content.length === 0 || content.endsWith('\n') ? '' : '\n'; + const appendContent = `${separator}\n# TestSprite failure artifacts\n.testsprite/\n`; + const newContent = `${content}${appendContent}`; + + await write(gitignorePath, newContent); + return true; // appended successfully +} From 7c9af7b7ff0ec9af657899cf3f037ee64cf656dd Mon Sep 17 00:00:00 2001 From: sandman-sh Date: Fri, 17 Jul 2026 03:05:19 +0530 Subject: [PATCH 2/2] fix(ci): upgrade actions/github-script to v8 to support Node 24 --- .github/workflows/ci-nudge.yml | 4 ++-- .github/workflows/issue-triage.yml | 2 +- .github/workflows/pr-triage.yml | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.github/workflows/ci-nudge.yml b/.github/workflows/ci-nudge.yml index d01ede3..b663dfb 100644 --- a/.github/workflows/ci-nudge.yml +++ b/.github/workflows/ci-nudge.yml @@ -41,7 +41,7 @@ jobs: app-id: ${{ secrets.TESTSPRITE_HOB_APP_ID || secrets.ALFHEIM_AGENT_APP_ID }} private-key: ${{ secrets.TESTSPRITE_HOB_PRIVATE_KEY || secrets.ALFHEIM_AGENT_PRIVATE_KEY }} - - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.0.1 + - uses: actions/github-script@v8 env: APP_TOKEN: ${{ steps.app-token.outputs.token }} with: @@ -51,7 +51,7 @@ jobs: const marker = process.env.MARKER; const appClient = process.env.APP_TOKEN - ? require('@actions/github').getOctokit(process.env.APP_TOKEN) + ? getOctokit(process.env.APP_TOKEN) : null; async function write(fn) { if (appClient) { diff --git a/.github/workflows/issue-triage.yml b/.github/workflows/issue-triage.yml index 6b6ce62..79cc020 100644 --- a/.github/workflows/issue-triage.yml +++ b/.github/workflows/issue-triage.yml @@ -49,7 +49,7 @@ jobs: # of inheriting the App's full installation permissions. permission-issues: write - - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.0.1 + - uses: actions/github-script@v8 with: # App token when available (→ testsprite-hob[bot]); else the default # GITHUB_TOKEN (→ github-actions[bot]). Either way the logic is identical. diff --git a/.github/workflows/pr-triage.yml b/.github/workflows/pr-triage.yml index a9cd23d..88f892d 100644 --- a/.github/workflows/pr-triage.yml +++ b/.github/workflows/pr-triage.yml @@ -61,7 +61,7 @@ jobs: app-id: ${{ secrets.TESTSPRITE_HOB_APP_ID || secrets.ALFHEIM_AGENT_APP_ID }} private-key: ${{ secrets.TESTSPRITE_HOB_PRIVATE_KEY || secrets.ALFHEIM_AGENT_PRIVATE_KEY }} - - uses: actions/github-script@f28e40c7f34bde8b3046d885e986cb6290c5673b # v7.0.1 + - uses: actions/github-script@v8 env: APP_TOKEN: ${{ steps.app-token.outputs.token }} with: @@ -76,7 +76,7 @@ jobs: const author = pr.user.login; const appClient = process.env.APP_TOKEN - ? require('@actions/github').getOctokit(process.env.APP_TOKEN) + ? getOctokit(process.env.APP_TOKEN) : null; // Prefer the App identity; fall back to github-actions[bot] when the // App is absent or lacks the Pull-requests permission (403/404).