Skip to content
7 changes: 6 additions & 1 deletion apps/server/src/git/GitManager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
import * as GitHubCli from "../sourceControl/GitHubCli.ts";
import * as TextGeneration from "../textGeneration/TextGeneration.ts";
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
import * as ProcessRunner from "../processRunner.ts";
import * as VcsProcess from "../vcs/VcsProcess.ts";
import * as GitHubSourceControlProvider from "../sourceControl/GitHubSourceControlProvider.ts";
import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
Expand Down Expand Up @@ -660,7 +661,11 @@ function makeManager(input?: {
),
vcsDriverLayer,
serverSettingsLayer,
).pipe(Layer.provideMerge(sourceControlRegistryLayer), Layer.provideMerge(NodeServices.layer));
).pipe(
Layer.provideMerge(sourceControlRegistryLayer),
Layer.provideMerge(ProcessRunner.layer),
Layer.provideMerge(NodeServices.layer),
);

return GitManager.make.pipe(
Effect.provide(managerLayer),
Expand Down
20 changes: 16 additions & 4 deletions apps/server/src/git/GitManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ import { extractBranchNameFromRemoteRef } from "./remoteRefs.ts";
import * as ServerSettings from "../serverSettings.ts";
import type { GitManagerServiceError } from "@t3tools/contracts";
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
import { canonicalizeSshRemoteUrl, sshConfigProbe } from "../vcs/sshRemoteUrl.ts";
import * as ProcessRunner from "../processRunner.ts";
import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts";
import { detectPrTemplate } from "../sourceControl/PrTemplateDetection.ts";
import type { ChangeRequest } from "@t3tools/contracts";
Expand Down Expand Up @@ -593,6 +595,7 @@ function toPullRequestHeadRemoteInfo(pr: {

export const make = Effect.gen(function* () {
const gitCore = yield* GitVcsDriver.GitVcsDriver;
const processRunner = yield* ProcessRunner.ProcessRunner;
const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry;
const textGeneration = yield* TextGeneration.TextGeneration;
const providerRegistry = yield* ProviderRegistry.ProviderRegistry;
Expand Down Expand Up @@ -1102,6 +1105,15 @@ export const make = Effect.gen(function* () {
const readConfigValueNullable = (cwd: string, key: string) =>
gitCore.readConfigValue(cwd, key).pipe(Effect.orElseSucceed(() => null));

const readRemoteUrl = (cwd: string, key: string) =>
readConfigValueNullable(cwd, key).pipe(
Effect.flatMap((remoteUrl) =>
remoteUrl === null
? Effect.succeed(null)
: canonicalizeSshRemoteUrl(remoteUrl, sshConfigProbe(processRunner)),
),
);

const resolveHostingProvider = Effect.fn("resolveHostingProvider")(function* (
cwd: string,
branch: string | null,
Expand All @@ -1111,8 +1123,8 @@ export const make = Effect.gen(function* () {
? "origin"
: ((yield* readConfigValueNullable(cwd, `branch.${branch}.remote`)) ?? "origin");
const remoteUrl =
(yield* readConfigValueNullable(cwd, `remote.${preferredRemoteName}.url`)) ??
(yield* readConfigValueNullable(cwd, "remote.origin.url"));
(yield* readRemoteUrl(cwd, `remote.${preferredRemoteName}.url`)) ??
(yield* readRemoteUrl(cwd, "remote.origin.url"));

return remoteUrl ? detectSourceControlProviderFromGitRemoteUrl(remoteUrl) : null;
});
Expand All @@ -1129,7 +1141,7 @@ export const make = Effect.gen(function* () {
};
}

const remoteUrl = yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`);
const remoteUrl = yield* readRemoteUrl(cwd, `remote.${remoteName}.url`);
const repositoryNameWithOwner = parseGitHubRepositoryNameWithOwnerFromRemoteUrl(remoteUrl);
return {
remoteUrlKey: remoteUrl ? normalizeGitRemoteUrl(remoteUrl) : null,
Expand Down Expand Up @@ -2320,4 +2332,4 @@ export const make = Effect.gen(function* () {
});
});

export const layer = Layer.effect(GitManager, make);
export const layer = Layer.effect(GitManager, make).pipe(Layer.provide(ProcessRunner.layer));
11 changes: 10 additions & 1 deletion apps/server/src/project/RepositoryIdentityResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import * as Exit from "effect/Exit";
import * as Layer from "effect/Layer";

import * as ProcessRunner from "../processRunner.ts";
import { canonicalizeSshRemoteUrl, sshConfigProbe } from "../vcs/sshRemoteUrl.ts";

const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512;
const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(1);
Expand Down Expand Up @@ -132,7 +133,15 @@ const resolveRepositoryIdentityFromCacheKey = Effect.fn(
}

const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.value.stdout));
return remote ? buildRepositoryIdentity({ ...remote, rootPath: cacheKey }) : null;
if (!remote) {
return null;
}

const remoteUrl = yield* canonicalizeSshRemoteUrl(
remote.remoteUrl,
sshConfigProbe(processRunner),
);
return buildRepositoryIdentity({ ...remote, remoteUrl, rootPath: cacheKey });
});

export const make = Effect.fn("RepositoryIdentityResolver.make")(function* (
Expand Down
25 changes: 24 additions & 1 deletion apps/server/src/vcs/GitVcsDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
import { makeGitVcsDriverCore } from "./GitVcsDriverCore.ts";
import * as VcsDriver from "./VcsDriver.ts";
import * as VcsProcess from "./VcsProcess.ts";
import { canonicalizeSshRemoteUrl } from "./sshRemoteUrl.ts";

export interface ExecuteGitInput {
readonly operation: string;
Expand Down Expand Up @@ -469,6 +470,22 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
},
).pipe(Effect.map((result) => result.exitCode === 0 && result.stdout.trim() === "true"));

const sshConfigProbe = (cwd: string) => (host: string) =>
vcsProcess
.run({
operation: "GitVcsDriver.sshConfig",
command: "ssh",
args: ["-G", "--", host],
cwd,
allowNonZeroExit: true,
timeoutMs: 5_000,
maxOutputBytes: 64 * 1024,
})
.pipe(
Effect.map((result) => result.stdout),
Effect.orElseSucceed(() => ""),
);

const execute: VcsDriver.VcsDriver["Service"]["execute"] = (input) =>
gitCommand(vcsProcess, input.operation, input.cwd, input.args, {
...(input.stdin !== undefined ? { stdin: input.stdin } : {}),
Expand Down Expand Up @@ -574,7 +591,7 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
}

const parsed = parseGitRemoteVerboseOutput(result.stdout);
const remotes = Array.from(parsed.entries()).flatMap(([name, remote]) => {
const configured = Array.from(parsed.entries()).flatMap(([name, remote]) => {
if (!remote.url) {
return [];
}
Expand All @@ -588,6 +605,12 @@ export const makeVcsDriverShape = Effect.fn("makeGitVcsDriverShape")(function* (
];
});

const remotes = yield* Effect.forEach(configured, (remote) =>
canonicalizeSshRemoteUrl(remote.url, sshConfigProbe(cwd)).pipe(
Effect.map((url) => ({ ...remote, url })),
),
);

return {
remotes,
freshness: yield* nowFreshness(),
Expand Down
102 changes: 102 additions & 0 deletions apps/server/src/vcs/sshRemoteUrl.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
import { describe, expect, it } from "@effect/vitest";
import * as Effect from "effect/Effect";

import { canonicalizeSshRemoteUrl, SshHostnameCache } from "./sshRemoteUrl.ts";

const sshConfig = (hostname: string) => (host: string) =>
Effect.succeed(`host ${host}\r\nhostname ${hostname}\r\nport 22\r\nuser git\r\n`);

const unresolved = (host: string) => Effect.succeed(`hostname ${host}\n`);

describe("canonicalizeSshRemoteUrl", () => {
it.effect("resolves an scp-style alias to its configured hostname", () =>
Effect.gen(function* () {
expect(
yield* canonicalizeSshRemoteUrl("git@alt:pingdotgg/t3chat.git", sshConfig("github.com")),
).toBe("git@github.com:pingdotgg/t3chat.git");
}),
);

it.effect("resolves an alias in an ssh:// remote, port and path intact", () =>
Effect.gen(function* () {
expect(
yield* canonicalizeSshRemoteUrl(
"ssh://git@work-main:2222/pingdotgg/t3chat.git",
sshConfig("gitlab.example.com"),
),
).toBe("ssh://git@gitlab.example.com:2222/pingdotgg/t3chat.git");
}),
);

it.effect("probes each host once", () =>
Effect.gen(function* () {
let probes = 0;
const probe = (host: string) => {
probes += 1;
return sshConfig("github.com")(host);
};
yield* canonicalizeSshRemoteUrl("git@cached-alias:pingdotgg/t3chat.git", probe);
yield* canonicalizeSshRemoteUrl("git@cached-alias:pingdotgg/t3code.git", probe);
expect(probes).toBe(1);
}).pipe(Effect.provideService(SshHostnameCache, new Map())),
);

it.effect("retries after a failed probe instead of caching it", () =>
Effect.gen(function* () {
const results = ["", "hostname github.com\n"];
const probe = () => Effect.succeed(results.shift() ?? "");
yield* canonicalizeSshRemoteUrl("git@flaky:pingdotgg/t3chat.git", probe);
expect(yield* canonicalizeSshRemoteUrl("git@flaky:pingdotgg/t3chat.git", probe)).toBe(
"git@github.com:pingdotgg/t3chat.git",
);
}).pipe(Effect.provideService(SshHostnameCache, new Map())),
);

it.effect("brackets an IPv6 hostname before substitution", () =>
Effect.gen(function* () {
expect(
yield* canonicalizeSshRemoteUrl("git@v6:pingdotgg/t3chat.git", sshConfig("2001:db8::1")),
).toBe("git@[2001:db8::1]:pingdotgg/t3chat.git");
expect(
yield* canonicalizeSshRemoteUrl(
"ssh://git@v6:2222/pingdotgg/t3chat.git",
sshConfig("2001:db8::1"),
),
).toBe("ssh://git@[2001:db8::1]:2222/pingdotgg/t3chat.git");
}).pipe(Effect.provideService(SshHostnameCache, new Map())),
);

it.effect("leaves non-ssh remotes and local paths alone", () =>
Effect.gen(function* () {
const failing = () => Effect.die("ssh must not be probed");
for (const remoteUrl of [
"https://github.com/pingdotgg/t3chat.git",
"git://github.com/pingdotgg/t3chat.git",
"/home/me/repo",
"C:/Users/me/repo",
"C:\\Users\\me\\repo",
"../sibling/repo",
]) {
expect(yield* canonicalizeSshRemoteUrl(remoteUrl, failing)).toBe(remoteUrl);
}
}),
);

it.effect("leaves a host that ssh does not rewrite alone", () =>
Effect.gen(function* () {
expect(
yield* canonicalizeSshRemoteUrl("git@github.com:pingdotgg/t3chat.git", unresolved),
).toBe("git@github.com:pingdotgg/t3chat.git");
}),
);

it.effect("keeps the remote when ssh cannot be run", () =>
Effect.gen(function* () {
expect(
yield* canonicalizeSshRemoteUrl("git@no-ssh-here:pingdotgg/t3chat.git", () =>
Effect.succeed(""),
),
).toBe("git@no-ssh-here:pingdotgg/t3chat.git");
}),
);
});
56 changes: 56 additions & 0 deletions apps/server/src/vcs/sshRemoteUrl.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import * as Context from "effect/Context";
import * as Effect from "effect/Effect";

import type * as ProcessRunner from "../processRunner.ts";

const SSH_URL_HOST = /^(ssh:\/\/(?:[^@/]*@)?)([^@:/]+)/iu;
const SCP_URL_HOST = /^((?:[^@:/]*@)?)([^@:/]{2,})(?=:(?!\/))/u;

const hostPattern = (remoteUrl: string): RegExp =>
/^ssh:\/\//iu.test(remoteUrl) ? SSH_URL_HOST : SCP_URL_HOST;

export type SshConfigProbe = (host: string) => Effect.Effect<string>;

export const sshConfigProbe =
(processRunner: ProcessRunner.ProcessRunner["Service"]): SshConfigProbe =>
(host) =>
processRunner
.run({ command: "ssh", args: ["-G", "--", host], timeoutBehavior: "timedOutResult" })
.pipe(
Effect.map((result) => result.stdout),
Effect.orElseSucceed(() => ""),
);

const HOSTNAME_TTL_MS = 5 * 60_000;

export const SshHostnameCache = Context.Reference<
Map<string, { readonly at: number; readonly hostname: string }>
>("@t3tools/server/vcs/SshHostnameCache", {
defaultValue: () => new Map(),
});

export const canonicalizeSshRemoteUrl = Effect.fnUntraced(function* (
remoteUrl: string,
probe: SshConfigProbe,
) {
const host = hostPattern(remoteUrl).exec(remoteUrl)?.[2];
if (host === undefined) return remoteUrl;

const cache = yield* SshHostnameCache;
const now = Date.now();
const cached = cache.get(host);
let hostname =
cached !== undefined && now - cached.at < HOSTNAME_TTL_MS ? cached.hostname : undefined;
if (hostname === undefined) {
// Only cache probes that yielded a hostname; a failed or empty ssh -G run
// must not pin the alias unresolved for the whole TTL.
const probed = /^hostname[ \t]+(\S+)/imu.exec(yield* probe(host))?.[1];
if (probed !== undefined) cache.set(host, { at: now, hostname: probed });
hostname = probed ?? host;
}

if (hostname === host) return remoteUrl;
const substituted =
hostname.includes(":") && !hostname.startsWith("[") ? `[${hostname}]` : hostname;
return remoteUrl.replace(hostPattern(remoteUrl), (_, prefix: string) => prefix + substituted);
});
7 changes: 4 additions & 3 deletions apps/web/src/state/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,9 @@ export interface EnvironmentQueryView<A> {
readonly refresh: () => void;
}

function formatError(cause: Cause.Cause<unknown>): string {
const error = Cause.squash(cause);
export function environmentQueryError<A, E>(result: AsyncResult.AsyncResult<A, E>): string | null {
if (result._tag !== "Failure" || Cause.hasInterruptsOnly(result.cause)) return null;
Comment on lines +17 to +18

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Mirror interrupt filtering in mobile query state

This fixes interrupted AsyncResult reads only in the web copy of useEnvironmentQuery, while apps/mobile/src/state/query.ts still formats every Failure as an error. Any mobile screen that refreshes overlapping environment atoms can therefore continue showing the same spurious “environment request failed” state that this patch removes from web/desktop; move this logic to shared client code or update the mobile hook as well.

AGENTS.md reference: AGENTS.md:L65-L75

Useful? React with 👍 / 👎.

const error = Cause.squash(result.cause);
return error instanceof Error && error.message.trim().length > 0
? error.message
: "The environment request failed.";
Expand All @@ -29,7 +30,7 @@ export function useEnvironmentQuery<A, E>(
const refresh = useAtomRefresh(selectedAtom);
return {
data: Option.getOrNull(AsyncResult.value(result)),
error: result._tag === "Failure" ? formatError(result.cause) : null,
error: environmentQueryError(result),
isPending: atom !== null && result.waiting,
refresh,
};
Expand Down
Loading