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
3 changes: 3 additions & 0 deletions FORK.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,3 +53,6 @@ adds the feature, and remove it in the PR that drops the feature.
- #21 Open in VS Code and other SSH editor links use the host the client reached directly when
sshd is listening, instead of the server's first advertised host: `apps/web/src/remoteOpen.ts`,
`apps/server/src/environment/RemoteOpenTargets.ts` (`sshd`).
- #30 A checkout's pull requests and repository come from the remote `gh repo set-default`
chose, ahead of `upstream`, so a fork lists its own PRs:
`apps/server/src/project/RepositoryIdentityResolver.ts` (`parseGhDefaultRemote`).
52 changes: 52 additions & 0 deletions apps/server/src/project/RepositoryIdentityResolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,58 @@ it.layer(NodeServices.layer)("RepositoryIdentityResolverLive", (it) => {
remoteUrl: "git@github.com:julius/t3code-fork.git",
displayName: "julius/t3code-fork",
});

yield* git(cwd, ["config", "remote.origin.gh-resolved", "base"]);
const forkDefault = yield* resolver.resolve(cwd, { refresh: true });

expect(forkDefault?.locator.remoteName).toBe("origin");
expect(forkDefault?.canonicalKey).toBe("github.com/julius/t3code-fork");
expect(forkDefault?.origin).toBeUndefined();
}).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
);

it.effect.each([
{
name: "a dotted remote name",
remote: ["my.fork", "git@github.com:julius/dotted-fork.git"],
marker: ["remote.my.fork.gh-resolved", "base"],
expected: "my.fork",
},
{
name: "a value other than base",
remote: undefined,
marker: ["remote.origin.gh-resolved", "julius/t3code-fork"],
expected: "upstream",
},
{
name: "a remote that no longer exists",
remote: undefined,
marker: ["remote.gone.gh-resolved", "base"],
expected: "upstream",
},
{
name: "a local-path remote",
remote: ["gb-local", "."],
marker: ["remote.gb-local.gh-resolved", "base"],
expected: "upstream",
},
] as const)("resolves a gh default marking $name to $expected", ({ remote, marker, expected }) =>
Effect.gen(function* () {
const fileSystem = yield* FileSystem.FileSystem;
const cwd = yield* fileSystem.makeTempDirectoryScoped({
prefix: "t3-repository-identity-gh-default-test-",
});

yield* git(cwd, ["init"]);
yield* git(cwd, ["remote", "add", "origin", "git@github.com:julius/t3code-fork.git"]);
yield* git(cwd, ["remote", "add", "upstream", "git@github.com:T3Tools/t3code.git"]);
if (remote) yield* git(cwd, ["remote", "add", ...remote]);
yield* git(cwd, ["config", ...marker]);

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

expect(identity?.locator.remoteName).toBe(expected);
}).pipe(Effect.provide(RepositoryIdentityResolver.layer)),
);

Expand Down
42 changes: 32 additions & 10 deletions apps/server/src/project/RepositoryIdentityResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,21 @@ function parseRemoteFetchUrls(stdout: string): Map<string, string> {
return remotes;
}

/** `gh repo set-default` marks the chosen remote `remote.<name>.gh-resolved base`. */
function parseGhDefaultRemote(stdout: string): string | undefined {
for (const line of stdout.split("\n")) {
const match = /^remote\.(.+)\.gh-resolved\s+base$/.exec(line.trim());
if (match?.[1]) return match[1];
}
return undefined;
}

function pickPrimaryRemote(
remotes: ReadonlyMap<string, string>,
ghDefaultRemote: string | undefined,
): { readonly remoteName: string; readonly remoteUrl: string } | null {
for (const preferredRemoteName of ["upstream", "origin"] as const) {
for (const preferredRemoteName of [ghDefaultRemote, "upstream", "origin"]) {
if (preferredRemoteName === undefined) continue;
const remoteUrl = remotes.get(preferredRemoteName);
if (remoteUrl) {
return { remoteName: preferredRemoteName, remoteUrl };
Expand Down Expand Up @@ -227,20 +238,31 @@ const resolveRepositoryIdentityFromCacheKey = Effect.fn(
cacheKey: RepositoryIdentityCacheKey,
): Effect.fn.Return<RepositoryIdentity | null, never, ProcessRunner.ProcessRunner> {
const processRunner = yield* ProcessRunner.ProcessRunner;
const remoteResult = yield* processRunner
.run({
command: "git",
args: ["-C", cacheKey.rootPath, ...(cacheKey.isBare ? ["--git-dir=."] : []), "remote", "-v"],
env: explicitBareRepositoryEnv(process.env, yield* HostProcess.Platform),
timeoutBehavior: "timedOutResult",
})
.pipe(Effect.option);
const env = explicitBareRepositoryEnv(process.env, yield* HostProcess.Platform);
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, ghDefaultResult] = yield* Effect.all(
[runGit(["remote", "-v"]), runGit(["config", "--get-regexp", "^remote\\..+\\.gh-resolved$"])],
{ concurrency: 2 },
);
if (remoteResult._tag === "None" || remoteResult.value.code !== 0) {
return null;
}

const remotes = parseRemoteFetchUrls(remoteResult.value.stdout);
const remote = pickPrimaryRemote(remotes);
const remote = pickPrimaryRemote(
remotes,
ghDefaultResult._tag === "Some" && ghDefaultResult.value.code === 0
? parseGhDefaultRemote(ghDefaultResult.value.stdout)
: undefined,
);
return remote
? buildRepositoryIdentity({
...remote,
Expand Down
Loading