Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions apps/server/src/project/RepositoryIdentityResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,41 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
}).pipe(Effect.provide(Layer.merge(TestClock.layer(), layerResolver)));
});

it.effect("resolves a partial clone whose fetch line carries its filter", () => {
const layerProcessRunner = Layer.succeed(ProcessRunner.ProcessRunner, {
run: (input) =>
Effect.succeed({
stdout: input.args.includes("rev-parse")
? "/repo\n"
: "origin\thttps://github.com/pingdotgg/t3code (fetch) [blob:none]\norigin\thttps://github.com/pingdotgg/t3code (push)\n",
stderr: "",
code: ChildProcessSpawner.ExitCode(0),
timedOut: false,
stdoutTruncated: false,
stderrTruncated: false,
stdoutInvalidUtf8: false,
stderrInvalidUtf8: false,
}),
});
const layerResolver = Layer.effect(
RepositoryIdentityResolver.RepositoryIdentityResolver,
RepositoryIdentityResolver.make(),
).pipe(Layer.provide(layerProcessRunner));

return Effect.gen(function* () {
const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
const identity = yield* resolver.resolve("/repo");

expect(identity?.canonicalKey).toBe("github.com/pingdotgg/t3code");
expect(identity?.locator).toEqual({
source: "git-remote",
remoteName: "origin",
remoteUrl: "https://github.com/pingdotgg/t3code",
});
expect(identity?.provider).toBe("github");
}).pipe(Effect.provide(layerResolver));
});

it.effect("normalizes equivalent GitHub remotes into a stable repository identity", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand Down
24 changes: 6 additions & 18 deletions apps/server/src/project/RepositoryIdentityResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { repositoryPathFromRemoteUrl } from "@t3tools/source-control-core/server
import {
detectSourceControlProviderFromGitRemoteUrl,
normalizeGitRemoteUrl,
parseGitRemoteVerbose,
} from "@t3tools/shared/git";
import * as Cache from "effect/Cache";
import * as Context from "effect/Context";
Expand Down Expand Up @@ -54,24 +55,11 @@ function isLocalRemoteUrl(url: string): boolean {
}

function parseRemoteFetchUrls(stdout: string): Map<string, string> {
const remotes = new Map<string, string>();
for (const line of stdout.split("\n")) {
const trimmed = line.trim();
if (trimmed.length === 0) continue;
const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed);
if (!match) continue;
const [, remoteName = "", remoteUrl = "", direction = ""] = match;
if (
direction !== "fetch" ||
remoteName.length === 0 ||
remoteUrl.length === 0 ||
isLocalRemoteUrl(remoteUrl)
) {
continue;
}
remotes.set(remoteName, remoteUrl);
}
return remotes;
return new Map(
parseGitRemoteVerbose(stdout)
.filter((remote) => remote.direction === "fetch" && !isLocalRemoteUrl(remote.url))
.map((remote) => [remote.name, remote.url]),
);
}

function pickPrimaryRemote(
Expand Down
32 changes: 32 additions & 0 deletions apps/server/src/vcs/GitVcsDriver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import * as Fiber from "effect/Fiber";
import * as TestClock from "effect/testing/TestClock";
import * as FileSystem from "effect/FileSystem";
import * as Layer from "effect/Layer";
import * as Option from "effect/Option";
import * as Path from "effect/Path";
import * as PlatformError from "effect/PlatformError";
import { ChildProcessSpawner } from "effect/process";
Expand Down Expand Up @@ -1149,6 +1150,37 @@ it.effect("GitVcsDriver forwards execute env to the VCS process", () => {
);
});

it.effect("GitVcsDriver lists a partial-clone remote whose fetch line carries its filter", () => {
const url = "https://github.com/pingdotgg/t3code";

return Effect.gen(function* () {
const driver = yield* GitVcsDriver.makeVcsDriverShape();

const { remotes } = yield* driver.listRemotes("/repo");

assert.deepStrictEqual(
remotes.map((remote) => ({ ...remote, pushUrl: Option.getOrNull(remote.pushUrl) })),
[{ name: "origin", url, pushUrl: url, isPrimary: true }],
);
}).pipe(
Effect.provide(
Layer.mergeAll(
NodeServices.layer,
Layer.mock(VcsProcess.VcsProcess)({
run: () =>
Effect.succeed({
exitCode: ChildProcessSpawner.ExitCode(0),
stdout: `origin\t${url} (fetch) [blob:none]\norigin\t${url} (push)\n`,
stderr: "",
stdoutTruncated: false,
stderrTruncated: false,
}),
}),
),
),
);
});

it.effect("GitVcsDriver flushes checkpoint objects and refs to disk before publishing them", () => {
const observedArgs: ReadonlyArray<string>[] = [];

Expand Down
19 changes: 2 additions & 17 deletions apps/server/src/vcs/GitVcsDriver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import {
type VcsStatusResult,
type WorktreeSubmodules,
} from "@t3tools/contracts";
import { parseGitRemoteVerbose } from "@t3tools/shared/git";
import {
makeGitVcsDriverCore,
PATCH_RENDER_PREFIX_ARGS,
Expand Down Expand Up @@ -459,23 +460,7 @@ function parseGitRemoteVerboseOutput(
output: string,
): Map<string, { url?: string; pushUrl?: string }> {
const remotes = new Map<string, { url?: string; pushUrl?: string }>();
for (const line of output.split("\n")) {
const trimmed = line.trim();
if (trimmed.length === 0) {
continue;
}

const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed);
if (!match) {
continue;
}

const name = match[1];
const url = match[2];
const direction = match[3];
if (!name || !url || !direction) {
continue;
}
for (const { name, url, direction } of parseGitRemoteVerbose(output)) {
const remote = remotes.get(name) ?? {};
if (direction === "fetch") {
remote.url = url;
Expand Down
31 changes: 31 additions & 0 deletions apps/server/src/vcs/GitVcsDriverCore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3981,6 +3981,37 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => {
assert.equal(yield* git(cwd, ["remote"]), "octocat\norigin");
}),
);

it.effect("ensureRemote reuses a partial-clone remote with an annotated fetch line", () =>
Effect.gen(function* () {
const url = "https://github.com/pingdotgg/t3code.git";
const commands: Array<ReadonlyArray<string>> = [];
const spawner = ChildProcessSpawner.make((command) =>
Effect.sync(() => {
const args = ChildProcess.isStandardCommand(command) ? command.args : [];
commands.push(args);
return makeSuccessfulHandle(
args[0] === "remote" && args[1] === "-v"
? `origin\t${url} (fetch) [blob:none]\norigin\t${url} (push)\n`
: "",
);
}),
);
const driver = yield* makeGitVcsDriverCore().pipe(
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
Effect.provide(layerServerConfig),
);

const remoteName = yield* driver.ensureRemote({
cwd: "/repo",
preferredName: "pingdotgg",
url: "git@github.com:pingdotgg/t3code.git",
});

assert.equal(remoteName, "origin");
assert.isFalse(commands.some((args) => args[0] === "remote" && args[1] === "add"));
}),
);
});

describe("commit context", () => {
Expand Down
24 changes: 6 additions & 18 deletions apps/server/src/vcs/GitVcsDriverCore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,11 @@ import {
type ReviewDiffPreviewSource,
type VcsRef,
} from "@t3tools/contracts";
import { dedupeRemoteBranchesWithLocalMatches, normalizeGitRemoteUrl } from "@t3tools/shared/git";
import {
dedupeRemoteBranchesWithLocalMatches,
normalizeGitRemoteUrl,
parseGitRemoteFetchUrls,
} from "@t3tools/shared/git";
import * as HostProcess from "@t3tools/shared/HostProcess";
import { compactTraceAttributes } from "@t3tools/shared/observability";
import { decodeJsonResult } from "@t3tools/shared/schemaJson";
Expand Down Expand Up @@ -358,22 +362,6 @@ function sanitizeRemoteName(value: string): string {
return sanitized.length > 0 ? sanitized : "fork";
}

function parseRemoteFetchUrls(stdout: string): Map<string, string> {
const remotes = new Map<string, string>();
for (const line of stdout.split("\n")) {
const trimmed = line.trim();
if (trimmed.length === 0) continue;
const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)$/.exec(trimmed);
if (!match) continue;
const [, remoteName = "", remoteUrl = "", direction = ""] = match;
if (direction !== "fetch" || remoteName.length === 0 || remoteUrl.length === 0) {
continue;
}
remotes.set(remoteName, remoteUrl);
}
return remotes;
}

function parseUpstreamRefWithRemoteNames(
upstreamRef: string,
remoteNames: ReadonlyArray<string>,
Expand Down Expand Up @@ -1642,7 +1630,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function*
"GitVcsDriver.ensureRemote.listRemoteUrls",
input.cwd,
["remote", "-v"],
).pipe(Effect.map((stdout) => parseRemoteFetchUrls(stdout)));
).pipe(Effect.map((stdout) => parseGitRemoteFetchUrls(stdout)));

for (const [remoteName, remoteUrl] of remoteFetchUrls.entries()) {
if (normalizeGitRemoteUrl(remoteUrl) === normalizedTargetUrl) {
Expand Down
72 changes: 72 additions & 0 deletions packages/shared/src/git.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import {
isTemporaryWorktreeBranch,
normalizeGitRemoteUrl,
parseGitHubRepositoryNameWithOwnerFromRemoteUrl,
parseGitRemoteFetchUrls,
parseGitRemoteVerbose,
parseOriginUrlFromGitConfig,
resolveAutoFeatureBranchName,
sanitizeFeatureBranchName,
Expand Down Expand Up @@ -87,6 +89,76 @@ describe("normalizeGitRemoteUrl", () => {
});
});

describe("parseGitRemoteVerbose", () => {
const url = "https://github.com/pingdotgg/t3code";

it("reads fetch and push lines", () => {
expect(
parseGitRemoteVerbose(
`origin\t${url} (fetch)\norigin\t${url} (push)\nfork\tgit@github.com:me/t3code.git (fetch)\n`,
),
).toEqual([
{ name: "origin", url, direction: "fetch" },
{ name: "origin", url, direction: "push" },
{ name: "fork", url: "git@github.com:me/t3code.git", direction: "fetch" },
]);
});

it("reads partial-clone lines that carry their filter after the direction", () => {
expect(
parseGitRemoteVerbose(`origin\t${url} (fetch) [blob:none]\norigin\t${url} (push)`),
).toEqual([
{ name: "origin", url, direction: "fetch" },
{ name: "origin", url, direction: "push" },
]);
expect(parseGitRemoteVerbose(`origin\t${url} (fetch) [tree:0]`)).toEqual([
{ name: "origin", url, direction: "fetch" },
]);
expect(parseGitRemoteVerbose(`origin\t${url} (fetch) [blob:none] [tree:0]`)).toEqual([
{ name: "origin", url, direction: "fetch" },
]);
});

it("tolerates CRLF line endings and blank lines", () => {
expect(
parseGitRemoteVerbose(
`\r\norigin\t${url} (fetch) [blob:none]\r\n\r\norigin\t${url} (push)\r\n`,
),
).toEqual([
{ name: "origin", url, direction: "fetch" },
{ name: "origin", url, direction: "push" },
]);
});

it("skips lines that are not remote entries", () => {
expect(
parseGitRemoteVerbose(
[
"origin",
`origin\t${url}`,
`origin\t${url} (pull)`,
`origin\t${url} (fetch) trailing`,
`origin\t${url} (fetch) [blob:none`,
`origin\t${url} (push)`,
].join("\n"),
),
).toEqual([{ name: "origin", url, direction: "push" }]);
});

it("maps each remote to its fetch URL", () => {
expect(
parseGitRemoteFetchUrls(
`origin\t${url} (fetch) [blob:none]\norigin\thttps://push.example/t3code (push)\nfork\tgit@github.com:me/t3code.git (fetch)\n`,
),
).toEqual(
new Map([
["origin", url],
["fork", "git@github.com:me/t3code.git"],
]),
);
});
});

describe("parseOriginUrlFromGitConfig", () => {
it("reads the origin url and ignores other remotes", () => {
const config = [
Expand Down
27 changes: 27 additions & 0 deletions packages/shared/src/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,33 @@ export function normalizeGitRemoteUrl(value: string): string {
return normalized;
}

/**
* Reads `git remote -v` output, one entry per remote and direction. Partial clones print the
* remote's filter after the direction, as in `origin <url> (fetch) [blob:none]`, so trailing
* bracketed annotations are accepted and dropped. Lines that are not remote entries are skipped.
*/
export function parseGitRemoteVerbose(stdout: string): Array<{
readonly name: string;
readonly url: string;
readonly direction: "fetch" | "push";
}> {
return stdout.split("\n").flatMap((line) => {
const match = /^(\S+)\s+(\S+)\s+\((fetch|push)\)(?:\s+\[[^\]]*\])*$/.exec(line.trim());
if (!match) return [];
const direction: "fetch" | "push" = match[3] === "fetch" ? "fetch" : "push";
return [{ name: match[1]!, url: match[2]!, direction }];
});
}

/** Fetch URL per remote name from `git remote -v` output; a later line for the same name wins. */
export function parseGitRemoteFetchUrls(stdout: string): Map<string, string> {
return new Map(
parseGitRemoteVerbose(stdout)
.filter((remote) => remote.direction === "fetch")
.map((remote) => [remote.name, remote.url]),
);
}

function parseGitConfigValue(raw: string): string {
let out = "";
let quoted = false;
Expand Down
12 changes: 7 additions & 5 deletions packages/source-control-forgejo/src/server/ForgejoCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import * as Semaphore from "effect/Semaphore";
import * as NodePath from "node:path";
import { FetchHttpClient, HttpClient, HttpClientRequest } from "effect/http";
import { ChildProcessSpawner } from "effect/process";
import { parseGitRemoteVerbose } from "@t3tools/shared/git";
import { decodeJsonResult } from "@t3tools/shared/schemaJson";
import * as HostProcess from "@t3tools/shared/HostProcess";
import * as SourceControlHost from "@t3tools/source-control-core/server/SourceControlHost";
Expand Down Expand Up @@ -514,12 +515,13 @@ export const make = Effect.gen(function* () {
if (input.host) {
const matchingUrls = [
...new Set(
result.stdout.split("\n").flatMap((line) => {
const url = /^\S+\s+(https?:\/\/\S+)\s+\(fetch\)$/.exec(line.trim())?.[1];
return url && parseForgejoRemote(url)?.host === input.host?.toLowerCase()
parseGitRemoteVerbose(result.stdout).flatMap(({ url, direction }) =>
direction === "fetch" &&
/^https?:\/\//.test(url) &&
parseForgejoRemote(url)?.host === input.host?.toLowerCase()
? [url]
: [];
}),
: [],
),
),
];
const origins = [...new Set(matchingUrls.map((url) => new URL(url).origin))];
Expand Down
Loading
Loading