1import type { ProjectCloneStage } from "@t3tools/contracts";
2
3export interface GitCloneProgressLine {
4 readonly stage: ProjectCloneStage;
5 readonly percent: number | null;
6 /** Transfer detail after the count, e.g. `12.30 MiB | 5.00 MiB/s`. */
7 readonly detail: string | null;
8}
9
10const STAGE_PREFIXES: ReadonlyArray<readonly [RegExp, ProjectCloneStage]> = [
11 [/^remote: Enumerating objects/, "counting"],
12 [/^remote: Counting objects/, "counting"],
13 [/^remote: Compressing objects/, "counting"],
14 [/^Receiving objects/, "receiving"],
15 [/^Resolving deltas/, "resolving"],
16 [/^Updating files/, "checkout"],
17 [/^Checking out files/, "checkout"],
18];
19
20const PERCENT = /:\s+(\d+)%\s+\((\d+)\/(\d+)\)(?:,\s*(.*?))?\s*(?:,\s*done\.)?\s*$/;
21
22/**
23 * Parses one line of `git clone --progress` stderr. Git redraws each counter
24 * with a bare `\r`, so callers hand over each redraw as its own line. Lines
25 * that are not progress counters (hints, warnings, `Cloning into ...`) return
26 * null and are left for the error surface.
27 */
28export function parseGitCloneProgressLine(line: string): GitCloneProgressLine | null {
29 const trimmed = line.trim();
30 const stageEntry = STAGE_PREFIXES.find(([pattern]) => pattern.test(trimmed));
31 if (!stageEntry) return null;
32 const stage = stageEntry[1];
33 const match = PERCENT.exec(trimmed);
34 if (!match) return { stage, percent: null, detail: null };
35 const percent = Number(match[1]);
36 const rawDetail = match[4]?.trim() ?? "";
37 // The trailer of a finished line is "done." which carries no information.
38 const detail = rawDetail.length > 0 && rawDetail !== "done." ? rawDetail : null;
39 return {
40 stage,
41 percent: Number.isFinite(percent) ? Math.max(0, Math.min(100, percent)) : null,
42 detail,
43 };
44}