diff --git a/.gitignore b/.gitignore index bb6bf32..fc765ea 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,7 @@ coverage .git/dubstack .worktrees +.dispatch # Dolt database files (added by bd init) .dolt/ diff --git a/src/commands/ai-resolve.test.ts b/src/commands/ai-resolve.test.ts new file mode 100644 index 0000000..f8493e4 --- /dev/null +++ b/src/commands/ai-resolve.test.ts @@ -0,0 +1,315 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { ConflictContext } from '../lib/conflict-context'; +import { DubError } from '../lib/errors'; +import type { AiResolveDeps } from './ai-resolve'; +import { aiResolve } from './ai-resolve'; + +function streamFrom(text: string) { + return { + fullStream: { + async *[Symbol.asyncIterator]() { + yield { type: 'text-delta' as const, text }; + }, + }, + }; +} + +function createMockContext( + overrides?: Partial, +): ConflictContext { + return { + operation: 'rebase', + conflictedBranch: 'feature-a', + parentBranch: 'main', + conflictedFiles: ['src/file.ts'], + conflictMarkers: { + 'src/file.ts': '<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>> abc123', + }, + upstreamCommits: 'abc123 upstream commit', + replayedCommits: 'def456 replayed commit', + ...overrides, + }; +} + +function aiResponse( + resolutions: Array<{ + path: string; + resolvedContent: string; + confidence: string; + explanation: string; + }>, +) { + return streamFrom(JSON.stringify(resolutions)); +} + +function createMockDeps(overrides?: Partial): AiResolveDeps { + const googleModel = vi.fn().mockReturnValue('fake-model'); + const defaultResolution = [ + { + path: 'src/file.ts', + resolvedContent: 'resolved content', + confidence: 'high', + explanation: 'merged both sides', + }, + ]; + + return { + streamText: vi.fn().mockReturnValue(aiResponse(defaultResolution)), + createGoogleGenerativeAI: vi.fn().mockReturnValue(googleModel), + createGateway: vi.fn(), + gatherConflictContext: vi.fn().mockResolvedValue(createMockContext()), + renderBatchPreview: vi.fn(), + promptBatchAction: vi.fn().mockResolvedValue('apply-all'), + promptFileAction: vi.fn().mockResolvedValue('apply'), + applyResolution: vi.fn().mockResolvedValue(undefined), + showScopeWarning: vi.fn().mockResolvedValue(true), + validateResolutionPaths: vi.fn(), + continueCommand: vi.fn().mockResolvedValue({ continued: 'rebase' }), + abortCommand: vi.fn().mockResolvedValue({ aborted: 'rebase' }), + ...overrides, + }; +} + +let envSnapshot: NodeJS.ProcessEnv; + +beforeEach(() => { + envSnapshot = { ...process.env }; + process.env.DUBSTACK_GEMINI_API_KEY = 'test-key'; + vi.clearAllMocks(); +}); + +afterEach(() => { + process.env = envSnapshot; +}); + +describe('aiResolve', () => { + it('throws when no active operation', async () => { + const deps = createMockDeps({ + gatherConflictContext: vi + .fn() + .mockRejectedValue( + new DubError( + 'No active rebase or restack operation. Nothing to resolve.', + ), + ), + }); + + await expect(aiResolve('/tmp/test', {}, deps)).rejects.toThrow( + 'No active rebase or restack operation', + ); + }); + + it('dry-run shows preview without applying', async () => { + const deps = createMockDeps(); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await aiResolve('/tmp/test', { dryRun: true }, deps); + + expect(deps.renderBatchPreview).toHaveBeenCalled(); + expect(deps.applyResolution).not.toHaveBeenCalled(); + + const output = logSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('Dry run'); + + logSpy.mockRestore(); + }); + + it('abort calls abortCommand', async () => { + const deps = createMockDeps(); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await aiResolve('/tmp/test', { abort: true }, deps); + + expect(deps.abortCommand).toHaveBeenCalledWith('/tmp/test'); + expect(deps.gatherConflictContext).not.toHaveBeenCalled(); + + logSpy.mockRestore(); + }); + + it('applies resolutions on apply-all', async () => { + const context = createMockContext({ + conflictedFiles: ['a.ts', 'b.ts'], + conflictMarkers: { + 'a.ts': '<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>>', + 'b.ts': '<<<<<<< HEAD\nours2\n=======\ntheirs2\n>>>>>>>', + }, + }); + const resolutions = [ + { + path: 'a.ts', + resolvedContent: 'resolved-a', + confidence: 'high', + explanation: 'merged', + }, + { + path: 'b.ts', + resolvedContent: 'resolved-b', + confidence: 'medium', + explanation: 'combined', + }, + ]; + const deps = createMockDeps({ + gatherConflictContext: vi.fn().mockResolvedValue(context), + streamText: vi.fn().mockReturnValue(aiResponse(resolutions)), + promptBatchAction: vi.fn().mockResolvedValue('apply-all'), + }); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await aiResolve('/tmp/test', {}, deps); + + expect(deps.applyResolution).toHaveBeenCalledTimes(2); + expect(deps.applyResolution).toHaveBeenCalledWith( + 'a.ts', + 'resolved-a', + '/tmp/test', + ); + expect(deps.applyResolution).toHaveBeenCalledWith( + 'b.ts', + 'resolved-b', + '/tmp/test', + ); + + logSpy.mockRestore(); + }); + + it('scope warning — user aborts, skips AI', async () => { + const context = createMockContext({ + scopeWarning: '15 conflicted files exceeds the 10-file threshold.', + }); + const deps = createMockDeps({ + gatherConflictContext: vi.fn().mockResolvedValue(context), + showScopeWarning: vi.fn().mockResolvedValue(false), + }); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await aiResolve('/tmp/test', {}, deps); + + expect(deps.showScopeWarning).toHaveBeenCalledWith( + '15 conflicted files exceeds the 10-file threshold.', + ); + expect(deps.streamText).not.toHaveBeenCalled(); + + logSpy.mockRestore(); + }); + + it('scope warning — user continues, proceeds to AI', async () => { + const context = createMockContext({ + scopeWarning: '15 conflicted files exceeds the 10-file threshold.', + }); + const deps = createMockDeps({ + gatherConflictContext: vi.fn().mockResolvedValue(context), + showScopeWarning: vi.fn().mockResolvedValue(true), + }); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await aiResolve('/tmp/test', {}, deps); + + expect(deps.showScopeWarning).toHaveBeenCalled(); + expect(deps.streamText).toHaveBeenCalled(); + expect(deps.applyResolution).toHaveBeenCalled(); + + logSpy.mockRestore(); + }); + + it('review individually — apply some, skip some', async () => { + const context = createMockContext({ + conflictedFiles: ['a.ts', 'b.ts'], + conflictMarkers: { + 'a.ts': '<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>>', + 'b.ts': '<<<<<<< HEAD\nours2\n=======\ntheirs2\n>>>>>>>', + }, + }); + const resolutions = [ + { + path: 'a.ts', + resolvedContent: 'resolved-a', + confidence: 'high', + explanation: 'merged', + }, + { + path: 'b.ts', + resolvedContent: 'resolved-b', + confidence: 'medium', + explanation: 'combined', + }, + ]; + const deps = createMockDeps({ + gatherConflictContext: vi.fn().mockResolvedValue(context), + streamText: vi.fn().mockReturnValue(aiResponse(resolutions)), + promptBatchAction: vi.fn().mockResolvedValue('review'), + promptFileAction: vi + .fn() + .mockResolvedValueOnce('apply') + .mockResolvedValueOnce('skip'), + }); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await aiResolve('/tmp/test', {}, deps); + + expect(deps.applyResolution).toHaveBeenCalledTimes(1); + expect(deps.applyResolution).toHaveBeenCalledWith( + 'a.ts', + 'resolved-a', + '/tmp/test', + ); + + logSpy.mockRestore(); + }); + + it('review individually — abort midway calls abortCommand', async () => { + const context = createMockContext({ + conflictedFiles: ['a.ts', 'b.ts'], + conflictMarkers: { + 'a.ts': '<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>>', + 'b.ts': '<<<<<<< HEAD\nours2\n=======\ntheirs2\n>>>>>>>', + }, + }); + const resolutions = [ + { + path: 'a.ts', + resolvedContent: 'resolved-a', + confidence: 'high', + explanation: 'merged', + }, + { + path: 'b.ts', + resolvedContent: 'resolved-b', + confidence: 'medium', + explanation: 'combined', + }, + ]; + const deps = createMockDeps({ + gatherConflictContext: vi.fn().mockResolvedValue(context), + streamText: vi.fn().mockReturnValue(aiResponse(resolutions)), + promptBatchAction: vi.fn().mockResolvedValue('review'), + promptFileAction: vi.fn().mockResolvedValue('abort'), + }); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await aiResolve('/tmp/test', {}, deps); + + expect(deps.abortCommand).toHaveBeenCalledWith('/tmp/test'); + expect(deps.applyResolution).not.toHaveBeenCalled(); + + logSpy.mockRestore(); + }); + + it('hands back to user after 1 failed retry', async () => { + const deps = createMockDeps({ + continueCommand: vi + .fn() + .mockRejectedValueOnce(new Error('conflict')) + .mockRejectedValueOnce(new Error('conflict again')), + }); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await aiResolve('/tmp/test', {}, deps); + + const output = logSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('AI could not fully resolve the conflicts'); + expect(output).toContain('dub continue'); + + logSpy.mockRestore(); + }); +}); diff --git a/src/commands/ai-resolve.ts b/src/commands/ai-resolve.ts new file mode 100644 index 0000000..01d144b --- /dev/null +++ b/src/commands/ai-resolve.ts @@ -0,0 +1,367 @@ +import { createGoogleGenerativeAI } from '@ai-sdk/google'; +import type { LanguageModel } from 'ai'; +import { createGateway, streamText } from 'ai'; +import chalk from 'chalk'; +import type { ConflictContext } from '../lib/conflict-context'; +import { gatherConflictContext } from '../lib/conflict-context'; +import type { FileResolution } from '../lib/conflict-ui'; +import { + applyResolution, + promptBatchAction, + promptFileAction, + renderBatchPreview, + showScopeWarning, + validateResolutionPaths, +} from '../lib/conflict-ui'; +import { DubError } from '../lib/errors'; +import { abortCommand } from './abort'; +import { continueCommand } from './continue'; + +export interface AiResolveDeps { + streamText: typeof streamText; + createGoogleGenerativeAI: typeof createGoogleGenerativeAI; + createGateway: typeof createGateway; + gatherConflictContext: typeof gatherConflictContext; + renderBatchPreview: typeof renderBatchPreview; + promptBatchAction: typeof promptBatchAction; + promptFileAction: typeof promptFileAction; + applyResolution: typeof applyResolution; + showScopeWarning: typeof showScopeWarning; + validateResolutionPaths: typeof validateResolutionPaths; + continueCommand: typeof continueCommand; + abortCommand: typeof abortCommand; +} + +const DEFAULT_DEPS: AiResolveDeps = { + streamText, + createGoogleGenerativeAI, + createGateway, + gatherConflictContext, + renderBatchPreview, + promptBatchAction, + promptFileAction, + applyResolution, + showScopeWarning, + validateResolutionPaths, + continueCommand, + abortCommand, +}; + +const PROVIDER_OPTIONS = { + google: { + thinkingConfig: { + thinkingLevel: 'high' as const, + includeThoughts: true, + }, + }, +} as const; + +export async function aiResolve( + cwd: string, + options: { dryRun?: boolean; abort?: boolean }, + deps: AiResolveDeps = DEFAULT_DEPS, +): Promise { + const sigintHandler = () => { + console.log( + '\nCancelled. Conflict state preserved — resolve manually or re-run `dub ai resolve`.', + ); + process.exit(130); + }; + process.on('SIGINT', sigintHandler); + + try { + if (options.abort) { + await deps.abortCommand(cwd); + console.log(chalk.green('Operation aborted.')); + return; + } + + const context = await deps.gatherConflictContext(cwd); + + if (context.conflictedFiles.length === 0) { + throw new DubError('No conflicted files detected.'); + } + + if (context.scopeWarning) { + const proceed = await deps.showScopeWarning(context.scopeWarning); + if (!proceed) return; + } + + const model = resolveModel(deps); + const resolutions = await streamResolutions(context, model, deps); + + deps.validateResolutionPaths(resolutions, context.conflictedFiles, cwd); + + if (options.dryRun) { + deps.renderBatchPreview(resolutions); + console.log(chalk.dim('\nDry run — no changes applied.')); + return; + } + + await applyAndContinue(cwd, resolutions, model, deps, 0); + } finally { + process.removeListener('SIGINT', sigintHandler); + } +} + +function buildConflictSystemPrompt(): string { + return [ + 'You are an AI assistant helping resolve git merge conflicts.', + 'Analyze the conflict markers and propose a clean resolution for each file.', + 'Output a JSON array of objects with: path, resolvedContent, confidence (high/medium/low), explanation.', + 'Never silently drop changes from either side.', + 'Explain what both sides changed and why in the explanation field.', + "Flag uncertain resolutions with 'low' confidence.", + 'Return ONLY the JSON array, no markdown fences or extra text.', + ].join(' '); +} + +function buildConflictUserPrompt( + context: ConflictContext, + errorFeedback?: string, +): string { + const sections: string[] = []; + + if (errorFeedback) { + sections.push(`Previous resolution attempt failed: ${errorFeedback}`); + sections.push(''); + } + + sections.push( + `Operation: ${context.operation}`, + `Branch: ${context.conflictedBranch} (rebasing onto ${context.parentBranch})`, + ); + + if (context.restackStep) { + sections.push(`Restack step: ${JSON.stringify(context.restackStep)}`); + if (context.remainingSteps !== undefined) { + sections.push(`Remaining steps: ${context.remainingSteps}`); + } + } + + sections.push( + '', + '--- Upstream commits (base being rebased onto) ---', + context.upstreamCommits || '(none)', + '', + '--- Replayed commits (branch being rebased) ---', + context.replayedCommits || '(none)', + '', + '--- Conflicted files with markers ---', + ); + + for (const file of context.conflictedFiles) { + sections.push(`\n=== ${file} ===`); + sections.push(context.conflictMarkers[file] ?? '(content unavailable)'); + } + + return sections.join('\n'); +} + +async function streamResolutions( + context: ConflictContext, + model: LanguageModel, + deps: AiResolveDeps, + errorFeedback?: string, +): Promise { + console.log( + chalk.dim( + `Analyzing ${context.conflictedFiles.length} conflicted file(s)...`, + ), + ); + + const result = deps.streamText({ + model, + system: buildConflictSystemPrompt(), + prompt: buildConflictUserPrompt(context, errorFeedback), + providerOptions: PROVIDER_OPTIONS as never, + }); + + let fullText = ''; + for await (const part of result.fullStream) { + if (part.type === 'text-delta') { + fullText += part.text ?? ''; + } else if (part.type === 'error') { + throw part.error instanceof Error + ? part.error + : new DubError('AI stream failed unexpectedly.'); + } + } + + const resolutions = parseResolutions(fullText); + + for (const res of resolutions) { + res.originalContent = context.conflictMarkers[res.path] ?? ''; + } + + return resolutions; +} + +function parseResolutions(text: string): FileResolution[] { + let jsonStr = text.trim(); + + // Strip markdown code fences if present + const fenceMatch = jsonStr.match(/```(?:json)?\s*\n?([\s\S]*?)\n?\s*```/); + if (fenceMatch) { + jsonStr = fenceMatch[1].trim(); + } + + // Extract JSON array + const arrayStart = jsonStr.indexOf('['); + const arrayEnd = jsonStr.lastIndexOf(']'); + if (arrayStart !== -1 && arrayEnd !== -1 && arrayEnd > arrayStart) { + jsonStr = jsonStr.slice(arrayStart, arrayEnd + 1); + } + + let parsed: unknown; + try { + parsed = JSON.parse(jsonStr); + } catch { + throw new DubError( + 'Could not parse AI response. Try again or resolve conflicts manually.', + ); + } + + if (!Array.isArray(parsed) || parsed.length === 0) { + throw new DubError( + 'AI returned no resolutions. Resolve conflicts manually.', + ); + } + + return (parsed as unknown[]).map((raw) => { + if (typeof raw !== 'object' || raw === null) { + throw new DubError( + 'AI returned an invalid resolution format. Resolve conflicts manually.', + ); + } + const item = raw as Record; + return { + path: String(item.path ?? ''), + originalContent: '', + resolvedContent: String(item.resolvedContent ?? ''), + confidence: validateConfidence(item.confidence), + explanation: String(item.explanation ?? ''), + }; + }); +} + +function validateConfidence(value: unknown): 'high' | 'medium' | 'low' { + if (value === 'high' || value === 'medium' || value === 'low') return value; + return 'low'; +} + +async function applyAndContinue( + cwd: string, + resolutions: FileResolution[], + model: LanguageModel, + deps: AiResolveDeps, + retryCount: number, +): Promise { + deps.renderBatchPreview(resolutions); + + const action = await deps.promptBatchAction(); + + if (action === 'abort') { + await deps.abortCommand(cwd); + console.log(chalk.yellow('Operation aborted.')); + return; + } + + if (action === 'apply-all') { + for (const res of resolutions) { + await deps.applyResolution(res.path, res.resolvedContent, cwd); + } + } else { + for (const res of resolutions) { + deps.renderBatchPreview([res]); + const fileAction = await deps.promptFileAction(res.path); + + if (fileAction === 'abort') { + await deps.abortCommand(cwd); + console.log(chalk.yellow('Operation aborted.')); + return; + } + + if (fileAction === 'apply') { + await deps.applyResolution(res.path, res.resolvedContent, cwd); + } + } + } + + try { + await deps.continueCommand(cwd); + console.log( + chalk.green('Conflicts resolved and operation continued successfully.'), + ); + } catch (err) { + if (retryCount >= 1) { + console.log( + chalk.yellow( + 'AI could not fully resolve the conflicts. Please resolve manually and run `dub continue`.', + ), + ); + return; + } + + try { + const errMsg = + err instanceof Error ? err.message : 'Unknown error during continue'; + console.log(chalk.yellow('New conflicts detected. Retrying with AI...')); + const retryContext = await deps.gatherConflictContext(cwd); + if (retryContext.conflictedFiles.length === 0) { + console.log( + chalk.yellow( + 'AI could not fully resolve the conflicts. Please resolve manually and run `dub continue`.', + ), + ); + return; + } + const retryResolutions = await streamResolutions( + retryContext, + model, + deps, + errMsg, + ); + deps.validateResolutionPaths( + retryResolutions, + retryContext.conflictedFiles, + cwd, + ); + await applyAndContinue( + cwd, + retryResolutions, + model, + deps, + retryCount + 1, + ); + } catch { + console.log( + chalk.yellow( + 'AI could not fully resolve the conflicts. Please resolve manually and run `dub continue`.', + ), + ); + } + } +} + +function resolveModel(deps: AiResolveDeps): LanguageModel { + const geminiApiKey = process.env.DUBSTACK_GEMINI_API_KEY?.trim(); + if (geminiApiKey) { + const geminiModel = + process.env.DUBSTACK_GEMINI_MODEL?.trim() || 'gemini-3-flash-preview'; + const google = deps.createGoogleGenerativeAI({ apiKey: geminiApiKey }); + return google(geminiModel); + } + + const gatewayApiKey = process.env.DUBSTACK_AI_GATEWAY_API_KEY?.trim(); + if (gatewayApiKey) { + const gatewayModel = + process.env.DUBSTACK_AI_GATEWAY_MODEL?.trim() || 'google/gemini-3-flash'; + const gateway = deps.createGateway({ apiKey: gatewayApiKey }); + return gateway(gatewayModel); + } + + throw new DubError( + "AI requires DUBSTACK_GEMINI_API_KEY or DUBSTACK_AI_GATEWAY_API_KEY. Run 'dub ai env' to configure.", + ); +} diff --git a/src/commands/continue.test.ts b/src/commands/continue.test.ts index fc46bb5..b1a93e3 100644 --- a/src/commands/continue.test.ts +++ b/src/commands/continue.test.ts @@ -1,13 +1,17 @@ +import { execa } from 'execa'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { DubError } from '../lib/errors'; import { rebaseContinue } from '../lib/git'; import { detectActiveOperation } from '../lib/operation-state'; +import { aiResolve } from './ai-resolve'; import { continueCommand } from './continue'; import { restackContinue } from './restack'; vi.mock('../lib/operation-state'); vi.mock('../lib/git'); vi.mock('./restack'); +vi.mock('./ai-resolve'); +vi.mock('execa'); describe('continue command', () => { const cwd = '/tmp/repo'; @@ -19,6 +23,7 @@ describe('continue command', () => { rebased: ['feat/a'], }); vi.mocked(rebaseContinue).mockResolvedValue(undefined); + vi.mocked(aiResolve).mockResolvedValue(undefined); }); it('throws when no operation is active', async () => { @@ -45,4 +50,36 @@ describe('continue command', () => { expect(restackContinue).toHaveBeenCalledWith(cwd); expect(result.continued).toBe('restack'); }); + + it('--ai triggers aiResolve when conflicts exist', async () => { + vi.mocked(execa).mockResolvedValue({ + stdout: 'src/foo.ts\nsrc/bar.ts', + } as never); + + const result = await continueCommand(cwd, { ai: true }); + + expect(execa).toHaveBeenCalledWith( + 'git', + ['diff', '--name-only', '--diff-filter=U'], + { cwd }, + ); + expect(aiResolve).toHaveBeenCalledWith(cwd, {}); + expect(result.continued).toBe('ai-resolve'); + }); + + it('--ai falls through to normal continue when no conflicts', async () => { + vi.mocked(execa).mockResolvedValue({ stdout: '' } as never); + vi.mocked(detectActiveOperation).mockResolvedValue('rebase'); + + const result = await continueCommand(cwd, { ai: true }); + + expect(execa).toHaveBeenCalledWith( + 'git', + ['diff', '--name-only', '--diff-filter=U'], + { cwd }, + ); + expect(aiResolve).not.toHaveBeenCalled(); + expect(rebaseContinue).toHaveBeenCalledWith(cwd); + expect(result.continued).toBe('rebase'); + }); }); diff --git a/src/commands/continue.ts b/src/commands/continue.ts index 9541b90..fdf7b14 100644 --- a/src/commands/continue.ts +++ b/src/commands/continue.ts @@ -1,16 +1,42 @@ +import { execa } from 'execa'; import { DubError } from '../lib/errors'; import { rebaseContinue } from '../lib/git'; import { detectActiveOperation } from '../lib/operation-state'; +import { aiResolve } from './ai-resolve'; import { restackContinue } from './restack'; interface ContinueCommandResult { - continued: 'rebase' | 'restack'; + continued: 'rebase' | 'restack' | 'ai-resolve'; restackResult?: Awaited>; } export async function continueCommand( cwd: string, + options?: { ai?: boolean }, ): Promise { + if (options?.ai) { + try { + const { stdout } = await execa( + 'git', + ['diff', '--name-only', '--diff-filter=U'], + { cwd }, + ); + const conflicted = stdout + .split('\n') + .map((l) => l.trim()) + .filter(Boolean); + + if (conflicted.length > 0) { + await aiResolve(cwd, {}); + return { continued: 'ai-resolve' }; + } + } catch { + throw new DubError( + 'Failed to check for merge conflicts. Ensure you are in a git repository.', + ); + } + } + const active = await detectActiveOperation(cwd); if (active === 'none') { throw new DubError( diff --git a/src/index.ts b/src/index.ts index 93631fd..8bdd492 100644 --- a/src/index.ts +++ b/src/index.ts @@ -520,8 +520,12 @@ Examples: program .command('continue') .description('Continue the active restack or git rebase operation') - .action(async () => { - const result = await continueCommand(process.cwd()); + .option('--ai', 'Use AI to resolve conflicts before continuing') + .action(async (options: { ai?: boolean }) => { + const result = await continueCommand(process.cwd(), { ai: options.ai }); + if (result.continued === 'ai-resolve') { + return; + } if (result.continued === 'rebase') { console.log(chalk.green('✔ Continued git rebase.')); return; @@ -990,6 +994,21 @@ program ); }, ), + ) + .addCommand( + new Command('resolve') + .description( + 'AI-assisted conflict resolution for rebase/restack conflicts', + ) + .option('--dry-run', 'Show proposed resolutions without applying') + .option('--abort', 'Abort the active rebase/restack operation') + .action(async (options: { dryRun?: boolean; abort?: boolean }) => { + const { aiResolve } = await import('./commands/ai-resolve'); + await aiResolve(process.cwd(), { + dryRun: options.dryRun, + abort: options.abort, + }); + }), ); program diff --git a/src/lib/conflict-context.test.ts b/src/lib/conflict-context.test.ts new file mode 100644 index 0000000..b4ac1d4 --- /dev/null +++ b/src/lib/conflict-context.test.ts @@ -0,0 +1,171 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { createTestRepo, gitInRepo } from '../../test/helpers'; +import { getRestackProgressPath } from './operation-state'; + +const { gatherConflictContext } = await import('./conflict-context'); + +let dir: string; +let cleanup: () => Promise; + +beforeEach(async () => { + const repo = await createTestRepo(); + dir = repo.dir; + cleanup = repo.cleanup; +}); + +afterEach(async () => { + await cleanup(); +}); + +describe('gatherConflictContext', () => { + it('throws DubError when no active operation', async () => { + await expect(gatherConflictContext(dir)).rejects.toThrow( + 'No active rebase or restack operation', + ); + }); + + it('gathers conflicted files from a real rebase conflict', async () => { + // Create a conflict: two branches modify the same file differently + fs.writeFileSync(path.join(dir, 'file.txt'), 'original\n'); + await gitInRepo(dir, ['add', 'file.txt']); + await gitInRepo(dir, ['commit', '-m', 'add file']); + + await gitInRepo(dir, ['checkout', '-b', 'feature']); + fs.writeFileSync(path.join(dir, 'file.txt'), 'feature change\n'); + await gitInRepo(dir, ['add', 'file.txt']); + await gitInRepo(dir, ['commit', '-m', 'feature change']); + + await gitInRepo(dir, ['checkout', 'main']); + fs.writeFileSync(path.join(dir, 'file.txt'), 'main change\n'); + await gitInRepo(dir, ['add', 'file.txt']); + await gitInRepo(dir, ['commit', '-m', 'main change']); + + await gitInRepo(dir, ['checkout', 'feature']); + + // Start a rebase that will conflict + try { + await gitInRepo(dir, ['rebase', 'main']); + } catch { + // expected conflict + } + + const ctx = await gatherConflictContext(dir); + + expect(ctx.operation).toBe('rebase'); + expect(ctx.conflictedFiles).toContain('file.txt'); + expect(ctx.conflictMarkers['file.txt']).toContain('<<<<<<<'); + }); + + it('reads conflict markers from files', async () => { + // Simulate a rebase-merge dir so detectActiveOperation returns 'rebase' + const rebaseDir = path.join(dir, '.git', 'rebase-merge'); + fs.mkdirSync(rebaseDir, { recursive: true }); + + // Create a file with fake conflict markers + const markerContent = [ + '<<<<<<< HEAD', + 'our side', + '=======', + 'their side', + '>>>>>>> abc1234', + ].join('\n'); + fs.writeFileSync(path.join(dir, 'conflict.txt'), markerContent); + + // Stage the file as unmerged by manually marking it + // Since we can't easily fake unmerged state, test with a real conflict + // This test verifies that conflictMarkers reads file content correctly + // when files are returned by getConflictedFiles + + const ctx = await gatherConflictContext(dir); + // No files will show as conflicted (no real unmerged entries), but + // the function should still complete without error + expect(ctx.operation).toBe('rebase'); + expect(ctx.conflictedFiles).toEqual([]); + }); + + it('sets scopeWarning when file count exceeds threshold', async () => { + // Create a real conflict to get conflicted files + fs.writeFileSync(path.join(dir, 'file.txt'), 'original\n'); + await gitInRepo(dir, ['add', 'file.txt']); + await gitInRepo(dir, ['commit', '-m', 'add file']); + + await gitInRepo(dir, ['checkout', '-b', 'feature']); + + // Create 11 files that will conflict + for (let i = 0; i < 11; i++) { + fs.writeFileSync(path.join(dir, `f${i}.txt`), `feature-${i}\n`); + } + await gitInRepo(dir, ['add', '.']); + await gitInRepo(dir, ['commit', '-m', 'feature files']); + + await gitInRepo(dir, ['checkout', 'main']); + for (let i = 0; i < 11; i++) { + fs.writeFileSync(path.join(dir, `f${i}.txt`), `main-${i}\n`); + } + await gitInRepo(dir, ['add', '.']); + await gitInRepo(dir, ['commit', '-m', 'main files']); + + await gitInRepo(dir, ['checkout', 'feature']); + try { + await gitInRepo(dir, ['rebase', 'main']); + } catch { + // expected conflict + } + + const ctx = await gatherConflictContext(dir); + expect(ctx.conflictedFiles.length).toBeGreaterThan(10); + expect(ctx.scopeWarning).toMatch(/exceeds the 10-file threshold/); + }); + + it('returns restackStep when restack progress exists', async () => { + // Set up rebase-merge dir + const rebaseDir = path.join(dir, '.git', 'rebase-merge'); + fs.mkdirSync(rebaseDir, { recursive: true }); + + // Write restack progress + const progressPath = await getRestackProgressPath(dir); + fs.mkdirSync(path.dirname(progressPath), { recursive: true }); + + const progress = { + originalBranch: 'main', + steps: [ + { + branch: 'feature-a', + parent: 'main', + parentOldTip: 'abc123', + status: 'done', + }, + { + branch: 'feature-b', + parent: 'feature-a', + parentOldTip: 'def456', + parentNewTip: 'ghi789', + status: 'conflicted', + }, + { + branch: 'feature-c', + parent: 'feature-b', + parentOldTip: 'jkl012', + status: 'pending', + }, + ], + }; + fs.writeFileSync(progressPath, JSON.stringify(progress, null, 2)); + + const ctx = await gatherConflictContext(dir); + + expect(ctx.operation).toBe('restack'); + expect(ctx.restackStep).toEqual({ + branch: 'feature-b', + parent: 'feature-a', + parentOldTip: 'def456', + parentNewTip: 'ghi789', + status: 'conflicted', + }); + expect(ctx.remainingSteps).toBe(1); + expect(ctx.conflictedBranch).toBe('feature-b'); + expect(ctx.parentBranch).toBe('feature-a'); + }); +}); diff --git a/src/lib/conflict-context.ts b/src/lib/conflict-context.ts new file mode 100644 index 0000000..9aa0504 --- /dev/null +++ b/src/lib/conflict-context.ts @@ -0,0 +1,175 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { execa } from 'execa'; +import { DubError } from './errors'; +import { getCurrentBranch } from './git'; +import { + detectActiveOperation, + getRestackProgressPath, + hasRestackProgress, +} from './operation-state'; +import { findStackForBranch, readState } from './state'; + +interface RestackStepInfo { + branch: string; + parent: string; + parentOldTip: string; + parentNewTip?: string; + status: 'pending' | 'done' | 'skipped' | 'conflicted'; +} + +export interface ConflictContext { + operation: 'rebase' | 'restack'; + conflictedBranch: string; + parentBranch: string; + conflictedFiles: string[]; + conflictMarkers: Record; + upstreamCommits: string; + replayedCommits: string; + restackStep?: RestackStepInfo; + remainingSteps?: number; + scopeWarning?: string; +} + +const SCOPE_MAX_FILES = 10; +const SCOPE_MAX_MARKER_LINES = 5000; + +export async function gatherConflictContext( + cwd: string, +): Promise { + const operation = await detectActiveOperation(cwd); + if (operation === 'none') { + throw new DubError( + 'No active rebase or restack operation. Nothing to resolve.', + ); + } + + const conflictedFiles = await getConflictedFiles(cwd); + + const conflictMarkers: Record = {}; + let totalMarkerLines = 0; + for (const file of conflictedFiles) { + try { + const filePath = path.resolve(cwd, file); + if (!filePath.startsWith(cwd + path.sep) && filePath !== cwd) continue; + const stat = fs.lstatSync(filePath); + if (stat.isSymbolicLink()) continue; + const content = fs.readFileSync(filePath, 'utf-8'); + conflictMarkers[file] = content; + totalMarkerLines += content.split('\n').length; + } catch { + // file may have been deleted in one side + } + } + + // During rebase: HEAD = upstream/base being rebased onto, REBASE_HEAD = commit being replayed + const [upstreamCommits, replayedCommits] = await Promise.all([ + getLogOutput(cwd, 'HEAD'), + getLogOutput(cwd, 'REBASE_HEAD'), + ]); + + const { conflictedBranch, parentBranch } = await resolveBranches( + cwd, + operation, + ); + + let restackStep: RestackStepInfo | undefined; + let remainingSteps: number | undefined; + + if (await hasRestackProgress(cwd)) { + const progressPath = await getRestackProgressPath(cwd); + const raw = fs.readFileSync(progressPath, 'utf-8'); + const progress = JSON.parse(raw) as { + steps: RestackStepInfo[]; + }; + restackStep = progress.steps.find((s) => s.status === 'conflicted'); + remainingSteps = progress.steps.filter( + (s) => s.status === 'pending', + ).length; + } + + let scopeWarning: string | undefined; + if (conflictedFiles.length > SCOPE_MAX_FILES) { + scopeWarning = `${conflictedFiles.length} conflicted files exceeds the ${SCOPE_MAX_FILES}-file threshold for AI-assisted resolution.`; + } else if (totalMarkerLines > SCOPE_MAX_MARKER_LINES) { + scopeWarning = `${totalMarkerLines} total lines in conflicted files exceeds the ${SCOPE_MAX_MARKER_LINES}-line threshold for AI-assisted resolution.`; + } + + return { + operation, + conflictedBranch, + parentBranch, + conflictedFiles, + conflictMarkers, + upstreamCommits, + replayedCommits, + restackStep, + remainingSteps, + scopeWarning, + }; +} + +async function getConflictedFiles(cwd: string): Promise { + try { + const { stdout } = await execa( + 'git', + ['diff', '--name-only', '--diff-filter=U'], + { cwd }, + ); + return stdout + .split('\n') + .map((l) => l.trim()) + .filter(Boolean); + } catch { + return []; + } +} + +async function getLogOutput(cwd: string, ref: string): Promise { + try { + const { stdout } = await execa('git', ['log', '--oneline', '-10', ref], { + cwd, + }); + return stdout; + } catch { + return ''; + } +} + +async function resolveBranches( + cwd: string, + operation: 'rebase' | 'restack', +): Promise<{ conflictedBranch: string; parentBranch: string }> { + if (operation === 'restack' && (await hasRestackProgress(cwd))) { + const progressPath = await getRestackProgressPath(cwd); + const raw = fs.readFileSync(progressPath, 'utf-8'); + const progress = JSON.parse(raw) as { + steps: RestackStepInfo[]; + }; + const conflicted = progress.steps.find((s) => s.status === 'conflicted'); + if (conflicted) { + return { + conflictedBranch: conflicted.branch, + parentBranch: conflicted.parent, + }; + } + } + + // Fall back to git state + const currentBranch = await getCurrentBranch(cwd).catch(() => 'unknown'); + let parentBranch = 'unknown'; + try { + const state = await readState(cwd); + const stack = findStackForBranch(state, currentBranch); + if (stack) { + const branch = stack.branches.find((b) => b.name === currentBranch); + if (branch?.parent) { + parentBranch = branch.parent; + } + } + } catch { + // state may not be readable during conflict + } + + return { conflictedBranch: currentBranch, parentBranch }; +} diff --git a/src/lib/conflict-ui.test.ts b/src/lib/conflict-ui.test.ts new file mode 100644 index 0000000..35f3fc4 --- /dev/null +++ b/src/lib/conflict-ui.test.ts @@ -0,0 +1,154 @@ +import { + beforeEach, + describe, + expect, + it, + type MockInstance, + vi, +} from 'vitest'; + +vi.mock('execa', () => ({ + execa: vi.fn(), +})); + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { execa } from 'execa'; +import type { FileResolution } from './conflict-ui'; +import { + applyResolution, + computeDiff, + renderBatchPreview, +} from './conflict-ui'; + +const mockExeca = execa as unknown as MockInstance; + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('computeDiff', () => { + it('returns empty hunks for identical content', () => { + const text = 'line1\nline2\nline3'; + expect(computeDiff(text, text)).toEqual([]); + }); + + it('detects added lines', () => { + const hunks = computeDiff('a\nb', 'a\nb\nc'); + expect(hunks.length).toBe(1); + const lines = hunks[0].lines; + expect(lines.some((l) => l === '+c')).toBe(true); + }); + + it('detects removed lines', () => { + const hunks = computeDiff('a\nb\nc', 'a\nc'); + expect(hunks.length).toBe(1); + const lines = hunks[0].lines; + expect(lines.some((l) => l === '-b')).toBe(true); + }); + + it('detects replaced lines', () => { + const hunks = computeDiff('a\nold\nc', 'a\nnew\nc'); + expect(hunks.length).toBe(1); + const lines = hunks[0].lines; + expect(lines.some((l) => l === '-old')).toBe(true); + expect(lines.some((l) => l === '+new')).toBe(true); + }); + + it('produces context lines around changes', () => { + const old = 'a\nb\nc\nd\ne\nf\ng'; + const changed = 'a\nb\nc\nX\ne\nf\ng'; + const hunks = computeDiff(old, changed, 2); + expect(hunks.length).toBe(1); + // Context lines start with space + const contextLines = hunks[0].lines.filter((l) => l.startsWith(' ')); + expect(contextLines.length).toBeGreaterThanOrEqual(2); + }); +}); + +describe('renderBatchPreview', () => { + it('outputs diff with file headers', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + const resolutions: FileResolution[] = [ + { + path: 'src/foo.ts', + originalContent: 'line1\nline2', + resolvedContent: 'line1\nchanged', + confidence: 'high', + explanation: 'straightforward fix', + }, + ]; + + renderBatchPreview(resolutions); + + const output = logSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('a/src/foo.ts'); + expect(output).toContain('b/src/foo.ts'); + expect(output).toContain('@@'); + + logSpy.mockRestore(); + }); + + it('shows confidence and explanation', () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + renderBatchPreview([ + { + path: 'x.ts', + originalContent: 'a', + resolvedContent: 'b', + confidence: 'low', + explanation: 'risky change', + }, + ]); + + const output = logSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('low'); + expect(output).toContain('risky change'); + + logSpy.mockRestore(); + }); +}); + +describe('applyResolution', () => { + it('writes file and runs git add', async () => { + const dir = await fs.promises.mkdtemp('/tmp/conflict-ui-test-'); + const file = 'test.ts'; + const content = 'resolved content'; + + // File must exist on disk for symlink/existence checks + fs.writeFileSync(path.join(dir, file), 'conflicted content'); + mockExeca.mockResolvedValueOnce({ stdout: '' }); + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + + await applyResolution(file, content, dir); + + const written = fs.readFileSync(path.join(dir, file), 'utf-8'); + expect(written).toBe(content); + + expect(mockExeca).toHaveBeenCalledWith('git', ['add', '--', file], { + cwd: dir, + }); + + const output = logSpy.mock.calls.map((c) => c[0]).join('\n'); + expect(output).toContain('Resolved test.ts'); + + logSpy.mockRestore(); + await fs.promises.rm(dir, { recursive: true, force: true }); + }); +}); + +describe('FileResolution type', () => { + it('accepts valid resolution objects', () => { + const res: FileResolution = { + path: 'src/index.ts', + originalContent: '<<<<<<< HEAD\nours\n=======\ntheirs\n>>>>>>>', + resolvedContent: 'merged', + confidence: 'medium', + explanation: 'combined both sides', + }; + expect(res.confidence).toBe('medium'); + expect(res.path).toBe('src/index.ts'); + }); +}); diff --git a/src/lib/conflict-ui.ts b/src/lib/conflict-ui.ts new file mode 100644 index 0000000..9bc6b04 --- /dev/null +++ b/src/lib/conflict-ui.ts @@ -0,0 +1,351 @@ +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import * as readline from 'node:readline/promises'; +import chalk from 'chalk'; +import { execa } from 'execa'; +import { DubError } from './errors'; + +export interface FileResolution { + path: string; + originalContent: string; + resolvedContent: string; + confidence: 'high' | 'medium' | 'low'; + explanation: string; +} + +interface DiffHunk { + oldStart: number; + oldCount: number; + newStart: number; + newCount: number; + lines: string[]; +} + +function confidenceColor(level: FileResolution['confidence']): string { + switch (level) { + case 'high': + return chalk.green(level); + case 'medium': + return chalk.yellow(level); + case 'low': + return chalk.red(level); + } +} + +/** + * Compute a simple line-by-line diff between two strings. + * Returns unified-diff style hunks with context lines. + */ +const DIFF_MAX_LINES = 3000; + +export function computeDiff( + oldText: string, + newText: string, + contextLines = 3, +): DiffHunk[] { + const oldLines = oldText.split('\n'); + const newLines = newText.split('\n'); + + // Guard against OOM on very large files — fall back to full-file diff + if (oldLines.length > DIFF_MAX_LINES || newLines.length > DIFF_MAX_LINES) { + return [ + { + oldStart: 1, + oldCount: oldLines.length, + newStart: 1, + newCount: newLines.length, + lines: [ + ...oldLines.map((l) => `-${l}`), + ...newLines.map((l) => `+${l}`), + ], + }, + ]; + } + + // Build longest common subsequence table + const m = oldLines.length; + const n = newLines.length; + const dp: number[][] = Array.from({ length: m + 1 }, () => + new Array(n + 1).fill(0), + ); + + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + if (oldLines[i - 1] === newLines[j - 1]) { + dp[i][j] = dp[i - 1][j - 1] + 1; + } else { + dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]); + } + } + } + + // Backtrack to get edit operations + type Op = { + type: 'equal' | 'delete' | 'insert'; + oldIdx: number; + newIdx: number; + line: string; + }; + const ops: Op[] = []; + let i = m; + let j = n; + + while (i > 0 || j > 0) { + if (i > 0 && j > 0 && oldLines[i - 1] === newLines[j - 1]) { + ops.push({ + type: 'equal', + oldIdx: i - 1, + newIdx: j - 1, + line: oldLines[i - 1], + }); + i--; + j--; + } else if (j > 0 && (i === 0 || dp[i][j - 1] >= dp[i - 1][j])) { + ops.push({ + type: 'insert', + oldIdx: i - 1, + newIdx: j - 1, + line: newLines[j - 1], + }); + j--; + } else { + ops.push({ + type: 'delete', + oldIdx: i - 1, + newIdx: -1, + line: oldLines[i - 1], + }); + i--; + } + } + + ops.reverse(); + + // Group into hunks with context + const changes: number[] = []; + for (let idx = 0; idx < ops.length; idx++) { + if (ops[idx].type !== 'equal') { + changes.push(idx); + } + } + + if (changes.length === 0) return []; + + // Merge nearby changes into hunk groups + const groups: number[][] = []; + let currentGroup = [changes[0]]; + + for (let idx = 1; idx < changes.length; idx++) { + if (changes[idx] - changes[idx - 1] <= contextLines * 2 + 1) { + currentGroup.push(changes[idx]); + } else { + groups.push(currentGroup); + currentGroup = [changes[idx]]; + } + } + groups.push(currentGroup); + + // Build hunks + const hunks: DiffHunk[] = []; + + for (const group of groups) { + const start = Math.max(0, group[0] - contextLines); + const end = Math.min( + ops.length - 1, + group[group.length - 1] + contextLines, + ); + + const lines: string[] = []; + let oldStart = 0; + let newStart = 0; + let oldCount = 0; + let newCount = 0; + let firstLine = true; + + for (let idx = start; idx <= end; idx++) { + const op = ops[idx]; + if (firstLine) { + if (op.type === 'equal' || op.type === 'delete') { + oldStart = op.oldIdx + 1; + } else { + // insert — old line position is one past the last old line before this + oldStart = op.oldIdx + 1 + 1; + } + if (op.type === 'equal' || op.type === 'insert') { + newStart = op.newIdx + 1; + } else { + newStart = op.newIdx + 1 + 1; + } + firstLine = false; + } + + switch (op.type) { + case 'equal': + lines.push(` ${op.line}`); + oldCount++; + newCount++; + break; + case 'delete': + lines.push(`-${op.line}`); + oldCount++; + break; + case 'insert': + lines.push(`+${op.line}`); + newCount++; + break; + } + } + + hunks.push({ oldStart, oldCount, newStart, newCount, lines }); + } + + return hunks; +} + +function formatHunkHeader(hunk: DiffHunk): string { + return chalk.cyan( + `@@ -${hunk.oldStart},${hunk.oldCount} +${hunk.newStart},${hunk.newCount} @@`, + ); +} + +function colorDiffLine(line: string): string { + if (line.startsWith('+')) return chalk.green(line); + if (line.startsWith('-')) return chalk.red(line); + return chalk.dim(line); +} + +export function renderBatchPreview(resolutions: FileResolution[]): void { + for (const res of resolutions) { + console.log(chalk.bold(`\n--- a/${res.path}`)); + console.log(chalk.bold(`+++ b/${res.path}`)); + console.log( + ` confidence: ${confidenceColor(res.confidence)} ${chalk.dim(res.explanation)}`, + ); + + const hunks = computeDiff(res.originalContent, res.resolvedContent); + for (const hunk of hunks) { + console.log(formatHunkHeader(hunk)); + for (const line of hunk.lines) { + console.log(colorDiffLine(line)); + } + } + } +} + +async function promptChoice( + question: string, + choices: Record, +): Promise { + const rl = readline.createInterface({ + input: process.stdin, + output: process.stdout, + }); + try { + for (;;) { + const answer = (await rl.question(question)).trim().toLowerCase(); + const match = choices[answer]; + if (match) return match; + console.log( + chalk.yellow( + `Invalid choice. Options: ${[...new Set(Object.keys(choices))].join(', ')}`, + ), + ); + } + } finally { + rl.close(); + } +} + +export async function promptBatchAction(): Promise< + 'apply-all' | 'review' | 'abort' +> { + return promptChoice('[A]pply All [R]eview Individually [C]ancel: ', { + a: 'apply-all', + 'apply all': 'apply-all', + r: 'review', + review: 'review', + c: 'abort', + cancel: 'abort', + }); +} + +export async function promptFileAction( + file: string, +): Promise<'apply' | 'skip' | 'abort'> { + return promptChoice(`${file}: [A]pply [S]kip [C]ancel: `, { + a: 'apply', + apply: 'apply', + s: 'skip', + skip: 'skip', + c: 'abort', + cancel: 'abort', + }); +} + +export function validateResolutionPaths( + resolutions: FileResolution[], + conflictedFiles: string[], + cwd: string, +): void { + const allowed = new Set(conflictedFiles); + const seen = new Set(); + for (const res of resolutions) { + if (seen.has(res.path)) { + throw new DubError(`Duplicate resolution path: ${res.path}`); + } + seen.add(res.path); + if (!allowed.has(res.path)) { + throw new DubError( + `AI returned path "${res.path}" which is not a conflicted file. Aborting for safety.`, + ); + } + const resolved = path.resolve(cwd, res.path); + if (!resolved.startsWith(cwd + path.sep) && resolved !== cwd) { + throw new DubError( + `Path "${res.path}" resolves outside repository. Aborting for safety.`, + ); + } + } +} + +export async function applyResolution( + file: string, + content: string, + cwd: string, +): Promise { + const filePath = path.resolve(cwd, file); + if (!filePath.startsWith(cwd + path.sep)) { + throw new DubError(`Refusing to write outside repository: ${file}`); + } + + // Reject symlinks to prevent writes outside the repo + try { + const stat = fs.lstatSync(filePath); + if (stat.isSymbolicLink()) { + throw new DubError(`Refusing to write to symlinked path: ${file}`); + } + } catch (err) { + if (err instanceof DubError) throw err; + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + throw new DubError( + `Conflicted file "${file}" does not exist. Aborting for safety.`, + ); + } + throw err; + } + + fs.writeFileSync(filePath, content, 'utf-8'); + await execa('git', ['add', '--', file], { cwd }); + console.log(chalk.green(`✔ Resolved ${file}`)); +} + +export async function showScopeWarning(warning: string): Promise { + console.log(chalk.yellow(warning)); + const choice = await promptChoice('[C]ontinue [A]bort: ', { + c: 'continue', + continue: 'continue', + a: 'abort', + abort: 'abort', + }); + return choice === 'continue'; +}