Skip to content
Merged
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
41 changes: 37 additions & 4 deletions apps/server/src/project/RepositoryIdentityResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,7 +109,7 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
}).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
);

it.effect("prefers upstream over origin when both remotes are configured", () =>
it.effect("uses the remote selected as gh's default repository", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const cwd = yield* fileSystem.makeTempDirectoryScoped({
Expand All @@ -119,14 +119,47 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
yield* git(cwd, ["init"]);
yield* git(cwd, ["remote", "add", "origin", "git@github.com:julius/t3code.git"]);
yield* git(cwd, ["remote", "add", "upstream", "git@github.com:T3Tools/t3code.git"]);
yield* git(cwd, ["config", "remote.origin.gh-resolved", "pingdotgg/t3code"]);

const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
const identity = yield* resolver.resolve(cwd);

expect(identity).not.toBeNull();
expect(identity?.locator.remoteName).toBe("upstream");
expect(identity?.canonicalKey).toBe("github.com/t3tools/t3code");
expect(identity?.displayName).toBe("t3tools/t3code");
expect(identity?.locator.remoteName).toBe("origin");
expect(identity?.canonicalKey).toBe("github.com/pingdotgg/t3code");
expect(identity?.displayName).toBe("pingdotgg/t3code");
}).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
);

it.effect("follows branch remote changes before gh's selected default", () =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const cwd = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-repository-identity-branch-remote-test-",
});

yield* git(cwd, ["init"]);
yield* git(cwd, ["checkout", "-b", "feature/branch-target"]);
yield* git(cwd, ["config", "user.email", "test@example.com"]);
yield* git(cwd, ["config", "user.name", "Test User"]);
yield* git(cwd, ["commit", "--allow-empty", "-m", "Initial commit"]);
yield* git(cwd, ["remote", "add", "origin", "git@github.com:T3Tools/t3code.git"]);
yield* git(cwd, ["remote", "add", "fork", "git@github.com:julius/t3code.git"]);
yield* git(cwd, ["config", "remote.origin.gh-resolved", "base"]);
yield* git(cwd, ["config", "branch.feature/branch-target.remote", "fork"]);
yield* git(cwd, ["config", "branch.feature/branch-target.merge", "refs/heads/main"]);

const resolver = yield* RepositoryIdentityResolver.RepositoryIdentityResolver;
const identity = yield* resolver.resolve(cwd);

expect(identity?.locator.remoteName).toBe("fork");
expect(identity?.canonicalKey).toBe("github.com/julius/t3code");

yield* git(cwd, ["checkout", "-b", "feature/no-branch-target"]);

const fallbackIdentity = yield* resolver.resolve(cwd);
expect(fallbackIdentity?.locator.remoteName).toBe("origin");
expect(fallbackIdentity?.canonicalKey).toBe("github.com/t3tools/t3code");
}).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
);

Expand Down
108 changes: 83 additions & 25 deletions apps/server/src/project/RepositoryIdentityResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import * as ProcessRunner from "../processRunner.ts";
const DEFAULT_REPOSITORY_IDENTITY_CACHE_CAPACITY = 512;
const DEFAULT_POSITIVE_CACHE_TTL = Duration.minutes(1);
const DEFAULT_NEGATIVE_CACHE_TTL = Duration.minutes(1);
const CACHE_KEY_SEPARATOR = "\0";

export interface RepositoryIdentityResolverOptions {
readonly cacheCapacity?: number;
Expand All @@ -29,26 +30,46 @@ export class RepositoryIdentityResolver extends Context.Service<
}
>()("t3/project/RepositoryIdentityResolver") {}

function parseRemoteFetchUrls(stdout: string): Map<string, string> {
function parseRemoteConfig(stdout: string): {
readonly remotes: ReadonlyMap<string, string>;
readonly ghDefaultRemote: {
readonly remoteName: string;
readonly repositoryPath: string | null;
} | null;
} {
const remotes = new Map<string, string>();
let ghDefaultRemote: {
readonly remoteName: string;
readonly repositoryPath: string | null;
} | null = null;

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;
const match = /^remote\.(.+)\.(url|gh-resolved)\s+(\S+)$/u.exec(line.trim());
if (!match?.[1] || !match[2] || !match[3]) continue;
if (match[2] === "url") {
if (!remotes.has(match[1])) remotes.set(match[1], match[3]);
} else if (ghDefaultRemote === null) {
ghDefaultRemote = {
remoteName: match[1],
repositoryPath: match[3] === "base" ? null : match[3].toLowerCase(),
};
}
remotes.set(remoteName, remoteUrl);
}
return remotes;
return { remotes, ghDefaultRemote };
}

function parseCurrentBranchRemoteName(stdout: string): string | null {
const current = stdout.split("\n").find((line) => line.startsWith("*\t"));
const remoteName = current?.slice(2).trim() ?? "";
return remoteName.length > 0 ? remoteName : null;
}

function pickPrimaryRemote(
remotes: ReadonlyMap<string, string>,
preferredRemoteNames: ReadonlyArray<string | null>,
): { readonly remoteName: string; readonly remoteUrl: string } | null {
for (const preferredRemoteName of ["upstream", "origin"] as const) {
for (const preferredRemoteName of preferredRemoteNames) {
if (preferredRemoteName === null) continue;
const remoteUrl = remotes.get(preferredRemoteName);
if (remoteUrl) {
return { remoteName: preferredRemoteName, remoteUrl };
Expand All @@ -63,9 +84,15 @@ function pickPrimaryRemote(
function buildRepositoryIdentity(input: {
readonly remoteName: string;
readonly remoteUrl: string;
readonly repositoryPath?: string;
readonly rootPath: string;
}): RepositoryIdentity {
const canonicalKey = normalizeGitRemoteUrl(input.remoteUrl);
const remoteCanonicalKey = normalizeGitRemoteUrl(input.remoteUrl);
const remoteHost = remoteCanonicalKey.split("/")[0] ?? "";
const canonicalKey =
input.repositoryPath && remoteHost
? `${remoteHost}/${input.repositoryPath}`
: remoteCanonicalKey;
const sourceControlProvider = detectSourceControlProviderFromGitRemoteUrl(input.remoteUrl);
const repositoryPath = canonicalKey.split("/").slice(1).join("/");
const repositoryPathSegments = repositoryPath.split("/").filter((segment) => segment.length > 0);
Expand All @@ -90,7 +117,7 @@ function buildRepositoryIdentity(input: {
const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver.resolveCacheKey")(
function* (cwd: string) {
const processRunner = yield* ProcessRunner.ProcessRunner;
let cacheKey = cwd;
let rootPath = cwd;

// git is a real executable on every platform — no cmd.exe shell mode, which
// would split paths containing spaces during cmd's re-tokenization.
Expand All @@ -101,16 +128,29 @@ const resolveRepositoryIdentityCacheKey = Effect.fn("RepositoryIdentityResolver.
timeoutBehavior: "timedOutResult",
})
.pipe(Effect.option);
if (topLevelResult._tag === "None" || topLevelResult.value.code !== 0) {
return cacheKey;
if (topLevelResult._tag === "Some" && topLevelResult.value.code === 0) {
rootPath = topLevelResult.value.stdout.trim() || cwd;
}

const candidate = topLevelResult.value.stdout.trim();
if (candidate.length > 0) {
cacheKey = candidate;
}
const branchRemoteResult = yield* processRunner
.run({
command: "git",
args: [
"-C",
rootPath,
"for-each-ref",
"--format=%(HEAD)%09%(upstream:remotename)",
"refs/heads",
],
timeoutBehavior: "timedOutResult",
})
.pipe(Effect.option);
const branchRemoteName =
branchRemoteResult._tag === "Some" && branchRemoteResult.value.code === 0
? parseCurrentBranchRemoteName(branchRemoteResult.value.stdout)
: null;

return cacheKey;
return `${rootPath}${CACHE_KEY_SEPARATOR}${branchRemoteName ?? ""}`;
},
);

Expand All @@ -120,19 +160,37 @@ const resolveRepositoryIdentityFromCacheKey = Effect.fn(
cacheKey: string,
): Effect.fn.Return<RepositoryIdentity | null, never, ProcessRunner.ProcessRunner> {
const processRunner = yield* ProcessRunner.ProcessRunner;
const remoteResult = yield* processRunner
const [rootPath = cacheKey, branchRemoteName = ""] = cacheKey.split(CACHE_KEY_SEPARATOR);
const remoteConfigResult = yield* processRunner
.run({
command: "git",
args: ["-C", cacheKey, "remote", "-v"],
args: ["-C", rootPath, "config", "--get-regexp", "^remote\\..*\\.(url|gh-resolved)$"],
timeoutBehavior: "timedOutResult",
})
.pipe(Effect.option);
if (remoteResult._tag === "None" || remoteResult.value.code !== 0) {
if (remoteConfigResult._tag === "None" || remoteConfigResult.value.code !== 0) {
return null;
}

const remote = pickPrimaryRemote(parseRemoteFetchUrls(remoteResult.value.stdout));
return remote ? buildRepositoryIdentity({ ...remote, rootPath: cacheKey }) : null;
const { remotes, ghDefaultRemote } = parseRemoteConfig(remoteConfigResult.value.stdout);
const remote = pickPrimaryRemote(remotes, [
branchRemoteName || null,
ghDefaultRemote?.remoteName ?? null,
"upstream",
"origin",
]);
if (!remote) return null;

const usesBranchRemote = branchRemoteName.length > 0 && remotes.has(branchRemoteName);
const repositoryPath =
!usesBranchRemote && remote.remoteName === ghDefaultRemote?.remoteName
? (ghDefaultRemote.repositoryPath ?? undefined)
: undefined;
return buildRepositoryIdentity({
...remote,
rootPath,
...(repositoryPath ? { repositoryPath } : {}),
});
});

export const make = Effect.fn("RepositoryIdentityResolver.make")(function* (
Expand Down
5 changes: 5 additions & 0 deletions docs/user/source-control.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,11 @@ T3 Code works with the platforms your team already uses:
- Open the review directly in your browser with one click
- Check out a teammate's branch to review code locally

When a GitHub project has multiple remotes, the **Pull requests** page follows the current branch's
tracked remote. If the branch has no tracked remote, it follows the repository selected by
`gh repo set-default`. The current branch's PR indicator verifies both the branch name and its head
repository, so a fork checkout does not pick up a same-named branch from `upstream`.

### Start a Thread from a GitHub Issue

Open the command palette and run **New thread from GitHub issue…** to pick from the current
Expand Down
Loading