1import {
2 type SourceControlDiscoveryResult,
3 type VcsDiscoveryItem,
4 type VcsDriverKind,
5} from "@t3tools/contracts";
6import * as Context from "effect/Context";
7import * as Duration from "effect/Duration";
8import * as Effect from "effect/Effect";
9import * as Layer from "effect/Layer";
10import * as Option from "effect/Option";
11import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner";
12
13import * as ServerConfig from "../config.ts";
14import { refreshPathFromLoginShell } from "../os-jank.ts";
15import * as VcsProcess from "../vcs/VcsProcess.ts";
16import {
17 detailFromCause,
18 failedProbeHint,
19 firstNonEmptyLine,
20} from "@t3tools/source-control-core/server/discovery";
21import * as SourceControlProviderRegistry from "./SourceControlProviderRegistry.ts";
22
23interface DiscoveryProbe {
24 readonly label: string;
25 readonly executable?: string;
26 readonly versionArgs?: ReadonlyArray<string>;
27 readonly implemented: boolean;
28 readonly installHint: string;
29}
30
31type VcsProbe = DiscoveryProbe & {
32 readonly kind: VcsDriverKind;
33 readonly executable: string;
34 readonly versionArgs: ReadonlyArray<string>;
35};
36
37interface DiscoveryProbeResult<Kind extends string> {
38 readonly kind: Kind;
39 readonly label: string;
40 readonly executable?: string;
41 readonly implemented: boolean;
42 readonly status: "available" | "missing";
43 readonly version: Option.Option<string>;
44 readonly installHint: string;
45 readonly detail: Option.Option<string>;
46}
47
48const VCS_PROBES: ReadonlyArray<VcsProbe> = [
49 {
50 kind: "git",
51 label: "Git",
52 executable: "git",
53 versionArgs: ["--version"],
54 implemented: true,
55 installHint: "Install Git from https://git-scm.com/downloads or with your package manager.",
56 },
57 {
58 kind: "jj",
59 label: "Jujutsu",
60 executable: "jj",
61 versionArgs: ["--version"],
62 implemented: false,
63 installHint: "Install Jujutsu with `brew install jj` or from https://github.com/jj-vcs/jj.",
64 },
65];
66
67export class SourceControlDiscovery extends Context.Service<
68 SourceControlDiscovery,
69 {
70 readonly discover: Effect.Effect<SourceControlDiscoveryResult>;
71 }
72>()("t3/sourceControl/SourceControlDiscovery") {}
73
74/** @public */
75export const make = Effect.gen(function* () {
76 const config = yield* ServerConfig.ServerConfig;
77 const process = yield* VcsProcess.VcsProcess;
78 const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry;
79 const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
80 // Discovery re-reads the login shell so a tool installed since launch
81 // resolves here and in every later spawn. Thread views, the palette and
82 // mobile also run discovery, so one interactive shell serves a short window.
83 const refreshPath = yield* refreshPathFromLoginShell().pipe(
84 Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
85 Effect.cachedWithTTL(Duration.seconds(10)),
86 );
87
88 const probe = <Kind extends VcsDriverKind>(
89 input: VcsProbe & { readonly kind: Kind },
90 ): Effect.Effect<DiscoveryProbeResult<Kind>> => {
91 const executable = input.executable;
92 const versionArgs = input.versionArgs;
93
94 return process
95 .run({
96 operation: "source-control.discovery.probe",
97 command: executable,
98 args: versionArgs,
99 cwd: config.cwd,
100 timeoutMs: 5_000,
101 maxOutputBytes: 8_000,
102 appendTruncationMarker: true,
103 })
104 .pipe(
105 Effect.map(
106 (result) =>
107 ({
108 kind: input.kind,
109 label: input.label,
110 executable,
111 implemented: input.implemented,
112 status: "available" as const,
113 version: Option.orElse(firstNonEmptyLine(result.stdout), () =>
114 firstNonEmptyLine(result.stderr),
115 ),
116 installHint: input.installHint,
117 detail: Option.none<string>(),
118 }) satisfies DiscoveryProbeResult<Kind>,
119 ),
120 Effect.catch((cause) =>
121 Effect.gen(function* () {
122 const installHint = yield* failedProbeHint(input, cause);
123 return {
124 kind: input.kind,
125 label: input.label,
126 executable,
127 implemented: input.implemented,
128 status: "missing" as const,
129 version: Option.none<string>(),
130 installHint,
131 detail: detailFromCause(cause),
132 } satisfies DiscoveryProbeResult<Kind>;
133 }),
134 ),
135 );
136 };
137
138 return SourceControlDiscovery.of({
139 discover: Effect.andThen(
140 refreshPath,
141 Effect.all({
142 versionControlSystems: Effect.all(
143 VCS_PROBES.map((entry) => probe(entry)) as ReadonlyArray<Effect.Effect<VcsDiscoveryItem>>,
144 { concurrency: "unbounded" },
145 ),
146 sourceControlProviders: sourceControlProviders.discover,
147 }),
148 ),
149 });
150});
151
152export const layer = Layer.effect(SourceControlDiscovery, make);