From 8763226ccd70f1f1c1f601a1a112b1c0624f8215 Mon Sep 17 00:00:00 2001 From: Harvmaster Date: Tue, 8 Sep 2026 17:33:55 +0000 Subject: [PATCH] add auto-updater --- src/app.ts | 19 +- src/cli/autocomplete/completions.ts | 1 + src/cli/index.ts | 32 ++++ src/services/app.ts | 14 ++ src/services/electrum.ts | 9 + src/services/invitation.ts | 5 + src/tui/App.tsx | 96 +++++++--- src/tui/hooks/useAppContext.tsx | 17 +- src/tui/types.ts | 15 +- src/updater/git-update-service.ts | 218 +++++++++++++++++++++++ src/updater/update-helper.ts | 192 ++++++++++++++++++++ tests/updater/git-update-service.test.ts | 96 ++++++++++ 12 files changed, 685 insertions(+), 29 deletions(-) create mode 100644 src/updater/git-update-service.ts create mode 100644 src/updater/update-helper.ts create mode 100644 tests/updater/git-update-service.test.ts diff --git a/src/app.ts b/src/app.ts index 88e7b47..f0a7bdb 100644 --- a/src/app.ts +++ b/src/app.ts @@ -8,6 +8,10 @@ import React from "react"; import { render, type Instance } from "ink"; import { App as AppComponent } from "./tui/App.js"; import { getDataDir } from "./utils/paths.js"; +import { + GitUpdateService, + type UpdateLaunchRequest, +} from "./updater/git-update-service.js"; /** * Configuration options for the CLI application. @@ -33,13 +37,16 @@ export class App { /** Application configuration */ private config: AppConfig; + private readonly updater: GitUpdateService; + private updateRequest: UpdateLaunchRequest | null = null; /** * Creates a new App instance. * @param config - Application configuration options */ - private constructor(config: AppConfig) { + private constructor(config: AppConfig, updater: GitUpdateService) { this.config = config; + this.updater = updater; } /** @@ -61,7 +68,7 @@ export class App { console.log("Full config:", fullConfig); - const app = new App(fullConfig); + const app = new App(fullConfig, new GitUpdateService()); await app.start(); return app; } @@ -76,13 +83,19 @@ export class App { this.inkInstance = render( React.createElement(AppComponent, { config: this.config, + updater: this.updater, + onUpdateAndRestart: (request: UpdateLaunchRequest) => { + this.updateRequest = request; + }, }), ); // Wait for the app to exit await this.inkInstance.waitUntilExit(); - process.exit(0); + if (this.updateRequest) { + this.updater.launchHelper(this.updateRequest, true); + } } /** diff --git a/src/cli/autocomplete/completions.ts b/src/cli/autocomplete/completions.ts index 27e593d..a87ac92 100644 --- a/src/cli/autocomplete/completions.ts +++ b/src/cli/autocomplete/completions.ts @@ -66,6 +66,7 @@ export const COMMAND_TREE = { receive: [], resource: RESOURCE_SUBS, settings: SETTINGS_SUBS, + update: [], help: [], completions: COMPLETIONS_SUBS, } as const; diff --git a/src/cli/index.ts b/src/cli/index.ts index 73eac9f..5dd418b 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -62,6 +62,7 @@ import { } from "./commands/index.js"; import { handleCompletionsCommand } from "./autocomplete/completions.js"; +import { GitUpdateService } from "../updater/git-update-service.js"; const createCommandIO = (verbose: boolean): CommandIO => ({ out: (message: string) => { @@ -124,6 +125,36 @@ async function main(): Promise { process.exit(0); } + if (command === "update") { + const updater = new GitUpdateService(); + const update = await updater.check(); + if (update.status === "unavailable") { + io.err(`Unable to check for updates: ${update.reason}`); + process.exit(1); + } + if (update.status === "up-to-date") { + io.out( + `XO is up to date on ${update.branch} (${update.currentCommit.slice(0, 7)}).`, + ); + process.exit(0); + } + + io.out( + `Update available on ${update.branch}: ${update.currentCommit.slice(0, 7)} -> ${update.targetCommit.slice(0, 7)}\n${update.summary}`, + ); + if (!update.canApply) { + io.err(update.blockReason ?? "This update cannot be applied automatically."); + process.exit(1); + } + if (options["check"] === "true") { + process.exit(0); + } + + updater.launchHelper(update, false); + io.out("The update will begin after this process exits."); + process.exit(0); + } + if (command === "mnemonic") { try { await handleMnemonicCommand({ io, paths }, subArgs, options); @@ -284,6 +315,7 @@ Commands: resource ${dim("Manage resources")} settings ${dim("Manage persisted wallet settings")} completions ${dim("Generate shell completion scripts (bash, zsh, fish)")} + update ${dim("Install updates from the current Git branch")} help ${dim("Show this help message")} Options: diff --git a/src/services/app.ts b/src/services/app.ts index fe7358c..aa3e7ae 100644 --- a/src/services/app.ts +++ b/src/services/app.ts @@ -74,6 +74,7 @@ export class AppService extends EventEmitter { onRemoved: () => void; } >(); + private stopPromise: Promise | null = null; static async create( seed: string, @@ -388,4 +389,17 @@ export class AppService extends EventEmitter { }), ); } + + /** Stop background services before the process exits or updates. */ + async stop(): Promise { + this.stopPromise ??= (async () => { + await Promise.allSettled([ + this.rates.stop(), + this.electrum.stop?.() ?? Promise.resolve(), + ...this.invitations.map((invitation) => invitation.stop()), + ]); + await this.engine.stop(); + })(); + await this.stopPromise; + } } diff --git a/src/services/electrum.ts b/src/services/electrum.ts index ef35948..d52afaa 100644 --- a/src/services/electrum.ts +++ b/src/services/electrum.ts @@ -10,6 +10,7 @@ export interface ElectrumServiceConfig { export abstract class BlockchainService { abstract hasSeenTransaction(transactionHash: string): Promise; + abstract stop?(): Promise; } /** @@ -54,4 +55,12 @@ export class ElectrumService { return false; } } + + /** Close the lazily-created client, if it was used. */ + async stop(): Promise { + if (!this.clientPromise) return; + const client = await this.clientPromise; + this.clientPromise = undefined; + await client.disconnect(true); + } } diff --git a/src/services/invitation.ts b/src/services/invitation.ts index 269361d..f4f62c2 100644 --- a/src/services/invitation.ts +++ b/src/services/invitation.ts @@ -279,6 +279,11 @@ export class Invitation extends EventEmitter { } } + /** Stop receiving remote invitation updates. */ + async stop(): Promise { + await this.syncServer.disconnect(); + } + /** * Handle an SSE message. * diff --git a/src/tui/App.tsx b/src/tui/App.tsx index 000c258..104c8d1 100644 --- a/src/tui/App.tsx +++ b/src/tui/App.tsx @@ -3,12 +3,17 @@ * Uses Ink for terminal rendering with React components. */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { Box, Text, useApp } from 'ink'; import { NavigationProvider, useNavigation } from './hooks/useNavigation.js'; import { AppProvider, useAppContext, useDialog, useStatus } from './hooks/useAppContext.js'; import { InputLayerProvider, useBlockableInput } from './hooks/useInputLayer.js'; import type { AppConfig } from '../app.js'; +import type { + GitUpdateService, + UpdateCheck, + UpdateLaunchRequest, +} from '../updater/git-update-service.js'; import { colors, logoSmall } from './theme.js'; // Screen imports @@ -18,13 +23,15 @@ import { TemplateListScreen } from './screens/TemplateList.js'; import { ActionWizardScreen } from './screens/action-wizard/ActionWizardScreen.js'; import { InvitationScreen } from './screens/invitations/InvitationScreen.js'; -import { MessageDialog } from './components/Dialog.js'; +import { ConfirmDialog, MessageDialog } from './components/Dialog.js'; /** * Props for the App component. */ interface AppProps { config: AppConfig; + updater: GitUpdateService; + onUpdateAndRestart: (request: UpdateLaunchRequest) => void; } /** @@ -52,7 +59,7 @@ function Router(): React.ReactElement { /** * Status bar component shown at the bottom of the screen. */ -function StatusBar(): React.ReactElement { +function StatusBar({ update }: { update: UpdateCheck | null }): React.ReactElement { const { status } = useStatus(); const { screen, canGoBack } = useNavigation(); @@ -64,7 +71,9 @@ function StatusBar(): React.ReactElement { justifyContent="space-between" > {logoSmall} - {status} + + {update?.status === 'available' ? 'Update available · press U' : status} + {canGoBack ? 'ESC: Back | ' : ''}q: Quit @@ -80,9 +89,23 @@ function DialogOverlay(): React.ReactElement | null { if (!dialog?.visible) return null; - const borderColor = dialog.type === 'error' ? colors.error : - dialog.type === 'confirm' ? colors.warning : - colors.info; + const contents = dialog.type === 'confirm' ? ( + {})} + onCancel={dialog.onCancel ?? (() => {})} + confirmLabel={dialog.confirmLabel} + cancelLabel={dialog.cancelLabel} + /> + ) : ( + {})} + type={dialog.type} + /> + ); return ( - {})} - type={dialog.type as 'error' | 'info' | 'success'} - /> + {contents} ); } @@ -108,18 +124,54 @@ function DialogOverlay(): React.ReactElement | null { /** * Main content wrapper with global keybindings. */ -function MainContent(): React.ReactElement { - const { exit } = useApp(); +function MainContent({ + updater, + onUpdateAndRestart, +}: Pick): React.ReactElement { const { goBack, canGoBack } = useNavigation(); const { screen } = useNavigation(); const appContext = useAppContext(); + const [update, setUpdate] = useState(null); + + useEffect(() => { + let active = true; + updater.check().then((result) => { + if (active) setUpdate(result); + }).catch(() => { + // Update checks are advisory and must never prevent wallet use. + }); + return () => { + active = false; + }; + }, [updater]); // Global keybindings — auto-blocked when any dialog/overlay is capturing input. useBlockableInput((input, key) => { // Quit on Ctrl+C if (key.ctrl && input === 'c') { - appContext.exit(); - exit(); + void appContext.exit(); + return; + } + + if ((input === 'u' || input === 'U') && update?.status === 'available') { + if (!update.canApply) { + appContext.showError(update.blockReason ?? 'This update cannot be applied automatically.'); + return; + } + + void appContext.confirm( + `Branch: ${update.branch}\nInstalled: ${update.currentCommit.slice(0, 7)}\nAvailable: ${update.targetCommit.slice(0, 7)}\n\n${update.summary}`, + { + title: 'XO update available', + confirmLabel: 'Update and restart', + cancelLabel: 'Later', + }, + ).then(async (confirmed) => { + if (!confirmed) return; + onUpdateAndRestart(update); + await appContext.exit(); + }); + return; } // Go back on Escape @@ -144,7 +196,7 @@ function MainContent(): React.ReactElement { {/* Status bar */} - + {/* Dialog overlay */} @@ -156,7 +208,7 @@ function MainContent(): React.ReactElement { * Main App component. * Sets up providers and renders the main content. */ -export function App({ config }: AppProps): React.ReactElement { +export function App({ config, updater, onUpdateAndRestart }: AppProps): React.ReactElement { const { exit } = useApp(); // Cleanup will be handled by React when components unmount @@ -171,7 +223,7 @@ export function App({ config }: AppProps): React.ReactElement { > - + diff --git a/src/tui/hooks/useAppContext.tsx b/src/tui/hooks/useAppContext.tsx index 619e3be..62a0488 100644 --- a/src/tui/hooks/useAppContext.tsx +++ b/src/tui/hooks/useAppContext.tsx @@ -109,12 +109,20 @@ export function AppProvider({ /** * Show a confirmation dialog and wait for user response. */ - const confirm = useCallback((message: string): Promise => { + const confirm = useCallback(( + message: string, + options?: { + title?: string; + confirmLabel?: string; + cancelLabel?: string; + }, + ): Promise => { return new Promise((resolve) => { setDialog({ visible: true, type: 'confirm', message, + ...options, onConfirm: () => { setDialog(null); resolve(true); @@ -134,6 +142,11 @@ export function AppProvider({ setStatusState(message); }, []); + const exit = useCallback(async () => { + await appService?.stop(); + onExit(); + }, [appService, onExit]); + const appValue: AppContextType = { appService, initializeWallet, @@ -142,7 +155,7 @@ export function AppProvider({ showError, showInfo, confirm, - exit: onExit, + exit, setStatus, }; diff --git a/src/tui/types.ts b/src/tui/types.ts index 359d44d..7942b44 100644 --- a/src/tui/types.ts +++ b/src/tui/types.ts @@ -67,9 +67,16 @@ export interface AppContextType { /** Show an info message dialog */ showInfo: (message: string) => void; /** Show a confirmation dialog */ - confirm: (message: string) => Promise; + confirm: ( + message: string, + options?: { + title?: string; + confirmLabel?: string; + cancelLabel?: string; + }, + ) => Promise; /** Exit the application */ - exit: () => void; + exit: () => Promise; /** Update status bar message */ setStatus: (message: string) => void; } @@ -84,6 +91,10 @@ export interface DialogState { type: "error" | "info" | "confirm"; /** Dialog message */ message: string; + /** Optional confirmation presentation labels. */ + title?: string; + confirmLabel?: string; + cancelLabel?: string; /** Callback for confirm dialog */ onConfirm?: () => void; /** Callback for cancel/dismiss */ diff --git a/src/updater/git-update-service.ts b/src/updater/git-update-service.ts new file mode 100644 index 0000000..ab9654b --- /dev/null +++ b/src/updater/git-update-service.ts @@ -0,0 +1,218 @@ +import { spawn, type SpawnOptionsWithoutStdio } from "node:child_process"; +import { existsSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +const UPDATE_REMOTE = "origin"; +const COMMAND_TIMEOUT_MS = 10_000; + +export interface CommandResult { + code: number; + stdout: string; + stderr: string; +} + +export type CommandRunner = ( + command: string, + args: string[], + options: SpawnOptionsWithoutStdio & { timeout?: number }, +) => Promise; + +export type UpdateCheck = + | { + status: "up-to-date"; + branch: string; + currentCommit: string; + } + | { + status: "available"; + branch: string; + currentCommit: string; + targetCommit: string; + summary: string; + canApply: boolean; + blockReason?: string; + } + | { + status: "unavailable"; + reason: string; + }; + +export interface UpdateLaunchRequest { + branch: string; + currentCommit: string; + targetCommit: string; +} + +export function getProjectRoot(): string { + return resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +} + +export const runCommand: CommandRunner = (command, args, options) => + new Promise((resolveResult) => { + const child = spawn(command, args, { + ...options, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + child.on("error", (error) => { + resolveResult({ code: 1, stdout, stderr: error.message }); + }); + child.on("close", (code) => { + resolveResult({ code: code ?? 1, stdout, stderr }); + }); + }); + +export class GitUpdateService { + constructor( + public readonly repoRoot: string = getProjectRoot(), + private readonly runner: CommandRunner = runCommand, + ) {} + + async check(): Promise { + if (!existsSync(resolve(this.repoRoot, ".git"))) { + return { + status: "unavailable", + reason: "This installation is not a Git checkout.", + }; + } + + const branchResult = await this.git(["branch", "--show-current"]); + const branch = branchResult.stdout.trim(); + if (branchResult.code !== 0 || branch.length === 0) { + return { + status: "unavailable", + reason: "Updates require a checked-out branch (not detached HEAD).", + }; + } + + const fetchResult = await this.git([ + "fetch", + "--quiet", + UPDATE_REMOTE, + `+refs/heads/${branch}:refs/remotes/${UPDATE_REMOTE}/${branch}`, + ]); + if (fetchResult.code !== 0) { + return { + status: "unavailable", + reason: this.commandFailure("Could not fetch updates", fetchResult), + }; + } + + const [headResult, targetResult, dirtyResult] = await Promise.all([ + this.git(["rev-parse", "HEAD"]), + this.git(["rev-parse", `refs/remotes/${UPDATE_REMOTE}/${branch}`]), + this.git(["status", "--porcelain"]), + ]); + if (headResult.code !== 0 || targetResult.code !== 0) { + return { + status: "unavailable", + reason: `The branch ${branch} is not available on ${UPDATE_REMOTE}.`, + }; + } + + const currentCommit = headResult.stdout.trim(); + const targetCommit = targetResult.stdout.trim(); + if (currentCommit === targetCommit) { + return { status: "up-to-date", branch, currentCommit }; + } + + const ancestry = await this.git([ + "merge-base", + "--is-ancestor", + currentCommit, + targetCommit, + ]); + if (ancestry.code !== 0) { + const remoteIsAncestor = await this.git([ + "merge-base", + "--is-ancestor", + targetCommit, + currentCommit, + ]); + if (remoteIsAncestor.code === 0) { + return { status: "up-to-date", branch, currentCommit }; + } + return { + status: "unavailable", + reason: `Local branch ${branch} has diverged from ${UPDATE_REMOTE}/${branch}; update it manually.`, + }; + } + + const summaryResult = await this.git([ + "log", + "-1", + "--format=%s", + targetCommit, + ]); + const isDirty = dirtyResult.code !== 0 || dirtyResult.stdout.trim() !== ""; + + return { + status: "available", + branch, + currentCommit, + targetCommit, + summary: summaryResult.stdout.trim() || "New version available", + canApply: !isDirty, + ...(isDirty + ? { + blockReason: + "The Git working tree has local changes. Commit or stash them before updating.", + } + : {}), + }; + } + + launchHelper(request: UpdateLaunchRequest, restart: boolean): void { + const helperPath = resolve(this.repoRoot, "dist/updater/update-helper.js"); + if (!existsSync(helperPath)) { + throw new Error( + "The updater helper is missing. Run `npm run build` and try again.", + ); + } + + const args = [ + helperPath, + "--repo", + this.repoRoot, + "--branch", + request.branch, + "--from", + request.currentCommit, + "--to", + request.targetCommit, + "--parent", + String(process.pid), + ]; + if (restart) args.push("--restart"); + + const child = spawn(process.execPath, args, { + cwd: this.repoRoot, + detached: true, + stdio: "inherit", + }); + child.unref(); + } + + private git(args: string[]): Promise { + return this.runner("git", args, { + cwd: this.repoRoot, + timeout: COMMAND_TIMEOUT_MS, + }); + } + + private commandFailure(prefix: string, result: CommandResult): string { + const detail = result.stderr.trim() || result.stdout.trim(); + return detail ? `${prefix}: ${detail}` : prefix; + } +} diff --git a/src/updater/update-helper.ts b/src/updater/update-helper.ts new file mode 100644 index 0000000..d6de260 --- /dev/null +++ b/src/updater/update-helper.ts @@ -0,0 +1,192 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { openSync, closeSync, readFileSync, unlinkSync } from "node:fs"; +import { resolve } from "node:path"; + +interface HelperOptions { + repo: string; + branch: string; + from: string; + to: string; + parent: number; + restart: boolean; +} + +function parseOptions(args: string[]): HelperOptions { + const values = new Map(); + let restart = false; + for (let index = 0; index < args.length; index += 1) { + const key = args[index]; + if (key === "--restart") { + restart = true; + continue; + } + const value = args[index + 1]; + if (!key?.startsWith("--") || value === undefined) { + throw new Error("Invalid updater helper arguments."); + } + values.set(key, value); + index += 1; + } + + const repo = values.get("--repo"); + const branch = values.get("--branch"); + const from = values.get("--from"); + const to = values.get("--to"); + const parent = Number(values.get("--parent")); + if (!repo || !branch || !from || !to || !Number.isInteger(parent)) { + throw new Error("Missing updater helper arguments."); + } + if (!/^[0-9a-f]{40,64}$/u.test(from) || !/^[0-9a-f]{40,64}$/u.test(to)) { + throw new Error("Invalid updater commit identifiers."); + } + return { repo, branch, from, to, parent, restart }; +} + +function run(command: string, args: string[], cwd: string): Promise { + return new Promise((resolveRun, reject) => { + const child = spawn(command, args, { cwd, stdio: "inherit" }); + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) resolveRun(); + else reject(new Error(`${command} exited with status ${code ?? 1}`)); + }); + }); +} + +function capture( + command: string, + args: string[], + cwd: string, +): Promise { + return new Promise((resolveCapture, reject) => { + const child = spawn(command, args, { + cwd, + stdio: ["ignore", "pipe", "pipe"], + }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk: string) => { + stdout += chunk; + }); + child.stderr.on("data", (chunk: string) => { + stderr += chunk; + }); + child.on("error", reject); + child.on("close", (code) => { + if (code === 0) resolveCapture(stdout.trim()); + else + reject( + new Error( + stderr.trim() || `${command} exited with status ${code ?? 1}`, + ), + ); + }); + }); +} + +async function waitForExit(pid: number): Promise { + while (true) { + try { + process.kill(pid, 0); + await new Promise((resolveWait) => setTimeout(resolveWait, 100)); + } catch { + return; + } + } +} + +async function main(): Promise { + const options = parseOptions(process.argv.slice(2)); + await waitForExit(options.parent); + + const packageJson = JSON.parse( + readFileSync(resolve(options.repo, "package.json"), "utf8"), + ) as { name?: unknown }; + if (packageJson.name !== "@xo-cash/cli") { + throw new Error("Refusing to update an unexpected repository."); + } + + const gitLockPath = await capture( + "git", + ["rev-parse", "--git-path", "xo-update.lock"], + options.repo, + ); + const lockPath = resolve(options.repo, gitLockPath); + let lock: number | undefined; + try { + lock = openSync(lockPath, "wx"); + } catch { + throw new Error("Another XO update is already running."); + } + + let updated = false; + try { + console.log(`Updating ${options.branch} to ${options.to.slice(0, 7)}…`); + const [branch, head, target, status] = await Promise.all([ + capture("git", ["branch", "--show-current"], options.repo), + capture("git", ["rev-parse", "HEAD"], options.repo), + capture( + "git", + ["rev-parse", `refs/remotes/origin/${options.branch}`], + options.repo, + ), + capture("git", ["status", "--porcelain"], options.repo), + ]); + if ( + branch !== options.branch || + head !== options.from || + target !== options.to + ) { + throw new Error( + "The checkout changed after the update check; please try again.", + ); + } + if (status !== "") { + throw new Error("The checkout has local changes; update cancelled."); + } + await run("git", ["merge", "--ff-only", options.to], options.repo); + updated = true; + await run("npm", ["ci"], options.repo); + await run("npm", ["run", "build"], options.repo); + console.log("XO was updated successfully."); + } catch (error) { + console.error("Update failed; restoring the previous revision."); + if (updated) { + try { + await run("git", ["reset", "--hard", options.from], options.repo); + await run("npm", ["ci"], options.repo); + await run("npm", ["run", "build"], options.repo); + console.error("The previous revision was restored."); + } catch (rollbackError) { + console.error("Automatic rollback also failed:", rollbackError); + } + } + throw error; + } finally { + if (lock !== undefined) closeSync(lock); + try { + unlinkSync(lockPath); + } catch { + // The lock is best-effort cleanup; a missing lock needs no action. + } + } + + if (options.restart) { + const entryPath = resolve(options.repo, "dist/index.js"); + const child = spawn(process.execPath, [entryPath], { + cwd: options.repo, + detached: true, + stdio: "inherit", + }); + child.unref(); + } +} + +main().catch((error) => { + console.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; +}); diff --git a/tests/updater/git-update-service.test.ts b/tests/updater/git-update-service.test.ts new file mode 100644 index 0000000..830d25e --- /dev/null +++ b/tests/updater/git-update-service.test.ts @@ -0,0 +1,96 @@ +import path from "node:path"; +import { describe, expect, test } from "vitest"; + +import { + GitUpdateService, + type CommandResult, + type CommandRunner, +} from "../../src/updater/git-update-service.js"; + +function result(stdout = "", code = 0, stderr = ""): CommandResult { + return { code, stdout, stderr }; +} + +function fakeRunner(responses: Map): CommandRunner { + return async (command, args) => + responses.get(`${command} ${args.join(" ")}`) ?? + result("", 1, "unexpected command"); +} + +describe("GitUpdateService", () => { + test("checks the currently checked-out branch", async () => { + const root = path.resolve("."); + const runner = fakeRunner( + new Map([ + ["git branch --show-current", result("feature/demo\n")], + [ + "git fetch --quiet origin +refs/heads/feature/demo:refs/remotes/origin/feature/demo", + result(), + ], + ["git rev-parse HEAD", result("aaa\n")], + ["git rev-parse refs/remotes/origin/feature/demo", result("bbb\n")], + ["git status --porcelain", result()], + ["git merge-base --is-ancestor aaa bbb", result()], + ["git log -1 --format=%s bbb", result("Useful update\n")], + ]), + ); + + const update = await new GitUpdateService(root, runner).check(); + + expect(update).toMatchObject({ + status: "available", + branch: "feature/demo", + currentCommit: "aaa", + targetCommit: "bbb", + canApply: true, + }); + }); + + test("reports an available update but blocks applying over local changes", async () => { + const root = path.resolve("."); + const runner = fakeRunner( + new Map([ + ["git branch --show-current", result("main\n")], + [ + "git fetch --quiet origin +refs/heads/main:refs/remotes/origin/main", + result(), + ], + ["git rev-parse HEAD", result("aaa\n")], + ["git rev-parse refs/remotes/origin/main", result("bbb\n")], + ["git status --porcelain", result(" M src/example.ts\n")], + ["git merge-base --is-ancestor aaa bbb", result()], + ["git log -1 --format=%s bbb", result("Update\n")], + ]), + ); + + const update = await new GitUpdateService(root, runner).check(); + + expect(update).toMatchObject({ status: "available", canApply: false }); + }); + + test("does not replace commits when the local branch is ahead", async () => { + const root = path.resolve("."); + const runner = fakeRunner( + new Map([ + ["git branch --show-current", result("main\n")], + [ + "git fetch --quiet origin +refs/heads/main:refs/remotes/origin/main", + result(), + ], + ["git rev-parse HEAD", result("bbb\n")], + ["git rev-parse refs/remotes/origin/main", result("aaa\n")], + ["git status --porcelain", result()], + ["git merge-base --is-ancestor bbb aaa", result("", 1)], + ["git merge-base --is-ancestor aaa bbb", result()], + ]), + ); + + await expect( + new GitUpdateService(root, runner).check(), + ).resolves.toMatchObject({ + status: "up-to-date", + branch: "main", + currentCommit: "bbb", + }); + }); +});