1import type { ProjectCloneStage } from "@t3tools/contracts";
2
3export interface GitCloneProgressLine {
4 readonly stage: ProjectCloneStage;
5 readonly percent: number | null;
6 readonly detail: string | null;
7}
8
9const STAGE_PREFIXES: ReadonlyArray<readonly [RegExp, ProjectCloneStage]> = [
10 [/^remote: Enumerating objects/, "counting"],
11 [/^remote: Counting objects/, "counting"],
12 [/^remote: Compressing objects/, "counting"],
13 [/^Receiving objects/, "receiving"],
14 [/^Resolving deltas/, "resolving"],
15 [/^Updating files/, "checkout"],
16 [/^Checking out files/, "checkout"],
17];
18
19// Linear on hostile remote output: the tail is trimmed in code (optional `\s*`/`.*?` groups backtrack
20// cubically), and `s` lets it span U+2028 so a failed `$` cannot retry at every later counter.
21const PERCENT = /:\s+(\d+)%\s+\((\d+)\/(\d+)\)(.*)$/s;
22const DONE_SUFFIX = ", done.";
23
24/** Pass each bare-CR progress redraw as a separate line. */
25export function parseGitCloneProgressLine(line: string): GitCloneProgressLine | null {
26 const trimmed = line.trim();
27 const stageEntry = STAGE_PREFIXES.find(([pattern]) => pattern.test(trimmed));
28 if (!stageEntry) return null;
29 const stage = stageEntry[1];
30 const match = PERCENT.exec(trimmed);
31 if (!match) return { stage, percent: null, detail: null };
32 let tail = (match[4] ?? "").trimEnd();
33 if (tail.endsWith(DONE_SUFFIX)) tail = tail.slice(0, -DONE_SUFFIX.length).trimEnd();
34 if (tail.length > 0 && !tail.startsWith(",")) return { stage, percent: null, detail: null };
35 const percent = Number(match[1]);
36 const rawDetail = tail.slice(1).trim();
37 const detail = rawDetail.length > 0 && rawDetail !== "done." ? rawDetail : null;
38 return {
39 stage,
40 percent: Number.isFinite(percent) ? Math.max(0, Math.min(100, percent)) : null,
41 detail,
42 };
43}