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. */