Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
10ee2f5
fix(server): honor gh default when resolving repository identity
juliusmarminge Oct 19, 2026
b8c1ef6
fix(server): fall back when gh defaults are ambiguous
juliusmarminge Oct 19, 2026
601a957
fix(shared): preserve HTTPS ports in repository identities
juliusmarminge Oct 19, 2026
b1c137b
fix(shared): retain web ports when matching change request links
juliusmarminge Oct 19, 2026
3e8a796
Merge remote-tracking branch 'origin/main' into HEAD
juliusmarminge Oct 20, 2026
b52258e
fix(shared): preserve Azure link identity during port matching
juliusmarminge Oct 20, 2026
34fb1cc
test(server): repository identity honors gh repo set-default (#15023)
juliusmarminge Oct 24, 2026
ba908f2
fix(server): repository identity follows gh repo set-default (#15023)
juliusmarminge Oct 24, 2026
6d7301a
chore: reconcile the prior repository identity fix
juliusmarminge Oct 24, 2026
1217f5b
chore: merge main into repository identity fix
juliusmarminge Oct 24, 2026
60c4f35
fix(server): restore normalizeGitRemoteUrl import after main merge
juliusmarminge Oct 24, 2026
5188c1b
Merge remote-tracking branch 'origin/main' into ssh-relay-remote-04/g…
juliusmarminge Nov 11, 2026
86c476e
refactor(server): parse gh-resolved marks with one pattern
juliusmarminge Nov 11, 2026
5cd3603
Merge remote-tracking branch 'origin/main' into ssh-relay-remote-04/g…
juliusmarminge Nov 13, 2026
ce65b22
refactor(server): drop a redundant alias in the gh mark picker
juliusmarminge Nov 13, 2026
1017139
fix(server): gh OWNER/REPO marks match remotes on the same HTTP port
juliusmarminge Nov 13, 2026
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
40 changes: 40 additions & 0 deletions apps/server/src/project/RepositoryIdentityResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -344,6 +344,46 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
}).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
);

it.effect("follows an OWNER/REPO mark only to a remote that fetches that repository", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const cwd = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-repository-identity-gh-parent-test-",
});
const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
const resolveFresh = () => resolver.resolve(cwd, { refresh: true });

yield* git(cwd, ["init"]);
yield* git(cwd, ["remote", "add", "fork", "https://github.com/julius/t3code-fork.git"]);
yield* git(cwd, ["remote", "add", "main", "https://github.com/T3Tools/t3code.git"]);
yield* git(cwd, ["remote", "add", "upstream", "https://github.com/other/t3code.git"]);
yield* git(cwd, ["config", "remote.fork.gh-resolved", "t3tools/t3code"]);
const parent = yield* resolveFresh();
expect(parent?.canonicalKey).toBe("github.com/t3tools/t3code");
expect(parent?.locator).toEqual({
source: "git-remote",
remoteName: "main",
remoteUrl: "https://github.com/T3Tools/t3code.git",
});

// No remote fetches the marked repository, and several marks leave gh's choice unknown.
yield* git(cwd, ["config", "remote.fork.gh-resolved", "someone/else"]);
expect((yield* resolveFresh())?.locator.remoteName).toBe("upstream");
yield* git(cwd, ["config", "remote.fork.gh-resolved", "base"]);
yield* git(cwd, ["config", "remote.main.gh-resolved", "base"]);
expect((yield* resolveFresh())?.locator.remoteName).toBe("upstream");

// The same path on another HTTPS port is a different server.
yield* git(cwd, ["config", "--unset", "remote.fork.gh-resolved"]);
yield* git(cwd, ["config", "--unset", "remote.main.gh-resolved"]);
yield* git(cwd, ["remote", "add", "backup", "https://ghe.example.com:9443/acme/web.git"]);
yield* git(cwd, ["remote", "add", "ghe-fork", "https://ghe.example.com:8443/me/web.git"]);
yield* git(cwd, ["remote", "add", "ghe-parent", "https://ghe.example.com:8443/acme/web.git"]);
yield* git(cwd, ["config", "remote.ghe-fork.gh-resolved", "acme/web"]);
expect((yield* resolveFresh())?.locator.remoteName).toBe("ghe-parent");
}).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
);

it.effect("reads the project's gh default when the server inherited GIT_DIR", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
Expand Down
93 changes: 54 additions & 39 deletions apps/server/src/project/RepositoryIdentityResolver.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import type { RepositoryIdentity, SourceControlProviderError } from "@t3tools/contracts";
import { parseGitRemoteHost } from "@t3tools/source-control-core/shared/definition";
import { repositoryPathFromRemoteUrl } from "@t3tools/source-control-core/server/SourceControlProvider";
import { normalizeGitRemoteUrl } from "@t3tools/shared/git";
import * as Cache from "effect/Cache";
import * as Context from "effect/Context";
import * as Data from "effect/Data";
Expand Down Expand Up @@ -69,21 +71,44 @@ function parseRemoteFetchUrls(stdout: string): Map<string, string> {
return remotes;
}

// `gh repo set-default` marks the chosen remote with `remote.<name>.gh-resolved base`.
function parseGhDefaultRemoteName(stdout: string): string | null {
const marked = stdout
.split("\n")
.map((line) => /^remote\.(.+)\.gh-resolved\s+base$/.exec(line.trim())?.[1])
.filter((name): name is string => name !== undefined);
return marked.length === 1 ? marked[0]! : null;
// `gh repo set-default` marks one remote with `base`, or with OWNER/REPO when it picked a
// parent repository. gh reads PRs from that repository, so the identity follows the mark when
// a remote fetches it. The locator must name a real remote: review checkouts fetch through it.
function pickMarkedRemote(
remotes: ReadonlyMap<string, string>,
resolved: string,
): { readonly remoteName: string; readonly remoteUrl: string } | null {
const marks = resolved.split("\n").flatMap((line) => {
const match = /^remote\.(.+)\.gh-resolved\s+(\S+)$/u.exec(line.trim());
const remoteUrl = match?.[1] === undefined ? undefined : remotes.get(match[1]);
return match?.[1] && match[2] && remoteUrl
? [{ remoteName: match[1], remoteUrl, value: match[2] }]
: [];
});
// gh's choice is ambiguous with several marks; GitHubSourceControlProvider ignores them too.
const [mark, ...others] = marks;
if (!mark || others.length > 0) return null;
if (mark.value === "base") return { remoteName: mark.remoteName, remoteUrl: mark.remoteUrl };
// Compare hosts with HTTP ports kept: one hostname can serve separate forges on two ports.
const host = parseGitRemoteHost(mark.remoteUrl)?.host;
const markedPath = mark.value.toLowerCase();
const [remoteName, remoteUrl] =
[...remotes.entries()].find(
([, url]) =>
parseGitRemoteHost(url)?.host === host &&
normalizeGitRemoteUrl(url).split("/").slice(1).join("/") === markedPath,
) ?? [];
return remoteName && remoteUrl ? { remoteName, remoteUrl } : null;
}

function pickPrimaryRemote(
remotes: ReadonlyMap<string, string>,
ghDefaultRemoteName: string | null,
resolved: string,
): { readonly remoteName: string; readonly remoteUrl: string } | null {
for (const preferredRemoteName of [ghDefaultRemoteName, "upstream", "origin"]) {
if (preferredRemoteName === null) continue;
const marked = pickMarkedRemote(remotes, resolved);
if (marked) return marked;

for (const preferredRemoteName of ["upstream", "origin"] as const) {
const remoteUrl = remotes.get(preferredRemoteName);
if (remoteUrl) {
return { remoteName: preferredRemoteName, remoteUrl };
Expand Down Expand Up @@ -242,40 +267,30 @@ const resolveRepositoryIdentityFromCacheKey = Effect.fn(
): Effect.fn.Return<RepositoryIdentity | null, never, ProcessRunner.ProcessRunner> {
const processRunner = yield* ProcessRunner.ProcessRunner;
const env = gitEnv(yield* HostProcess.Platform);
const remoteResult = yield* processRunner
.run({
command: "git",
args: ["-C", cacheKey.rootPath, ...(cacheKey.isBare ? ["--git-dir=."] : []), "remote", "-v"],
env,
timeoutBehavior: "timedOutResult",
})
.pipe(Effect.option);
const runGit = (args: ReadonlyArray<string>) =>
processRunner
.run({
command: "git",
args: ["-C", cacheKey.rootPath, ...(cacheKey.isBare ? ["--git-dir=."] : []), ...args],
env,
timeoutBehavior: "timedOutResult",
})
.pipe(Effect.option);
const [remoteResult, resolvedResult] = yield* Effect.all(
[runGit(["remote", "-v"]), runGit(["config", "--get-regexp", "^remote\\..*\\.gh-resolved$"])],
{ concurrency: "unbounded" },
);
if (remoteResult._tag === "None" || remoteResult.value.code !== 0) {
return null;
}

const ghResolvedResult = yield* processRunner
.run({
command: "git",
args: [
"-C",
cacheKey.rootPath,
...(cacheKey.isBare ? ["--git-dir=."] : []),
"config",
"--get-regexp",
"^remote\\..*\\.gh-resolved$",
],
env,
timeoutBehavior: "timedOutResult",
})
.pipe(Effect.option);
const ghDefaultRemoteName =
ghResolvedResult._tag === "Some" && ghResolvedResult.value.code === 0
? parseGhDefaultRemoteName(ghResolvedResult.value.stdout)
: null;

const remotes = parseRemoteFetchUrls(remoteResult.value.stdout);
const remote = pickPrimaryRemote(remotes, ghDefaultRemoteName);
// Exits 1 when no remote has a mark.
const resolved =
resolvedResult._tag === "Some" && resolvedResult.value.code === 0
? resolvedResult.value.stdout
: "";
const remote = pickPrimaryRemote(remotes, resolved);
return remote
? buildRepositoryIdentity({
...remote,
Expand Down
Loading