diff --git a/packages/docs/src/pages/guide.astro b/packages/docs/src/pages/guide.astro index 6e15eaf0..0283eadb 100644 --- a/packages/docs/src/pages/guide.astro +++ b/packages/docs/src/pages/guide.astro @@ -97,7 +97,21 @@ export WARDEN_ANTHROPIC_API_KEY=sk-ant-...`} /> -

Warden analyzes staged and unstaged changes, running any skills that match via your configured triggers.

+

Warden analyzes all uncommitted changes (staged and unstaged) against HEAD, running any skills that match via your configured triggers.

+ +

Review Only Staged Changes

+ +

For pre-commit workflows, analyze only what you're about to commit:

+ + + + + +

This uses git diff --cached so only staged files are analyzed.

Review Before Pushing

diff --git a/src/cli/args.ts b/src/cli/args.ts index 7c3ca53c..badc3ee1 100644 --- a/src/cli/args.ts +++ b/src/cli/args.ts @@ -37,6 +37,8 @@ export const CLIOptionsSchema = z.object({ list: z.boolean().default(false), /** Force interpretation of ambiguous targets as git refs */ git: z.boolean().default(false), + /** Analyze only staged changes (git diff --cached) */ + staged: z.boolean().default(false), /** Remote repository reference for skills (e.g., "owner/repo" or "owner/repo@sha") */ remote: z.string().optional(), /** Skip network operations - only use cached remote skills */ @@ -109,6 +111,7 @@ Options: --fix Automatically apply all suggested fixes --parallel Max concurrent trigger/skill executions (default: 4) -x, --fail-fast Stop after first finding + --staged Analyze only staged changes --git Force ambiguous targets to be treated as git refs --quiet Errors and final summary only -v, --verbose Show real-time findings and hunk details @@ -291,6 +294,7 @@ export function parseCliArgs(argv: string[] = process.argv.slice(2)): ParsedArgs 'fail-fast': { type: 'boolean', short: 'x', default: false }, parallel: { type: 'string' }, git: { type: 'boolean', default: false }, + staged: { type: 'boolean', default: false }, log: { type: 'boolean', default: false }, help: { type: 'boolean', short: 'h', default: false }, version: { type: 'boolean', short: 'V', default: false }, @@ -465,6 +469,7 @@ export function parseCliArgs(argv: string[] = process.argv.slice(2)): ParsedArgs force: values.force, parallel: values.parallel ? parseInt(values.parallel, 10) : undefined, git: values.git, + staged: values.staged, log: values.log, offline: values.offline, failFast: values['fail-fast'], diff --git a/src/cli/commands/init.test.ts b/src/cli/commands/init.test.ts index 2c4cc66b..82b24d19 100644 --- a/src/cli/commands/init.test.ts +++ b/src/cli/commands/init.test.ts @@ -26,6 +26,7 @@ function createOptions(overrides: Partial = {}): CLIOptions { force: false, list: false, git: false, + staged: false, offline: false, failFast: false, ...overrides, diff --git a/src/cli/commands/logs.test.ts b/src/cli/commands/logs.test.ts index fdcb4be4..7e77d8a9 100644 --- a/src/cli/commands/logs.test.ts +++ b/src/cli/commands/logs.test.ts @@ -27,6 +27,7 @@ function createDefaultOptions(overrides: Partial = {}): CLIOptions { force: false, list: false, git: false, + staged: false, offline: false, failFast: false, log: false, diff --git a/src/cli/commands/setup-app.test.ts b/src/cli/commands/setup-app.test.ts new file mode 100644 index 00000000..1f9aecbe --- /dev/null +++ b/src/cli/commands/setup-app.test.ts @@ -0,0 +1,222 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { EventEmitter } from 'node:events'; +import { runSetupApp } from './setup-app.js'; +import { Reporter, parseVerbosity } from '../output/index.js'; +import type { SetupAppOptions } from '../args.js'; + +// Mock all external dependencies so we never hit the network or filesystem. +vi.mock('./setup-app/manifest.js', () => ({ + buildManifest: () => ({ name: 'test-app', url: 'http://localhost', public: false }), +})); + +vi.mock('./setup-app/browser.js', () => ({ + openBrowser: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock('./setup-app/credentials.js', () => ({ + exchangeCodeForCredentials: vi.fn().mockResolvedValue({ + id: 12345, + name: 'test-app', + pem: '-----BEGIN RSA PRIVATE KEY-----\nfake\n-----END RSA PRIVATE KEY-----', + htmlUrl: 'https://github.com/apps/test-app', + }), +})); + +vi.mock('../git.js', () => ({ + getGitHubRepoUrl: () => 'https://github.com/test/repo', +})); + +// We need fine-grained control over the callback server mock, so we build it +// per-test in a factory that the mock delegates to. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let serverFactory: () => any; + +vi.mock('./setup-app/server.js', () => ({ + startCallbackServer: (..._args: unknown[]) => serverFactory(), +})); + +function createTestReporter(): Reporter { + const mode = { isTTY: false, supportsColor: false, columns: 80 }; + return new Reporter(mode, parseVerbosity(false, 0, false)); +} + +function createOptions(overrides: Partial = {}): SetupAppOptions { + return { + port: 3456, + timeout: 60, + open: false, + ...overrides, + }; +} + +/** + * Build a fake server handle whose `server` is an EventEmitter so we can + * simulate 'error' events. The `waitForCallback` promise can be resolved or + * rejected externally. + */ +function createMockServerHandle() { + const emitter = new EventEmitter(); + let resolveCallback!: (v: { code: string }) => void; + let rejectCallback!: (e: Error) => void; + const waitForCallback = new Promise<{ code: string }>((resolve, reject) => { + resolveCallback = resolve; + rejectCallback = reject; + }); + const close = vi.fn(); + + return { + handle: { + server: emitter, + waitForCallback, + close, + startUrl: 'http://localhost:3456/start', + }, + resolveCallback, + rejectCallback, + close, + }; +} + +describe('runSetupApp', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + describe('server error handling', () => { + it('returns exit code 1 and calls close() when the server emits EADDRINUSE', async () => { + const mock = createMockServerHandle(); + serverFactory = () => mock.handle; + + const reporter = createTestReporter(); + const errorSpy = vi.spyOn(reporter, 'error'); + + // Emit the error on the next microtask so `runSetupApp` has time to + // register its listener and start awaiting the promise race. + const errorObj: NodeJS.ErrnoException = new Error('listen EADDRINUSE: address already in use'); + errorObj.code = 'EADDRINUSE'; + + setTimeout(() => mock.handle.server.emit('error', errorObj), 5); + + const exitCode = await runSetupApp(createOptions(), reporter); + + expect(exitCode).toBe(1); + expect(mock.close).toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('already in use'), + ); + }); + + it('returns exit code 1 for generic server errors', async () => { + const mock = createMockServerHandle(); + serverFactory = () => mock.handle; + + const reporter = createTestReporter(); + const errorSpy = vi.spyOn(reporter, 'error'); + + const errorObj: NodeJS.ErrnoException = new Error('something broke'); + errorObj.code = 'EACCES'; + + setTimeout(() => mock.handle.server.emit('error', errorObj), 5); + + const exitCode = await runSetupApp(createOptions(), reporter); + + expect(exitCode).toBe(1); + expect(mock.close).toHaveBeenCalled(); + expect(errorSpy).toHaveBeenCalledWith( + expect.stringContaining('Server error: something broke'), + ); + }); + + it('does not call process.exit on server error', async () => { + const mock = createMockServerHandle(); + serverFactory = () => mock.handle; + + const reporter = createTestReporter(); + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + + const errorObj: NodeJS.ErrnoException = new Error('port in use'); + errorObj.code = 'EADDRINUSE'; + + setTimeout(() => mock.handle.server.emit('error', errorObj), 5); + + await runSetupApp(createOptions(), reporter); + + expect(exitSpy).not.toHaveBeenCalled(); + }); + }); + + describe('cleanup on errors', () => { + it('calls serverHandle.close() even when waitForCallback rejects', async () => { + const mock = createMockServerHandle(); + serverFactory = () => mock.handle; + + const reporter = createTestReporter(); + + setTimeout(() => mock.rejectCallback(new Error('Timeout')), 5); + + const exitCode = await runSetupApp(createOptions(), reporter); + + expect(exitCode).toBe(1); + expect(mock.close).toHaveBeenCalled(); + }); + }); + + describe('happy path', () => { + it('returns exit code 0 on successful callback exchange', async () => { + const mock = createMockServerHandle(); + serverFactory = () => mock.handle; + + const reporter = createTestReporter(); + + setTimeout(() => mock.resolveCallback({ code: 'test-code' }), 5); + + const exitCode = await runSetupApp(createOptions(), reporter); + + expect(exitCode).toBe(0); + expect(mock.close).toHaveBeenCalled(); + }); + }); + + describe('URL display', () => { + it('shows URL when open is false', async () => { + const mock = createMockServerHandle(); + serverFactory = () => mock.handle; + + const reporter = createTestReporter(); + const textSpy = vi.spyOn(reporter, 'text'); + + setTimeout(() => mock.resolveCallback({ code: 'test-code' }), 5); + + await runSetupApp(createOptions({ open: false }), reporter); + + const textCalls = textSpy.mock.calls.map((c) => c[0]); + expect(textCalls).toContainEqual( + expect.stringContaining('Open this URL in your browser'), + ); + }); + + it('shows URL when browser open fails', async () => { + const { openBrowser } = await import('./setup-app/browser.js'); + vi.mocked(openBrowser).mockRejectedValueOnce(new Error('xdg-open not found')); + + const mock = createMockServerHandle(); + serverFactory = () => mock.handle; + + const reporter = createTestReporter(); + const textSpy = vi.spyOn(reporter, 'text'); + const warnSpy = vi.spyOn(reporter, 'warning'); + + setTimeout(() => mock.resolveCallback({ code: 'test-code' }), 5); + + await runSetupApp(createOptions({ open: true }), reporter); + + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('Could not open browser'), + ); + const textCalls = textSpy.mock.calls.map((c) => c[0]); + expect(textCalls).toContainEqual( + expect.stringContaining('Open this URL in your browser'), + ); + }); + }); +}); diff --git a/src/cli/commands/setup-app.ts b/src/cli/commands/setup-app.ts index 3d88d504..a59cd1cb 100644 --- a/src/cli/commands/setup-app.ts +++ b/src/cli/commands/setup-app.ts @@ -48,29 +48,35 @@ export async function runSetupApp(options: SetupAppOptions, reporter: Reporter): org, }); - // Handle server errors (e.g., port already in use) - serverHandle.server.on('error', (error: NodeJS.ErrnoException) => { - if (error.code === 'EADDRINUSE') { - reporter.error(`Port ${port} is already in use. Try a different port with --port `); - } else { - reporter.error(`Server error: ${error.message}`); - } - process.exit(1); + // Handle server errors (e.g., port already in use) by racing a rejection + // against the callback promise so the error flows into the catch block below. + const serverError = new Promise((_, reject) => { + serverHandle.server.on('error', (error: NodeJS.ErrnoException) => { + if (error.code === 'EADDRINUSE') { + reject(new Error(`Port ${port} is already in use. Try a different port with --port `)); + } else { + reject(new Error(`Server error: ${error.message}`)); + } + }); }); + // Prevent unhandled rejection if the server errors before Promise.race is reached + serverError.catch((_e: unknown) => undefined); try { // Open browser to our local server (which will POST to GitHub) + let showUrl = !open; if (open) { reporter.step('Opening browser...'); try { await openBrowser(serverHandle.startUrl); } catch { reporter.warning('Could not open browser automatically.'); - reporter.blank(); - reporter.text('Open this URL in your browser:'); - reporter.text(chalk.cyan(serverHandle.startUrl)); + showUrl = true; } - } else { + } + + // Show the URL if the browser was not opened (or failed to open) + if (showUrl) { reporter.blank(); reporter.text('Open this URL in your browser:'); reporter.text(chalk.cyan(serverHandle.startUrl)); @@ -81,8 +87,8 @@ export async function runSetupApp(options: SetupAppOptions, reporter: Reporter): reporter.blank(); reporter.text(chalk.dim('Waiting for GitHub callback... (Ctrl+C to cancel)')); - // Wait for callback - const { code } = await serverHandle.waitForCallback; + // Wait for callback, but also abort if the server errors out + const { code } = await Promise.race([serverHandle.waitForCallback, serverError]); // Exchange code for credentials reporter.blank(); diff --git a/src/cli/context.ts b/src/cli/context.ts index fe8c4e4a..88b22b45 100644 --- a/src/cli/context.ts +++ b/src/cli/context.ts @@ -34,6 +34,8 @@ export interface LocalContextOptions { cwd?: string; /** Override auto-detected default branch (from config) */ defaultBranch?: string; + /** Analyze only staged changes (git diff --cached) */ + staged?: boolean; } /** @@ -49,12 +51,14 @@ export function buildLocalEventContext(options: LocalContextOptions = {}): Event const { owner, name } = getRepoName(cwd); const defaultBranch = options.defaultBranch ?? getDefaultBranch(cwd); - const base = options.base ?? defaultBranch; + // When staged, always diff against HEAD (index vs HEAD) + const staged = options.staged ?? false; + const base = staged ? 'HEAD' : (options.base ?? defaultBranch); const head = options.head; // undefined means working tree const currentBranch = getCurrentBranch(cwd); const headSha = head ? head : getHeadSha(cwd); - const changedFiles = getChangedFilesWithPatches(base, head, cwd); + const changedFiles = getChangedFilesWithPatches(base, head, cwd, { staged }); const files = changedFiles.map(toFileChange); // Use actual commit message when analyzing a specific commit @@ -64,6 +68,9 @@ export function buildLocalEventContext(options: LocalContextOptions = {}): Event const commitMsg = getCommitMessage(head, cwd); title = commitMsg.subject || `Commit ${head}`; body = commitMsg.body || `Analyzing changes in ${head}`; + } else if (staged) { + title = `Staged changes: ${currentBranch}`; + body = `Analyzing staged changes`; } else { title = `Local changes: ${currentBranch}`; body = `Analyzing local changes from ${base} to working tree`; diff --git a/src/cli/git.ts b/src/cli/git.ts index 4cbcf02a..3ac59ae6 100644 --- a/src/cli/git.ts +++ b/src/cli/git.ts @@ -170,18 +170,36 @@ function mapStatus(status: string): GitFileChange['status'] { } } +export interface DiffOptions { + /** Use --cached to diff only staged changes against HEAD */ + staged?: boolean; +} + +/** + * Build the git diff arguments for a given base/head/staged configuration. + */ +function buildDiffArgs(base: string, head: string | undefined, options?: DiffOptions): string[] { + if (options?.staged) { + return ['diff', '--cached']; + } + const diffRef = head ? `${base}...${head}` : base; + return ['diff', diffRef]; +} + /** * Get list of changed files between two refs. * If head is undefined, compares against the working tree. + * If options.staged is true, compares only staged changes against HEAD. */ export function getChangedFiles( base: string, head?: string, - cwd: string = process.cwd() + cwd: string = process.cwd(), + options?: DiffOptions ): GitFileChange[] { // Get file statuses - const diffRef = head ? `${base}...${head}` : base; - const nameStatusOutput = git(['diff', '--name-status', diffRef], cwd); + const baseArgs = buildDiffArgs(base, head, options); + const nameStatusOutput = git([...baseArgs, '--name-status'], cwd); if (!nameStatusOutput) { return []; @@ -207,7 +225,7 @@ export function getChangedFiles( } // Get numstat for additions/deletions - const numstatOutput = git(['diff', '--numstat', diffRef], cwd); + const numstatOutput = git([...baseArgs, '--numstat'], cwd); if (numstatOutput) { for (const line of numstatOutput.split('\n')) { if (!line.trim()) continue; @@ -233,11 +251,12 @@ export function getFilePatch( base: string, head: string | undefined, filename: string, - cwd: string = process.cwd() + cwd: string = process.cwd(), + options?: DiffOptions ): string | undefined { try { - const diffRef = head ? `${base}...${head}` : base; - return git(['diff', diffRef, '--', filename], cwd); + const baseArgs = buildDiffArgs(base, head, options); + return git([...baseArgs, '--', filename], cwd); } catch { return undefined; } @@ -276,9 +295,10 @@ function parseCombinedDiff(diffOutput: string): Map { export function getChangedFilesWithPatches( base: string, head?: string, - cwd: string = process.cwd() + cwd: string = process.cwd(), + options?: DiffOptions ): GitFileChange[] { - const files = getChangedFiles(base, head, cwd); + const files = getChangedFiles(base, head, cwd, options); if (files.length === 0) { return files; @@ -286,8 +306,8 @@ export function getChangedFilesWithPatches( // Get all patches in a single git diff command try { - const diffRef = head ? `${base}...${head}` : base; - const combinedDiff = git(['diff', diffRef], cwd); + const baseArgs = buildDiffArgs(base, head, options); + const combinedDiff = git(baseArgs, cwd); const patches = parseCombinedDiff(combinedDiff); for (const file of files) { @@ -297,7 +317,7 @@ export function getChangedFilesWithPatches( } catch { // Fall back to per-file patches if combined diff fails for (const file of files) { - file.patch = getFilePatch(base, head, file.filename, cwd); + file.patch = getFilePatch(base, head, file.filename, cwd, options); file.chunks = countPatchChunks(file.patch); } } diff --git a/src/cli/main.ts b/src/cli/main.ts index a7e321fc..ad034e0e 100644 --- a/src/cli/main.ts +++ b/src/cli/main.ts @@ -582,11 +582,14 @@ async function runConfigMode(options: CLIOptions, reporter: Reporter): Promise' - : undefined; - reporter.renderEmptyState('No changes found', tip); + if (options.staged) { + reporter.renderEmptyState('No staged changes found'); + } else { + const tip = !hasUncommittedChanges(repoPath) + ? 'Specify a git ref: warden HEAD~3 --skill ' + : undefined; + reporter.renderEmptyState('No uncommitted changes found', tip); + } reporter.blank(); } return 0; @@ -736,11 +743,13 @@ async function runDirectSkillMode(options: CLIOptions, reporter: Reporter): Prom const config = existsSync(configPath) ? loadWardenConfig(dirname(configPath)) : null; // Build context from local git - compare against HEAD for true uncommitted changes - reporter.startContext('Analyzing uncommitted changes...'); + const statusMessage = options.staged ? 'Analyzing staged changes...' : 'Analyzing uncommitted changes...'; + reporter.startContext(statusMessage); const context = buildLocalEventContext({ base: 'HEAD', cwd: repoPath, defaultBranch: config?.defaults?.defaultBranch, + staged: options.staged, }); const pullRequest = context.pullRequest; @@ -755,8 +764,12 @@ async function runDirectSkillMode(options: CLIOptions, reporter: Reporter): Prom process.stdout.write(content); } else { writeEmptyRunLog(repoPath, { traceId: getTraceId(), outputPath: options.output }); - const tip = 'Specify a git ref to analyze committed changes: warden main --skill '; - reporter.renderEmptyState('No uncommitted changes found', tip); + if (options.staged) { + reporter.renderEmptyState('No staged changes found'); + } else { + const tip = 'Specify a git ref to analyze committed changes: warden main --skill '; + reporter.renderEmptyState('No uncommitted changes found', tip); + } reporter.blank(); } return 0; @@ -770,6 +783,11 @@ async function runDirectSkillMode(options: CLIOptions, reporter: Reporter): Prom async function runCommand(options: CLIOptions, reporter: Reporter): Promise { const targets = options.targets ?? []; + // --staged is only meaningful without explicit targets + if (options.staged && targets.length > 0) { + reporter.warning('--staged is ignored when targets are specified'); + } + // No targets with --skill → run skill directly on uncommitted changes if (targets.length === 0 && options.skill) { return runDirectSkillMode(options, reporter);