@@ -4328,6 +4365,7 @@ function SiteRow({ sitePath, initialized, createdAt, label, onInitialized, onSit
style={{ justifyContent:'center' }}
>Link ticket
{ticketError ?
{ticketError}
: null}
+ {switchProgressLine}
>
)}
diff --git a/src/switch-progress.cjs b/src/switch-progress.cjs
new file mode 100644
index 0000000..a4b238e
--- /dev/null
+++ b/src/switch-progress.cjs
@@ -0,0 +1,183 @@
+'use strict';
+
+/**
+ * What a ticket switch is doing while it does it (issue #173).
+ *
+ * Switching tickets is a worktree scan and a full checkout — seconds of silence
+ * on a real `wordpress-develop`, during which the window looks hung. The
+ * natural responses are clicking again or force-quitting, and force-quitting
+ * part-way through a checkout leaves the half-swapped worktree that
+ * `withSwitchMarker` exists to recover from. This module is the vocabulary for
+ * saying what is happening instead.
+ *
+ * Two jobs, kept together because they are two ends of one contract: the
+ * throttle that decides which events are worth sending, and the sentence the
+ * panel shows for one. Pure and dependency-free, so `node --test` drives it
+ * directly while both the main process and the renderer bundle require it.
+ */
+
+// A checkout of ~1500 files calls back about 4400 times in 143ms. At 100ms
+// between frames a switch produces a couple of dozen sends — enough for the
+// line to move, few enough that the IPC channel stays a channel.
+const DEFAULT_INTERVAL_MS = 100;
+
+// isomorphic-git's own phase strings, which belong to it and not to us. Pinned
+// here so a version bump breaks one lookup rather than leaking a foreign
+// vocabulary into the UI.
+const CHECKOUT_PHASES = {
+ 'Analyzing workdir': 'analyze',
+ 'Updating workdir': 'apply'
+};
+
+/**
+ * Coalesces a flood of progress events down to what is worth sending.
+ *
+ * A stage change always goes out immediately — the stage *is* the sentence on
+ * screen, and making it wait for the interval is what produces a line that
+ * describes the previous thing. Within a stage, events are held to one per
+ * interval.
+ *
+ * The rule that matters most is the one that keeps a line from stopping at 87%
+ * and jumping to done: whatever was suppressed last is flushed when the stage
+ * changes, and again by `flush()` at the end. A progress line that freezes is
+ * read as a hang, which is the exact failure this is meant to prevent.
+ *
+ * `emit` is deliberately synchronous and returns nothing: isomorphic-git awaits
+ * whatever `onProgress` returns, so a promise here would add a microtask
+ * between every one of those 4400 events.
+ *
+ * @param {Object} options
+ * @param {Function} options.onEmit Called with each payload that survives.
+ * @param {number} [options.intervalMs] Minimum gap within a stage. `Infinity`
+ * reduces a switch to one event per
+ * stage, for an append-only log.
+ * @param {Function} [options.now] Clock, injected so tests need no timers.
+ * @return {{emit: Function, flush: Function}} The emitter and its final flush.
+ */
+function createProgressThrottle({ onEmit, intervalMs = DEFAULT_INTERVAL_MS, now = Date.now } = {}) {
+ let lastStage = null;
+ let lastAt = -Infinity;
+ let pending = null;
+
+ const send = (payload) => {
+ pending = null;
+ lastStage = payload.stage;
+ lastAt = now();
+ if (onEmit) onEmit(payload);
+ };
+
+ return {
+ emit(payload) {
+ if (!payload) return;
+ if (payload.stage !== lastStage) {
+ if (pending) send(pending);
+ send(payload);
+ return;
+ }
+ if (now() - lastAt >= intervalMs) {
+ send(payload);
+ return;
+ }
+ pending = payload;
+ },
+ flush() {
+ if (pending) send(pending);
+ }
+ };
+}
+
+/**
+ * One of isomorphic-git's checkout progress events, in this app's vocabulary.
+ *
+ * `Analyzing workdir` reports a running count with no total — there is no
+ * honest percentage for that half, and the sentence for it says so rather than
+ * inventing one.
+ *
+ * @param {{phase: string, loaded: number, total: number}} event
+ * @return {{stage: string, loaded: number, total: ?number}} Our shape.
+ */
+function mapCheckoutPhase(event = {}) {
+ return {
+ stage: CHECKOUT_PHASES[event.phase] || 'apply',
+ loaded: event.loaded,
+ total: event.total
+ };
+}
+
+/**
+ * A ticket number from a branch ref, or null for trunk and anything else.
+ *
+ * @param {?string} ref
+ */
+function ticketOf(ref) {
+ const match = /^ticket\/(\d+)$/.exec(String(ref || ''));
+ return match ? match[1] : null;
+}
+
+/**
+ * What the panel says for one progress event.
+ *
+ * The parking sentences name the ticket being left, which is the point of the
+ * whole feature: they are what stops someone force-quitting during the seconds
+ * when their edits are not committed anywhere yet. Branch refs never reach the
+ * screen — a contributor knows `#59234`, not `ticket/59234`.
+ *
+ * @param {Object} progress
+ * @param {string} progress.stage
+ * @param {number} [progress.loaded]
+ * @param {number} [progress.total]
+ * @param {?string} [progress.from] Branch being left.
+ * @param {?string} [progress.to] Branch being entered.
+ * @return {string} A sentence, never empty, for any stage including a new one.
+ */
+function describeSwitchProgress({ stage, loaded, total, from, to } = {}) {
+ const saving = () => {
+ const leaving = ticketOf(from);
+ return leaving ? `your work on #${leaving}` : 'your work';
+ };
+ const entering = () => {
+ const id = ticketOf(to);
+ return id ? ` for #${id}` : '';
+ };
+
+ switch (stage) {
+ case 'scan':
+ return `Saving ${saving()}…`;
+ case 'stage':
+ return `Saving ${saving()}… ${withCount(loaded, total)}`;
+ case 'commit':
+ return `Saving ${saving()}…`;
+ case 'analyze':
+ return 'Checking which files change…';
+ case 'apply':
+ return `Swapping files${entering()}… ${withCount(loaded, total)}`;
+ case 'done':
+ return ticketOf(to) ? `Ready to work on #${ticketOf(to)}` : 'Ready';
+ default:
+ // A stage this version does not know — a newer isomorphic-git, or a
+ // caller ahead of this module. Saying something true and vague beats
+ // rendering nothing where a sentence was.
+ return 'Working…';
+ }
+}
+
+/**
+ * The trailing "42%" or "1,200 files", or nothing when neither is knowable.
+ *
+ * @param {?number} loaded
+ * @param {?number} total
+ */
+function withCount(loaded, total) {
+ if (Number.isFinite(total) && total > 0 && Number.isFinite(loaded)) {
+ return `${Math.min(100, Math.round((loaded / total) * 100))}%`;
+ }
+ if (Number.isFinite(loaded)) return `${loaded.toLocaleString()} files`;
+ return '';
+}
+
+module.exports = {
+ DEFAULT_INTERVAL_MS,
+ createProgressThrottle,
+ mapCheckoutPhase,
+ describeSwitchProgress
+};
diff --git a/src/ticket-branches.js b/src/ticket-branches.js
index cd066f9..7d01ccf 100644
--- a/src/ticket-branches.js
+++ b/src/ticket-branches.js
@@ -29,6 +29,7 @@
const fs = require('fs');
const git = require('isomorphic-git');
const { ensureAutocrlf } = require('./trunk-update.js');
+const { mapCheckoutPhase } = require('./switch-progress.cjs');
/** The pristine snapshot branch. Never committed to, never deleted. */
const TRUNK = 'trunk';
@@ -100,24 +101,28 @@ async function listTicketBranches(dir) {
* `statusMatrix` excludes gitignored paths by default, so `node_modules` and
* `build/` never enter the index no matter how large they have grown.
*
- * @param {string} dir
- * @param {Array} [matrix] a statusMatrix the caller already computed
+ * @param {string} dir
+ * @param {Array} [matrix] a statusMatrix the caller already computed
+ * @param {Function} [onProgress] told how far the staging has got (#173)
*/
-async function stageWorktree(dir, matrix = null) {
+async function stageWorktree(dir, matrix = null, onProgress = null) {
// The caller has usually just scanned the worktree to decide whether there
// was anything to park. On wordpress-develop that scan hashes thousands of
// files on the main process's event loop, so it is passed in and reused
// rather than repeated.
if (!matrix) matrix = await git.statusMatrix({ fs, dir });
+ // The one stage of a park with a real total, and it comes free: the rows
+ // worth staging are known before any of them is written.
+ const pending = matrix.filter(([, head, workdir, stage]) => !(head === workdir && workdir === stage));
let staged = 0;
- for (const [filepath, head, workdir, stage] of matrix) {
- if (head === workdir && workdir === stage) continue;
+ for (const [filepath, , workdir] of pending) {
if (workdir === 0) {
// Gone from disk: drop it from the index, and from the next commit.
try { await git.remove({ fs, dir, filepath }); staged += 1; } catch {}
} else {
try { await git.add({ fs, dir, filepath }); staged += 1; } catch {}
}
+ if (onProgress) onProgress({ stage: 'stage', loaded: staged, total: pending.length });
}
return staged;
}
@@ -157,12 +162,13 @@ async function scanWorktree(dir, ref = 'HEAD') {
* work that starts there is carried into a branch by `startTicketBranch`, not
* committed where every other branch's diff base lives.
*
- * @param {string} dir
- * @param {Object} root0
- * @param {string} [root0.baseOid] branch point; resolved from `trunk` when absent
- * @param {Object} [root0.author]
+ * @param {string} dir
+ * @param {Object} root0
+ * @param {string} [root0.baseOid] branch point; resolved from `trunk` when absent
+ * @param {Object} [root0.author]
+ * @param {Function} [root0.onProgress] told which stage of the park is running (#173)
*/
-async function parkCurrentWork(dir, { baseOid, author = WIP_AUTHOR } = {}) {
+async function parkCurrentWork(dir, { baseOid, author = WIP_AUTHOR, onProgress = null } = {}) {
await ensureAutocrlf(dir);
const branch = await currentBranchName(dir);
if (!branch || branch === TRUNK) {
@@ -171,6 +177,11 @@ async function parkCurrentWork(dir, { baseOid, author = WIP_AUTHOR } = {}) {
throw error;
}
+ // Announced before the scan rather than after it: `statusMatrix` reports
+ // nothing while it runs and is about a third of a switch, so this is the
+ // stretch that would otherwise be silent — and the stretch during which the
+ // contributor's edits are not committed anywhere yet.
+ if (onProgress) onProgress({ stage: 'scan', from: branch });
const { matrix, changed } = await scanWorktree(dir);
if (!changed) return { parked: false, branch, oid: null };
@@ -178,7 +189,11 @@ async function parkCurrentWork(dir, { baseOid, author = WIP_AUTHOR } = {}) {
// wiped by hand, a site adopted from disk) still parks against something
// sane instead of throwing.
const parent = baseOid || await git.resolveRef({ fs, dir, ref: TRUNK });
- await stageWorktree(dir, matrix);
+ // `from` added here rather than inside the loop: staging is the longest
+ // stretch of a park, and without it the sentence loses the ticket number for
+ // exactly the seconds it most needs to name it.
+ await stageWorktree(dir, matrix, onProgress && ((p) => onProgress({ from: branch, ...p })));
+ if (onProgress) onProgress({ stage: 'commit', from: branch });
const oid = await git.commit({ fs, dir, message: WIP_MESSAGE, author, parent: [parent] });
return { parked: true, branch, oid };
}
@@ -220,13 +235,23 @@ async function startTicketBranch(dir, ticketId) {
* honest options — start a ticket for the work, or discard it. Silently
* destroying edits is the one outcome this feature must never produce.
*
- * @param {string} dir
- * @param {string} ref
- * @param {Object} [root0]
- * @param {string} [root0.baseOid] branch point of the branch being left
- * @param {Object} [root0.author]
+ * Progress (#173) is reported through `onProgress` in this module's own
+ * vocabulary — `scan`, `stage`, `commit`, then the checkout's own phases mapped
+ * by switch-progress.cjs. The scans are announced before they start, because
+ * `statusMatrix` says nothing while it runs and is roughly a third of a switch.
+ *
+ * `deleteTicketBranch` checks out too and is deliberately left silent: it runs
+ * under a different busy flag in the panel, so covering it would mean a second
+ * progress surface for a rarely-used destructive action.
+ *
+ * @param {string} dir
+ * @param {string} ref
+ * @param {Object} [root0]
+ * @param {string} [root0.baseOid] branch point of the branch being left
+ * @param {Object} [root0.author]
+ * @param {Function} [root0.onProgress] told which stage is running (#173)
*/
-async function switchToBranch(dir, ref, { baseOid, author = WIP_AUTHOR } = {}) {
+async function switchToBranch(dir, ref, { baseOid, author = WIP_AUTHOR, onProgress = null } = {}) {
await ensureAutocrlf(dir);
const from = await currentBranchName(dir);
if (from === ref) return { switched: false, from, to: ref, parked: false };
@@ -238,15 +263,23 @@ async function switchToBranch(dir, ref, { baseOid, author = WIP_AUTHOR } = {}) {
throw error;
}
+ // Every payload carries where the switch is going, so the panel can name the
+ // destination without tracking it separately; the park stages add where it
+ // came from, which is the ticket whose work is being saved.
+ const report = onProgress ? (p) => onProgress({ to: ref, ...p }) : null;
+
let parked = false;
if (from === TRUNK) {
+ // A full scan that usually ends in "nothing to do" and occasionally in a
+ // refusal — silent either way without this.
+ if (report) report({ stage: 'scan', from });
if (await hasChangesAgainst(dir)) {
const error = new Error('Uncommitted work on trunk would be lost by switching');
error.code = 'dirty-trunk';
throw error;
}
} else if (from) {
- ({ parked } = await parkCurrentWork(dir, { baseOid, author }));
+ ({ parked } = await parkCurrentWork(dir, { baseOid, author, onProgress: report }));
}
// Tagged with the stage it died in, the same contract updateToLatestTrunk
@@ -260,7 +293,17 @@ async function switchToBranch(dir, ref, { baseOid, author = WIP_AUTHOR } = {}) {
// parking rewrites rather than appends, put the real work out of reach. The
// caller has to record that and refuse to park until it is reconciled.
try {
- await git.checkout({ fs, dir, ref, force: true });
+ // No `nonBlocking`/`batchSize`: measured, the progress events already
+ // arrive spread across the whole checkout without them, and yielding to
+ // the event loop between batches would only widen the window in which the
+ // worktree is half-swapped — the state described above.
+ await git.checkout({
+ fs,
+ dir,
+ ref,
+ force: true,
+ ...(report ? { onProgress: (p) => report(mapCheckoutPhase(p)) } : {})
+ });
} catch (e) {
if (e && typeof e === 'object') {
e.stage = 'checkout';
@@ -269,6 +312,7 @@ async function switchToBranch(dir, ref, { baseOid, author = WIP_AUTHOR } = {}) {
}
throw e;
}
+ if (report) report({ stage: 'done', from });
return { switched: true, from, to: ref, parked };
}
diff --git a/test/ipc-wiring.test.cjs b/test/ipc-wiring.test.cjs
index 7a1c511..e1b8671 100644
--- a/test/ipc-wiring.test.cjs
+++ b/test/ipc-wiring.test.cjs
@@ -1915,6 +1915,173 @@ test('branches:list reports the branches on disk with their stored context', asy
assert.equal(result.branches[1].baseOid, null, 'a branch the registry has never seen still lists');
});
+// The same wait, for the trunk update's own :done channel — and on the clock
+// for the same reason, since a park reads the worktree before anything else.
+async function updateDone(event, updateId) {
+ return waitForDone(event, 'git:update-trunk:done', 'updateId', updateId);
+}
+
+// --- switch progress -> src/switch-progress.cjs (#173) ---------------------
+
+// The channel name, in one place. It is a send-only channel, so the
+// classification guard below cannot see it and a rename would silently
+// unsubscribe the panel instead of failing anything.
+const SWITCH_PROGRESS_CHANNEL = 'switch:progress';
+
+// A switch is a worktree scan and a full checkout — seconds during which the
+// window said nothing and looked hung. The handler still returns its result the
+// way it always did; the progress rides alongside, so nothing about the call
+// shape changed.
+test('sites:set-ticket streams switch progress for the site it is switching (issue #173)', async () => {
+ const switchToBranch = spy(async (dir, ref, options) => {
+ options.onProgress({ stage: 'scan', from: 'ticket/59234', to: ref });
+ options.onProgress({ stage: 'done', from: 'ticket/59234', to: ref });
+ return { switched: true, parked: true };
+ });
+ const currentBranchName = spy(async () => 'ticket/59234');
+ const listTicketBranches = spy(async () => ['ticket/61002']);
+ const settings = fakeSettingsStore({
+ sites: ['/sites/wp'],
+ siteMeta: { '/sites/wp': { branches: { 'ticket/59234': { baseOid: 'abc' } }, currentBranch: 'ticket/59234' } }
+ });
+ const main = loadMain({
+ stubs: { ...silentLogging(), ...settings.stubs, './ticket-branches': { switchToBranch, currentBranchName, listTicketBranches } }
+ });
+
+ const event = createIpcEvent();
+ const result = await main.invokeWith('sites:set-ticket', event, '/sites/wp', '61002');
+
+ assert.equal(result.ok, true, 'progress is additive — the answer is unchanged');
+ const progress = event.sent.filter((m) => m.channel === SWITCH_PROGRESS_CHANNEL);
+ assert.deepEqual(progress.map((m) => m.payload.stage), ['scan', 'done']);
+ assert.equal(
+ progress.every((m) => m.payload.sitePath === '/sites/wp'),
+ true,
+ 'the renderer keeps one subscription for every site, so each frame has to say which one it is'
+ );
+});
+
+// A multi-second checkout easily outlives the window that asked for it. The
+// switch itself completed; reporting that it failed because nobody was left to
+// tell would be a lie with a mid-switch marker attached.
+test('a window closed mid-switch does not turn a finished switch into a failure (issue #173)', async () => {
+ const switchToBranch = spy(async (dir, ref, options) => {
+ options.onProgress({ stage: 'scan', to: ref });
+ return { switched: true, parked: false };
+ });
+ const currentBranchName = spy(async () => 'ticket/59234');
+ const settings = fakeSettingsStore({
+ sites: ['/sites/wp'],
+ siteMeta: { '/sites/wp': { branches: { 'ticket/59234': { baseOid: 'abc' } }, currentBranch: 'ticket/59234' } }
+ });
+ const main = loadMain({
+ stubs: { ...silentLogging(), ...settings.stubs, './ticket-branches': { switchToBranch, currentBranchName } }
+ });
+
+ const event = createIpcEvent();
+ event.sender.send = () => { throw new Error('Object has been destroyed'); };
+
+ const result = await main.invokeWith('sites:set-ticket', event, '/sites/wp', '');
+
+ assert.equal(result.ok, true);
+ assert.equal(result.ticket, null);
+});
+
+// A switch that dies mid-checkout is exactly when the last frame it reached is
+// worth having — and it is the case where nothing calls flush unless the flush
+// is in a finally.
+test('a switch that fails still delivers the last frame it reached (issue #173)', async () => {
+ const switchToBranch = spy(async (dir, ref, options) => {
+ options.onProgress({ stage: 'apply', loaded: 1, total: 900, to: ref });
+ // Suppressed by the throttle: same stage, well inside the interval.
+ options.onProgress({ stage: 'apply', loaded: 400, total: 900, to: ref });
+ const e = new Error('EPERM');
+ e.stage = 'checkout';
+ throw e;
+ });
+ const currentBranchName = spy(async () => 'ticket/59234');
+ const settings = fakeSettingsStore({
+ sites: ['/sites/wp'],
+ siteMeta: { '/sites/wp': { branches: { 'ticket/59234': { baseOid: 'abc' } }, currentBranch: 'ticket/59234' } }
+ });
+ const main = loadMain({
+ stubs: { ...silentLogging(), ...settings.stubs, './ticket-branches': { switchToBranch, currentBranchName } }
+ });
+
+ const event = createIpcEvent();
+ const result = await main.invokeWith('branches:switch', event, '/sites/wp', 'ticket/61002');
+
+ assert.equal(result.ok, false, 'the failure is still a failure');
+ const progress = event.sent.filter((m) => m.channel === SWITCH_PROGRESS_CHANNEL);
+ assert.deepEqual(
+ progress.map((m) => m.payload.loaded),
+ [1, 400],
+ 'the frame held back by the throttle has to arrive, or the line freezes part-way'
+ );
+});
+
+// No renderer calls this door yet. It streams anyway: the day the switcher is
+// wired to it, silence would arrive with nothing failing — the guard below
+// cannot see a send-only channel.
+test('branches:switch streams the same progress as sites:set-ticket (issue #173)', async () => {
+ const switchToBranch = spy(async (dir, ref, options) => {
+ options.onProgress({ stage: 'apply', loaded: 1, total: 2, to: ref });
+ return { switched: true, parked: false };
+ });
+ const currentBranchName = spy(async () => 'ticket/59234');
+ const settings = fakeSettingsStore({
+ sites: ['/sites/wp'],
+ siteMeta: { '/sites/wp': { branches: { 'ticket/59234': { baseOid: 'abc' } }, currentBranch: 'ticket/59234' } }
+ });
+ const main = loadMain({
+ stubs: { ...silentLogging(), ...settings.stubs, './ticket-branches': { switchToBranch, currentBranchName } }
+ });
+
+ const event = createIpcEvent();
+ await main.invokeWith('branches:switch', event, '/sites/wp', 'ticket/61002');
+
+ const progress = event.sent.filter((m) => m.channel === SWITCH_PROGRESS_CHANNEL);
+ assert.deepEqual(progress.map((m) => m.payload.stage), ['apply']);
+ assert.equal(progress[0].payload.sitePath, '/sites/wp');
+});
+
+// The trunk update parks and returns, and it already owns a log stream the
+// renderer is watching. Sending its switch stages to the panel's channel as
+// well would give one operation two progress surfaces, which is how the two end
+// up disagreeing.
+test('the trunk update reports its switches in its own log, not on the switch channel (issue #173)', async () => {
+ const switchToBranch = spy(async (dir, ref, options) => {
+ if (options && options.onProgress) options.onProgress({ stage: 'scan', from: 'ticket/59234', to: ref });
+ return { switched: true, parked: true };
+ });
+ const currentBranchName = spy(async () => 'ticket/59234');
+ const updateToLatestTrunk = spy(async () => { throw new Error('stop after the park'); });
+ const settings = fakeSettingsStore({
+ sites: ['/sites/wp'],
+ siteMeta: { '/sites/wp': { branches: { 'ticket/59234': { baseOid: 'abc' } }, currentBranch: 'ticket/59234' } }
+ });
+ const main = loadMain({
+ stubs: {
+ ...silentLogging(),
+ ...settings.stubs,
+ './ticket-branches': { switchToBranch, currentBranchName },
+ './trunk-update': { updateToLatestTrunk, ensureAutocrlf: async () => {}, readTrunkInfo: async () => ({}) }
+ }
+ });
+
+ const event = createIpcEvent();
+ const { updateId } = await main.invokeWith('git:update-trunk', event, '/sites/wp');
+ await updateDone(event, updateId);
+
+ const logs = event.sent.filter((m) => m.channel === 'git:update-trunk:log').map((m) => m.payload.data).join('');
+ assert.match(logs, /Saving your work on #59234/, 'the stage reaches the terminal it belongs to');
+ assert.deepEqual(
+ event.sent.filter((m) => m.channel === SWITCH_PROGRESS_CHANNEL),
+ [],
+ 'and not the panel channel, which would be a second surface for one operation'
+ );
+});
+
test('branches:switch delegates to ticket-branches and records the new active branch', async () => {
const switchToBranch = spy(async () => ({ switched: true, from: 'ticket/59234', to: 'ticket/61002', parked: true }));
const currentBranchName = spy(async () => 'ticket/59234');
diff --git a/test/preload-listeners.test.cjs b/test/preload-listeners.test.cjs
index 411fe25..b4fcb2a 100644
--- a/test/preload-listeners.test.cjs
+++ b/test/preload-listeners.test.cjs
@@ -450,3 +450,68 @@ test('runNpmScript returns the run id to the caller', async () => {
// attached so early logs/URL are captured"), which is what the fix would look
// like here. Left as a note rather than a test: asserting the current ordering
// would pin the gap shut, and closing it is not #149.
+
+// --- the subscribe* family (#173) ------------------------------------------
+//
+// The other half of the bridge, and until now untested. These are long-lived
+// subscriptions rather than the per-run pairs above, which is exactly why the
+// switch progress uses one: a switch emits its first event milliseconds in,
+// well before its invoke answers, so the ordering gap noted at the end of this
+// file would have swallowed the start of every switch.
+//
+// The leak shape here is different too. `App` subscribes once for every site
+// rather than per run, so an unsubscribe that removed by channel instead of by
+// handler would silence a site that is still open.
+const SUBSCRIPTIONS = [
+ { name: 'subscribeSwitchProgress', channel: 'switch:progress' },
+ { name: 'subscribeSetupProgress', channel: 'download:progress' },
+ { name: 'subscribeSetupStatus', channel: 'download:status' }
+];
+
+for (const sub of SUBSCRIPTIONS) {
+ test(`${sub.name}: subscribing listens once and unsubscribing stops (issue #173)`, () => {
+ const { api, ipcRenderer } = loadPreload();
+
+ const unsubscribe = api[sub.name](() => {});
+ assert.equal(ipcRenderer.listenerCount(sub.channel), 1);
+
+ unsubscribe();
+ assert.equal(ipcRenderer.listenerCount(sub.channel), 0);
+ });
+
+ test(`${sub.name}: the handler gets the payload, never the Electron event (issue #173)`, () => {
+ const { api, ipcRenderer } = loadPreload();
+ const seen = [];
+
+ api[sub.name]((payload) => seen.push(payload));
+ ipcRenderer.emit(sub.channel, { sitePath: '/sites/wp', stage: 'scan' });
+
+ assert.deepEqual(seen, [{ sitePath: '/sites/wp', stage: 'scan' }]);
+ });
+
+ test(`${sub.name}: one unsubscribe leaves other subscribers alone (issue #173)`, () => {
+ const { api, ipcRenderer } = loadPreload();
+ const mine = [];
+ const theirs = [];
+
+ const unsubscribeMine = api[sub.name]((p) => mine.push(p));
+ api[sub.name]((p) => theirs.push(p));
+ unsubscribeMine();
+
+ ipcRenderer.emit(sub.channel, { stage: 'done' });
+ assert.equal(ipcRenderer.listenerCount(sub.channel), 1);
+ assert.deepEqual(mine, []);
+ assert.deepEqual(theirs, [{ stage: 'done' }]);
+ });
+
+ test(`${sub.name}: unsubscribing twice, and no handler at all, are both safe (issue #173)`, () => {
+ const { api, ipcRenderer } = loadPreload();
+
+ const unsubscribe = api[sub.name]();
+ ipcRenderer.emit(sub.channel, { stage: 'scan' });
+ unsubscribe();
+ unsubscribe();
+
+ assert.equal(ipcRenderer.listenerCount(sub.channel), 0);
+ });
+}
diff --git a/test/switch-progress.test.cjs b/test/switch-progress.test.cjs
new file mode 100644
index 0000000..bc4ba40
--- /dev/null
+++ b/test/switch-progress.test.cjs
@@ -0,0 +1,189 @@
+'use strict';
+
+const test = require('node:test');
+const assert = require('node:assert');
+const {
+ createProgressThrottle,
+ mapCheckoutPhase,
+ describeSwitchProgress
+} = require('../src/switch-progress.cjs');
+
+// A clock the tests move by hand: throttling asserted without timers, so none
+// of this can go flaky on a loaded CI runner.
+function harness(options = {}) {
+ let clock = 1000;
+ const emitted = [];
+ const throttle = createProgressThrottle({
+ onEmit: (p) => emitted.push(p),
+ now: () => clock,
+ ...options
+ });
+ return { emitted, throttle, advance: (ms) => { clock += ms; } };
+}
+
+// --- createProgressThrottle ------------------------------------------------
+
+// A `git.checkout` of wordpress-develop calls onProgress thousands of times in
+// well under a second. Sending each one would flood IPC with frames nobody can
+// read, so within a stage they are coalesced.
+test('throttle: repeats inside the interval are coalesced (issue #173)', () => {
+ const { emitted, throttle, advance } = harness({ intervalMs: 100 });
+
+ throttle.emit({ stage: 'apply', loaded: 1, total: 500 });
+ throttle.emit({ stage: 'apply', loaded: 2, total: 500 });
+ advance(30);
+ throttle.emit({ stage: 'apply', loaded: 3, total: 500 });
+
+ assert.deepStrictEqual(emitted.map((p) => p.loaded), [1]);
+});
+
+test('throttle: once the interval has passed the next event goes out (issue #173)', () => {
+ const { emitted, throttle, advance } = harness({ intervalMs: 100 });
+
+ throttle.emit({ stage: 'apply', loaded: 1, total: 500 });
+ advance(100);
+ throttle.emit({ stage: 'apply', loaded: 2, total: 500 });
+
+ assert.deepStrictEqual(emitted.map((p) => p.loaded), [1, 2]);
+});
+
+// A stage is what the sentence on screen is made of, so it can never wait for
+// the interval — the whole point is that the panel keeps saying what is
+// happening.
+test('throttle: a new stage is emitted immediately, interval or not (issue #173)', () => {
+ const { emitted, throttle } = harness({ intervalMs: 100 });
+
+ throttle.emit({ stage: 'scan', loaded: 1 });
+ throttle.emit({ stage: 'commit', loaded: 1 });
+
+ assert.deepStrictEqual(emitted.map((p) => p.stage), ['scan', 'commit']);
+});
+
+// The 87% rule: a progress line that stops partway and jumps to done is worse
+// than no line at all, because it reads as a hang at exactly the moment the
+// user is deciding whether to force-quit. The last suppressed event of a stage
+// has to arrive before the next stage starts.
+test('throttle: the last suppressed event of a stage survives into the next one (issue #173)', () => {
+ const { emitted, throttle } = harness({ intervalMs: 100 });
+
+ throttle.emit({ stage: 'apply', loaded: 1, total: 500 });
+ throttle.emit({ stage: 'apply', loaded: 250, total: 500 });
+ throttle.emit({ stage: 'apply', loaded: 500, total: 500 });
+ throttle.emit({ stage: 'done' });
+
+ assert.deepStrictEqual(
+ emitted.map((p) => `${p.stage}:${p.loaded ?? ''}`),
+ ['apply:1', 'apply:500', 'done:'],
+ 'the 500/500 frame is what makes the line reach the end'
+ );
+});
+
+test('throttle: flush emits what is pending, and only once (issue #173)', () => {
+ const { emitted, throttle } = harness({ intervalMs: 100 });
+
+ throttle.emit({ stage: 'apply', loaded: 1, total: 500 });
+ throttle.emit({ stage: 'apply', loaded: 2, total: 500 });
+ throttle.flush();
+ throttle.flush();
+
+ assert.deepStrictEqual(emitted.map((p) => p.loaded), [1, 2]);
+});
+
+test('throttle: flush with nothing pending emits nothing (issue #173)', () => {
+ const { emitted, throttle } = harness({ intervalMs: 100 });
+
+ throttle.flush();
+
+ assert.deepStrictEqual(emitted, []);
+});
+
+// isomorphic-git awaits whatever onProgress returns. A thenable would put a
+// microtask between every one of ~4400 checkout events, so the callback is
+// synchronous by contract, not by accident.
+test('throttle: emit is synchronous and returns nothing to await (issue #173)', () => {
+ const { throttle } = harness();
+
+ const result = throttle.emit({ stage: 'scan', loaded: 1 });
+
+ assert.strictEqual(result, undefined);
+ assert.strictEqual(typeof (result && result.then), 'undefined');
+});
+
+// The trunk update writes into an append-only terminal, where one line per
+// stage is informative and fifteen is spam. Same module, no second throttle.
+test('throttle: an infinite interval reduces a switch to one line per stage (issue #173)', () => {
+ const { emitted, throttle } = harness({ intervalMs: Infinity });
+
+ throttle.emit({ stage: 'scan', loaded: 1 });
+ throttle.emit({ stage: 'scan', loaded: 2 });
+ throttle.emit({ stage: 'apply', loaded: 1, total: 9 });
+ throttle.emit({ stage: 'apply', loaded: 5, total: 9 });
+ throttle.flush();
+
+ assert.deepStrictEqual(
+ emitted.map((p) => `${p.stage}:${p.loaded}`),
+ ['scan:1', 'scan:2', 'apply:1', 'apply:5'],
+ 'stage changes and the flush get through; the middle of a stage does not'
+ );
+});
+
+// --- mapCheckoutPhase ------------------------------------------------------
+
+test('mapCheckoutPhase: the two phases checkout actually emits (issue #173)', () => {
+ assert.deepStrictEqual(
+ mapCheckoutPhase({ phase: 'Analyzing workdir', loaded: 12 }),
+ { stage: 'analyze', loaded: 12, total: undefined }
+ );
+ assert.deepStrictEqual(
+ mapCheckoutPhase({ phase: 'Updating workdir', loaded: 3, total: 40 }),
+ { stage: 'apply', loaded: 3, total: 40 }
+ );
+});
+
+// isomorphic-git owns these strings, not us. A version bump that renames or
+// adds one must degrade to a generic stage rather than an undefined that
+// renders as "undefined" in front of a contributor.
+test('mapCheckoutPhase: an unknown phase still produces a usable stage (issue #173)', () => {
+ const mapped = mapCheckoutPhase({ phase: 'Reticulating splines', loaded: 1, total: 2 });
+
+ assert.strictEqual(typeof mapped.stage, 'string');
+ assert.ok(mapped.stage.length > 0);
+ assert.strictEqual(typeof describeSwitchProgress(mapped), 'string');
+});
+
+// --- describeSwitchProgress ------------------------------------------------
+
+// The sentence this whole issue exists for: someone who force-quits here loses
+// work that is not committed anywhere yet, so the panel has to say that this is
+// what it is doing, and for which ticket.
+test('describeSwitchProgress: parking names the ticket being left (issue #173)', () => {
+ const line = describeSwitchProgress({ stage: 'scan', from: 'ticket/59234', to: 'ticket/61002' });
+
+ assert.match(line, /59234/);
+ assert.doesNotMatch(line, /ticket\//, 'a branch ref is our word for it, not the contributor\'s');
+});
+
+test('describeSwitchProgress: leaving trunk talks about the work, not a branch name (issue #173)', () => {
+ const line = describeSwitchProgress({ stage: 'scan', from: 'trunk', to: 'ticket/61002' });
+
+ assert.strictEqual(typeof line, 'string');
+ assert.doesNotMatch(line, /trunk/);
+});
+
+// `Analyzing workdir` reports `loaded` with no `total`, so there is no honest
+// percentage for that half of the checkout — and a made-up one is worse than
+// none.
+test('describeSwitchProgress: a percentage only when there is a total (issue #173)', () => {
+ assert.doesNotMatch(describeSwitchProgress({ stage: 'analyze', loaded: 900 }), /%/);
+ assert.doesNotMatch(describeSwitchProgress({ stage: 'apply', loaded: 5, total: 0 }), /%/);
+ assert.match(describeSwitchProgress({ stage: 'apply', loaded: 25, total: 100 }), /25%/);
+});
+
+test('describeSwitchProgress: every stage says something, including one we do not know (issue #173)', () => {
+ for (const stage of ['scan', 'stage', 'commit', 'analyze', 'apply', 'done', 'something-new']) {
+ const line = describeSwitchProgress({ stage, loaded: 1, total: 2, from: 'trunk', to: 'ticket/1' });
+ assert.strictEqual(typeof line, 'string', stage);
+ assert.ok(line.length > 0, stage);
+ assert.doesNotMatch(line, /undefined|NaN/, stage);
+ }
+});
diff --git a/test/ticket-branches.integration.test.cjs b/test/ticket-branches.integration.test.cjs
index 45f56ab..ce577c1 100644
--- a/test/ticket-branches.integration.test.cjs
+++ b/test/ticket-branches.integration.test.cjs
@@ -25,6 +25,7 @@ const {
switchToBranch,
deleteTicketBranch
} = require('../src/ticket-branches.js');
+const { describeSwitchProgress } = require('../src/switch-progress.cjs');
const AUTHOR = { name: 'test', email: 'test@example.com' };
@@ -234,3 +235,110 @@ test('switching to a branch that does not exist refuses (issue #108)', async (t)
(e) => e.code === 'no-such-branch'
);
});
+
+// --- progress while the worktree is swapped (issue #173) -------------------
+
+// A switch is a worktree scan and a full checkout: seconds of silence on a real
+// wordpress-develop, during which the window is indistinguishable from hung.
+// The stages below are what the panel turns into a sentence, so their order and
+// their presence is the contract — particularly the park stages, which cover
+// the stretch where the contributor's edits are not committed anywhere yet.
+test('switchToBranch reports every stage of a park and a checkout (issue #173)', async (t) => {
+ const { dir, baseOid } = await makeSite(t);
+ await startTicketBranch(dir, 59234);
+ await switchToBranch(dir, TRUNK, { baseOid });
+ await startTicketBranch(dir, 61002);
+ fs.writeFileSync(path.join(dir, 'wp-login.php'), ' seen.push(p) });
+
+ const stages = seen.map((p) => p.stage);
+ assert.ok(stages.includes('scan'), stages.join(','));
+ assert.ok(stages.includes('stage'), stages.join(','));
+ assert.ok(stages.includes('commit'), stages.join(','));
+ assert.equal(stages.indexOf('scan') < stages.indexOf('stage'), true, 'the scan comes before what it feeds');
+ assert.equal(stages.indexOf('stage') < stages.indexOf('commit'), true);
+ assert.equal(stages[stages.length - 1], 'done', 'the line has to reach the end');
+ // Where it is going, on every payload, so the panel need not track it.
+ assert.equal(seen.every((p) => p.to === ticketBranchRef(59234)), true);
+ // And where the work being saved came from.
+ assert.equal(seen.find((p) => p.stage === 'commit').from, ticketBranchRef(61002));
+ // The staging stage is the one with an honest total.
+ const staging = seen.filter((p) => p.stage === 'stage');
+ assert.ok(staging.length > 0);
+ assert.equal(staging.every((p) => Number.isFinite(p.total) && p.total > 0), true);
+ // And it is the longest stretch of the park, so it has to keep naming the
+ // ticket — a sentence that drops to "Saving your work…" for most of the wait
+ // is the one that fails to stop someone force-quitting.
+ assert.equal(staging.every((p) => p.from === ticketBranchRef(61002)), true);
+ assert.equal(
+ seen.every((p) => describeSwitchProgress(p).length > 0),
+ true,
+ 'every payload has to render as something'
+ );
+ assert.match(describeSwitchProgress(staging[0]), /#61002/);
+});
+
+// Nothing to park is the common case — switching away from a ticket you only
+// read. The scan still runs and still costs, so it is still announced; the
+// commit never happens and must not be claimed.
+test('a clean branch reports the scan but never claims to commit (issue #173)', async (t) => {
+ const { dir, baseOid } = await makeSite(t);
+ await startTicketBranch(dir, 59234);
+ await switchToBranch(dir, TRUNK, { baseOid });
+ await startTicketBranch(dir, 61002);
+
+ const seen = [];
+ await switchToBranch(dir, ticketBranchRef(59234), { baseOid, onProgress: (p) => seen.push(p) });
+
+ const stages = seen.map((p) => p.stage);
+ assert.equal(stages[0], 'scan');
+ assert.equal(stages.includes('commit'), false, 'nothing was committed, so nothing may say so');
+ assert.equal(stages[stages.length - 1], 'done');
+});
+
+// Leaving trunk runs a full scan that usually ends in "nothing to do". Silent
+// before this, and it is the same cost as any other scan.
+test('leaving a clean trunk still reports its scan (issue #173)', async (t) => {
+ const { dir, baseOid } = await makeSite(t);
+ await startTicketBranch(dir, 59234);
+ await switchToBranch(dir, TRUNK, { baseOid });
+
+ const seen = [];
+ await switchToBranch(dir, ticketBranchRef(59234), { baseOid, onProgress: (p) => seen.push(p) });
+
+ assert.equal(seen[0].stage, 'scan');
+ assert.equal(seen[0].from, TRUNK);
+ assert.equal(seen[seen.length - 1].stage, 'done');
+});
+
+// A refused switch changed nothing, so it must not report a checkout it never
+// ran, and must not say it is done.
+test('a refused dirty-trunk switch reports the scan and stops there (issue #173)', async (t) => {
+ const { dir, baseOid } = await makeSite(t);
+ await startTicketBranch(dir, 59234);
+ await switchToBranch(dir, TRUNK, { baseOid });
+ fs.writeFileSync(path.join(dir, 'wp-login.php'), ' switchToBranch(dir, ticketBranchRef(59234), { baseOid, onProgress: (p) => seen.push(p) }),
+ (e) => e.code === 'dirty-trunk'
+ );
+
+ assert.deepEqual(seen.map((p) => p.stage), ['scan']);
+});
+
+// Progress is an addition, not a requirement: every existing caller passes no
+// callback and must keep working.
+test('a switch without a progress callback still works (issue #173)', async (t) => {
+ const { dir, baseOid } = await makeSite(t);
+ await startTicketBranch(dir, 59234);
+ fs.writeFileSync(path.join(dir, 'wp-login.php'), '