diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx
index 93bb6165524..0254a775372 100644
--- a/apps/mobile/src/Stack.tsx
+++ b/apps/mobile/src/Stack.tsx
@@ -29,6 +29,7 @@ import { ThreadTerminalRouteScreen } from "./features/terminal/ThreadTerminalRou
import { GitBranchesSheet } from "./features/threads/git/GitBranchesSheet";
import { GitCommitSheet } from "./features/threads/git/GitCommitSheet";
import { GitConfirmSheet } from "./features/threads/git/GitConfirmSheet";
+import { GitDefaultRepositorySheet } from "./features/threads/git/GitDefaultRepositorySheet";
import { GitOverviewSheet } from "./features/threads/git/GitOverviewSheet";
import { ThreadRouteScreen } from "./features/threads/ThreadRouteScreen";
import { ConnectionsRouteScreen } from "./features/connection/ConnectionsRouteScreen";
@@ -288,6 +289,7 @@ const WORKSPACE_OVERLAY_ROUTES = new Set([
"ConnectionsNew",
"GitBranches",
"GitCommit",
+ "GitDefaultRepository",
"GitConfirm",
"GitOverview",
"NewTaskSheet",
@@ -489,6 +491,15 @@ export const RootStack = createNativeStackNavigator({
sheetGrabberVisible: true,
},
}),
+ GitDefaultRepository: createNativeStackScreen({
+ screen: GitDefaultRepositorySheet,
+ linking: `${THREAD_LINKING_PREFIX}/git/default-repository`,
+ options: {
+ presentation: "formSheet",
+ sheetAllowedDetents: [0.4, 0.7],
+ sheetGrabberVisible: true,
+ },
+ }),
GitConfirm: createNativeStackScreen({
screen: GitConfirmSheet,
linking: `${THREAD_LINKING_PREFIX}/git-confirm`,
diff --git a/apps/mobile/src/features/projects/AddProjectDestinationRoute.tsx b/apps/mobile/src/features/projects/AddProjectDestinationRoute.tsx
index 04e2e236bea..51daccb4304 100644
--- a/apps/mobile/src/features/projects/AddProjectDestinationRoute.tsx
+++ b/apps/mobile/src/features/projects/AddProjectDestinationRoute.tsx
@@ -5,7 +5,9 @@ type AddProjectDestinationRouteParams = {
readonly environmentId?: string | string[];
readonly source?: string | string[];
readonly remoteUrl?: string | string[];
+ readonly repository?: string | string[];
readonly repositoryTitle?: string | string[];
+ readonly parentRepository?: string | string[];
};
export function AddProjectDestinationRoute({
diff --git a/apps/mobile/src/features/projects/AddProjectScreen.tsx b/apps/mobile/src/features/projects/AddProjectScreen.tsx
index 038a91171aa..d4d8c81dc47 100644
--- a/apps/mobile/src/features/projects/AddProjectScreen.tsx
+++ b/apps/mobile/src/features/projects/AddProjectScreen.tsx
@@ -5,11 +5,13 @@ import {
buildAddProjectRemoteSourceReadiness,
buildProjectCreateCommand,
canCreateProjectInEnvironment,
+ repositoryOwnerAvatarUrl,
findExistingAddProject,
getAddProjectInitialQuery,
getCloneDestinationQuery,
resolveAddProjectPath,
sortAddProjectProviderSources,
+ type AddProjectRemoteProviderKind,
type AddProjectRemoteSource,
} from "@t3tools/client-runtime/operations/projects";
import {
@@ -28,11 +30,16 @@ import {
getBrowseDirectoryPath,
inferProjectTitleFromPath,
} from "@t3tools/client-runtime/state/projects";
-import { CommandId, type EnvironmentId, ProjectId } from "@t3tools/contracts";
+import {
+ CommandId,
+ type EnvironmentId,
+ ProjectId,
+ type SourceControlCloneDefaultRepository,
+} from "@t3tools/contracts";
import { StackActions, useNavigation } from "@react-navigation/native";
import { SymbolView } from "../../components/AppSymbol";
import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
-import { ActivityIndicator, Alert, Pressable, ScrollView, View } from "react-native";
+import { ActivityIndicator, Alert, Image, Pressable, ScrollView, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import * as Arr from "effect/Array";
import * as Cause from "effect/Cause";
@@ -196,6 +203,45 @@ function ListRow(props: {
);
}
+/** Owner avatar for a repository row, falling back to the provider mark. */
+function RepositoryOwnerAvatar(props: {
+ readonly nameWithOwner: string;
+ readonly remoteUrl: string | null;
+ readonly provider: AddProjectRemoteProviderKind;
+}) {
+ const iconColor = useThemeColor("--color-icon");
+ const [hasFailed, setHasFailed] = useState(false);
+ const avatarUrl = props.remoteUrl
+ ? repositoryOwnerAvatarUrl({
+ repositoryUrl: props.remoteUrl,
+ nameWithOwner: props.nameWithOwner,
+ size: 96,
+ })
+ : null;
+
+ if (avatarUrl === null || hasFailed) {
+ return ;
+ }
+
+ return (
+ {
+ setHasFailed(true);
+ }}
+ />
+ );
+}
+
+function SelectedCheckmark(props: { readonly selected: boolean }) {
+ const primaryColor = useThemeColor("--color-primary");
+ if (!props.selected) {
+ return null;
+ }
+ return ;
+}
+
function PrimaryActionButton(props: {
readonly label: string;
readonly disabled?: boolean;
@@ -657,7 +703,11 @@ export function AddProjectRepositoryScreen(props: {
environmentId: environment.environmentId,
source,
remoteUrl: repository.sshUrl,
+ repository: repository.nameWithOwner,
repositoryTitle: repository.nameWithOwner,
+ ...(repository.parentNameWithOwner
+ ? { parentRepository: repository.parentNameWithOwner }
+ : {}),
},
});
}
@@ -846,8 +896,11 @@ export function AddProjectLocalFolderScreen(props: { readonly environmentId?: st
export function AddProjectDestinationScreen(props: {
readonly environmentId?: string | string[];
+ readonly source?: string | string[];
readonly remoteUrl?: string | string[];
+ readonly repository?: string | string[];
readonly repositoryTitle?: string | string[];
+ readonly parentRepository?: string | string[];
}) {
const cloneRepository = useAtomCommand(sourceControlEnvironment.cloneRepository, {
reportFailure: false,
@@ -855,7 +908,14 @@ export function AddProjectDestinationScreen(props: {
const environment = useEnvironmentFromParam(props.environmentId);
const createProject = useCreateProject(environment);
const remoteUrl = stringParam(props.remoteUrl);
+ const provider = addProjectRemoteSourceProvider(sourceFromParam(props.source));
+ const repository = stringParam(props.repository);
const repositoryTitle = stringParam(props.repositoryTitle);
+ const parentRepository = stringParam(props.parentRepository);
+ // Forks pick which repository `gh` targets, the way `gh repo set-default`
+ // does. The fork leads: it is the repository being cloned.
+ const [defaultRepository, setDefaultRepository] =
+ useState("cloned");
const { isBrowseNavigating, navigateToBrowsePath, pathInput, setPathInput } = useBrowsePathInput(
environment,
{ nameWithOwner: repositoryTitle, remoteUrl },
@@ -876,10 +936,17 @@ export function AddProjectDestinationScreen(props: {
return;
}
+ // A fork is the only clone that needs its repository named on the server.
+ const isForkClone = provider !== null && repository !== null && parentRepository !== null;
+
setIsSubmitting(true);
const cloneResult = await cloneRepository({
environmentId: environment.environmentId,
input: {
+ // Only a fork needs naming: it is what lets the server wire up the
+ // upstream remote. Every other clone stays a plain URL clone, with no
+ // second repository lookup on the server.
+ ...(isForkClone ? { provider, repository, defaultRepository } : {}),
remoteUrl,
destinationPath: resolved.path,
},
@@ -887,6 +954,14 @@ export function AddProjectDestinationScreen(props: {
if (AsyncResult.isFailure(cloneResult)) {
setError(errorMessage(Cause.squash(cloneResult.cause)));
} else {
+ // The clone itself succeeded, so this is a warning rather than a failure:
+ // the repository is on disk, just without the remote that was asked for.
+ if (isForkClone && !cloneResult.value.upstream) {
+ Alert.alert(
+ "Upstream remote not added",
+ `Cloned, but ${parentRepository} could not be wired up as a remote.`,
+ );
+ }
const createResult = await createProject(cloneResult.value.cwd);
if (createResult && AsyncResult.isFailure(createResult)) {
setError(errorMessage(Cause.squash(createResult.cause)));
@@ -896,11 +971,15 @@ export function AddProjectDestinationScreen(props: {
}, [
cloneRepository,
createProject,
+ defaultRepository,
environment,
isBrowseNavigating,
isSubmitting,
+ parentRepository,
pathInput,
+ provider,
remoteUrl,
+ repository,
]);
return (
@@ -910,10 +989,47 @@ export function AddProjectDestinationScreen(props: {
{repositoryTitle}
- {remoteUrl}
+ {parentRepository ? `forked from ${parentRepository}` : remoteUrl}
) : null}
+ {parentRepository && repositoryTitle && provider ? (
+ <>
+ Default repository
+
+ Where pull requests, issues, and releases go
+
+
+
+ }
+ right={}
+ onPress={() => setDefaultRepository("cloned")}
+ />
+
+ }
+ right={}
+ onPress={() => setDefaultRepository("parent")}
+ />
+
+ >
+ ) : null}
{environment ? (
<>
;
+
+/** Sentinel row, matching the "Not set" option web offers. */
+const UNSET_ROW_KEY = "__unset__";
+
+function remoteLabel(state: SourceControlDefaultRepositoryState, remoteName: string): string {
+ const remote = state.remotes.find((candidate) => candidate.remoteName === remoteName);
+ return remote?.nameWithOwner ?? remote?.url ?? remoteName;
+}
+
+/**
+ * Mobile's half of Settings → Projects → Checkout → Default repository on web:
+ * which repository this checkout's pull requests, issues, and releases target.
+ * Same git config the GitHub CLI's `gh repo set-default` writes.
+ */
+export function GitDefaultRepositorySheet(_props: GitDefaultRepositorySheetProps) {
+ const navigation = useNavigation();
+ const insets = useSafeAreaInsets();
+ const iconColor = useThemeColor("--color-icon");
+ const primaryColor = useThemeColor("--color-primary");
+ const { selectedThread } = useThreadSelection();
+ const { selectedThreadCwd } = useSelectedThreadWorktree();
+
+ const [state, setState] = useState(null);
+ const [error, setError] = useState(null);
+ const [isSaving, setIsSaving] = useState(false);
+
+ const readDefaultRepository = useAtomQueryRunner(sourceControlEnvironment.defaultRepository, {
+ reportFailure: false,
+ });
+ const writeDefaultRepository = useAtomCommand(sourceControlEnvironment.setDefaultRepository, {
+ reportFailure: false,
+ });
+
+ const environmentId = selectedThread?.environmentId ?? null;
+ useEffect(() => {
+ if (environmentId === null || selectedThreadCwd === null) return;
+ let cancelled = false;
+ void readDefaultRepository({ environmentId, input: { cwd: selectedThreadCwd } }).then(
+ (result) => {
+ if (cancelled) return;
+ if (AsyncResult.isFailure(result)) {
+ setError(errorMessage(Cause.squash(result.cause)));
+ } else {
+ setState(result.value);
+ }
+ },
+ );
+ return () => {
+ cancelled = true;
+ };
+ }, [environmentId, readDefaultRepository, selectedThreadCwd]);
+
+ const select = useCallback(
+ async (remoteName: string | null) => {
+ if (environmentId === null || selectedThreadCwd === null || isSaving) return;
+ setError(null);
+ setIsSaving(true);
+ const result = await writeDefaultRepository({
+ environmentId,
+ input: { cwd: selectedThreadCwd, remoteName },
+ });
+ setIsSaving(false);
+ if (AsyncResult.isFailure(result)) {
+ setError(errorMessage(Cause.squash(result.cause)));
+ return;
+ }
+ setState(result.value);
+ navigation.goBack();
+ },
+ [environmentId, isSaving, navigation, selectedThreadCwd, writeDefaultRepository],
+ );
+
+ const rows =
+ state === null
+ ? []
+ : [
+ ...state.remotes.map((remote) => ({
+ key: remote.remoteName,
+ title:
+ remote.remoteName === state.defaultRemoteName && state.defaultRepositoryPath
+ ? state.defaultRepositoryPath
+ : remoteLabel(state, remote.remoteName),
+ subtitle: remote.remoteName,
+ selected: remote.remoteName === state.defaultRemoteName,
+ })),
+ {
+ key: UNSET_ROW_KEY,
+ title: "Not set",
+ subtitle: "GitHub CLI decides",
+ selected: state.defaultRemoteName === null,
+ },
+ ];
+
+ return (
+
+ {Platform.OS === "android" ? (
+ navigation.goBack()} />
+ ) : null}
+
+ {error ? : null}
+
+ Where pull requests, issues, and releases go for this checkout.
+
+
+ {rows.map((row, index) => (
+ 0 && "border-t border-border-subtle",
+ )}
+ onPress={() => void select(row.key === UNSET_ROW_KEY ? null : row.key)}
+ >
+
+ {row.title}
+ {row.subtitle}
+
+ {row.selected ? (
+
+ ) : null}
+
+ ))}
+ {state === null ? (
+
+
+ Reading remotes…
+
+ ) : null}
+
+
+
+ );
+}
+
+function errorMessage(error: unknown): string {
+ return error instanceof Error && error.message.trim().length > 0
+ ? error.message
+ : "An error occurred.";
+}
diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx
index 17e4de0ab6f..952068dd047 100644
--- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx
+++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx
@@ -24,6 +24,7 @@ import { AppText as Text } from "../../../components/AppText";
import { nativeHeaderScrollEdgeEffects } from "../../../native/StackHeader";
import { tryOpenExternalUrl } from "../../../lib/openExternalUrl";
import { useEnvironmentQuery } from "../../../state/query";
+import { sourceControlEnvironment } from "../../../state/sourceControl";
import { useThreadSelection } from "../../../state/use-thread-selection";
import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions";
import { useSelectedThreadGitState } from "../../../state/use-selected-thread-git-state";
@@ -67,6 +68,30 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
: null,
);
+ // Only a checkout with something to choose between shows the row: a fork
+ // clone, or any repository whose remotes span more than one GitHub repo.
+ const defaultRepository = useEnvironmentQuery(
+ selectedThread !== null && selectedThreadCwd !== null
+ ? sourceControlEnvironment.defaultRepository({
+ environmentId: selectedThread.environmentId,
+ input: { cwd: selectedThreadCwd },
+ })
+ : null,
+ );
+ const defaultRepositoryState = defaultRepository.data ?? null;
+ const canChooseDefaultRepository =
+ defaultRepositoryState !== null &&
+ defaultRepositoryState.remotes.length > 1 &&
+ defaultRepositoryState.remotes.some((remote) => remote.provider === "github");
+ const defaultRepositoryLabel = !defaultRepositoryState
+ ? null
+ : (defaultRepositoryState.defaultRepositoryPath ??
+ defaultRepositoryState.remotes.find(
+ (remote) => remote.remoteName === defaultRepositoryState.defaultRemoteName,
+ )?.nameWithOwner ??
+ defaultRepositoryState.defaultRemoteName ??
+ "Not set");
+
const currentBranchLabel = gitStatus.data?.refName ?? selectedThread?.branch ?? "Detached HEAD";
const currentStatusSummary = statusSummary(gitStatus.data);
const currentWorktreePath = selectedThreadWorktreePath;
@@ -271,6 +296,23 @@ export function GitOverviewSheet(props: GitOverviewSheetProps) {
);
}}
/>
+ {canChooseDefaultRepository ? (
+ <>
+
+
+ navigation.navigate("GitDefaultRepository", {
+ environmentId: String(environmentId),
+ threadId: String(threadId),
+ })
+ }
+ />
+ >
+ ) : null}
();
- let ghDefaultRemote: {
- readonly remoteName: string;
- readonly repositoryPath: string | null;
- } | null = null;
-
- for (const line of stdout.split("\n")) {
- 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(),
- };
- }
+ for (const entry of entries) {
+ if (entry.url) remotes.set(entry.remoteName, entry.url);
}
+
+ const pinned = entries.find((entry) => entry.ghResolved !== null);
+ const ghDefaultRemote = pinned?.ghResolved
+ ? {
+ remoteName: pinned.remoteName,
+ repositoryPath: pinned.ghResolved === "base" ? null : pinned.ghResolved.toLowerCase(),
+ }
+ : null;
+
return { remotes, ghDefaultRemote };
}
diff --git a/apps/server/src/sourceControl/GitHubCli.test.ts b/apps/server/src/sourceControl/GitHubCli.test.ts
index acd4c35e58c..1d067191df9 100644
--- a/apps/server/src/sourceControl/GitHubCli.test.ts
+++ b/apps/server/src/sourceControl/GitHubCli.test.ts
@@ -422,6 +422,50 @@ describe("GitHubCli.layer", () => {
}).pipe(Effect.provide(layer)),
);
+ it.effect("reports the fork parent when the repository has one", () =>
+ Effect.gen(function* () {
+ mockRun.mockReturnValueOnce(
+ Effect.succeed(
+ processOutput(
+ // @effect-diagnostics-next-line preferSchemaOverJson:off
+ JSON.stringify({
+ nameWithOwner: "octocat/codething-mvp",
+ url: "https://github.com/octocat/codething-mvp",
+ sshUrl: "git@github.com:octocat/codething-mvp.git",
+ // Shape `gh repo view --json parent` really returns, ids included.
+ parent: {
+ id: "R_kgDORLtfbQ",
+ name: "codething-mvp",
+ owner: { id: "MDEyOk9yZ2FuaXphdGlvbg==", login: "codething" },
+ },
+ }),
+ ),
+ ),
+ );
+
+ const gh = yield* GitHubCli.GitHubCli;
+ const result = yield* gh.getRepositoryCloneUrls({
+ cwd: "/repo",
+ repository: "octocat/codething-mvp",
+ });
+
+ assert.strictEqual(result.parentNameWithOwner, "codething/codething-mvp");
+ expect(mockRun).toHaveBeenNthCalledWith(1, {
+ operation: "GitHubCli.execute",
+ command: "gh",
+ args: [
+ "repo",
+ "view",
+ "octocat/codething-mvp",
+ "--json",
+ "nameWithOwner,url,sshUrl,parent",
+ ],
+ cwd: "/repo",
+ timeoutMs: 30_000,
+ });
+ }).pipe(Effect.provide(layer)),
+ );
+
it.effect("creates repositories and parses clone URLs from create output", () =>
Effect.gen(function* () {
mockRun.mockReturnValueOnce(
diff --git a/apps/server/src/sourceControl/GitHubCli.ts b/apps/server/src/sourceControl/GitHubCli.ts
index 252b37bc63d..142e5a7e727 100644
--- a/apps/server/src/sourceControl/GitHubCli.ts
+++ b/apps/server/src/sourceControl/GitHubCli.ts
@@ -249,6 +249,7 @@ export interface GitHubRepositoryCloneUrls {
readonly nameWithOwner: string;
readonly url: string;
readonly sshUrl: string;
+ readonly parentNameWithOwner?: string;
}
export class GitHubCli extends Context.Service<
@@ -315,10 +316,17 @@ export class GitHubCli extends Context.Service<
}
>()("t3/sourceControl/GitHubCli") {}
+/** `gh repo view --json parent` reports the fork parent as owner/name, without its URLs. */
+const RawGitHubRepositoryParentSchema = Schema.Struct({
+ name: TrimmedNonEmptyString,
+ owner: Schema.Struct({ login: TrimmedNonEmptyString }),
+});
+
const RawGitHubRepositoryCloneUrlsSchema = Schema.Struct({
nameWithOwner: TrimmedNonEmptyString,
url: TrimmedNonEmptyString,
sshUrl: TrimmedNonEmptyString,
+ parent: Schema.optional(Schema.NullOr(RawGitHubRepositoryParentSchema)),
});
const decodeRawGitHubRepositoryCloneUrls = Schema.decodeEffect(
Schema.fromJsonString(RawGitHubRepositoryCloneUrlsSchema),
@@ -331,6 +339,7 @@ function normalizeRepositoryCloneUrls(
nameWithOwner: raw.nameWithOwner,
url: raw.url,
sshUrl: raw.sshUrl,
+ ...(raw.parent ? { parentNameWithOwner: `${raw.parent.owner.login}/${raw.parent.name}` } : {}),
};
}
@@ -524,7 +533,7 @@ export const make = Effect.gen(function* () {
getRepositoryCloneUrls: (input) =>
execute({
cwd: input.cwd,
- args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl"],
+ args: ["repo", "view", input.repository, "--json", "nameWithOwner,url,sshUrl,parent"],
}).pipe(
Effect.map((result) => result.stdout.trim()),
Effect.flatMap((raw) =>
diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts
index 7c32a5fce90..030cb210322 100644
--- a/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts
+++ b/apps/server/src/sourceControl/SourceControlRepositoryService.test.ts
@@ -21,6 +21,22 @@ const CLONE_URLS = {
sshUrl: "git@github.com:octocat/t3code.git",
};
+const PARENT_URLS = {
+ nameWithOwner: "t3/t3code",
+ url: "https://github.com/t3/t3code",
+ sshUrl: "git@github.com:t3/t3code.git",
+};
+
+const FORK_URLS = { ...CLONE_URLS, parentNameWithOwner: PARENT_URLS.nameWithOwner };
+
+/** Answers the fork lookup and the follow-up parent lookup from one provider mock. */
+function makeForkProvider() {
+ return makeProvider({
+ getRepositoryCloneUrls: (input) =>
+ Effect.succeed(input.repository === PARENT_URLS.nameWithOwner ? PARENT_URLS : FORK_URLS),
+ });
+}
+
function makeProvider(
overrides: Partial = {},
): SourceControlProvider.SourceControlProvider["Service"] {
@@ -70,6 +86,8 @@ function makeLayer(input: {
Layer.mock(GitVcsDriver.GitVcsDriver)({
execute: () => Effect.succeed(processOutput()),
ensureRemote: () => Effect.succeed("origin"),
+ resolvePrimaryRemoteName: () => Effect.succeed("origin"),
+ fetchRemote: () => Effect.void,
pushCurrentBranch: () =>
Effect.succeed({
status: "pushed" as const,
@@ -181,6 +199,7 @@ it.effect("clones a looked-up repository into the requested destination", () =>
args: ["clone", CLONE_URLS.url, "t3code"],
},
]);
+ assert.strictEqual("upstream" in result, false);
}).pipe(
Effect.provide(
makeLayer({
@@ -197,6 +216,422 @@ it.effect("clones a looked-up repository into the requested destination", () =>
}).pipe(Effect.provide(NodeServices.layer)),
);
+/** Answers `git config --get-regexp` for a fork clone that already has both remotes. */
+function forkRemoteConfigStdout(defaultRemoteName: string | null): string {
+ return [
+ `remote.origin.url ${CLONE_URLS.url}`,
+ `remote.upstream.url ${PARENT_URLS.url}`,
+ ...(defaultRemoteName ? [`remote.${defaultRemoteName}.gh-resolved base`] : []),
+ ].join("\n");
+}
+
+it.effect("lists remote candidates and the current default repository", () =>
+ Effect.gen(function* () {
+ const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
+ const state = yield* service.getDefaultRepository({ cwd: "/workspace" });
+
+ assert.deepStrictEqual(state, {
+ remotes: [
+ {
+ remoteName: "origin",
+ url: CLONE_URLS.url,
+ nameWithOwner: "octocat/t3code",
+ provider: "github",
+ },
+ {
+ remoteName: "upstream",
+ url: PARENT_URLS.url,
+ nameWithOwner: "t3/t3code",
+ provider: "github",
+ },
+ ],
+ defaultRemoteName: "upstream",
+ });
+ }).pipe(
+ Effect.provide(
+ makeLayer({
+ git: {
+ execute: () =>
+ Effect.succeed({ ...processOutput(), stdout: forkRemoteConfigStdout("upstream") }),
+ },
+ }),
+ ),
+ ),
+);
+
+it.effect("moves the default repository pin to the chosen remote", () => {
+ const configCalls: Array> = [];
+
+ return Effect.gen(function* () {
+ const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
+ yield* service.setDefaultRepository({ cwd: "/workspace", remoteName: "origin" });
+
+ assert.deepStrictEqual(configCalls, [
+ ["config", "--unset-all", "remote.upstream.gh-resolved"],
+ ["config", "--replace-all", "remote.origin.gh-resolved", "base"],
+ ]);
+ }).pipe(
+ Effect.provide(
+ makeLayer({
+ git: {
+ execute: (input) =>
+ Effect.sync(() => {
+ if (input.args[1] !== "--get-regexp") {
+ configCalls.push(input.args);
+ }
+ return { ...processOutput(), stdout: forkRemoteConfigStdout("upstream") };
+ }),
+ },
+ }),
+ ),
+ );
+});
+
+it.effect("rejects a default repository that is not one of the remotes", () =>
+ Effect.gen(function* () {
+ const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
+ const error = yield* Effect.flip(
+ service.setDefaultRepository({ cwd: "/workspace", remoteName: "fork" }),
+ );
+
+ assert.strictEqual(error.operation, "setDefaultRepository");
+ assert.strictEqual(error.detail, "Choose a remote that exists in this repository.");
+ }).pipe(
+ Effect.provide(
+ makeLayer({
+ git: {
+ execute: () =>
+ Effect.succeed({ ...processOutput(), stdout: forkRemoteConfigStdout(null) }),
+ },
+ }),
+ ),
+ ),
+);
+
+it.effect("clears every pin when the default repository is unset", () => {
+ const configCalls: Array> = [];
+
+ return Effect.gen(function* () {
+ const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
+ const state = yield* service.setDefaultRepository({ cwd: "/workspace", remoteName: null });
+
+ assert.deepStrictEqual(configCalls, [["config", "--unset-all", "remote.upstream.gh-resolved"]]);
+ // The mocked config keeps reporting the pin, so this asserts the read-back
+ // shape rather than the cleared value.
+ assert.strictEqual(state.remotes.length, 2);
+ }).pipe(
+ Effect.provide(
+ makeLayer({
+ git: {
+ execute: (input) =>
+ Effect.sync(() => {
+ if (input.args[1] !== "--get-regexp") {
+ configCalls.push(input.args);
+ }
+ return { ...processOutput(), stdout: forkRemoteConfigStdout("upstream") };
+ }),
+ },
+ }),
+ ),
+ );
+});
+
+it.effect("reports the repository a pin names when it is not the remote's own", () =>
+ Effect.gen(function* () {
+ const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
+ const state = yield* service.getDefaultRepository({ cwd: "/workspace" });
+
+ // What `gh repo set-default` writes for a fork cloned without an upstream
+ // remote: the pin lives on origin but names the parent repository.
+ assert.strictEqual(state.defaultRemoteName, "origin");
+ assert.strictEqual(state.defaultRepositoryPath, PARENT_URLS.nameWithOwner);
+ }).pipe(
+ Effect.provide(
+ makeLayer({
+ git: {
+ execute: () =>
+ Effect.succeed({
+ ...processOutput(),
+ stdout: [
+ `remote.origin.url ${CLONE_URLS.url}`,
+ `remote.origin.gh-resolved ${PARENT_URLS.nameWithOwner}`,
+ ].join("\n"),
+ }),
+ },
+ }),
+ ),
+ ),
+);
+
+it.effect("reads remote names containing dots, and a repository with no remotes", () =>
+ Effect.gen(function* () {
+ const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
+ const state = yield* service.getDefaultRepository({ cwd: "/workspace" });
+
+ assert.deepStrictEqual(
+ state.remotes.map((remote) => remote.remoteName),
+ ["my.fork"],
+ );
+ assert.strictEqual(state.defaultRemoteName, "my.fork");
+
+ const empty = yield* service.getDefaultRepository({ cwd: "/empty" });
+ assert.deepStrictEqual(empty, { remotes: [], defaultRemoteName: null });
+ }).pipe(
+ Effect.provide(
+ makeLayer({
+ git: {
+ execute: (input) =>
+ Effect.succeed({
+ ...processOutput(),
+ stdout:
+ input.cwd === "/empty"
+ ? ""
+ : [
+ `remote.my.fork.url ${CLONE_URLS.url}`,
+ "remote.my.fork.gh-resolved base",
+ ].join("\n"),
+ }),
+ },
+ }),
+ ),
+ ),
+);
+
+it.effect("wires a cloned fork to its parent and pins the fork by default", () =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const parent = yield* fs.makeTempDirectoryScoped({
+ prefix: "t3-source-control-clone-fork-",
+ });
+ const destinationPath = `${parent}/t3code`;
+ const gitCalls: Array<{ cwd: string; args: ReadonlyArray }> = [];
+ const remoteCalls: Array<{ cwd: string; preferredName: string; url: string }> = [];
+
+ yield* Effect.gen(function* () {
+ const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
+ const result = yield* service.cloneRepository({
+ provider: "github",
+ repository: "octocat/t3code",
+ destinationPath,
+ protocol: "https",
+ });
+
+ assert.deepStrictEqual(result.upstream, {
+ remoteName: "upstream",
+ nameWithOwner: PARENT_URLS.nameWithOwner,
+ remoteUrl: PARENT_URLS.url,
+ });
+ assert.deepStrictEqual(remoteCalls, [
+ { cwd: destinationPath, preferredName: "upstream", url: PARENT_URLS.url },
+ ]);
+ assert.deepStrictEqual(gitCalls, [
+ { cwd: parent, args: ["clone", CLONE_URLS.url, "t3code"] },
+ {
+ cwd: destinationPath,
+ args: ["config", "--replace-all", "remote.origin.gh-resolved", "base"],
+ },
+ ]);
+ }).pipe(
+ Effect.provide(
+ makeLayer({
+ provider: makeForkProvider(),
+ git: {
+ execute: (input) =>
+ Effect.sync(() => {
+ gitCalls.push({ cwd: input.cwd, args: input.args });
+ return processOutput();
+ }),
+ ensureRemote: (input) =>
+ Effect.sync(() => {
+ remoteCalls.push(input);
+ return "upstream";
+ }),
+ },
+ }),
+ ),
+ );
+ }).pipe(Effect.provide(NodeServices.layer)),
+);
+
+it.effect("pins the parent when the clone asks to contribute upstream", () =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const parent = yield* fs.makeTempDirectoryScoped({
+ prefix: "t3-source-control-clone-fork-parent-",
+ });
+ const destinationPath = `${parent}/t3code`;
+ const gitCalls: Array<{ cwd: string; args: ReadonlyArray }> = [];
+ const remoteCalls: Array<{ cwd: string; preferredName: string; url: string }> = [];
+
+ yield* Effect.gen(function* () {
+ const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
+ yield* service.cloneRepository({
+ provider: "github",
+ repository: "octocat/t3code",
+ destinationPath,
+ protocol: "https",
+ defaultRepository: "parent",
+ });
+
+ assert.deepStrictEqual(gitCalls, [
+ { cwd: parent, args: ["clone", CLONE_URLS.url, "t3code"] },
+ {
+ cwd: destinationPath,
+ args: ["config", "--replace-all", "remote.upstream.gh-resolved", "base"],
+ },
+ ]);
+ }).pipe(
+ Effect.provide(
+ makeLayer({
+ provider: makeForkProvider(),
+ git: {
+ execute: (input) =>
+ Effect.sync(() => {
+ gitCalls.push({ cwd: input.cwd, args: input.args });
+ return processOutput();
+ }),
+ ensureRemote: (input) =>
+ Effect.sync(() => {
+ remoteCalls.push(input);
+ return "upstream";
+ }),
+ },
+ }),
+ ),
+ );
+ }).pipe(Effect.provide(NodeServices.layer)),
+);
+
+it.effect("fetches the upstream remote, and keeps the clone when that fetch fails", () =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const parent = yield* fs.makeTempDirectoryScoped({
+ prefix: "t3-source-control-clone-fork-fetch-",
+ });
+ const destinationPath = `${parent}/t3code`;
+ const fetchCalls: Array<{ cwd: string; remoteName: string }> = [];
+
+ yield* Effect.gen(function* () {
+ const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
+ const result = yield* service.cloneRepository({
+ provider: "github",
+ repository: "octocat/t3code",
+ destinationPath,
+ });
+
+ assert.deepStrictEqual(fetchCalls, [{ cwd: destinationPath, remoteName: "upstream" }]);
+ // The remote is wired up either way, so a failed fetch still reports it.
+ assert.strictEqual(result.upstream?.remoteName, "upstream");
+ }).pipe(
+ Effect.provide(
+ makeLayer({
+ provider: makeForkProvider(),
+ git: {
+ ensureRemote: () => Effect.succeed("upstream"),
+ fetchRemote: (input) =>
+ Effect.sync(() => {
+ fetchCalls.push(input);
+ }).pipe(
+ Effect.andThen(
+ new GitCommandError({
+ operation: "GitVcsDriver.fetchRemote",
+ command: "git fetch upstream",
+ cwd: input.cwd,
+ detail: "fatal: could not read from remote repository",
+ }),
+ ),
+ ),
+ },
+ }),
+ ),
+ );
+ }).pipe(Effect.provide(NodeServices.layer)),
+);
+
+it.effect("keeps a fork clone when the upstream remote cannot be added", () =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const parent = yield* fs.makeTempDirectoryScoped({
+ prefix: "t3-source-control-clone-fork-failure-",
+ });
+ const destinationPath = `${parent}/t3code`;
+
+ yield* Effect.gen(function* () {
+ const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
+ const result = yield* service.cloneRepository({
+ provider: "github",
+ repository: "octocat/t3code",
+ destinationPath,
+ });
+
+ assert.strictEqual(result.cwd, destinationPath);
+ assert.strictEqual(result.upstream, undefined);
+ }).pipe(
+ Effect.provide(
+ makeLayer({
+ provider: makeForkProvider(),
+ git: {
+ ensureRemote: (input) =>
+ new GitCommandError({
+ operation: "GitVcsDriver.ensureRemote.add",
+ command: "git remote add upstream",
+ cwd: input.cwd,
+ detail: "fatal: could not add remote",
+ }),
+ },
+ }),
+ ),
+ );
+ }).pipe(Effect.provide(NodeServices.layer)),
+);
+
+it.effect("falls back to the requested clone URL when the repository lookup fails", () =>
+ Effect.gen(function* () {
+ const fs = yield* FileSystem.FileSystem;
+ const parent = yield* fs.makeTempDirectoryScoped({
+ prefix: "t3-source-control-clone-lookup-failure-",
+ });
+ const destinationPath = `${parent}/t3code`;
+ const cloneCalls: Array> = [];
+
+ yield* Effect.gen(function* () {
+ const service = yield* SourceControlRepositoryService.SourceControlRepositoryService;
+ const result = yield* service.cloneRepository({
+ provider: "github",
+ repository: "octocat/t3code",
+ remoteUrl: CLONE_URLS.sshUrl,
+ destinationPath,
+ });
+
+ assert.strictEqual(result.repository, null);
+ assert.strictEqual(result.remoteUrl, CLONE_URLS.sshUrl);
+ assert.deepStrictEqual(cloneCalls, [["clone", CLONE_URLS.sshUrl, "t3code"]]);
+ }).pipe(
+ Effect.provide(
+ makeLayer({
+ provider: makeProvider({
+ getRepositoryCloneUrls: (input) =>
+ new SourceControlProviderError({
+ provider: "github",
+ operation: "getRepositoryCloneUrls",
+ cwd: input.cwd,
+ repository: input.repository,
+ detail: "gh is not authenticated",
+ }),
+ }),
+ git: {
+ execute: (input) =>
+ Effect.sync(() => {
+ cloneCalls.push(input.args);
+ return processOutput();
+ }),
+ },
+ }),
+ ),
+ );
+ }).pipe(Effect.provide(NodeServices.layer)),
+);
+
it.effect("preserves destination probe failures instead of treating them as missing paths", () => {
const fileSystemCause = PlatformError.systemError({
_tag: "PermissionDenied",
diff --git a/apps/server/src/sourceControl/SourceControlRepositoryService.ts b/apps/server/src/sourceControl/SourceControlRepositoryService.ts
index 8442d093409..a08cb7d19c2 100644
--- a/apps/server/src/sourceControl/SourceControlRepositoryService.ts
+++ b/apps/server/src/sourceControl/SourceControlRepositoryService.ts
@@ -9,9 +9,13 @@ import * as Schema from "effect/Schema";
import {
SourceControlProviderError,
SourceControlRepositoryError,
+ type SourceControlCloneDefaultRepository,
type SourceControlCloneRepositoryInput,
type SourceControlCloneRepositoryResult,
type SourceControlCloneProtocol,
+ type SourceControlDefaultRepositoryRemote,
+ type SourceControlDefaultRepositoryState,
+ type SourceControlGetDefaultRepositoryInput,
type SourceControlProviderKind,
type SourceControlPublishRepositoryInput,
type SourceControlPublishRepositoryResult,
@@ -22,7 +26,14 @@ import {
type SourceControlListIssuesInput,
type SourceControlListIssuesResult,
type SourceControlRepositoryLookupInput,
+ type SourceControlSetDefaultRepositoryInput,
} from "@t3tools/contracts";
+import {
+ detectSourceControlProviderFromGitRemoteUrl,
+ normalizeGitRemoteUrl,
+ parseGitHubRepositoryNameWithOwnerFromRemoteUrl,
+ parseGitRemoteConfig,
+} from "@t3tools/shared/git";
import { ServerConfig } from "../config.ts";
import * as GitVcsDriver from "../vcs/GitVcsDriver.ts";
@@ -41,6 +52,17 @@ export class SourceControlRepositoryService extends Context.Service<
readonly publishRepository: (
input: SourceControlPublishRepositoryInput,
) => Effect.Effect;
+ /**
+ * Reads the candidates and current pick for the repository `gh` treats as
+ * this checkout's default. Both of these read and write the same git config
+ * `gh repo set-default` uses, so the two stay interchangeable.
+ */
+ readonly getDefaultRepository: (
+ input: SourceControlGetDefaultRepositoryInput,
+ ) => Effect.Effect;
+ readonly setDefaultRepository: (
+ input: SourceControlSetDefaultRepositoryInput,
+ ) => Effect.Effect;
/**
* Issue browsing resolves the provider from the working directory's remote
* rather than an explicit `provider` field, so it keeps the richer
@@ -78,6 +100,7 @@ function toRepositoryInfo(
nameWithOwner: urls.nameWithOwner,
url: urls.url,
sshUrl: urls.sshUrl,
+ ...(urls.parentNameWithOwner ? { parentNameWithOwner: urls.parentNameWithOwner } : {}),
};
}
@@ -94,6 +117,72 @@ function selectRemoteUrl(
}
}
+/**
+ * `gh` records the default repository as `remote..gh-resolved`, which is
+ * also what `RepositoryIdentityResolver` reads when it decides which remote
+ * identifies a project. One `git config` read covers both the remote list and
+ * the current pick.
+ */
+const GH_RESOLVED_CONFIG_PATTERN = "^remote\\..*\\.(url|gh-resolved)$";
+
+interface ParsedRemoteConfig {
+ readonly state: SourceControlDefaultRepositoryState;
+ /** Every remote carrying a pin, so a stale second pin gets cleared too. */
+ readonly pinnedRemoteNames: ReadonlyArray;
+}
+
+/**
+ * A remote URL names its repository, but only GitHub's `github.com` shape is
+ * parsed with case intact; every other host falls back to the normalized
+ * `host/owner/repo` key so Enterprise remotes still read as a repository rather
+ * than a URL.
+ */
+function repositoryNameWithOwnerFromRemoteUrl(url: string): string | null {
+ const gitHubNameWithOwner = parseGitHubRepositoryNameWithOwnerFromRemoteUrl(url);
+ if (gitHubNameWithOwner) {
+ return gitHubNameWithOwner;
+ }
+ const segments = normalizeGitRemoteUrl(url).split("/");
+ return segments.length > 1 ? segments.slice(1).join("/") : null;
+}
+
+function parseRemoteConfig(stdout: string): ParsedRemoteConfig {
+ const entries = parseGitRemoteConfig(stdout);
+ const remotes: ReadonlyArray = entries.flatMap((entry) =>
+ entry.url === null
+ ? []
+ : [
+ {
+ remoteName: entry.remoteName,
+ url: entry.url,
+ nameWithOwner: repositoryNameWithOwnerFromRemoteUrl(entry.url),
+ provider: detectSourceControlProviderFromGitRemoteUrl(entry.url)?.kind ?? "unknown",
+ },
+ ],
+ );
+
+ const pinnedRemoteNames = entries
+ .filter((entry) => entry.ghResolved !== null)
+ .map((entry) => entry.remoteName);
+ const pinned = entries.find(
+ (entry) => entry.ghResolved !== null && remotes.some((r) => r.remoteName === entry.remoteName),
+ );
+
+ // `base` means the pinned remote's own repository; anything else names a
+ // different one that `gh` reaches through that remote.
+ const defaultRepositoryPath =
+ pinned && pinned.ghResolved !== "base" ? pinned.ghResolved : undefined;
+
+ return {
+ state: {
+ remotes,
+ defaultRemoteName: pinned?.remoteName ?? null,
+ ...(defaultRepositoryPath ? { defaultRepositoryPath } : {}),
+ },
+ pinnedRemoteNames,
+ };
+}
+
function expandHomePath(input: string, path: Path.Path): string {
if (input === "~") {
return NodeOS.homedir();
@@ -194,6 +283,133 @@ export const make = Effect.gen(function* () {
},
);
+ const readRemoteConfig = Effect.fn("SourceControlRepositoryService.readRemoteConfig")(function* (
+ cwd: string,
+ ) {
+ const result = yield* git.execute({
+ operation: "SourceControlRepositoryService.getDefaultRepository",
+ cwd,
+ args: ["config", "--get-regexp", GH_RESOLVED_CONFIG_PATTERN],
+ // Exits non-zero when nothing matches, which just means no remotes.
+ allowNonZeroExit: true,
+ });
+ return parseRemoteConfig(result.stdout);
+ });
+
+ const getDefaultRepository = Effect.fn("SourceControlRepositoryService.getDefaultRepository")(
+ function* (input: SourceControlGetDefaultRepositoryInput) {
+ return (yield* readRemoteConfig(input.cwd)).state;
+ },
+ );
+
+ /** `gh` keeps exactly one pin, so clear any others before writing the pick. */
+ const pinDefaultRemote = Effect.fn("SourceControlRepositoryService.pinDefaultRemote")(
+ function* (input: {
+ readonly cwd: string;
+ readonly remoteName: string | null;
+ readonly pinnedRemoteNames: ReadonlyArray;
+ }) {
+ for (const pinnedRemoteName of input.pinnedRemoteNames) {
+ if (pinnedRemoteName === input.remoteName) continue;
+ yield* git.execute({
+ operation: "SourceControlRepositoryService.setDefaultRepository.unset",
+ cwd: input.cwd,
+ args: ["config", "--unset-all", `remote.${pinnedRemoteName}.gh-resolved`],
+ // Exits non-zero when the pin vanished between read and write.
+ allowNonZeroExit: true,
+ });
+ }
+
+ if (input.remoteName) {
+ yield* git.execute({
+ operation: "SourceControlRepositoryService.setDefaultRepository.set",
+ cwd: input.cwd,
+ // `--replace-all`: `gh` adds resolutions rather than setting them, so
+ // the key can already hold several values, and a plain write refuses
+ // to overwrite those.
+ args: ["config", "--replace-all", `remote.${input.remoteName}.gh-resolved`, "base"],
+ });
+ }
+ },
+ );
+
+ const setDefaultRepository = Effect.fn("SourceControlRepositoryService.setDefaultRepository")(
+ function* (input: SourceControlSetDefaultRepositoryInput) {
+ const config = yield* readRemoteConfig(input.cwd);
+ const remoteName = input.remoteName?.trim() || null;
+ if (remoteName && !config.state.remotes.some((remote) => remote.remoteName === remoteName)) {
+ return yield* new SourceControlRepositoryError({
+ operation: "setDefaultRepository",
+ provider: "unknown",
+ detail: "Choose a remote that exists in this repository.",
+ });
+ }
+
+ yield* pinDefaultRemote({
+ cwd: input.cwd,
+ remoteName,
+ pinnedRemoteNames: config.pinnedRemoteNames,
+ });
+ return yield* getDefaultRepository({ cwd: input.cwd });
+ },
+ );
+
+ /**
+ * Wires a freshly cloned fork to the repository it was forked from. The
+ * `upstream` remote is the easy half; pinning the default repository is the
+ * half that keeps the clone honest. `gh` picks a fork's parent as its base
+ * repository whenever several remotes exist, so adding `upstream` without a
+ * pin would silently retarget `gh pr create` and `gh issue list` at the
+ * parent project, whichever repository the user actually meant.
+ */
+ const wireForkUpstream = Effect.fn("SourceControlRepositoryService.wireForkUpstream")(
+ function* (input: {
+ readonly cwd: string;
+ readonly provider: SourceControlProviderKind;
+ readonly parentNameWithOwner: string;
+ readonly protocol: SourceControlCloneProtocol | undefined;
+ readonly defaultRepository: SourceControlCloneDefaultRepository;
+ }) {
+ const parent = yield* lookupRepository({
+ provider: input.provider,
+ repository: input.parentNameWithOwner,
+ cwd: input.cwd,
+ });
+ const remoteUrl = selectRemoteUrl(parent, input.protocol);
+ const clonedRemoteName = yield* git.resolvePrimaryRemoteName(input.cwd);
+ const remoteName = yield* git.ensureRemote({
+ cwd: input.cwd,
+ preferredName: "upstream",
+ url: remoteUrl,
+ });
+
+ // The remotes were just created here, so the pick needs no re-validation;
+ // a fresh clone also has nothing pinned to clear.
+ yield* pinDefaultRemote({
+ cwd: input.cwd,
+ remoteName: input.defaultRepository === "parent" ? remoteName : clonedRemoteName,
+ pinnedRemoteNames: [],
+ });
+
+ // `gh repo clone` leaves a fetched upstream behind, so `upstream/main`
+ // resolves immediately. A fork shares history with its parent, so this is
+ // usually a small incremental fetch — and the remote is already wired up,
+ // so a slow or failing network here must not undo any of the above.
+ yield* git.fetchRemote({ cwd: input.cwd, remoteName }).pipe(
+ Effect.tapError((cause) =>
+ Effect.logWarning("Fetching the fork upstream remote failed", {
+ cwd: input.cwd,
+ remoteName,
+ cause,
+ }),
+ ),
+ Effect.ignore,
+ );
+
+ return { remoteName, nameWithOwner: parent.nameWithOwner, remoteUrl };
+ },
+ );
+
const cloneRepository = Effect.fn("SourceControlRepositoryService.cloneRepository")(function* (
input: SourceControlCloneRepositoryInput,
) {
@@ -203,13 +419,19 @@ export const make = Effect.gen(function* () {
let provider: SourceControlProviderKind = input.provider ?? "unknown";
if (input.provider && input.repository) {
+ provider = input.provider;
repository = yield* lookupRepository({
provider: input.provider,
repository: input.repository,
cwd: preparedDestination.parentPath,
- });
- remoteUrl = selectRemoteUrl(repository, input.protocol);
- provider = input.provider;
+ }).pipe(
+ // A clone URL the client already resolved is enough to clone from. A
+ // failed lookup then only costs the fork wiring below, not the clone.
+ Effect.catch((cause) => (remoteUrl ? Effect.succeed(null) : Effect.fail(cause))),
+ );
+ if (repository) {
+ remoteUrl = selectRemoteUrl(repository, input.protocol);
+ }
}
if (!remoteUrl) {
@@ -228,10 +450,37 @@ export const make = Effect.gen(function* () {
maxOutputBytes: 256 * 1024,
});
+ const parentNameWithOwner = repository?.parentNameWithOwner ?? null;
+ const upstream = !parentNameWithOwner
+ ? null
+ : yield* wireForkUpstream({
+ cwd: preparedDestination.destinationPath,
+ provider,
+ parentNameWithOwner,
+ protocol: input.protocol,
+ // `gh repo clone` would pick the parent here, but T3 identifies a
+ // checkout by the remote its branch tracks: pinning the fork keeps
+ // the two agreeing for work on the fork, and choosing the parent
+ // stays one keystroke away for contributing upstream.
+ defaultRepository: input.defaultRepository ?? "cloned",
+ }).pipe(
+ // The clone is already on disk and usable; a fork whose parent could
+ // not be wired up is a warning, not a failed clone.
+ Effect.tapError((cause) =>
+ Effect.logWarning("Fork upstream wiring failed after clone", {
+ cwd: preparedDestination.destinationPath,
+ parent: parentNameWithOwner,
+ cause,
+ }),
+ ),
+ Effect.orElseSucceed(() => null),
+ );
+
return {
cwd: preparedDestination.destinationPath,
remoteUrl,
repository,
+ ...(upstream ? { upstream } : {}),
};
});
@@ -321,6 +570,10 @@ export const make = Effect.gen(function* () {
),
publishRepository: (input) =>
publishRepository(input).pipe(mapRepositoryError("publishRepository", input.provider)),
+ getDefaultRepository: (input) =>
+ getDefaultRepository(input).pipe(mapRepositoryError("getDefaultRepository", "unknown")),
+ setDefaultRepository: (input) =>
+ setDefaultRepository(input).pipe(mapRepositoryError("setDefaultRepository", "unknown")),
});
});
diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts
index 978d375a310..4f58c5821c4 100644
--- a/apps/server/src/ws.ts
+++ b/apps/server/src/ws.ts
@@ -1761,6 +1761,22 @@ const makeWsRpcLayer = (
"rpc.aggregate": "source-control",
},
),
+ [WS_METHODS.sourceControlGetDefaultRepository]: (input) =>
+ observeRpcEffect(
+ WS_METHODS.sourceControlGetDefaultRepository,
+ sourceControlRepositories.getDefaultRepository(input),
+ {
+ "rpc.aggregate": "source-control",
+ },
+ ),
+ [WS_METHODS.sourceControlSetDefaultRepository]: (input) =>
+ observeRpcEffect(
+ WS_METHODS.sourceControlSetDefaultRepository,
+ sourceControlRepositories.setDefaultRepository(input),
+ {
+ "rpc.aggregate": "source-control",
+ },
+ ),
[WS_METHODS.sourceControlListIssues]: (input) =>
observeRpcEffect(
WS_METHODS.sourceControlListIssues,
diff --git a/apps/web/src/components/CommandPalette.tsx b/apps/web/src/components/CommandPalette.tsx
index 09f7793387e..4116dbacf42 100644
--- a/apps/web/src/components/CommandPalette.tsx
+++ b/apps/web/src/components/CommandPalette.tsx
@@ -4,6 +4,7 @@ import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environ
import {
canCreateProjectInEnvironment,
getCloneDestinationQuery,
+ repositoryOwnerAvatarUrl,
} from "@t3tools/client-runtime/operations/projects";
import { connectionStatusText } from "@t3tools/client-runtime/connection";
import { threadSearchMatchKey } from "@t3tools/client-runtime/state/thread-search";
@@ -23,6 +24,7 @@ import {
type EnvironmentId,
type FilesystemBrowseResult,
type ProjectId,
+ type SourceControlCloneDefaultRepository,
type SourceControlDiscoveryResult,
type SourceControlProviderKind,
type SourceControlRepositoryInfo,
@@ -161,6 +163,40 @@ import { getSourceControlPresentation } from "../sourceControlPresentation";
const EMPTY_BROWSE_ENTRIES: FilesystemBrowseResult["entries"] = [];
+/**
+ * Shows who owns a repository. The avatar is derived from the repository URL, so
+ * a host that does not serve one (or an offline client) falls back to the
+ * provider icon rather than an empty box.
+ */
+function RepositoryOwnerAvatar({
+ fallback,
+ nameWithOwner,
+ repositoryUrl,
+}: {
+ fallback: ReactNode;
+ nameWithOwner: string;
+ repositoryUrl: string;
+}) {
+ const [hasFailed, setHasFailed] = useState(false);
+ const avatarUrl = repositoryOwnerAvatarUrl({ nameWithOwner, repositoryUrl });
+ if (avatarUrl === null || hasFailed) {
+ return fallback;
+ }
+
+ return (
+
{
+ setHasFailed(true);
+ }}
+ />
+ );
+}
+
function projectFavicon(project: Project) {
return (
= [
@@ -292,6 +339,7 @@ function remoteProjectSourceIcon(source: AddProjectRemoteSource, className: stri
function remoteProjectInputPlaceholder(flow: AddProjectCloneFlow | null): string | null {
if (!flow) return null;
if (flow.step === "confirm") return null;
+ if (flow.step === "default") return "Choose the default repository";
if (flow.source === "url") {
return "Enter Git clone URL";
}
@@ -850,17 +898,20 @@ function OpenCommandPaletteDialog(props: {
browseEnvironment?.serverConfig?.environment.platform.os,
);
const isRemoteProjectCloneFlow = addProjectCloneFlow !== null;
- const isRemoteProjectRepositoryStep = addProjectCloneFlow?.step === "repository";
+ const isRemoteProjectDefaultStep = addProjectCloneFlow?.step === "default";
const isCloneDestinationStep = addProjectCloneFlow?.step === "confirm";
+ // The repository and default-repository steps type into a list, not a path.
+ const isRemoteProjectPathStep =
+ addProjectCloneFlow === null || addProjectCloneFlow.step === "confirm";
const browsePath = useMemo(
() =>
getFilesystemBrowsePath(
query,
browseEnvironmentPlatform,
- !isRemoteProjectRepositoryStep,
+ isRemoteProjectPathStep,
isCloneDestinationStep,
),
- [browseEnvironmentPlatform, isCloneDestinationStep, isRemoteProjectRepositoryStep, query],
+ [browseEnvironmentPlatform, isCloneDestinationStep, isRemoteProjectPathStep, query],
);
const isBrowsing = browsePath.isBrowsing;
const browseDirectoryPath = browsePath.directoryPath;
@@ -2041,6 +2092,33 @@ function OpenCommandPaletteDialog(props: {
return getAddProjectInitialQueryForEnvironment(environmentId);
}
+ /** Leaves the fork's default-repository step for the destination step. */
+ function chooseCloneDefaultRepository(
+ defaultRepository: SourceControlCloneDefaultRepository,
+ ): void {
+ if (addProjectCloneFlow?.step !== "default") {
+ return;
+ }
+ setAddProjectCloneFlow({
+ step: "confirm",
+ environmentId: addProjectCloneFlow.environmentId,
+ source: addProjectCloneFlow.source,
+ repositoryInput: addProjectCloneFlow.repositoryInput,
+ repository: addProjectCloneFlow.repository,
+ remoteUrl: addProjectCloneFlow.remoteUrl,
+ defaultRepository,
+ });
+ setHighlightedItemValue(null);
+ setQuery(
+ getCloneDestinationQuery({
+ parentPath: getDefaultCloneParentPath(addProjectCloneFlow.environmentId),
+ nameWithOwner: addProjectCloneFlow.repository.nameWithOwner,
+ remoteUrl: addProjectCloneFlow.remoteUrl,
+ }),
+ );
+ setBrowseGeneration((generation) => generation + 1);
+ }
+
async function submitAddProjectCloneFlow(destinationPathInput?: string): Promise {
if (!addProjectCloneFlow) {
return;
@@ -2104,6 +2182,21 @@ function OpenCommandPaletteDialog(props: {
return;
}
const repository = lookupResult.value;
+ if (repository.parentNameWithOwner) {
+ setAddProjectCloneFlow({
+ step: "default",
+ environmentId: addProjectCloneFlow.environmentId,
+ source: addProjectCloneFlow.source,
+ repositoryInput: rawRepository,
+ repository,
+ parentNameWithOwner: repository.parentNameWithOwner,
+ remoteUrl: repository.sshUrl,
+ });
+ setHighlightedItemValue(null);
+ setQuery("");
+ setBrowseGeneration((generation) => generation + 1);
+ return;
+ }
const destinationPath = getCloneDestinationQuery({
parentPath: getDefaultCloneParentPath(addProjectCloneFlow.environmentId),
nameWithOwner: repository.nameWithOwner,
@@ -2123,6 +2216,11 @@ function OpenCommandPaletteDialog(props: {
return;
}
+ // The default-repository step advances by picking a list item, not by submit.
+ if (addProjectCloneFlow.step !== "confirm") {
+ return;
+ }
+
const rawDestination = (destinationPathInput ?? query).trim();
if (rawDestination.length === 0 || isRemoteProjectCloning) {
return;
@@ -2158,10 +2256,28 @@ function OpenCommandPaletteDialog(props: {
return;
}
+ // A fork is the only clone that needs its repository named on the server.
+ const forkRepository =
+ addProjectCloneFlow.repository?.parentNameWithOwner === undefined
+ ? null
+ : addProjectCloneFlow.repository;
+
setIsRemoteProjectCloning(true);
const cloneResult = await cloneRepository({
environmentId: addProjectCloneFlow.environmentId,
input: {
+ // Only a fork needs naming: it is what lets the server wire up the
+ // upstream remote. Every other clone stays a plain URL clone, with no
+ // second repository lookup on the server.
+ ...(forkRepository
+ ? {
+ provider: forkRepository.provider,
+ repository: forkRepository.nameWithOwner,
+ ...(addProjectCloneFlow.defaultRepository
+ ? { defaultRepository: addProjectCloneFlow.defaultRepository }
+ : {}),
+ }
+ : {}),
remoteUrl: addProjectCloneFlow.remoteUrl,
destinationPath,
},
@@ -2179,6 +2295,17 @@ function OpenCommandPaletteDialog(props: {
}
return;
}
+ // The clone itself succeeded, so this is a warning rather than a failure:
+ // the repository is on disk, just without the remote that was asked for.
+ if (forkRepository && !cloneResult.value.upstream) {
+ toastManager.add(
+ stackedThreadToast({
+ type: "error",
+ title: "Upstream remote not added",
+ description: `Cloned, but ${forkRepository.parentNameWithOwner} could not be wired up as a remote.`,
+ }),
+ );
+ }
await handleAddProject(cloneResult.value.cwd);
}
@@ -2241,20 +2368,84 @@ function OpenCommandPaletteDialog(props: {
);
const remoteProjectContext = useMemo(() => {
- if (addProjectCloneFlow?.step !== "confirm") {
+ if (addProjectCloneFlow?.step !== "confirm" && addProjectCloneFlow?.step !== "default") {
return null;
}
+ const flow = addProjectCloneFlow;
+ const parentNameWithOwner =
+ flow.step === "default" ? flow.parentNameWithOwner : flow.repository?.parentNameWithOwner;
return {
- title: addProjectCloneFlow.repository?.nameWithOwner ?? addProjectCloneFlow.repositoryInput,
- description: addProjectCloneFlow.repository?.url ?? addProjectCloneFlow.remoteUrl,
- icon: remoteProjectSourceIcon(addProjectCloneFlow.source, ITEM_ICON_CLASS),
+ title: flow.repository?.nameWithOwner ?? flow.repositoryInput,
+ // A fork keeps the same second line across both steps. The clone URL only
+ // stands in where the title is not already the repository's full name.
+ description: parentNameWithOwner
+ ? `forked from ${parentNameWithOwner}`
+ : (flow.repository?.url ?? flow.remoteUrl),
+ icon: remoteProjectSourceIcon(flow.source, ITEM_ICON_CLASS),
};
}, [addProjectCloneFlow]);
+ /**
+ * The fork step, modelled on `gh repo set-default`: pick which repository
+ * pull requests, issues, and releases should target once both remotes exist.
+ * The fork leads: it is the repository the user asked to clone, and it keeps
+ * the pin agreeing with the remote a branch on the fork tracks.
+ */
+ const cloneDefaultRepositoryGroups = useMemo((): CommandPaletteView["groups"] => {
+ if (addProjectCloneFlow?.step !== "default") {
+ return [];
+ }
+
+ const flow = addProjectCloneFlow;
+ const options: ReadonlyArray<{
+ readonly choice: SourceControlCloneDefaultRepository;
+ readonly nameWithOwner: string;
+ readonly remoteName: string;
+ }> = [
+ { choice: "cloned", nameWithOwner: flow.repository.nameWithOwner, remoteName: "origin" },
+ { choice: "parent", nameWithOwner: flow.parentNameWithOwner, remoteName: "upstream" },
+ ];
+
+ return [
+ {
+ value: "clone-default-repository",
+ // Reads on its own: the palette title sits above the repository card, too
+ // far away to be read as one sentence with this.
+ label: "Where pull requests, issues, and releases go",
+ items: options.map((option) => ({
+ kind: "action" as const,
+ value: `action:clone-default-repository:${option.choice}`,
+ searchTerms: [option.nameWithOwner, option.choice, option.remoteName],
+ title: option.nameWithOwner,
+ // The remote name says which repository this is without a sentence.
+ titleTrailingContent: (
+
+ {option.remoteName}
+
+ ),
+ icon: (
+
+ ),
+ keepOpen: true,
+ run: async () => {
+ chooseCloneDefaultRepository(option.choice);
+ },
+ })),
+ },
+ ];
+ // `chooseCloneDefaultRepository` reads the same flow state this memo keys on.
+ }, [addProjectCloneFlow]);
+
let displayedGroups: CommandPaletteView["groups"] = filteredGroups;
if (addProjectCloneFlow?.step === "repository") {
displayedGroups = [];
+ } else if (addProjectCloneFlow?.step === "default") {
+ displayedGroups = cloneDefaultRepositoryGroups;
} else if (addProjectCloneFlow?.step === "confirm") {
displayedGroups = relativePathNeedsActiveProject ? [] : cloneDestinationBrowseGroups;
} else if (isBrowsing) {
@@ -2626,7 +2817,9 @@ function OpenCommandPaletteDialog(props: {
candidate.remoteName === remoteName);
+ const label = remote?.nameWithOwner ?? remote?.url ?? remoteName;
+ return remote?.nameWithOwner ? `${label} (${remoteName})` : label;
+}
+
+/**
+ * A pin can name a repository its remote does not point at, which is what the
+ * GitHub CLI writes for a fork cloned without an upstream remote. The trigger
+ * has to show the repository actually targeted, while the options keep
+ * describing the remotes they would switch to.
+ */
+function currentDefaultRepositoryLabel(state: SourceControlDefaultRepositoryState): string {
+ return state.defaultRemoteName && state.defaultRepositoryPath
+ ? `${state.defaultRepositoryPath} (via ${state.defaultRemoteName})`
+ : defaultRepositoryLabel(state, state.defaultRemoteName);
+}
+
export function ProjectSettingsPage({ projectKey }: { projectKey: string }) {
const navigate = useNavigate();
const canGoBack = useCanGoBack();
@@ -471,6 +503,58 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
);
const keybindings = selectedServerConfig?.keybindings ?? DEFAULT_RESOLVED_KEYBINDINGS;
const scripts = selectedCheckout.scripts;
+
+ // ----- default repository (the config `gh repo set-default` writes) -----
+ const [defaultRepository, setDefaultRepositoryState] =
+ useState(null);
+ const readDefaultRepository = useAtomQueryRunner(sourceControlEnvironment.defaultRepository, {
+ reportFailure: false,
+ });
+ const writeDefaultRepository = useAtomCommand(sourceControlEnvironment.setDefaultRepository, {
+ reportFailure: false,
+ });
+ const selectedCheckoutRef = useRef(selectedCheckout.workspaceRoot);
+ useEffect(() => {
+ let cancelled = false;
+ selectedCheckoutRef.current = selectedCheckout.workspaceRoot;
+ setDefaultRepositoryState(null);
+ void readDefaultRepository({
+ environmentId: selectedCheckout.environmentId,
+ input: { cwd: selectedCheckout.workspaceRoot },
+ }).then((result) => {
+ if (!cancelled && result._tag === "Success") {
+ setDefaultRepositoryState(result.value);
+ }
+ });
+ return () => {
+ cancelled = true;
+ };
+ }, [readDefaultRepository, selectedCheckout.environmentId, selectedCheckout.workspaceRoot]);
+
+ const updateDefaultRepository = useCallback(
+ async (remoteName: string | null): Promise => {
+ const checkout = selectedCheckout;
+ const result = await writeDefaultRepository({
+ environmentId: checkout.environmentId,
+ input: { cwd: checkout.workspaceRoot, remoteName },
+ });
+ // Switching checkouts mid-write must not drop the old checkout's state
+ // into the new checkout's row.
+ if (result._tag === "Success" && checkout.workspaceRoot === selectedCheckoutRef.current) {
+ setDefaultRepositoryState(result.value);
+ }
+ },
+ [selectedCheckout, writeDefaultRepository],
+ );
+
+ /**
+ * The pin only means something to the GitHub CLI, and only a checkout with
+ * more than one remote (or an existing pin to clear) has a choice to make.
+ */
+ const canChooseDefaultRepository =
+ defaultRepository !== null &&
+ defaultRepository.remotes.some((remote) => remote.provider === "github") &&
+ (defaultRepository.remotes.length > 1 || defaultRepository.defaultRemoteName !== null);
const [editorRequest, setEditorRequest] = useState(null);
// Script writes replace the whole array, so two overlapping writes computed
// from the same snapshot would drop each other's changes. One at a time.
@@ -1001,6 +1085,37 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
}
/>
+ {canChooseDefaultRepository && defaultRepository ? (
+ {
+ const remoteName = String(value);
+ void updateDefaultRepository(
+ remoteName === UNSET_DEFAULT_REPOSITORY_VALUE ? null : remoteName,
+ );
+ }}
+ >
+
+ {currentDefaultRepositoryLabel(defaultRepository)}
+
+
+ {defaultRepository.remotes.map((remote) => (
+
+ {defaultRepositoryLabel(defaultRepository, remote.remoteName)}
+
+ ))}
+
+ {defaultRepositoryLabel(defaultRepository, null)}
+
+
+
+ }
+ />
+ ) : null}
{group.memberProjects.length > 1 ? (
.gh-resolved` — the same key `gh repo set-default` writes, so T3 and the GitHub CLI always agree. `base` means the pinned remote's own repository; any other value names a repository reached through that remote. `SourceControlRepositoryService` reads and writes it (a fork clone pins the repository that was cloned), and `RepositoryIdentityResolver` reads it as the fallback after the current branch's tracked remote, so it decides a checkout's identity — and therefore its sidebar grouping — only while the branch has no upstream.
+
### Thread timeline
#### Thread
diff --git a/docs/user/source-control.md b/docs/user/source-control.md
index 629c0146e7a..7ddeebe5081 100644
--- a/docs/user/source-control.md
+++ b/docs/user/source-control.md
@@ -21,6 +21,15 @@ T3 Code works with the platforms your team already uses:
- Choose **GitHub repository**, **GitLab repository**, **Bitbucket repository**, **Azure DevOps repository**, or paste any **Git URL**
- Enter the repository path (`owner/repo`, `group/project`, `workspace/repository`, or `project/repository`) or a full Git URL, pick a destination, and start coding
+**Forks come wired up**
+
+- Cloning a GitHub fork also adds the repository it was forked from as an `upstream` remote, so you can fetch from the original project right away
+- Because two remotes means two possible targets, cloning a fork adds one step: choose which repository is the **default repository**. Your fork is offered first; choose the original project instead when you are cloning to contribute upstream.
+- The default repository is where pull requests, issues, and releases go — including the ones T3 Code creates and the **Pull requests** page it lists.
+- Branches keep their own alignment: once a branch tracks a remote, that repository is the one T3 Code treats it as belonging to, so a branch pushed to the original project groups with the original project and a branch on your fork stays with your fork.
+- Change your mind later on web or desktop in **Settings → Projects → Checkout → Default repository**. It is the same setting as the GitHub CLI's `gh repo set-default`, so the two agree in both directions.
+- Fork detection is GitHub-only. Clones from GitLab, Bitbucket, Azure DevOps, or a plain Git URL are untouched.
+
**Publish local projects to the cloud**
- Have a local Git repository without a remote?
diff --git a/packages/client-runtime/src/operations/projects.test.ts b/packages/client-runtime/src/operations/projects.test.ts
index 473230ebd90..a4576cd694b 100644
--- a/packages/client-runtime/src/operations/projects.test.ts
+++ b/packages/client-runtime/src/operations/projects.test.ts
@@ -14,11 +14,57 @@ import {
findExistingAddProject,
getAddProjectInitialQuery,
getCloneDestinationQuery,
+ repositoryOwnerAvatarUrl,
resolveAddProjectPath,
sortAddProjectProviderSources,
} from "./projects.ts";
import type { EnvironmentProject } from "../state/models.ts";
+describe("repository owner avatars", () => {
+ it("derives an owner avatar from either transport of a GitHub remote", () => {
+ expect(
+ repositoryOwnerAvatarUrl({
+ repositoryUrl: "https://github.com/commaai/openpilot",
+ nameWithOwner: "commaai/openpilot",
+ }),
+ ).toBe("https://github.com/commaai.png?size=64");
+ expect(
+ repositoryOwnerAvatarUrl({
+ repositoryUrl: "git@github.com:commaai/openpilot.git",
+ nameWithOwner: "commaai/openpilot",
+ size: 96,
+ }),
+ ).toBe("https://github.com/commaai.png?size=96");
+ });
+
+ it("keeps GitHub Enterprise hosts", () => {
+ expect(
+ repositoryOwnerAvatarUrl({
+ repositoryUrl: "https://github.example.com/team/service",
+ nameWithOwner: "team/service",
+ }),
+ ).toBe("https://github.example.com/team.png?size=64");
+ });
+
+ it("returns null where no avatar path exists, rather than sending clients after a 404", () => {
+ expect(
+ repositoryOwnerAvatarUrl({
+ repositoryUrl: "https://gitlab.com/group/project",
+ nameWithOwner: "group/project",
+ }),
+ ).toBeNull();
+ expect(
+ repositoryOwnerAvatarUrl({ repositoryUrl: "not a url", nameWithOwner: "owner/repo" }),
+ ).toBeNull();
+ expect(
+ repositoryOwnerAvatarUrl({
+ repositoryUrl: "https://github.com/commaai/openpilot",
+ nameWithOwner: "",
+ }),
+ ).toBeNull();
+ });
+});
+
describe("add project shared logic", () => {
it("only allows project creation in connected environments", () => {
expect(canCreateProjectInEnvironment("connected")).toBe(true);
diff --git a/packages/client-runtime/src/operations/projects.ts b/packages/client-runtime/src/operations/projects.ts
index 3d65194ae0d..98dbc78a3ea 100644
--- a/packages/client-runtime/src/operations/projects.ts
+++ b/packages/client-runtime/src/operations/projects.ts
@@ -8,6 +8,10 @@ import type {
SourceControlProviderKind,
SourceControlRepositoryInfo,
} from "@t3tools/contracts";
+import {
+ detectSourceControlProviderFromGitRemoteUrl,
+ normalizeGitRemoteUrl,
+} from "@t3tools/shared/git";
import * as Arr from "effect/Array";
import * as Option from "effect/Option";
import * as Order from "effect/Order";
@@ -105,6 +109,30 @@ export function addProjectRemoteSourceProvider(
return source === "url" ? null : source;
}
+/**
+ * GitHub, including Enterprise hosts, serves an owner's avatar at `/.png`,
+ * so one remote URL is enough to show who owns a repository without an API call.
+ * The URL may be either transport, since clone flows carry the SSH one. Other
+ * providers have no such path, so this returns null rather than sending the
+ * client after a guaranteed 404; callers fall back to the provider icon, which
+ * is also what they should do when the image fails to load.
+ */
+export function repositoryOwnerAvatarUrl(input: {
+ readonly repositoryUrl: string;
+ readonly nameWithOwner: string;
+ readonly size?: number;
+}): string | null {
+ if (detectSourceControlProviderFromGitRemoteUrl(input.repositoryUrl)?.kind !== "github") {
+ return null;
+ }
+ const owner = input.nameWithOwner.split("/")[0]?.trim();
+ const host = normalizeGitRemoteUrl(input.repositoryUrl).split("/")[0];
+ if (!owner || !host?.includes(".")) {
+ return null;
+ }
+ return `https://${host}/${owner}.png?size=${input.size ?? 64}`;
+}
+
export function sortAddProjectProviderSources(
readinessBySource: AddProjectRemoteSourceReadiness,
): ReadonlyArray {
diff --git a/packages/client-runtime/src/state/sourceControl.ts b/packages/client-runtime/src/state/sourceControl.ts
index ba733c2e623..36d5207d7b9 100644
--- a/packages/client-runtime/src/state/sourceControl.ts
+++ b/packages/client-runtime/src/state/sourceControl.ts
@@ -24,6 +24,10 @@ export function createSourceControlEnvironmentAtoms(
label: "environment-data:source-control:repository",
tag: WS_METHODS.sourceControlLookupRepository,
}),
+ defaultRepository: createEnvironmentRpcQueryAtomFamily(runtime, {
+ label: "environment-data:source-control:default-repository",
+ tag: WS_METHODS.sourceControlGetDefaultRepository,
+ }),
issues: createEnvironmentRpcQueryAtomFamily(runtime, {
label: "environment-data:source-control:issues",
tag: WS_METHODS.sourceControlListIssues,
@@ -41,6 +45,12 @@ export function createSourceControlEnvironmentAtoms(
key: ({ environmentId }) => environmentId,
},
}),
+ setDefaultRepository: createEnvironmentRpcCommand(runtime, {
+ label: "environment-data:source-control:set-default-repository",
+ tag: WS_METHODS.sourceControlSetDefaultRepository,
+ scheduler: vcsCommandScheduler,
+ concurrency: vcsCommandConcurrency,
+ }),
publishRepository: createEnvironmentRpcCommand(runtime, {
label: "environment-data:source-control:publish-repository",
tag: WS_METHODS.sourceControlPublishRepository,
diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts
index 7f9fdb271d4..09ded864cd6 100644
--- a/packages/contracts/src/ipc.ts
+++ b/packages/contracts/src/ipc.ts
@@ -95,10 +95,13 @@ import type { ClientSettings } from "./settings.ts";
import type {
SourceControlCloneRepositoryInput,
SourceControlCloneRepositoryResult,
+ SourceControlDefaultRepositoryState,
+ SourceControlGetDefaultRepositoryInput,
SourceControlPublishRepositoryInput,
SourceControlPublishRepositoryResult,
SourceControlRepositoryInfo,
SourceControlRepositoryLookupInput,
+ SourceControlSetDefaultRepositoryInput,
} from "./sourceControl.ts";
export interface ContextMenuItem {
@@ -1225,6 +1228,12 @@ export interface EnvironmentApi {
publishRepository: (
input: SourceControlPublishRepositoryInput,
) => Promise;
+ getDefaultRepository: (
+ input: SourceControlGetDefaultRepositoryInput,
+ ) => Promise;
+ setDefaultRepository: (
+ input: SourceControlSetDefaultRepositoryInput,
+ ) => Promise;
};
vcs: {
listRefs: (input: VcsListRefsInput) => Promise;
diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts
index 83504e5b045..7f322757deb 100644
--- a/packages/contracts/src/rpc.ts
+++ b/packages/contracts/src/rpc.ts
@@ -183,7 +183,9 @@ import { ServerSettings, ServerSettingsError, ServerSettingsPatch } from "./sett
import {
SourceControlCloneRepositoryInput,
SourceControlCloneRepositoryResult,
+ SourceControlDefaultRepositoryState,
SourceControlDiscoveryResult,
+ SourceControlGetDefaultRepositoryInput,
SourceControlGetIssueInput,
SourceControlIssue,
SourceControlListIssuesInput,
@@ -194,6 +196,7 @@ import {
SourceControlRepositoryError,
SourceControlRepositoryInfo,
SourceControlRepositoryLookupInput,
+ SourceControlSetDefaultRepositoryInput,
} from "./sourceControl.ts";
import { VcsError } from "./vcs.ts";
import {
@@ -314,6 +317,8 @@ export const WS_METHODS = {
sourceControlLookupRepository: "sourceControl.lookupRepository",
sourceControlCloneRepository: "sourceControl.cloneRepository",
sourceControlPublishRepository: "sourceControl.publishRepository",
+ sourceControlGetDefaultRepository: "sourceControl.getDefaultRepository",
+ sourceControlSetDefaultRepository: "sourceControl.setDefaultRepository",
sourceControlListIssues: "sourceControl.listIssues",
sourceControlGetIssue: "sourceControl.getIssue",
@@ -641,6 +646,24 @@ export const WsSourceControlPublishRepositoryRpc = Rpc.make(
},
);
+export const WsSourceControlGetDefaultRepositoryRpc = Rpc.make(
+ WS_METHODS.sourceControlGetDefaultRepository,
+ {
+ payload: SourceControlGetDefaultRepositoryInput,
+ success: SourceControlDefaultRepositoryState,
+ error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]),
+ },
+);
+
+export const WsSourceControlSetDefaultRepositoryRpc = Rpc.make(
+ WS_METHODS.sourceControlSetDefaultRepository,
+ {
+ payload: SourceControlSetDefaultRepositoryInput,
+ success: SourceControlDefaultRepositoryState,
+ error: Schema.Union([SourceControlRepositoryError, EnvironmentAuthorizationError]),
+ },
+);
+
export const WsSourceControlListIssuesRpc = Rpc.make(WS_METHODS.sourceControlListIssues, {
payload: SourceControlListIssuesInput,
success: SourceControlListIssuesResult,
@@ -1067,6 +1090,8 @@ export const WsRpcGroup = RpcGroup.make(
WsSourceControlLookupRepositoryRpc,
WsSourceControlCloneRepositoryRpc,
WsSourceControlPublishRepositoryRpc,
+ WsSourceControlGetDefaultRepositoryRpc,
+ WsSourceControlSetDefaultRepositoryRpc,
WsSourceControlListIssuesRpc,
WsSourceControlGetIssueRpc,
WsProjectsListEntriesRpc,
diff --git a/packages/contracts/src/sourceControl.ts b/packages/contracts/src/sourceControl.ts
index 2e288b6d3cd..00d2f8f80b8 100644
--- a/packages/contracts/src/sourceControl.ts
+++ b/packages/contracts/src/sourceControl.ts
@@ -93,6 +93,8 @@ export const SourceControlRepositoryCloneUrls = Schema.Struct({
nameWithOwner: TrimmedNonEmptyString,
url: TrimmedNonEmptyString,
sshUrl: TrimmedNonEmptyString,
+ /** Repository this one was forked from, when the provider reports a parent. */
+ parentNameWithOwner: Schema.optional(TrimmedNonEmptyString),
});
export type SourceControlRepositoryCloneUrls = typeof SourceControlRepositoryCloneUrls.Type;
@@ -107,6 +109,8 @@ export const SourceControlRepositoryInfo = Schema.Struct({
nameWithOwner: TrimmedNonEmptyString,
url: TrimmedNonEmptyString,
sshUrl: TrimmedNonEmptyString,
+ /** Repository this one was forked from, when the provider reports a parent. */
+ parentNameWithOwner: Schema.optional(TrimmedNonEmptyString),
});
export type SourceControlRepositoryInfo = typeof SourceControlRepositoryInfo.Type;
@@ -117,22 +121,80 @@ export const SourceControlRepositoryLookupInput = Schema.Struct({
});
export type SourceControlRepositoryLookupInput = typeof SourceControlRepositoryLookupInput.Type;
+/**
+ * Which repository a fork clone should treat as its default: the repository
+ * that was cloned, or the one it was forked from. Mirrors the choice
+ * `gh repo set-default` writes, and only applies when the clone is a fork.
+ * Omitted means `parent`, which is what `gh repo clone` picks for a fork.
+ */
+export const SourceControlCloneDefaultRepository = Schema.Literals(["cloned", "parent"]);
+export type SourceControlCloneDefaultRepository = typeof SourceControlCloneDefaultRepository.Type;
+
export const SourceControlCloneRepositoryInput = Schema.Struct({
provider: Schema.optional(SourceControlProviderKind),
repository: Schema.optional(TrimmedNonEmptyString),
remoteUrl: Schema.optional(TrimmedNonEmptyString),
destinationPath: TrimmedNonEmptyString,
protocol: Schema.optional(SourceControlCloneProtocol),
+ defaultRepository: Schema.optional(SourceControlCloneDefaultRepository),
});
export type SourceControlCloneRepositoryInput = typeof SourceControlCloneRepositoryInput.Type;
+export const SourceControlUpstreamRemote = Schema.Struct({
+ remoteName: TrimmedNonEmptyString,
+ nameWithOwner: TrimmedNonEmptyString,
+ remoteUrl: TrimmedNonEmptyString,
+});
+export type SourceControlUpstreamRemote = typeof SourceControlUpstreamRemote.Type;
+
export const SourceControlCloneRepositoryResult = Schema.Struct({
cwd: TrimmedNonEmptyString,
remoteUrl: TrimmedNonEmptyString,
repository: Schema.NullOr(SourceControlRepositoryInfo),
+ /** Present when the clone was a fork and its parent was wired up as a remote. */
+ upstream: Schema.optional(SourceControlUpstreamRemote),
});
export type SourceControlCloneRepositoryResult = typeof SourceControlCloneRepositoryResult.Type;
+/**
+ * One candidate for a project's default repository. `nameWithOwner` is derived
+ * from the remote URL, so listing candidates never needs a provider CLI call.
+ */
+export const SourceControlDefaultRepositoryRemote = Schema.Struct({
+ remoteName: TrimmedNonEmptyString,
+ url: TrimmedNonEmptyString,
+ nameWithOwner: Schema.NullOr(TrimmedNonEmptyString),
+ provider: SourceControlProviderKind,
+});
+export type SourceControlDefaultRepositoryRemote = typeof SourceControlDefaultRepositoryRemote.Type;
+
+export const SourceControlDefaultRepositoryState = Schema.Struct({
+ remotes: Schema.Array(SourceControlDefaultRepositoryRemote),
+ /** Remote currently pinned as the default, or null when nothing is pinned. */
+ defaultRemoteName: Schema.NullOr(TrimmedNonEmptyString),
+ /**
+ * Repository the pin names when it is not the pinned remote's own — what
+ * `gh repo set-default` writes when the default is reachable through a remote
+ * but is not that remote's repository, as for a fork cloned without upstream.
+ */
+ defaultRepositoryPath: Schema.optional(TrimmedNonEmptyString),
+});
+export type SourceControlDefaultRepositoryState = typeof SourceControlDefaultRepositoryState.Type;
+
+export const SourceControlGetDefaultRepositoryInput = Schema.Struct({
+ cwd: TrimmedNonEmptyString,
+});
+export type SourceControlGetDefaultRepositoryInput =
+ typeof SourceControlGetDefaultRepositoryInput.Type;
+
+export const SourceControlSetDefaultRepositoryInput = Schema.Struct({
+ cwd: TrimmedNonEmptyString,
+ /** Null clears the pin, the way `gh repo set-default --unset` does. */
+ remoteName: Schema.NullOr(TrimmedNonEmptyString),
+});
+export type SourceControlSetDefaultRepositoryInput =
+ typeof SourceControlSetDefaultRepositoryInput.Type;
+
export const SourceControlPublishRepositoryInput = Schema.Struct({
cwd: TrimmedNonEmptyString,
provider: SourceControlProviderKind,
diff --git a/packages/shared/src/git.test.ts b/packages/shared/src/git.test.ts
index 96539f0aae2..5e5234c24b8 100644
--- a/packages/shared/src/git.test.ts
+++ b/packages/shared/src/git.test.ts
@@ -7,6 +7,7 @@ import {
isTemporaryWorktreeBranch,
normalizeGitRemoteUrl,
parseGitHubRepositoryNameWithOwnerFromRemoteUrl,
+ parseGitRemoteConfig,
WORKTREE_BRANCH_PREFIX,
} from "./git.ts";
@@ -163,3 +164,58 @@ describe("applyGitStatusStreamEvent", () => {
});
});
});
+
+describe("parseGitRemoteConfig", () => {
+ it("pairs each remote's url with its gh-resolved pin", () => {
+ expect(
+ parseGitRemoteConfig(
+ [
+ "remote.origin.url git@github.com:incognitojam/openpilot.git",
+ "remote.upstream.url git@github.com:commaai/openpilot.git",
+ "remote.upstream.gh-resolved base",
+ ].join("\n"),
+ ),
+ ).toEqual([
+ {
+ remoteName: "origin",
+ url: "git@github.com:incognitojam/openpilot.git",
+ ghResolved: null,
+ },
+ {
+ remoteName: "upstream",
+ url: "git@github.com:commaai/openpilot.git",
+ ghResolved: "base",
+ },
+ ]);
+ });
+
+ it("keeps an owner/repo pin, which is what a fork cloned without upstream carries", () => {
+ expect(
+ parseGitRemoteConfig(
+ [
+ "remote.origin.url git@github.com:incognitojam/openpilot.git",
+ "remote.origin.gh-resolved commaai/openpilot",
+ ].join("\n"),
+ ),
+ ).toEqual([
+ {
+ remoteName: "origin",
+ url: "git@github.com:incognitojam/openpilot.git",
+ ghResolved: "commaai/openpilot",
+ },
+ ]);
+ });
+
+ it("handles dotted remote names, repeated keys, and empty output", () => {
+ expect(
+ parseGitRemoteConfig(
+ [
+ "remote.my.fork.url https://github.com/o/r",
+ "remote.my.fork.gh-resolved base",
+ "remote.my.fork.gh-resolved o/r",
+ ].join("\n"),
+ ),
+ ).toEqual([{ remoteName: "my.fork", url: "https://github.com/o/r", ghResolved: "base" }]);
+ expect(parseGitRemoteConfig("")).toEqual([]);
+ });
+});
diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts
index 71fe2e806cf..d3713c31b50 100644
--- a/packages/shared/src/git.ts
+++ b/packages/shared/src/git.ts
@@ -108,6 +108,47 @@ export function isTemporaryWorktreeBranch(refName: string): boolean {
return TEMP_WORKTREE_BRANCH_PATTERN.test(refName.trim().toLowerCase());
}
+export interface GitRemoteConfigEntry {
+ readonly remoteName: string;
+ readonly url: string | null;
+ /**
+ * Value of `remote..gh-resolved`, or null when the remote carries no
+ * pin. `gh repo set-default` writes `base` when the default repository is the
+ * remote's own, and an `owner/repo` when it is a different one — which is what
+ * a single-remote fork gets when its parent is chosen.
+ */
+ readonly ghResolved: string | null;
+}
+
+/**
+ * Parses `git config --get-regexp ^remote\..*\.(url|gh-resolved)$` output. Both
+ * the source-control service and the repository identity resolver read the same
+ * config, so they share one parse rather than two regexps that can drift.
+ */
+export function parseGitRemoteConfig(stdout: string): ReadonlyArray {
+ const entries = new Map();
+
+ for (const line of stdout.split("\n")) {
+ const match = /^remote\.(.+)\.(url|gh-resolved)\s+(\S+)$/u.exec(line.trim());
+ const remoteName = match?.[1];
+ const key = match?.[2];
+ const value = match?.[3];
+ if (!remoteName || !key || !value) continue;
+
+ const entry = entries.get(remoteName) ?? { url: null, ghResolved: null };
+ // A key can repeat (`git config --add`); the first value is the one git
+ // reports for a single-value read, so later ones do not overwrite it.
+ if (key === "url") {
+ entry.url ??= value;
+ } else {
+ entry.ghResolved ??= value;
+ }
+ entries.set(remoteName, entry);
+ }
+
+ return [...entries].map(([remoteName, entry]) => ({ remoteName, ...entry }));
+}
+
/**
* Normalize a git remote URL into a stable comparison key.
*/