From 9b328cd5ec64dfe2c4174a016cefe59c9ad8306a Mon Sep 17 00:00:00 2001 From: Cameron Clough Date: Thu, 13 Aug 2026 19:48:51 +0100 Subject: [PATCH] feat(source-control): wire up fork remotes on clone Cloning a GitHub fork left the parent repository unreachable: no `upstream` remote, and `gh` silently resolved a two-remote checkout to the parent, so pull requests created from the workspace could target the wrong repository. Clone a fork with its parent wired up as `upstream`, fetched the way `gh repo clone` leaves it, and pin a default repository in the same git config `gh repo set-default` writes. A new step in the Add Project clone flow chooses which repository that is, and the choice is editable afterwards in Settings for web and desktop, or the thread git sheet on mobile. The cloned fork is offered first, so the pin agrees with the remote a branch on the fork tracks; picking the parent stays one keystroke away for contributing upstream. Verification: - `vp test run apps/server/src/sourceControl apps/server/src/project packages/shared/src/git.test.ts packages/client-runtime` - 1455 tests passed - Targeted typecheck, lint, and formatting for the touched packages - Web: clone flow and Settings driven by hand against a fork - iOS Simulator: fork chooser verified on device; the git sheet row is typechecked but unexercised - Two independent agent reviews; findings folded in, notably the `gh-resolved` value parse and the multi-value `git config` write Model: claude-opus-5; harness: Claude Code. --- apps/mobile/src/Stack.tsx | 11 + .../projects/AddProjectDestinationRoute.tsx | 2 + .../features/projects/AddProjectScreen.tsx | 122 ++++- .../threads/git/GitDefaultRepositorySheet.tsx | 174 +++++++ .../features/threads/git/GitOverviewSheet.tsx | 42 ++ apps/server/src/auth/RpcAuthorization.ts | 2 + .../src/project/RepositoryIdentityResolver.ts | 29 +- .../src/sourceControl/GitHubCli.test.ts | 44 ++ apps/server/src/sourceControl/GitHubCli.ts | 11 +- .../SourceControlRepositoryService.test.ts | 435 ++++++++++++++++++ .../SourceControlRepositoryService.ts | 259 ++++++++++- apps/server/src/ws.ts | 16 + apps/web/src/components/CommandPalette.tsx | 209 ++++++++- .../settings/ProjectSettingsPanel.tsx | 115 +++++ docs/internals/glossary.md | 4 + docs/user/source-control.md | 9 + .../src/operations/projects.test.ts | 46 ++ .../client-runtime/src/operations/projects.ts | 28 ++ .../client-runtime/src/state/sourceControl.ts | 10 + packages/contracts/src/ipc.ts | 9 + packages/contracts/src/rpc.ts | 25 + packages/contracts/src/sourceControl.ts | 62 +++ packages/shared/src/git.test.ts | 56 +++ packages/shared/src/git.ts | 41 ++ 24 files changed, 1730 insertions(+), 31 deletions(-) create mode 100644 apps/mobile/src/features/threads/git/GitDefaultRepositorySheet.tsx 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. */