diff --git a/NOTICE.md b/NOTICE.md index 46dddc0..0ff3827 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -10,7 +10,7 @@ topological order that prefers newer commits, then assigning columns by keeping a list of active branches and letting the first parent continue its child's column — follows the algorithms described in: -> Pierre Vigier, *Commit graph drawing algorithms* (2019) +> Pierre Vigier, _Commit graph drawing algorithms_ (2019) > The implementation in `crates/gigit-git/src/graph` was written from that diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index e5e4694..1df78a9 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -6,9 +6,11 @@ use std::path::PathBuf; use gigit_git::{RefEntry, RepositorySummary}; +use tauri::ipc::Channel; use tauri::State; use crate::error::Result; +use crate::graph_stream::GraphEvent; use crate::workspace::{OpenedRepository, RepositoryId, WorkspaceHandle}; #[tauri::command] @@ -42,3 +44,27 @@ pub async fn list_references( ) -> Result> { workspace.references(id).await } + +/// Start streaming the commit graph. +/// +/// `budget` is how many rows may be sent before the frontend asks for more — +/// enough to fill the first screen, typically. +#[tauri::command] +pub async fn stream_graph( + id: RepositoryId, + channel: Channel, + budget: usize, + workspace: State<'_, WorkspaceHandle>, +) -> Result<()> { + workspace.stream_graph(id, channel, budget).await +} + +/// Allow the running stream to send `rows` more rows. +#[tauri::command] +pub async fn request_more_rows( + id: RepositoryId, + rows: usize, + workspace: State<'_, WorkspaceHandle>, +) -> Result<()> { + workspace.request_more_rows(id, rows).await +} diff --git a/src-tauri/src/graph_stream.rs b/src-tauri/src/graph_stream.rs new file mode 100644 index 0000000..796f68c --- /dev/null +++ b/src-tauri/src/graph_stream.rs @@ -0,0 +1,116 @@ +//! Streaming graph rows to the frontend. +//! +//! Rows are pushed rather than returned, and the push is bounded by a budget +//! the frontend tops up as it scrolls. That is credit-based flow control: the +//! first rows leave for the webview the moment they exist, without waiting to +//! be asked, and a million-commit repository still never floods the IPC +//! boundary because nothing is sent beyond what has been asked for. +//! +//! A plain request/response would have been simpler, but it costs a round trip +//! before anything can be drawn and gives the backend no way to speak on its +//! own — which is what pushing new commits into an open view will need later. + +use gigit_git::{GraphRow, GraphWalk}; +use serde::Serialize; +use tauri::ipc::Channel; + +/// Rows per event. Large enough that the per-message overhead is irrelevant, +/// small enough that a big budget arrives as several paintable batches rather +/// than one long freeze. +const ROWS_PER_EVENT: usize = 200; + +/// What the frontend receives on the channel. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase", tag = "event", content = "data")] +pub enum GraphEvent { + Rows { + /// Row index of the first row in this batch, so the frontend can place + /// them without tracking how many it has seen. + start: u32, + rows: Vec, + }, + /// The history is fully drawn; no more events will arrive. + Exhausted { + total: u32, + /// Whether the walk had to fall back on approximate ordering. + degraded: bool, + }, + Failed { + message: String, + }, +} + +/// An open graph stream: a walk, where to send it, and how much has been asked +/// for but not yet sent. +pub struct GraphStream { + walk: GraphWalk, + channel: Channel, + budget: usize, + sent: u32, +} + +impl GraphStream { + pub fn new(walk: GraphWalk, channel: Channel, budget: usize) -> Self { + Self { + walk, + channel, + budget, + sent: 0, + } + } + + /// Raise the budget, as the frontend scrolls towards the end of what it has. + pub fn extend_budget(&mut self, rows: usize) { + self.budget = self.budget.saturating_add(rows); + } + + /// Send whatever the budget allows. + /// + /// Returns whether the stream is still alive. A finished history, a failed + /// walk, or a channel the webview has dropped all end it — and returning + /// `false` is what makes the caller drop the walk, which is the whole of + /// cancellation. + #[must_use] + pub fn pump(&mut self) -> bool { + while self.budget > 0 { + let wanted = self.budget.min(ROWS_PER_EVENT); + + let rows = match self.walk.next_chunk(wanted) { + Ok(rows) => rows, + Err(error) => { + // A failure to send here means nobody is listening anyway. + let _ = self.channel.send(GraphEvent::Failed { + message: error.to_string(), + }); + + return false; + } + }; + + if rows.is_empty() { + break; + } + + self.budget -= rows.len(); + let start = self.sent; + self.sent += u32::try_from(rows.len()).unwrap_or(u32::MAX); + + if self.channel.send(GraphEvent::Rows { start, rows }).is_err() { + // The webview closed the channel: stop walking rather than + // computing rows nobody will ever see. + return false; + } + } + + if self.walk.is_exhausted() { + let _ = self.channel.send(GraphEvent::Exhausted { + total: self.sent, + degraded: self.walk.is_degraded(), + }); + + return false; + } + + true + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 72c65d0..aef1193 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,9 +1,11 @@ mod commands; mod error; +mod graph_stream; mod repository_actor; mod workspace; pub use error::Error; +pub use graph_stream::GraphEvent; pub use workspace::{OpenedRepository, RepositoryId, WorkspaceHandle}; #[cfg_attr(mobile, tauri::mobile_entry_point)] @@ -21,6 +23,8 @@ pub fn run() { commands::close_repository, commands::repository_summary, commands::list_references, + commands::stream_graph, + commands::request_more_rows, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/repository_actor.rs b/src-tauri/src/repository_actor.rs index 9f933b3..9436fb1 100644 --- a/src-tauri/src/repository_actor.rs +++ b/src-tauri/src/repository_actor.rs @@ -8,9 +8,11 @@ use std::path::{Path, PathBuf}; use gigit_git::{RefEntry, Repository, RepositorySummary}; +use tauri::ipc::Channel; use tokio::sync::{mpsc, oneshot}; use crate::error::{Error, Result}; +use crate::graph_stream::{GraphEvent, GraphStream}; /// How many requests can be in flight before callers start waiting. Requests /// are answered in microseconds to milliseconds, so this only ever fills up if @@ -28,6 +30,17 @@ enum Message { References { respond: oneshot::Sender>>, }, + /// Begin streaming the commit graph, replacing any stream already running. + StreamGraph { + channel: Channel, + budget: usize, + respond: oneshot::Sender>, + }, + /// Ask for more rows on the stream that is already running. + RequestMoreRows { + rows: usize, + respond: oneshot::Sender>, + }, } /// A cheap, clonable way to talk to one repository's thread. @@ -66,6 +79,25 @@ impl RepositoryHandle { .await } + /// Start streaming the commit graph down `channel`. + /// + /// Only one stream runs per repository; starting another replaces it, and + /// the old channel simply stops receiving. + pub async fn stream_graph(&self, channel: Channel, budget: usize) -> Result<()> { + self.request(|respond| Message::StreamGraph { + channel, + budget, + respond, + }) + .await + } + + /// Let the running stream send another `rows` rows. + pub async fn request_more_rows(&self, rows: usize) -> Result<()> { + self.request(|respond| Message::RequestMoreRows { rows, respond }) + .await + } + /// Send a request and wait for its answer. /// /// Both channels can only fail by the actor being gone, which for a handle @@ -113,6 +145,10 @@ fn run( return; } + // At most one graph stream per repository. Dropping it drops the walk, + // which is all there is to cancelling one. + let mut stream: Option = None; + // `blocking_recv` is the point of running on a dedicated thread: the // blocking git work below never touches the async runtime's workers. while let Some(message) = inbox.blocking_recv() { @@ -123,6 +159,43 @@ fn run( Message::References { respond } => { let _ = respond.send(repository.references()); } + Message::StreamGraph { + channel, + budget, + respond, + } => { + // Replaces whatever was streaming before; the previous walk is + // dropped here. + stream = None; + + match repository.graph() { + Ok(walk) => { + let mut started = GraphStream::new(walk, channel, budget); + if started.pump() { + stream = Some(started); + } + + let _ = respond.send(Ok(())); + } + Err(error) => { + let _ = respond.send(Err(error)); + } + } + } + Message::RequestMoreRows { rows, respond } => { + // Asking for more when nothing is streaming is not an error — + // the stream may have finished between the frontend deciding + // to ask and the message arriving. + if let Some(running) = &mut stream { + running.extend_budget(rows); + + if !running.pump() { + stream = None; + } + } + + let _ = respond.send(Ok(())); + } } } } diff --git a/src-tauri/src/workspace.rs b/src-tauri/src/workspace.rs index 1f96e00..e15b5d1 100644 --- a/src-tauri/src/workspace.rs +++ b/src-tauri/src/workspace.rs @@ -9,9 +9,11 @@ use std::path::PathBuf; use gigit_git::{RefEntry, RepositorySummary}; use serde::{Deserialize, Serialize}; +use tauri::ipc::Channel; use tokio::sync::{mpsc, oneshot}; use crate::error::{Error, Result}; +use crate::graph_stream::GraphEvent; use crate::repository_actor::RepositoryHandle; const INBOX_CAPACITY: usize = 32; @@ -101,6 +103,22 @@ impl WorkspaceHandle { self.repository(id).await?.summary().await } + pub async fn stream_graph( + &self, + id: RepositoryId, + channel: Channel, + budget: usize, + ) -> Result<()> { + self.repository(id) + .await? + .stream_graph(channel, budget) + .await + } + + pub async fn request_more_rows(&self, id: RepositoryId, rows: usize) -> Result<()> { + self.repository(id).await?.request_more_rows(rows).await + } + async fn repository(&self, id: RepositoryId) -> Result { let found = self .request(|respond| Message::Lookup { diff --git a/src-tauri/tests/streaming.rs b/src-tauri/tests/streaming.rs new file mode 100644 index 0000000..8040b31 --- /dev/null +++ b/src-tauri/tests/streaming.rs @@ -0,0 +1,377 @@ +//! Flow control on the graph stream. +//! +//! A `Channel` can be built with a plain closure, so these run against the real +//! streaming code with no webview involved — what the closure receives is +//! exactly what would have crossed the IPC boundary. + +use std::sync::{Arc, Mutex}; + +use gigit_git::testing::Fixture; +use gigit_lib::{RepositoryId, WorkspaceHandle}; +use tauri::ipc::Channel; + +/// Collects everything sent down a channel, as parsed JSON. +#[derive(Clone, Default)] +struct Received(Arc>>); + +impl Received { + fn channel(&self) -> Channel { + let events = Arc::clone(&self.0); + + Channel::new(move |body| { + let json = match body { + tauri::ipc::InvokeResponseBody::Json(text) => { + serde_json::from_str(&text).expect("events should be valid json") + } + tauri::ipc::InvokeResponseBody::Raw(bytes) => { + serde_json::from_slice(&bytes).expect("events should be valid json") + } + }; + + events.lock().expect("not poisoned").push(json); + + Ok(()) + }) + } + + fn events(&self) -> Vec { + self.0.lock().expect("not poisoned").clone() + } + + /// Every row across every batch, in the order they arrived. + fn rows(&self) -> Vec { + self.events() + .into_iter() + .filter(|event| event["event"] == "rows") + .flat_map(|event| { + event["data"]["rows"] + .as_array() + .expect("rows should be an array") + .clone() + }) + .collect() + } + + fn is_exhausted(&self) -> bool { + self.events() + .iter() + .any(|event| event["event"] == "exhausted") + } +} + +fn workspace() -> WorkspaceHandle { + let (handle, actor) = WorkspaceHandle::new(); + tokio::spawn(actor); + + handle +} + +/// A repository with `count` commits on a single branch. +fn history(count: usize) -> Fixture { + let fixture = Fixture::with_one_commit(); + for step in 2..=count { + fixture.commit(&format!("commit {step}")); + } + + fixture +} + +async fn open(workspace: &WorkspaceHandle, fixture: &Fixture) -> RepositoryId { + workspace + .open(fixture.path().to_path_buf()) + .await + .expect("should open") + .id +} + +#[tokio::test] +async fn the_initial_budget_arrives_without_being_asked_for() { + // The point of pushing rather than answering: rows are on their way before + // the frontend says anything more. + let fixture = history(20); + let workspace = workspace(); + let id = open(&workspace, &fixture).await; + let received = Received::default(); + + workspace + .stream_graph(id, received.channel(), 5) + .await + .expect("should start streaming"); + + assert_eq!(received.rows().len(), 5); +} + +#[tokio::test] +async fn nothing_is_sent_beyond_the_budget() { + // The whole point of the budget: a large history does not flood the + // channel just because it exists. + let fixture = history(50); + let workspace = workspace(); + let id = open(&workspace, &fixture).await; + let received = Received::default(); + + workspace + .stream_graph(id, received.channel(), 10) + .await + .expect("should start streaming"); + + assert_eq!(received.rows().len(), 10); + assert!( + !received.is_exhausted(), + "there are forty more commits to come" + ); +} + +#[tokio::test] +async fn topping_up_the_budget_sends_more() { + let fixture = history(30); + let workspace = workspace(); + let id = open(&workspace, &fixture).await; + let received = Received::default(); + + workspace + .stream_graph(id.clone(), received.channel(), 10) + .await + .expect("should start streaming"); + assert_eq!(received.rows().len(), 10); + + workspace + .request_more_rows(id.clone(), 10) + .await + .expect("should send more"); + assert_eq!(received.rows().len(), 20); + + workspace + .request_more_rows(id, 10) + .await + .expect("should send the rest"); + assert_eq!(received.rows().len(), 30); +} + +#[tokio::test] +async fn rows_arrive_in_order_and_only_once() { + let fixture = history(25); + let workspace = workspace(); + let id = open(&workspace, &fixture).await; + let received = Received::default(); + + workspace + .stream_graph(id.clone(), received.channel(), 10) + .await + .expect("should start streaming"); + workspace + .request_more_rows(id, 20) + .await + .expect("should send more"); + + let indices: Vec = received + .rows() + .iter() + .map(|row| row["row"].as_u64().expect("row should be a number")) + .collect(); + + assert_eq!( + indices, + (0..25).collect::>(), + "resuming a stream must not repeat or skip a row" + ); +} + +#[tokio::test] +async fn each_batch_says_where_it_starts() { + // So the frontend can place a batch without counting what it has seen. + let fixture = history(15); + let workspace = workspace(); + let id = open(&workspace, &fixture).await; + let received = Received::default(); + + workspace + .stream_graph(id.clone(), received.channel(), 5) + .await + .expect("should start streaming"); + workspace + .request_more_rows(id, 5) + .await + .expect("should send more"); + + let starts: Vec = received + .events() + .iter() + .filter(|event| event["event"] == "rows") + .map(|event| event["data"]["start"].as_u64().expect("a number")) + .collect(); + + assert_eq!(starts, vec![0, 5]); +} + +#[tokio::test] +async fn the_end_of_the_history_is_announced() { + let fixture = history(5); + let workspace = workspace(); + let id = open(&workspace, &fixture).await; + let received = Received::default(); + + // A budget larger than the history. + workspace + .stream_graph(id, received.channel(), 100) + .await + .expect("should start streaming"); + + assert_eq!(received.rows().len(), 5); + + let end = received + .events() + .into_iter() + .find(|event| event["event"] == "exhausted") + .expect("the frontend has to be told there is no more"); + + assert_eq!(end["data"]["total"], 5); + assert_eq!(end["data"]["degraded"], false); +} + +#[tokio::test] +async fn asking_for_more_after_the_end_is_harmless() { + let fixture = history(3); + let workspace = workspace(); + let id = open(&workspace, &fixture).await; + let received = Received::default(); + + workspace + .stream_graph(id.clone(), received.channel(), 100) + .await + .expect("should start streaming"); + + // The frontend may well have decided to scroll before the exhausted event + // reached it, so this has to be a no-op rather than an error. + workspace + .request_more_rows(id, 50) + .await + .expect("should not fail"); + + assert_eq!(received.rows().len(), 3); +} + +#[tokio::test] +async fn a_second_stream_replaces_the_first() { + let fixture = history(20); + let workspace = workspace(); + let id = open(&workspace, &fixture).await; + + let first = Received::default(); + let second = Received::default(); + + workspace + .stream_graph(id.clone(), first.channel(), 5) + .await + .expect("should start streaming"); + workspace + .stream_graph(id.clone(), second.channel(), 5) + .await + .expect("should restart streaming"); + + // Topping up now feeds the new stream only. + workspace + .request_more_rows(id, 5) + .await + .expect("should send more"); + + assert_eq!(first.rows().len(), 5, "the replaced stream stopped"); + assert_eq!(second.rows().len(), 10, "the new stream carried on"); +} + +#[tokio::test] +async fn a_restarted_stream_begins_again_from_the_top() { + let fixture = history(10); + let workspace = workspace(); + let id = open(&workspace, &fixture).await; + + let first = Received::default(); + workspace + .stream_graph(id.clone(), first.channel(), 4) + .await + .expect("should start streaming"); + + let second = Received::default(); + workspace + .stream_graph(id, second.channel(), 4) + .await + .expect("should restart streaming"); + + let indices: Vec = second + .rows() + .iter() + .map(|row| row["row"].as_u64().expect("a number")) + .collect(); + + assert_eq!(indices, vec![0, 1, 2, 3], "a fresh stream is a fresh walk"); +} + +#[tokio::test] +async fn an_empty_repository_streams_nothing_and_says_so() { + let fixture = Fixture::empty(); + let workspace = workspace(); + let id = open(&workspace, &fixture).await; + let received = Received::default(); + + workspace + .stream_graph(id, received.channel(), 50) + .await + .expect("should start streaming"); + + assert!(received.rows().is_empty()); + assert!(received.is_exhausted()); +} + +#[tokio::test] +async fn streaming_a_closed_repository_fails_cleanly() { + let fixture = history(5); + let workspace = workspace(); + let id = open(&workspace, &fixture).await; + let received = Received::default(); + + workspace.close(id.clone()).await.expect("should close"); + + workspace + .stream_graph(id, received.channel(), 10) + .await + .expect_err("a closed repository has nothing to stream"); + + assert!(received.events().is_empty()); +} + +#[tokio::test] +async fn the_graph_rows_carry_what_the_frontend_needs_to_draw() { + let fixture = Fixture::with_one_commit(); + fixture.branch_off("side"); + fixture.commit("on the side"); + fixture.checkout("main"); + fixture.commit("on main"); + fixture.merge(&["side"]); + + let workspace = workspace(); + let id = open(&workspace, &fixture).await; + let received = Received::default(); + + workspace + .stream_graph(id, received.channel(), 50) + .await + .expect("should start streaming"); + + let rows = received.rows(); + let merge = &rows[0]; + + // Field names cross as camelCase, which is what the TypeScript expects. + assert!(merge["id"].is_string()); + assert_eq!(merge["dot"], "merge"); + assert!(merge["column"].is_number()); + assert!(merge["lane"].is_number()); + assert!(merge["width"].is_number()); + assert!(merge["edges"].is_array()); + assert!(merge["refs"].is_array()); + assert!(merge["summary"].is_string()); + + let edge = &merge["edges"][0]; + assert!(edge["fromColumn"].is_number(), "got {edge}"); + assert!(edge["toColumn"].is_number()); + assert!(edge["kind"].is_string()); +} diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..f063738 --- /dev/null +++ b/src/lib/api.ts @@ -0,0 +1,72 @@ +/** + * Calling the Rust side. + * + * Thin wrappers around `invoke`, so components never touch command names or + * argument shapes directly. + */ + +import { Channel, invoke } from "@tauri-apps/api/core"; + +import type { + GraphEvent, + OpenedRepository, + RefEntry, + RepositoryId, + RepositorySummary, +} from "./bindings"; + +export function openRepository(path: string): Promise { + return invoke("open_repository", { path }); +} + +export function closeRepository(id: RepositoryId): Promise { + return invoke("close_repository", { id }); +} + +export function repositorySummary(id: RepositoryId): Promise { + return invoke("repository_summary", { id }); +} + +export function listReferences(id: RepositoryId): Promise { + return invoke("list_references", { id }); +} + +/** A running graph stream. */ +export type GraphStream = { + /** + * Allow the backend to send `rows` more rows. Call this as the view + * approaches the end of what it has. + */ + requestMore(rows: number): Promise; +}; + +/** + * Start streaming the commit graph. + * + * Rows are pushed rather than requested one page at a time: `budget` rows are + * on their way as soon as they exist, without a round trip, and nothing beyond + * the budget is sent until {@link GraphStream.requestMore} raises it. That is + * what keeps a million-commit repository from flooding the boundary while + * still putting the first screen up immediately. + * + * Only one stream runs per repository — starting another replaces it and + * restarts the walk from the newest commit. + */ +export async function streamGraph( + id: RepositoryId, + budget: number, + onEvent: (event: GraphEvent) => void, +): Promise { + const channel = new Channel(); + // Tauri's Channel is not an EventTarget — `onmessage` is the only way to + // receive from it, so the usual advice to prefer addEventListener does not + // apply here. + // oxlint-disable-next-line unicorn/prefer-add-event-listener + channel.onmessage = onEvent; + + await invoke("stream_graph", { id, channel, budget }); + + return { + requestMore: (rows: number) => invoke("request_more_rows", { id, rows }), + }; +} diff --git a/src/lib/bindings.ts b/src/lib/bindings.ts new file mode 100644 index 0000000..110d6c0 --- /dev/null +++ b/src/lib/bindings.ts @@ -0,0 +1,117 @@ +/** + * The shapes that cross the IPC boundary. + * + * Written by hand rather than generated: the surface is small, and a generator + * would be another dependency to keep current. The Rust side has a test that + * asserts the wire format matches these names, so a drift here fails there. + * + * @see src-tauri/tests/streaming.rs + */ + +/** The canonical path to a repository's `.git` directory. */ +export type RepositoryId = string; + +export type RefKind = "localBranch" | "remoteBranch" | "tag" | "other"; + +/** A reference, flattened for rendering. */ +export type RefEntry = { + /** The full name, e.g. `refs/heads/main`. */ + name: string; + /** The name as a human would write it, e.g. `main`. */ + shorthand: string; + kind: RefKind; + /** `null` for a symbolic ref such as `refs/remotes/origin/HEAD`. */ + target: string | null; +}; + +/** + * Where HEAD points. Three cases rather than a nullable name, because an + * unborn HEAD in a fresh repository and a detached HEAD are both states the UI + * has to draw, not errors. + */ +export type Head = + | { state: "branch"; name: string; shorthand: string; id: string } + | { state: "detached"; id: string } + | { state: "unborn"; name: string; shorthand: string }; + +export type RepositorySummary = { + gitDir: string; + /** `null` for a bare repository. */ + workdir: string | null; + isBare: boolean; + head: Head; +}; + +export type OpenedRepository = { + id: RepositoryId; + summary: RepositorySummary; +}; + +/** What a commit looks like on its row. */ +export type DotKind = "normal" | "merge" | "root"; + +/** How a line relates to the commit on the row it crosses. */ +export type EdgeKind = + /** Comes down from a child and ends at this commit's dot. */ + | "toCommit" + /** Leaves this commit's dot heading down towards a parent. */ + | "fromCommit" + /** Another branch passing by, untouched by this commit. */ + | "through"; + +/** + * One line segment crossing a single row. Columns are measured at the row's + * edges, so a `through` segment has both the same and draws vertically. + */ +export type Edge = { + fromColumn: number; + toColumn: number; + /** An index into the lane palette, not a colour. */ + lane: number; + kind: EdgeKind; +}; + +export type RefBadge = { + shorthand: string; + kind: RefKind; + isHead: boolean; +}; + +/** Everything needed to draw one row of the graph. */ +export type GraphRow = { + id: string; + /** Position from the top, starting at zero. */ + row: number; + column: number; + /** An index into the lane palette, not a colour. */ + lane: number; + dot: DotKind; + edges: Edge[]; + /** How many columns this row spans. */ + width: number; + summary: string; + author: string; + /** Committer time, seconds since the epoch. */ + time: number; + refs: RefBadge[]; +}; + +/** What arrives on a graph stream's channel. */ +export type GraphEvent = + | { event: "rows"; data: { start: number; rows: GraphRow[] } } + | { event: "exhausted"; data: { total: number; degraded: boolean } } + | { event: "failed"; data: { message: string } }; + +/** + * A command failure. `kind` is stable and safe to branch on; `message` is for + * people and may change wording. + */ +export type GigitError = { + kind: "git" | "unknownRepository" | "workerStopped" | "spawnThread"; + message: string; +}; + +/** Whether a rejected command gave us a structured error. */ +export function isGigitError(value: unknown): value is GigitError { + return typeof value === "object" && value !== null && "kind" in value && "message" in value; +}