import express from 'express'; import { execSync, spawn } from 'child_process'; import { readFileSync, writeFileSync } from 'fs'; import { join, dirname } from 'path'; import { tmpdir } from 'os'; import { fileURLToPath } from 'url'; import { parsePatchFiles, DEFAULT_THEMES } from '@pierre/diffs'; import { preloadFileDiff } from '@pierre/diffs/ssr'; const __dirname = dirname(fileURLToPath(import.meta.url)); // --- arg parsing --- const outIdx = process.argv.indexOf('--out'); const outFile = outIdx !== -1 ? process.argv[outIdx + 1] : null; const isStatic = process.argv.includes('--static'); const positional = []; let skipNext = false; for (const arg of process.argv.slice(2)) { if (skipNext) { skipNext = false; continue; } if (arg === '--static') continue; if (arg === '--out') { skipNext = true; continue; } positional.push(arg); } const [projectDir, mainBranch, featureBranch, portArg] = positional; const port = parseInt(portArg, 10) || 3000; if (!projectDir || !mainBranch || !featureBranch) { console.error('Usage: difftool.sh [--static] [--out ]
[port]'); process.exit(1); } // --- git --- function run(cmd) { try { return execSync(cmd, { encoding: 'utf-8', cwd: projectDir }).trim(); } catch { return ''; } } const branchDiff = run(`git diff ${mainBranch} ${featureBranch} --no-color`); const stagedDiff = run('git diff --cached --no-color'); const unstagedDiff = run('git diff --no-color'); const untrackedFiles = run('git ls-files --others --exclude-standard') .split('\n') .filter(Boolean); let untrackedPatch = ''; for (const file of untrackedFiles) { let content; try { content = readFileSync(join(projectDir, file), 'utf-8'); } catch { continue; } const lines = content.split('\n'); const lineCount = content.endsWith('\n') ? lines.length - 1 : lines.length; untrackedPatch += `diff --git a/${file} b/${file}\n`; untrackedPatch += `new file mode 100644\n`; untrackedPatch += `--- /dev/null\n`; untrackedPatch += `+++ b/${file}\n`; untrackedPatch += `@@ -0,0 +1,${lineCount} @@\n`; for (let i = 0; i < lineCount; i++) { untrackedPatch += `+${lines[i]}\n`; } } const patch = [branchDiff, stagedDiff, unstagedDiff, untrackedPatch] .filter(Boolean) .join('\n'); if (!patch.trim()) { console.log('No differences found'); process.exit(0); } const parsedEntries = parsePatchFiles(patch); const files = parsedEntries.flatMap(e => e.files ?? []); if (files.length === 0) { console.log('No file diffs found in patch'); process.exit(0); } // --- SSR diffs --- const ssrOptions = { theme: DEFAULT_THEMES, themeType: 'system', diffStyle: 'unified', useTokenTransformer: true, }; const renderedFiles = []; for (const fileDiff of files) { try { const result = await preloadFileDiff({ fileDiff, options: ssrOptions }); renderedFiles.push({ name: fileDiff.name || 'unknown', prerenderedHTML: result.prerenderedHTML, }); } catch (err) { console.warn(`Failed to SSR-render ${fileDiff.name || 'unknown'}: ${err.message}`); renderedFiles.push({ name: fileDiff.name || 'unknown', prerenderedHTML: null, }); } } // --- helpers --- function escapeHtml(str) { return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); } function fileId(name) { return 'DF-' + name.replace(/[^a-zA-Z0-9_-]/g, '_'); } // --- HTML template --- function pageHtml(opts) { const { mode, sidebarHtml, extraScript } = opts; const fileSections = renderedFiles.map((f) => { const escapedName = escapeHtml(f.name); const id = fileId(f.name); if (f.prerenderedHTML !== null) { return `
${escapedName}
`; } return `
${escapedName}
`; }).join('\n'); const hasLocal = stagedDiff || unstagedDiff || untrackedPatch; const subtitle = hasLocal ? `${escapeHtml(mainBranch)} \u2192 ${escapeHtml(featureBranch)} (includes local changes)` : `${escapeHtml(mainBranch)} \u2192 ${escapeHtml(featureBranch)}`; const title = `Diff: ${escapeHtml(mainBranch)} \u2192 ${escapeHtml(featureBranch)}`; const rawLink = mode === 'server' ? 'View raw patch \u2192' : ''; return ` ${title}

Diff

${subtitle} ${rawLink}
${sidebarHtml}
${fileSections}
${extraScript} `; } // --- static HTML generation (shared by --static and --out) --- function buildStaticHtml() { const sidebarHtml = ` `; const escapedPatch = escapeHtml(patch); const extraScript = ` `; return pageHtml({ mode: 'static', sidebarHtml, extraScript }) .replace('', `
View raw patch
${escapedPatch}
\n`); } // --- mode dispatch --- if (outFile) { // -- out mode: write HTML to specified path, exit -- const html = buildStaticHtml(); writeFileSync(outFile, html, 'utf-8'); console.log(`Written to ${outFile}`); process.exit(0); } if (isStatic) { // -- static mode: generate self-contained HTML file, open in browser, exit -- const html = buildStaticHtml(); const tmpFile = join(tmpdir(), `difftool-${Date.now()}.html`); writeFileSync(tmpFile, html, 'utf-8'); const openCmd = process.platform === 'darwin' ? 'open' : process.platform === 'win32' ? 'start' : 'xdg-open'; console.log(`Opening ${tmpFile}`); try { spawn(openCmd, [tmpFile], { detached: true, stdio: 'ignore' }).unref(); } catch { console.log(`HTML file saved to ${tmpFile}`); } } else { // -- server mode: start Express -- const app = express(); app.use(express.static(join(__dirname, 'public'))); app.get('/patch', (req, res) => { res.type('text/plain').send(patch); }); app.get('/', (req, res) => { const fileDataJson = JSON.stringify(renderedFiles.map(f => ({ name: f.name }))) .replace(/<\/script>/gi, '<\\/script>'); const sidebarHtml = ''; const extraScript = ` `; const html = pageHtml({ mode: 'server', sidebarHtml, extraScript }); res.type('html').send(html); }); app.listen(port, () => { console.log(`Difftool running at http://localhost:${port}`); console.log(`Patch endpoint: http://localhost:${port}/patch`); }); }