From 06fef784842bb555bedb1d27933dfc8d45017335 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Mon, 5 Jan 2026 11:32:16 +0100 Subject: [PATCH 01/11] Add a simple script to generate screenshots This is a script (developed using Claude Opus' assistance) which uses Playwright to generate before/after screenshots. Its functionality is quite basic for now. Signed-off-by: Johannes Schindelin --- script/compare-screenshots.js | 61 +++++++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 script/compare-screenshots.js diff --git a/script/compare-screenshots.js b/script/compare-screenshots.js new file mode 100644 index 0000000000..da2756e08d --- /dev/null +++ b/script/compare-screenshots.js @@ -0,0 +1,61 @@ +#!/usr/bin/env node +// @ts-check + +const usage = `Generate before/after screenshots for two URLs using Playwright. + +Usage: + node script/compare-screenshots.js + +Examples: + node script/compare-screenshots.js https://git-scm.com http://localhost:5000 + node script/compare-screenshots.js https://git-scm.com https://myuser.github.io/git-scm.com/`; + +const { chromium } = require('@playwright/test'); + +async function main() { + const args = process.argv.slice(2); + + if (args.length < 2) { + console.error(usage); + process.exit(1); + } + + const beforeUrl = args[0]; + const afterUrl = args[1]; + + const browser = await chromium.launch(); + + try { + const context = await browser.newContext({ + viewport: { width: 1280, height: 720 }, + ignoreHTTPSErrors: true, + }); + + const page = await context.newPage(); + + // Take "before" screenshot + console.error(`Navigating to before URL: ${beforeUrl}`); + await page.goto(beforeUrl, { waitUntil: 'networkidle' }); + const beforePath = '.before.png'; + await page.screenshot({ path: beforePath, fullPage: true }); + console.error(`Saved: ${beforePath}`); + + // Take "after" screenshot + console.error(`Navigating to after URL: ${afterUrl}`); + await page.goto(afterUrl, { waitUntil: 'networkidle' }); + const afterPath = '.after.png'; + await page.screenshot({ path: afterPath, fullPage: true }); + console.error(`Saved: ${afterPath}`); + + console.error(`\nScreenshots saved:`); + console.error(' - .before.png'); + console.error(' - .after.png'); + } finally { + await browser.close(); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); From 5cdc41b92f09f9c02641024d2103a407d75a3fbd Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Mon, 5 Jan 2026 11:39:49 +0100 Subject: [PATCH 02/11] compare-screenshots: optionally compare dark mode Thanks, Claude Opus! Signed-off-by: Johannes Schindelin --- script/compare-screenshots.js | 41 ++++++++++++++++++++++++++++------- 1 file changed, 33 insertions(+), 8 deletions(-) diff --git a/script/compare-screenshots.js b/script/compare-screenshots.js index da2756e08d..8154eccd56 100644 --- a/script/compare-screenshots.js +++ b/script/compare-screenshots.js @@ -4,35 +4,60 @@ const usage = `Generate before/after screenshots for two URLs using Playwright. Usage: - node script/compare-screenshots.js + node script/compare-screenshots.js [--dark|--light] + +Options: + --dark Emulate dark mode (prefers-color-scheme: dark) + --light Emulate light mode (default) Examples: node script/compare-screenshots.js https://git-scm.com http://localhost:5000 + node script/compare-screenshots.js --dark https://git-scm.com http://localhost:5000 node script/compare-screenshots.js https://git-scm.com https://myuser.github.io/git-scm.com/`; const { chromium } = require('@playwright/test'); async function main() { const args = process.argv.slice(2); + const options = { + viewport: { width: 1280, height: 720 }, + ignoreHTTPSErrors: true, + colorScheme: 'light', + }; + + const urls = args.filter(arg => { + if (!arg.startsWith('--')) return true; - if (args.length < 2) { + if (arg === '--dark') { + options.colorScheme = 'dark'; + } else if (arg === '--light') { + options.colorScheme = 'light'; + } else { + console.error(`Unknown option: ${arg}`); + process.exit(1); + } + return false; + }); + + if (urls.length !== 2) { console.error(usage); process.exit(1); } - const beforeUrl = args[0]; - const afterUrl = args[1]; + const beforeUrl = urls[0]; + const afterUrl = urls[1]; const browser = await chromium.launch(); try { - const context = await browser.newContext({ - viewport: { width: 1280, height: 720 }, - ignoreHTTPSErrors: true, - }); + const context = await browser.newContext(options); const page = await context.newPage(); + if (options.colorScheme === 'dark') { + console.error('Using dark mode (prefers-color-scheme: dark)'); + } + // Take "before" screenshot console.error(`Navigating to before URL: ${beforeUrl}`); await page.goto(beforeUrl, { waitUntil: 'networkidle' }); From d2aa909ce5edefe73a13e7d4d28b5c7abd528a6e Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Mon, 5 Jan 2026 12:38:47 +0100 Subject: [PATCH 03/11] compare-screenshots: refactor snapshotting logic into a helper function Extract the duplicated screenshot logic into a takeScreenshot() function to prepare for adding more screenshot options. Assisted-by: Claude Opus 4.5 Signed-off-by: Johannes Schindelin --- script/compare-screenshots.js | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/script/compare-screenshots.js b/script/compare-screenshots.js index 8154eccd56..72609cfd86 100644 --- a/script/compare-screenshots.js +++ b/script/compare-screenshots.js @@ -58,19 +58,15 @@ async function main() { console.error('Using dark mode (prefers-color-scheme: dark)'); } - // Take "before" screenshot - console.error(`Navigating to before URL: ${beforeUrl}`); - await page.goto(beforeUrl, { waitUntil: 'networkidle' }); - const beforePath = '.before.png'; - await page.screenshot({ path: beforePath, fullPage: true }); - console.error(`Saved: ${beforePath}`); - - // Take "after" screenshot - console.error(`Navigating to after URL: ${afterUrl}`); - await page.goto(afterUrl, { waitUntil: 'networkidle' }); - const afterPath = '.after.png'; - await page.screenshot({ path: afterPath, fullPage: true }); - console.error(`Saved: ${afterPath}`); + async function takeScreenshot(url, outputPath) { + console.error(`Navigating to: ${url}`); + await page.goto(url, { waitUntil: 'networkidle' }); + await page.screenshot({ path: outputPath, fullPage: true }); + console.error(`Saved: ${outputPath}`); + } + + await takeScreenshot(beforeUrl, '.before.png'); + await takeScreenshot(afterUrl, '.after.png'); console.error(`\nScreenshots saved:`); console.error(' - .before.png'); From 405a437f1792e3c31f064439e63a0dd552684f28 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Mon, 5 Jan 2026 15:20:34 +0100 Subject: [PATCH 04/11] compare-screenshots: add --clip option This allows clipping the screenshots to a specified region using the ImageMagick-style geometry format WxH+X+Y. The viewport is automatically expanded to accommodate the clip region, and the output now includes both the clip dimensions and the full page dimensions. Signed-off-by: Johannes Schindelin --- script/compare-screenshots.js | 38 +++++++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/script/compare-screenshots.js b/script/compare-screenshots.js index 72609cfd86..f4cb03bc21 100644 --- a/script/compare-screenshots.js +++ b/script/compare-screenshots.js @@ -4,16 +4,17 @@ const usage = `Generate before/after screenshots for two URLs using Playwright. Usage: - node script/compare-screenshots.js [--dark|--light] + node script/compare-screenshots.js [options] Options: - --dark Emulate dark mode (prefers-color-scheme: dark) - --light Emulate light mode (default) + --dark Emulate dark mode (prefers-color-scheme: dark) + --light Emulate light mode (default) + --clip= Clip screenshots to specified region (e.g., --clip=1280x720+0+0) Examples: node script/compare-screenshots.js https://git-scm.com http://localhost:5000 node script/compare-screenshots.js --dark https://git-scm.com http://localhost:5000 - node script/compare-screenshots.js https://git-scm.com https://myuser.github.io/git-scm.com/`; + node script/compare-screenshots.js --clip=1280x720+0+0 https://git-scm.com http://localhost:5000`; const { chromium } = require('@playwright/test'); @@ -24,6 +25,7 @@ async function main() { ignoreHTTPSErrors: true, colorScheme: 'light', }; + let clip; const urls = args.filter(arg => { if (!arg.startsWith('--')) return true; @@ -32,6 +34,23 @@ async function main() { options.colorScheme = 'dark'; } else if (arg === '--light') { options.colorScheme = 'light'; + } else if (arg.startsWith('--clip=')) { + const match = arg.slice(7).match(/^(\d+)x(\d+)\+(\d+)\+(\d+)$/); + if (!match) { + console.error(`Invalid clip format: ${arg} (expected --clip=WxH+X+Y)`); + process.exit(1); + } + clip = { + width: parseInt(match[1], 10), + height: parseInt(match[2], 10), + x: parseInt(match[3], 10), + y: parseInt(match[4], 10), + }; + // Ensure viewport is large enough to contain the clip region + options.viewport = { + width: Math.max(options.viewport.width, clip.x + clip.width), + height: Math.max(options.viewport.height, clip.y + clip.height), + }; } else { console.error(`Unknown option: ${arg}`); process.exit(1); @@ -61,8 +80,15 @@ async function main() { async function takeScreenshot(url, outputPath) { console.error(`Navigating to: ${url}`); await page.goto(url, { waitUntil: 'networkidle' }); - await page.screenshot({ path: outputPath, fullPage: true }); - console.error(`Saved: ${outputPath}`); + await page.screenshot({ path: outputPath, clip, fullPage: !clip }); + const pageDims = await page.evaluate(() => ({ + width: document.documentElement.scrollWidth, + height: document.documentElement.scrollHeight, + })); + const info = clip + ? `${clip.width}x${clip.height}+${clip.x}+${clip.y} of ${pageDims.width}x${pageDims.height}` + : `${pageDims.width}x${pageDims.height}`; + console.error(`Saved: ${outputPath} (${info})`); } await takeScreenshot(beforeUrl, '.before.png'); From fa8e6bc52291722de203a6c3ab4f041f6db4ab7d Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Tue, 6 Jan 2026 13:52:01 +0100 Subject: [PATCH 05/11] compare-screenshots: support worktree paths as arguments When a path to a git-scm.com worktree is given instead of a URL, the script now automatically builds the site with Hugo and starts a local server to serve it. The server is torn down immediately after taking the screenshot. Due to indentation changes, this commit is best viewed with `-w`. Signed-off-by: Johannes Schindelin --- script/compare-screenshots.js | 85 ++++++++++++++++++++++++++++++----- 1 file changed, 73 insertions(+), 12 deletions(-) diff --git a/script/compare-screenshots.js b/script/compare-screenshots.js index f4cb03bc21..3e522d5cf3 100644 --- a/script/compare-screenshots.js +++ b/script/compare-screenshots.js @@ -6,6 +6,9 @@ const usage = `Generate before/after screenshots for two URLs using Playwright. Usage: node script/compare-screenshots.js [options] +Arguments can be URLs or paths to git-scm.com worktrees. When a worktree +path is given, Hugo is run to build the site and a local server is started. + Options: --dark Emulate dark mode (prefers-color-scheme: dark) --light Emulate light mode (default) @@ -13,10 +16,54 @@ Options: Examples: node script/compare-screenshots.js https://git-scm.com http://localhost:5000 + node script/compare-screenshots.js https://git-scm.com /path/to/worktree node script/compare-screenshots.js --dark https://git-scm.com http://localhost:5000 node script/compare-screenshots.js --clip=1280x720+0+0 https://git-scm.com http://localhost:5000`; const { chromium } = require('@playwright/test'); +const { spawn, execSync } = require('child_process'); +const fs = require('fs'); +const path = require('path'); + +function isWorktree(arg) { + if (arg.startsWith('http://') || arg.startsWith('https://')) return false; + try { + return fs.statSync(path.join(arg, 'hugo.yml')).isFile(); + } catch { + return false; + } +} + +async function startServer(worktreePath, port) { + // Build Hugo site + console.error(`Building Hugo site in ${worktreePath}...`); + execSync('hugo', { cwd: worktreePath, stdio: 'inherit' }); + + // Start serve-public.js + const serverScript = path.join(worktreePath, 'script', 'serve-public.js'); + const server = spawn('node', [serverScript], { + cwd: worktreePath, + env: { ...process.env, PORT: String(port) }, + stdio: ['ignore', 'pipe', 'inherit'], + }); + + // Wait for server to be ready + await new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject(new Error('Server startup timeout')), 30000); + server.stdout.on('data', (data) => { + if (data.toString().includes('Now listening')) { + clearTimeout(timeout); + resolve(); + } + }); + server.on('error', (err) => { + clearTimeout(timeout); + reject(err); + }); + }); + + return server; +} async function main() { const args = process.argv.slice(2); @@ -77,18 +124,32 @@ async function main() { console.error('Using dark mode (prefers-color-scheme: dark)'); } - async function takeScreenshot(url, outputPath) { - console.error(`Navigating to: ${url}`); - await page.goto(url, { waitUntil: 'networkidle' }); - await page.screenshot({ path: outputPath, clip, fullPage: !clip }); - const pageDims = await page.evaluate(() => ({ - width: document.documentElement.scrollWidth, - height: document.documentElement.scrollHeight, - })); - const info = clip - ? `${clip.width}x${clip.height}+${clip.x}+${clip.y} of ${pageDims.width}x${pageDims.height}` - : `${pageDims.width}x${pageDims.height}`; - console.error(`Saved: ${outputPath} (${info})`); + async function takeScreenshot(urlOrWorktree, outputPath) { + let server; + let url = urlOrWorktree; + + if (isWorktree(urlOrWorktree)) { + server = await startServer(urlOrWorktree, 5000); + url = 'http://localhost:5000/'; + } + + try { + console.error(`Navigating to: ${url}`); + await page.goto(url, { waitUntil: 'networkidle' }); + await page.screenshot({ path: outputPath, clip, fullPage: !clip }); + const pageDims = await page.evaluate(() => ({ + width: document.documentElement.scrollWidth, + height: document.documentElement.scrollHeight, + })); + const info = clip + ? `${clip.width}x${clip.height}+${clip.x}+${clip.y} of ${pageDims.width}x${pageDims.height}` + : `${pageDims.width}x${pageDims.height}`; + console.error(`Saved: ${outputPath} (${info})`); + } finally { + if (server) { + server.kill(); + } + } } await takeScreenshot(beforeUrl, '.before.png'); From d5a84b6f023d8104e18eb6c19dd96627385b1641 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Tue, 6 Jan 2026 13:59:52 +0100 Subject: [PATCH 06/11] compare-screenshots: support page paths in worktree arguments Allow specifying a specific page to navigate to when using a worktree by appending a colon and the page path, e.g. /path/to/worktree:/docs/git-config Signed-off-by: Johannes Schindelin --- script/compare-screenshots.js | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/script/compare-screenshots.js b/script/compare-screenshots.js index 3e522d5cf3..3f373d3f21 100644 --- a/script/compare-screenshots.js +++ b/script/compare-screenshots.js @@ -8,6 +8,7 @@ Usage: Arguments can be URLs or paths to git-scm.com worktrees. When a worktree path is given, Hugo is run to build the site and a local server is started. +Use worktree:/path/to/page to navigate to a specific page. Options: --dark Emulate dark mode (prefers-color-scheme: dark) @@ -17,6 +18,7 @@ Options: Examples: node script/compare-screenshots.js https://git-scm.com http://localhost:5000 node script/compare-screenshots.js https://git-scm.com /path/to/worktree + node script/compare-screenshots.js https://git-scm.com/docs/git-config /path/to/worktree:/docs/git-config node script/compare-screenshots.js --dark https://git-scm.com http://localhost:5000 node script/compare-screenshots.js --clip=1280x720+0+0 https://git-scm.com http://localhost:5000`; @@ -25,13 +27,18 @@ const { spawn, execSync } = require('child_process'); const fs = require('fs'); const path = require('path'); -function isWorktree(arg) { +function getWorktreeInfo(arg) { if (arg.startsWith('http://') || arg.startsWith('https://')) return false; + const colonIndex = arg.indexOf(':'); + const worktreePath = colonIndex === -1 ? arg : arg.slice(0, colonIndex); + const pagePath = colonIndex === -1 ? '' : arg.slice(colonIndex + 1).replace(/^\/+/, ''); try { - return fs.statSync(path.join(arg, 'hugo.yml')).isFile(); + if (fs.statSync(path.join(worktreePath, 'hugo.yml')).isFile()) { + return { worktreePath, pagePath }; + } } catch { - return false; } + return false; } async function startServer(worktreePath, port) { @@ -128,9 +135,10 @@ async function main() { let server; let url = urlOrWorktree; - if (isWorktree(urlOrWorktree)) { - server = await startServer(urlOrWorktree, 5000); - url = 'http://localhost:5000/'; + const worktreeInfo = getWorktreeInfo(urlOrWorktree); + if (worktreeInfo) { + server = await startServer(worktreeInfo.worktreePath, 5000); + url = `http://localhost:5000/${worktreeInfo.pagePath}`; } try { From 8d47c54245e6c899ea6d38a5c0eaa4e449d6ece9 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Tue, 6 Jan 2026 14:13:31 +0100 Subject: [PATCH 07/11] compare-screenshots: support checking out a specific commit Allow specifying a commit to checkout before building by appending @commit to the worktree path, e.g. .@HEAD~2 or /path/to/worktree@main. The original branch or detached HEAD state is restored after taking the screenshot. Signed-off-by: Johannes Schindelin --- script/compare-screenshots.js | 56 ++++++++++++++++++++++++++++++++--- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/script/compare-screenshots.js b/script/compare-screenshots.js index 3f373d3f21..d0a6a05b5e 100644 --- a/script/compare-screenshots.js +++ b/script/compare-screenshots.js @@ -8,7 +8,9 @@ Usage: Arguments can be URLs or paths to git-scm.com worktrees. When a worktree path is given, Hugo is run to build the site and a local server is started. +Use worktree@commit to checkout a specific commit before building. Use worktree:/path/to/page to navigate to a specific page. +Both can be combined: worktree@commit:/path/to/page Options: --dark Emulate dark mode (prefers-color-scheme: dark) @@ -18,6 +20,7 @@ Options: Examples: node script/compare-screenshots.js https://git-scm.com http://localhost:5000 node script/compare-screenshots.js https://git-scm.com /path/to/worktree + node script/compare-screenshots.js https://git-scm.com .@HEAD~2 node script/compare-screenshots.js https://git-scm.com/docs/git-config /path/to/worktree:/docs/git-config node script/compare-screenshots.js --dark https://git-scm.com http://localhost:5000 node script/compare-screenshots.js --clip=1280x720+0+0 https://git-scm.com http://localhost:5000`; @@ -27,21 +30,53 @@ const { spawn, execSync } = require('child_process'); const fs = require('fs'); const path = require('path'); +/** + * Parse a worktree argument to extract worktree path, commit, and page path. + * + * Format: worktree[@commit][:/page/path] + * + * Examples: + * . -> { worktreePath: '.', commit: undefined, pagePath: '' } + * .@HEAD~2 -> { worktreePath: '.', commit: 'HEAD~2', pagePath: '' } + * /path/to/worktree:/docs/git -> { worktreePath: '/path/to/worktree', commit: undefined, pagePath: 'docs/git' } + * .@main:/about -> { worktreePath: '.', commit: 'main', pagePath: 'about' } + * + * Returns false if the argument is a URL or not a valid worktree. + */ function getWorktreeInfo(arg) { if (arg.startsWith('http://') || arg.startsWith('https://')) return false; const colonIndex = arg.indexOf(':'); - const worktreePath = colonIndex === -1 ? arg : arg.slice(0, colonIndex); + const beforeColon = colonIndex === -1 ? arg : arg.slice(0, colonIndex); const pagePath = colonIndex === -1 ? '' : arg.slice(colonIndex + 1).replace(/^\/+/, ''); + const atIndex = beforeColon.indexOf('@'); + const worktreePath = atIndex === -1 ? beforeColon : beforeColon.slice(0, atIndex); + const commit = atIndex === -1 ? undefined : beforeColon.slice(atIndex + 1); try { if (fs.statSync(path.join(worktreePath, 'hugo.yml')).isFile()) { - return { worktreePath, pagePath }; + return { worktreePath, commit, pagePath }; } } catch { } return false; } -async function startServer(worktreePath, port) { +async function startServer(worktreePath, port, commit) { + let restoreRef; + let wasDetached = false; + + if (commit) { + // Determine if we're on a branch (symbolic ref) or detached HEAD + try { + restoreRef = execSync('git symbolic-ref --short HEAD', { cwd: worktreePath, encoding: 'utf-8' }).trim(); + } catch { + // Not on a branch, save the commit SHA + restoreRef = execSync('git rev-parse HEAD', { cwd: worktreePath, encoding: 'utf-8' }).trim(); + wasDetached = true; + } + console.log(`Switching to ${commit} in ${worktreePath}...`); + execSync(`git switch -d ${commit}`, { cwd: worktreePath, stdio: 'inherit' }); + } + // Build Hugo site console.error(`Building Hugo site in ${worktreePath}...`); execSync('hugo', { cwd: worktreePath, stdio: 'inherit' }); @@ -54,6 +89,18 @@ async function startServer(worktreePath, port) { stdio: ['ignore', 'pipe', 'inherit'], }); + // Attach restore function to server + server.restore = () => { + if (restoreRef) { + console.log(`Restoring ${worktreePath} to ${restoreRef}...`); + if (wasDetached) { + execSync(`git switch -d ${restoreRef}`, { cwd: worktreePath, stdio: 'inherit' }); + } else { + execSync(`git switch ${restoreRef}`, { cwd: worktreePath, stdio: 'inherit' }); + } + } + }; + // Wait for server to be ready await new Promise((resolve, reject) => { const timeout = setTimeout(() => reject(new Error('Server startup timeout')), 30000); @@ -137,7 +184,7 @@ async function main() { const worktreeInfo = getWorktreeInfo(urlOrWorktree); if (worktreeInfo) { - server = await startServer(worktreeInfo.worktreePath, 5000); + server = await startServer(worktreeInfo.worktreePath, 5000, worktreeInfo.commit); url = `http://localhost:5000/${worktreeInfo.pagePath}`; } @@ -156,6 +203,7 @@ async function main() { } finally { if (server) { server.kill(); + server.restore(); } } } From ebebddb3c2c798d9b9cf3a980eb3297a226d916c Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Tue, 6 Jan 2026 14:17:54 +0100 Subject: [PATCH 08/11] compare-screenshots: allow omitting dot for current directory As a convenience, @HEAD~2 is now equivalent to .@HEAD~2, meaning the current directory will be used as the worktree. Signed-off-by: Johannes Schindelin --- script/compare-screenshots.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/script/compare-screenshots.js b/script/compare-screenshots.js index d0a6a05b5e..4136bb2423 100644 --- a/script/compare-screenshots.js +++ b/script/compare-screenshots.js @@ -20,7 +20,7 @@ Options: Examples: node script/compare-screenshots.js https://git-scm.com http://localhost:5000 node script/compare-screenshots.js https://git-scm.com /path/to/worktree - node script/compare-screenshots.js https://git-scm.com .@HEAD~2 + node script/compare-screenshots.js https://git-scm.com @HEAD~2 node script/compare-screenshots.js https://git-scm.com/docs/git-config /path/to/worktree:/docs/git-config node script/compare-screenshots.js --dark https://git-scm.com http://localhost:5000 node script/compare-screenshots.js --clip=1280x720+0+0 https://git-scm.com http://localhost:5000`; @@ -33,10 +33,11 @@ const path = require('path'); /** * Parse a worktree argument to extract worktree path, commit, and page path. * - * Format: worktree[@commit][:/page/path] + * Format: [worktree][@commit][:/page/path] * * Examples: * . -> { worktreePath: '.', commit: undefined, pagePath: '' } + * @HEAD~2 -> { worktreePath: '.', commit: 'HEAD~2', pagePath: '' } * .@HEAD~2 -> { worktreePath: '.', commit: 'HEAD~2', pagePath: '' } * /path/to/worktree:/docs/git -> { worktreePath: '/path/to/worktree', commit: undefined, pagePath: 'docs/git' } * .@main:/about -> { worktreePath: '.', commit: 'main', pagePath: 'about' } @@ -45,6 +46,8 @@ const path = require('path'); */ function getWorktreeInfo(arg) { if (arg.startsWith('http://') || arg.startsWith('https://')) return false; + // Allow @commit as shorthand for .@commit (current directory) + if (arg.startsWith('@')) arg = '.' + arg; const colonIndex = arg.indexOf(':'); const beforeColon = colonIndex === -1 ? arg : arg.slice(0, colonIndex); const pagePath = colonIndex === -1 ? '' : arg.slice(colonIndex + 1).replace(/^\/+/, ''); From 44cfaee3cccad9c8beacda3e1609ae3dc4a9ae8f Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Tue, 6 Jan 2026 14:19:06 +0100 Subject: [PATCH 09/11] compare-screenshots: allow @{u} shorthand for upstream Treat @{u} as equivalent to @@{u} since refs cannot start with a curly brace. This allows conveniently comparing the upstream branch against the current worktree. Signed-off-by: Johannes Schindelin --- script/compare-screenshots.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/script/compare-screenshots.js b/script/compare-screenshots.js index 4136bb2423..e3c9cb1e85 100644 --- a/script/compare-screenshots.js +++ b/script/compare-screenshots.js @@ -11,6 +11,8 @@ path is given, Hugo is run to build the site and a local server is started. Use worktree@commit to checkout a specific commit before building. Use worktree:/path/to/page to navigate to a specific page. Both can be combined: worktree@commit:/path/to/page +As a convenience, @commit implies the current directory, and @{u} is +treated as @@{u} since refs cannot start with a curly brace. Options: --dark Emulate dark mode (prefers-color-scheme: dark) @@ -21,6 +23,7 @@ Examples: node script/compare-screenshots.js https://git-scm.com http://localhost:5000 node script/compare-screenshots.js https://git-scm.com /path/to/worktree node script/compare-screenshots.js https://git-scm.com @HEAD~2 + node script/compare-screenshots.js @{u} . node script/compare-screenshots.js https://git-scm.com/docs/git-config /path/to/worktree:/docs/git-config node script/compare-screenshots.js --dark https://git-scm.com http://localhost:5000 node script/compare-screenshots.js --clip=1280x720+0+0 https://git-scm.com http://localhost:5000`; @@ -39,6 +42,7 @@ const path = require('path'); * . -> { worktreePath: '.', commit: undefined, pagePath: '' } * @HEAD~2 -> { worktreePath: '.', commit: 'HEAD~2', pagePath: '' } * .@HEAD~2 -> { worktreePath: '.', commit: 'HEAD~2', pagePath: '' } + * @{u} -> { worktreePath: '.', commit: '@{u}', pagePath: '' } * /path/to/worktree:/docs/git -> { worktreePath: '/path/to/worktree', commit: undefined, pagePath: 'docs/git' } * .@main:/about -> { worktreePath: '.', commit: 'main', pagePath: 'about' } * @@ -53,7 +57,9 @@ function getWorktreeInfo(arg) { const pagePath = colonIndex === -1 ? '' : arg.slice(colonIndex + 1).replace(/^\/+/, ''); const atIndex = beforeColon.indexOf('@'); const worktreePath = atIndex === -1 ? beforeColon : beforeColon.slice(0, atIndex); - const commit = atIndex === -1 ? undefined : beforeColon.slice(atIndex + 1); + let commit = atIndex === -1 ? undefined : beforeColon.slice(atIndex + 1); + // Allow @{u} as shorthand for @@{u} since refs can't start with { + if (commit && commit.startsWith('{')) commit = '@' + commit; try { if (fs.statSync(path.join(worktreePath, 'hugo.yml')).isFile()) { return { worktreePath, commit, pagePath }; From ccd9195771cac7ce5a51f38b949410ad834019aa Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Tue, 6 Jan 2026 14:28:41 +0100 Subject: [PATCH 10/11] compare-screenshots: inherit page path from first argument When the first argument specifies a page path (either via URL or worktree:/path syntax), the second argument will automatically use the same page path if none is specified. Signed-off-by: Johannes Schindelin --- script/compare-screenshots.js | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/script/compare-screenshots.js b/script/compare-screenshots.js index e3c9cb1e85..c21f49f653 100644 --- a/script/compare-screenshots.js +++ b/script/compare-screenshots.js @@ -33,6 +33,8 @@ const { spawn, execSync } = require('child_process'); const fs = require('fs'); const path = require('path'); +let lastPagePath; + /** * Parse a worktree argument to extract worktree path, commit, and page path. * @@ -46,23 +48,38 @@ const path = require('path'); * /path/to/worktree:/docs/git -> { worktreePath: '/path/to/worktree', commit: undefined, pagePath: 'docs/git' } * .@main:/about -> { worktreePath: '.', commit: 'main', pagePath: 'about' } * + * If no page path is specified, inherits the page path from the previous call. + * * Returns false if the argument is a URL or not a valid worktree. */ function getWorktreeInfo(arg) { - if (arg.startsWith('http://') || arg.startsWith('https://')) return false; + if (arg.startsWith('http://') || arg.startsWith('https://')) { + // Extract path from URL for inheritance + try { + lastPagePath = new URL(arg).pathname.replace(/^\/+/, ''); + } catch { + } + return false; + } // Allow @commit as shorthand for .@commit (current directory) if (arg.startsWith('@')) arg = '.' + arg; const colonIndex = arg.indexOf(':'); const beforeColon = colonIndex === -1 ? arg : arg.slice(0, colonIndex); - const pagePath = colonIndex === -1 ? '' : arg.slice(colonIndex + 1).replace(/^\/+/, ''); + let pagePath = colonIndex === -1 ? undefined : arg.slice(colonIndex + 1).replace(/^\/+/, ''); const atIndex = beforeColon.indexOf('@'); const worktreePath = atIndex === -1 ? beforeColon : beforeColon.slice(0, atIndex); let commit = atIndex === -1 ? undefined : beforeColon.slice(atIndex + 1); // Allow @{u} as shorthand for @@{u} since refs can't start with { if (commit && commit.startsWith('{')) commit = '@' + commit; + // Inherit page path from previous call if not specified + if (pagePath === undefined && lastPagePath !== undefined) { + pagePath = lastPagePath; + } else if (pagePath !== undefined) { + lastPagePath = pagePath; + } try { if (fs.statSync(path.join(worktreePath, 'hugo.yml')).isFile()) { - return { worktreePath, commit, pagePath }; + return { worktreePath, commit, pagePath: pagePath || '' }; } } catch { } From 9745e6a7de85c14bb984ca634b9db9bb2f2cc313 Mon Sep 17 00:00:00 2001 From: Johannes Schindelin Date: Tue, 6 Jan 2026 14:34:26 +0100 Subject: [PATCH 11/11] compare-screenshots: output PR comment template After taking screenshots, output an HTML table template that can be copied into a PR comment. The absolute paths serve as placeholders that can be selected and replaced by uploading the images. The paths are placed on their own lines so that users can easily triple-click to select the entire path, copy it, and then paste the uploaded image URL in its place. Signed-off-by: Johannes Schindelin --- script/compare-screenshots.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/script/compare-screenshots.js b/script/compare-screenshots.js index c21f49f653..83a2092355 100644 --- a/script/compare-screenshots.js +++ b/script/compare-screenshots.js @@ -240,6 +240,25 @@ async function main() { console.error(`\nScreenshots saved:`); console.error(' - .before.png'); console.error(' - .after.png'); + + console.error(`\nPR comment template (copy paths, then replace by uploading images):\n`); + console.log(``); + console.log(``); + console.log(``); + console.log(``); + console.log(``); + console.log(``); + console.log(``); + console.log(`
BeforeAfter
`); + console.log(path.resolve('.before.png')); + console.log(``); + console.log(path.resolve('.after.png')); + console.log(`
`); } finally { await browser.close(); }