Yieldless
Recipes

Electron Git Client

An end-to-end shape for a desktop Git app using IPC tuples, abortable subprocesses, and typed renderer calls.

Yieldless fits Electron well because the important boundaries in an Electron app are all failure-heavy: the renderer asks for work through IPC, the main process touches the filesystem, the main process launches long-running child processes like git clone.

Main-process contract

import type { IpcProcedure } from "yieldless/ipc";

type GitContract = {
  cloneRepository: IpcProcedure<
    [url: string, directory: string],
    { path: string },
    Error
  >;
  getStatus: IpcProcedure<[directory: string], { output: string }, Error>;
};

Main-process implementation

import { createAbortableIpcMain } from "yieldless/ipc";
import { runTaskGroup } from "yieldless/task";
import { runCommandSafe } from "yieldless/node";

const ipc = createAbortableIpcMain<GitContract>(ipcMain);
const windowLifecycle = new AbortController();

ipc.handle("cloneRepository", async (_event, requestSignal, url, directory) => {
  return await runTaskGroup(async (_group, signal) => {
    const [cloneError] = await runCommandSafe(
      "git",
      ["clone", url, directory],
      { signal },
    );

    if (cloneError) {
      return [cloneError, null];
    }

    return [null, { path: directory }];
  }, {
    signal: AbortSignal.any([windowLifecycle.signal, requestSignal]),
  });
});

If the window is torn down or a parent task group is canceled, the subprocess is aborted through Node's native child-process signal handling.

Preload bridge

import { createAbortableIpcBridge, createAbortableIpcRenderer } from "yieldless/ipc";

const client = createAbortableIpcRenderer<GitContract>(ipcRenderer);

export const gitBridge = createAbortableIpcBridge(client, [
  "cloneRepository",
  "getStatus",
] as const);

Renderer usage

const controller = new AbortController();
const [error, result] = await window.gitBridge.withSignal.cloneRepository(
  controller.signal,
  "git@github.com:binbandit/yieldless.git",
  "/tmp/yieldless",
);

if (error) {
  showToast(error.message);
  return;
}

openRepository(result.path);

Why this boundary feels cleaner

  • The renderer never depends on Electron's lossy thrown-error conversion.
  • The main process can use the same tuple style it uses everywhere else.
  • Long-running Git subprocesses can be canceled as soon as the UI no longer cares about them.

Good additions

Deduplicate status reads if several panels can request the same repository at once.

import { singleFlight } from "yieldless/singleflight";

const getStatus = singleFlight(
  (signal, directory: string) =>
    runCommandSafe("git", ["status", "--short"], {
      cwd: directory,
      signal,
    }),
);

Wait for one app or window event with cleanup.

import { onceEventSafe } from "yieldless/event";

const [closeError] = await onceEventSafe(mainWindow, "closed", {
  signal: windowLifecycle.signal,
});

Avoid

Do not expose arbitrary IPC channels to the renderer.

contextBridge.exposeInMainWorld("ipc", ipcRenderer);

Build a small preload bridge with createIpcBridge() or createAbortableIpcBridge() so the renderer only sees the operations it is allowed to call.

On this page