diff --git a/packages/actions-fleet-core/src/diff/plan.ts b/packages/actions-fleet-core/src/diff/plan.ts index 10d08f38..40404133 100644 --- a/packages/actions-fleet-core/src/diff/plan.ts +++ b/packages/actions-fleet-core/src/diff/plan.ts @@ -17,6 +17,8 @@ export interface PlannedFileDiff { mergeStrategy: PlannedFile['mergeStrategy']; newContent: string; newHash: string; + /** The content of the file as it exists on disk (or null if the file does not exist). */ + existingContent: string | null; status: DiffStatus; } @@ -170,6 +172,7 @@ export async function planDiff(options: PlanDiffOptions): Promise { mergeStrategy: file.mergeStrategy, newContent: file.content, newHash: file.hash, + existingContent: existing, status, }); } diff --git a/packages/actions-fleet-core/src/diff/render-diff.test.ts b/packages/actions-fleet-core/src/diff/render-diff.test.ts new file mode 100644 index 00000000..9ca19f76 --- /dev/null +++ b/packages/actions-fleet-core/src/diff/render-diff.test.ts @@ -0,0 +1,186 @@ +import { describe, it, expect } from 'vitest'; +import { createHash } from 'node:crypto'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { renderUnifiedDiff, renderPlanPreview } from './render-diff.js'; +import { planDiff } from './plan.js'; +import type { RenderResult } from '../action-pack/render.js'; + +function bodyHash(body: string): string { + return createHash('sha256').update(body, 'utf8').digest('hex'); +} + +function withHeader(packId: string, version: string, hash: string, body: string): string { + return [ + '# Managed by sh1pt Actions Fleet', + `# pack: ${packId}@${version}`, + '# install: sh1pt-actions-store', + `# hash: sha256:${hash}`, + '', + body, + ].join('\n'); +} + +function makeRender(content: string, hash: string, destination = '.github/workflows/ci.yml'): RenderResult { + return { + packId: 'test-pack', + packVersion: '1.0.0', + files: [ + { + source: 'ci.yml.hbs', + destination, + mergeStrategy: 'replace-managed', + content, + hash, + }, + ], + }; +} + +// ---------- renderUnifiedDiff ---------- + +describe('renderUnifiedDiff', () => { + it('returns empty string for identical content', () => { + const text = 'line1\nline2\n'; + expect(renderUnifiedDiff(text, text, 'file.txt')).toBe(''); + }); + + it('shows all lines as additions for a new file (null old content)', () => { + const diff = renderUnifiedDiff(null, 'line1\nline2\n', 'new.yml'); + expect(diff).toContain('+line1'); + expect(diff).toContain('+line2'); + expect(diff).toContain('--- /dev/null'); + expect(diff).toContain('+++ b/new.yml'); + // No removal lines (lines starting with a single '-', not the '---' header) + const removalLines = diff.split('\n').filter((l) => /^-(?!-)/.test(l)); + expect(removalLines).toHaveLength(0); + }); + + it('shows removed lines with - prefix', () => { + const diff = renderUnifiedDiff('old\n', 'new\n', 'file.txt'); + expect(diff).toContain('-old'); + expect(diff).toContain('+new'); + }); + + it('includes @@ hunk header', () => { + const diff = renderUnifiedDiff('a\n', 'b\n', 'file.txt'); + expect(diff).toContain('@@'); + }); + + it('includes context lines around changes', () => { + const oldContent = ['ctx1', 'ctx2', 'ctx3', 'CHANGE', 'ctx4', 'ctx5', 'ctx6'].join('\n') + '\n'; + const newContent = ['ctx1', 'ctx2', 'ctx3', 'CHANGED', 'ctx4', 'ctx5', 'ctx6'].join('\n') + '\n'; + const diff = renderUnifiedDiff(oldContent, newContent, 'file.txt'); + expect(diff).toContain(' ctx1'); + expect(diff).toContain(' ctx6'); + expect(diff).toContain('-CHANGE'); + expect(diff).toContain('+CHANGED'); + }); + + it('respects custom context line count', () => { + const oldContent = ['a', 'b', 'c', 'CHANGE', 'd', 'e', 'f'].join('\n') + '\n'; + const newContent = ['a', 'b', 'c', 'CHANGED', 'd', 'e', 'f'].join('\n') + '\n'; + const diff1 = renderUnifiedDiff(oldContent, newContent, 'file.txt', 1); + const diff3 = renderUnifiedDiff(oldContent, newContent, 'file.txt', 3); + // With 1 line of context, lines 'a' and 'b' should NOT appear as context lines + const contextLines1 = diff1.split('\n').filter((l) => /^ /.test(l)); + expect(contextLines1.some((l) => l.trim() === 'a')).toBe(false); + // With 3 lines of context, 'a' should appear as a context line + const contextLines3 = diff3.split('\n').filter((l) => /^ /.test(l)); + expect(contextLines3.some((l) => l.trim() === 'a')).toBe(true); + }); +}); + +// ---------- renderPlanPreview ---------- + +describe('renderPlanPreview', () => { + const body = 'name: CI\non: push\n'; + const hash = bodyHash(body); + const newContent = withHeader('test-pack', '1.0.0', hash, body); + + it('returns "(no changes)" when all files are unchanged', async () => { + const repoDir = await mkdtemp(join(tmpdir(), 'sh1pt-diff-preview-')); + try { + const plan = await planDiff({ + repoDir, + render: makeRender(newContent, hash), + readExisting: async () => withHeader('test-pack', '1.0.0', hash, body), + }); + const preview = renderPlanPreview(plan); + expect(preview.trim()).toBe('(no changes)'); + } finally { + await rm(repoDir, { recursive: true, force: true }); + } + }); + + it('shows additions for a new file', async () => { + const repoDir = await mkdtemp(join(tmpdir(), 'sh1pt-diff-preview-')); + try { + const plan = await planDiff({ + repoDir, + render: makeRender(newContent, hash), + readExisting: async () => null, + }); + const preview = renderPlanPreview(plan); + expect(preview).toContain('--- /dev/null'); + expect(preview).toContain('+++ b/.github/workflows/ci.yml'); + expect(preview).toContain('+name: CI'); + } finally { + await rm(repoDir, { recursive: true, force: true }); + } + }); + + it('shows a unified diff for an updated file', async () => { + const repoDir = await mkdtemp(join(tmpdir(), 'sh1pt-diff-preview-')); + try { + const oldBody = 'name: OLD\non: push\n'; + const oldHash = bodyHash(oldBody); + const oldContent = withHeader('test-pack', '0.9.0', oldHash, oldBody); + + const plan = await planDiff({ + repoDir, + render: makeRender(newContent, hash), + readExisting: async () => oldContent, + }); + const preview = renderPlanPreview(plan); + expect(preview).toContain('-name: OLD'); + expect(preview).toContain('+name: CI'); + } finally { + await rm(repoDir, { recursive: true, force: true }); + } + }); + + it('shows a conflict warning for unmanaged files', async () => { + const repoDir = await mkdtemp(join(tmpdir(), 'sh1pt-diff-preview-')); + try { + const plan = await planDiff({ + repoDir, + render: makeRender(newContent, hash), + readExisting: async () => 'name: existing\n', + }); + const preview = renderPlanPreview(plan); + expect(preview).toContain('CONFLICT (unmanaged)'); + expect(preview).toContain('--force'); + } finally { + await rm(repoDir, { recursive: true, force: true }); + } + }); + + it('shows a conflict warning for other-pack files', async () => { + const repoDir = await mkdtemp(join(tmpdir(), 'sh1pt-diff-preview-')); + try { + const otherBody = 'name: other\n'; + const plan = await planDiff({ + repoDir, + render: makeRender(newContent, hash), + readExisting: async () => withHeader('other-pack', '2.0.0', bodyHash(otherBody), otherBody), + }); + const preview = renderPlanPreview(plan); + expect(preview).toContain('CONFLICT (other-pack)'); + expect(preview).toContain('other-pack@2.0.0'); + } finally { + await rm(repoDir, { recursive: true, force: true }); + } + }); +}); diff --git a/packages/actions-fleet-core/src/diff/render-diff.ts b/packages/actions-fleet-core/src/diff/render-diff.ts new file mode 100644 index 00000000..55edf440 --- /dev/null +++ b/packages/actions-fleet-core/src/diff/render-diff.ts @@ -0,0 +1,267 @@ +import type { DiffPlan, PlannedFileDiff } from './plan.js'; + +// ---------- LCS-based unified diff engine ---------- + +type EditOp = { kind: 'equal' | 'add' | 'remove'; line: string }; + +/** + * Compute the longest common subsequence length table for two line arrays. + * Uses O(m*n) DP — sufficient for typical workflow files (< 2 000 lines). + */ +function lcsTable(a: string[], b: string[]): number[][] { + const m = a.length; + const n = b.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++) { + // All indices are within bounds by construction; non-null assertions are safe. + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + dp[i]![j] = a[i - 1] === b[j - 1] ? dp[i - 1]![j - 1]! + 1 : Math.max(dp[i - 1]![j]!, dp[i]![j - 1]!); + } + } + return dp; +} + +/** Iterative back-track of the LCS table to produce a sequence of edit operations. */ +function diffLines(oldLines: string[], newLines: string[]): EditOp[] { + const dp = lcsTable(oldLines, newLines); + const ops: EditOp[] = []; + let i = oldLines.length; + let j = newLines.length; + while (i > 0 || j > 0) { + if (i === 0) { + ops.unshift({ kind: 'add', line: newLines[j - 1]! }); + j--; + } else if (j === 0) { + ops.unshift({ kind: 'remove', line: oldLines[i - 1]! }); + i--; + } else if (oldLines[i - 1] === newLines[j - 1]) { + ops.unshift({ kind: 'equal', line: oldLines[i - 1]! }); + i--; + j--; + } else if (dp[i - 1]![j]! >= dp[i]![j - 1]!) { + ops.unshift({ kind: 'remove', line: oldLines[i - 1]! }); + i--; + } else { + ops.unshift({ kind: 'add', line: newLines[j - 1]! }); + j--; + } + } + return ops; +} + +interface Hunk { + oldStart: number; + newStart: number; + lines: string[]; +} + +/** + * Group edit operations into unified-diff hunks with the given context radius. + */ +function buildHunks(ops: EditOp[], context: number): Hunk[] { + const hunks: Hunk[] = []; + let oldLine = 1; + let newLine = 1; + + // Collect all change positions (in terms of op index) + const changeIndices: number[] = []; + for (let idx = 0; idx < ops.length; idx++) { + if (ops[idx]!.kind !== 'equal') changeIndices.push(idx); + } + + let idx = 0; + while (idx < changeIndices.length) { + const firstChange = changeIndices[idx]!; + const contextStart = Math.max(0, firstChange - context); + + // Extend until no more changes within context distance + let lastIncluded = firstChange; + let k = idx; + while (k < changeIndices.length) { + const curr = changeIndices[k]!; + if (curr <= lastIncluded + 2 * context + 1) { + lastIncluded = curr; + k++; + } else { + break; + } + } + idx = k; + + const contextEnd = Math.min(ops.length - 1, lastIncluded + context); + + // Compute line numbers up to contextStart + let o = oldLine; + let n = newLine; + for (let i = 0; i < contextStart; i++) { + const op = ops[i]!; + if (op.kind !== 'add') o++; + if (op.kind !== 'remove') n++; + } + + const hunkLines: string[] = []; + let hunkOldStart = o; + let hunkNewStart = n; + + for (let i = contextStart; i <= contextEnd; i++) { + const op = ops[i]!; + if (op.kind === 'equal') { + hunkLines.push(` ${op.line}`); + o++; + n++; + } else if (op.kind === 'remove') { + hunkLines.push(`-${op.line}`); + o++; + } else { + hunkLines.push(`+${op.line}`); + n++; + } + } + + // Count old/new line spans for the @@ header + let oldCount = 0; + let newCount = 0; + for (const l of hunkLines) { + if (l.startsWith('-')) oldCount++; + else if (l.startsWith('+')) newCount++; + else { oldCount++; newCount++; } + } + + const header = + oldCount === 1 && newCount === 1 + ? `@@ -${hunkOldStart} +${hunkNewStart} @@` + : `@@ -${hunkOldStart},${oldCount} +${hunkNewStart},${newCount} @@`; + + hunks.push({ oldStart: hunkOldStart, newStart: hunkNewStart, lines: [header, ...hunkLines] }); + + // Advance the running line counters past contextEnd + for (let i = 0; i <= contextEnd; i++) { + const op = ops[i]!; + if (op.kind !== 'add') oldLine++; + if (op.kind !== 'remove') newLine++; + } + } + + return hunks; +} + +// ---------- Public API ---------- + +/** + * Render a unified diff between `oldContent` (or null for new files) and + * `newContent` for the given `filename`. + * + * Returns an empty string when the contents are identical. + */ +export function renderUnifiedDiff( + oldContent: string | null, + newContent: string, + filename: string, + contextLines = 3, +): string { + const oldText = oldContent ?? ''; + if (oldText === newContent) return ''; + + const isNew = oldContent === null; + const oldLabel = isNew ? '/dev/null' : `a/${filename}`; + const newLabel = `b/${filename}`; + + const oldLines = oldText === '' ? [] : oldText.split('\n'); + const newLines = newContent === '' ? [] : newContent.split('\n'); + + // Strip trailing empty string caused by a trailing newline + if (oldLines[oldLines.length - 1] === '') oldLines.pop(); + if (newLines[newLines.length - 1] === '') newLines.pop(); + + const ops = diffLines(oldLines, newLines); + const hunks = buildHunks(ops, contextLines); + + if (hunks.length === 0) return ''; + + const parts: string[] = [ + `--- ${oldLabel}`, + `+++ ${newLabel}`, + ]; + for (const hunk of hunks) { + parts.push(...hunk.lines); + } + return parts.join('\n') + '\n'; +} + +/** + * Render a human-readable diff preview for every file in a `DiffPlan`. + * + * - **create**: shows all lines as additions + * - **update-managed**: shows a unified diff of old vs new + * - **unchanged**: omitted (nothing to show) + * - **conflict-unmanaged / conflict-other-pack**: shows a warning header with + * the proposed new content as additions so reviewers can assess the risk + * + * Returns a plain-text string suitable for printing to a terminal or storing in + * a PR description. + */ +export function renderPlanPreview(plan: DiffPlan, contextLines = 3): string { + const sections: string[] = []; + + for (const file of plan.files) { + const section = renderFileDiffSection(file, contextLines); + if (section) sections.push(section); + } + + if (sections.length === 0) return '(no changes)\n'; + return sections.join('\n'); +} + +function renderFileDiffSection(file: PlannedFileDiff, contextLines: number): string | null { + switch (file.status.kind) { + case 'unchanged': + return null; + + case 'create': { + const diff = renderUnifiedDiff(null, file.newContent, file.destination, contextLines); + return diff || null; + } + + case 'update-managed': { + // For update-managed, the file existed on disk when the plan was built. + // existingContent is guaranteed to be non-null in this state. + const existing = file.existingContent ?? ''; + const diff = renderUnifiedDiff(existing, file.newContent, file.destination, contextLines); + return diff || null; + } + + case 'conflict-unmanaged': { + const header = [ + `# CONFLICT (unmanaged): ${file.destination}`, + '# This file exists but is not managed by sh1pt Actions Fleet.', + '# Re-run with --force to overwrite.', + '#', + ].join('\n'); + const diff = renderUnifiedDiff( + file.existingContent, + file.newContent, + file.destination, + contextLines, + ); + return `${header}\n${diff || '(no diff available)\n'}`; + } + + case 'conflict-other-pack': { + const { existingPackId, existingPackVersion } = file.status; + const header = [ + `# CONFLICT (other-pack): ${file.destination}`, + `# This file is already managed by pack ${existingPackId}@${existingPackVersion}.`, + '# Re-run with --force to overwrite.', + '#', + ].join('\n'); + const diff = renderUnifiedDiff( + file.existingContent, + file.newContent, + file.destination, + contextLines, + ); + return `${header}\n${diff || '(no diff available)\n'}`; + } + } +} diff --git a/packages/actions-fleet-core/src/index.ts b/packages/actions-fleet-core/src/index.ts index 2ad2c5e8..e730ead3 100644 --- a/packages/actions-fleet-core/src/index.ts +++ b/packages/actions-fleet-core/src/index.ts @@ -3,6 +3,7 @@ export * from './action-pack/validate.js'; export * from './action-pack/render.js'; export * from './action-pack/catalog.js'; export * from './diff/plan.js'; +export * from './diff/render-diff.js'; export * from './local/install.js'; export * from './remote/gh-auth.js'; export * from './remote/github-api.js'; diff --git a/packages/cli/src/commands/build-actions.ts b/packages/cli/src/commands/build-actions.ts index df9af6c9..3e97f858 100644 --- a/packages/cli/src/commands/build-actions.ts +++ b/packages/cli/src/commands/build-actions.ts @@ -8,6 +8,7 @@ import { openPackPullRequest, planDiff, renderPack, + renderPlanPreview, type CatalogEntry, type DiffPlan, type OpenPrOutcome, @@ -58,6 +59,24 @@ function printStatusLine(destination: string, statusKind: string, reason?: strin console.log(` ${tag} ${destination}${suffix}`); } +function colorizeUnifiedDiff(text: string): void { + for (const line of text.split('\n')) { + if (line.startsWith('---') || line.startsWith('+++')) { + console.log(kleur.bold(line)); + } else if (line.startsWith('-')) { + console.log(kleur.red(line)); + } else if (line.startsWith('+')) { + console.log(kleur.green(line)); + } else if (line.startsWith('@@')) { + console.log(kleur.cyan(line)); + } else if (line.startsWith('#')) { + console.log(kleur.yellow(line)); + } else { + console.log(line); + } + } +} + function printCatalogRows(rows: Array<{ id: string; name: string; @@ -318,8 +337,9 @@ export function createActionsCmd(): Command { .argument('', 'pack id') .option('-r, --repo ', 'target repo directory', '.') .option('-i, --input ', 'pack input as key=value (repeatable)') + .option('--diff', 'show a unified diff of all file changes') .option('--json', 'emit machine-readable JSON') - .action(async (packId: string, opts: { repo: string; input?: string[]; json?: boolean }) => { + .action(async (packId: string, opts: { repo: string; input?: string[]; diff?: boolean; json?: boolean }) => { const inputs = parseInputPairs(opts.input); const plan = await buildPlan(packId, opts.repo, inputs); @@ -333,6 +353,16 @@ export function createActionsCmd(): Command { for (const file of plan.files) { printStatusLine(file.destination, file.status.kind); } + + if (opts.diff) { + const preview = renderPlanPreview(plan); + if (preview.trim()) { + console.log(); + console.log(kleur.bold('Diff preview:')); + console.log(); + colorizeUnifiedDiff(preview); + } + } }); actionsCmd