diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index b2ad65cf05374..3f37702a8b28d 100644 --- a/apps/mobile/src/Stack.tsx +++ b/apps/mobile/src/Stack.tsx @@ -57,6 +57,7 @@ import { ThreadTerminalRouteScreen } from "./features/terminal/ThreadTerminalRou import { DevicePreviewRouteScreen } from "./features/devices/DevicePreviewRouteScreen"; import { BrowserPreviewRouteScreen } from "./features/browser/BrowserPreviewRouteScreen"; import { GitBranchesSheet } from "./features/threads/git/GitBranchesSheet"; +import { GitRemoteSheet } from "./features/threads/git/GitRemoteSheet"; import { GitCommitSheet } from "./features/threads/git/GitCommitSheet"; import { GitConfirmSheet } from "./features/threads/git/GitConfirmSheet"; import { GitOverviewSheet } from "./features/threads/git/GitOverviewSheet"; @@ -703,6 +704,11 @@ const RootStackConfig = createWorkspaceStackNavigator({ linking: `${THREAD_LINKING_PREFIX}/git/branches`, options: sheetRoute({ detent: 0.55, androidFit: true }), }), + GitRemote: createNativeStackScreen({ + screen: GitRemoteSheet, + linking: `${THREAD_LINKING_PREFIX}/git/remote`, + options: sheetRoute({ detent: 0.55, androidFit: true }), + }), GitConfirm: createNativeStackScreen({ screen: GitConfirmSheet, linking: `${THREAD_LINKING_PREFIX}/git-confirm`, diff --git a/apps/mobile/src/features/layout/workspace-overlay-routes.ts b/apps/mobile/src/features/layout/workspace-overlay-routes.ts index 0e8a009995063..dff841662949d 100644 --- a/apps/mobile/src/features/layout/workspace-overlay-routes.ts +++ b/apps/mobile/src/features/layout/workspace-overlay-routes.ts @@ -11,6 +11,7 @@ const LISTED_OVERLAY_ROUTES: ReadonlySet = new Set([ "GitBranches", "GitCommit", "GitConfirm", + "GitRemote", "GitOverview", "NewTaskProject", "NewTaskFiles", diff --git a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx index c3a591f50ef4b..22c19f440d801 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -299,6 +299,22 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) { ) } /> + {gitStatus.data?.remotes && gitStatus.data.refName ? ( + + presentRoute(() => + navigation.navigate("GitRemote", { + environmentId: String(environmentId), + threadId: String(threadId), + }), + ) + } + /> + ) : null} {linkedPrActions.canLink ? ( ; + +export function GitRemoteSheet(props: GitRemoteSheetProps) { + const { environmentId, threadId, action, commitMessage, featureBranch, filePaths } = + props.route.params; + const navigation = useNavigation(); + const insets = useSafeAreaInsets(); + const { height: windowHeight } = useWindowDimensions(); + const { selectedThread } = useThreadSelection(); + const { selectedThreadCwd } = useSelectedThreadWorktree(); + const gitActions = useSelectedThreadGitActions(); + const gitStatus = useEnvironmentQuery( + selectedThread !== null && selectedThreadCwd !== null + ? vcsEnvironment.status({ + environmentId: selectedThread.environmentId, + input: { cwd: selectedThreadCwd }, + }) + : null, + ); + const remotes = gitStatus.data?.remotes ?? []; + const pushRemote = gitStatus.data?.pushRemote; + + const selectRemote = async (remoteName: string) => { + if (action) { + navigation.dispatch(StackActions.replace("Thread", { environmentId, threadId })); + await gitActions.onRunSelectedThreadGitAction({ + action, + pushRemote: remoteName, + ...(commitMessage ? { commitMessage } : {}), + ...(featureBranch === "true" ? { featureBranch: true } : {}), + ...(filePaths ? { filePaths: JSON.parse(filePaths) as string[] } : {}), + }); + return; + } + const result = await gitActions.onSetSelectedThreadPushRemote(remoteName); + if (result !== null) navigation.goBack(); + }; + + return ( + + + + + {remotes.map((remoteName) => ( + void selectRemote(remoteName)} + /> + ))} + + + + navigation.goBack()} + /> + + ); +} diff --git a/apps/mobile/src/state/use-selected-thread-git-actions.test.ts b/apps/mobile/src/state/use-selected-thread-git-actions.test.ts index 548ae2d3ce6b0..0d4f781ea29af 100644 --- a/apps/mobile/src/state/use-selected-thread-git-actions.test.ts +++ b/apps/mobile/src/state/use-selected-thread-git-actions.test.ts @@ -53,7 +53,10 @@ vi.mock("./queries", () => ({ useBranches: () => ({ data: { refs: state.cachedRefs }, refresh: () => {} }), })); vi.mock("./use-atom-command", () => ({ useAtomCommand: (command: unknown) => command })); -vi.mock("./atom-registry", () => ({ appAtomRegistry: {} })); +vi.mock("./atom-registry", () => ({ + appAtomRegistry: { get: () => AsyncResult.initial() }, +})); +vi.mock("@react-navigation/native", () => ({ useNavigation: () => ({ navigate: () => {} }) })); vi.mock("./use-remote-environment-registry", () => ({ environmentConnectedAtom: () => null, setPendingConnectionError: () => {}, @@ -84,6 +87,7 @@ vi.mock("./threads", () => ({ vi.mock("./vcs", () => ({ vcsEnvironment: { readRefs: state.readRefs, + status: () => null, refreshStatus: async () => { state.statusRefreshes += 1; return AsyncResult.success({ refName: state.branch }); diff --git a/apps/mobile/src/state/use-selected-thread-git-actions.ts b/apps/mobile/src/state/use-selected-thread-git-actions.ts index 7ecc10d0b6d85..afd7f9064d305 100644 --- a/apps/mobile/src/state/use-selected-thread-git-actions.ts +++ b/apps/mobile/src/state/use-selected-thread-git-actions.ts @@ -1,3 +1,4 @@ +import { useNavigation } from "@react-navigation/native"; import { useAtomMount } from "@effect/atom-react"; import { EnvironmentProject, EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell"; @@ -5,6 +6,7 @@ import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime"; import { createVcsStatusRevalidationAtomFamily, type GitActionRequestInput, + pushRemoteChoicesFor, type VcsActionOperation, type VcsRef, } from "@t3tools/client-runtime/state/vcs"; @@ -53,12 +55,14 @@ const selectedThreadStatusRevalidation = createVcsStatusRevalidationAtomFamily({ }); export function useSelectedThreadGitActions() { + const navigation = useNavigation(); const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, { reportFailure: false, }); const readRefs = useAtomCommand(vcsEnvironment.readRefs, { reportFailure: false }); const refreshStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false }); const switchRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false }); + const setPushRemote = useAtomCommand(vcsEnvironment.setPushRemote, { reportFailure: false }); const createRef = useAtomCommand(vcsEnvironment.createRef, { reportFailure: false }); const createWorktree = useAtomCommand(vcsEnvironment.createWorktree, { reportFailure: false }); const pull = useAtomCommand(vcsEnvironment.pull, { reportFailure: false }); @@ -356,9 +360,54 @@ export function useSelectedThreadGitActions() { ); }; + const selectedThreadGitStatus = () => + selectedThread && selectedThreadCwd + ? AsyncResult.getOrElse( + appAtomRegistry.get( + vcsEnvironment.status({ + environmentId: selectedThread.environmentId, + input: { cwd: selectedThreadCwd }, + }), + ), + () => null, + ) + : null; + + const onSetSelectedThreadPushRemote = async (remoteName: string) => { + const refName = selectedThreadGitStatus()?.refName; + if (!refName) return null; + return runSelectedThreadGitMutation( + "switch_ref", + "Changing push remote", + async ({ thread, cwd }) => { + const result = await setPushRemote({ + environmentId: thread.environmentId, + input: { cwd, refName, remoteName }, + }); + if (AsyncResult.isSuccess(result)) { + await refreshSelectedThreadGitStatus({ quiet: true, cwd }); + } + return result; + }, + ); + }; + const onRunSelectedThreadGitAction = async ( input: GitActionRequestInput, ): Promise => { + // Every entry point lands here, after any default-branch confirmation. + const pushRemoteChoices = pushRemoteChoicesFor(input, selectedThreadGitStatus()); + if (pushRemoteChoices && selectedThread) { + navigation.navigate("GitRemote", { + environmentId: String(selectedThread.environmentId), + threadId: String(selectedThread.id), + action: input.action, + ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), + ...(input.featureBranch ? { featureBranch: "true" } : {}), + ...(input.filePaths?.length ? { filePaths: JSON.stringify(input.filePaths) } : {}), + }); + return null; + } const actionId = uuidv4(); return await runSelectedThreadGitMutation( "run_change_request", @@ -370,6 +419,7 @@ export function useSelectedThreadGitActions() { ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), ...(input.featureBranch ? { featureBranch: input.featureBranch } : {}), ...(input.filePaths?.length ? { filePaths: [...input.filePaths] } : {}), + ...(input.pushRemote ? { pushRemote: input.pushRemote } : {}), threadId: thread.id, }); if (AsyncResult.isFailure(result)) { @@ -412,6 +462,7 @@ export function useSelectedThreadGitActions() { onCreateSelectedThreadBranch, onCreateSelectedThreadWorktree, onPullSelectedThreadBranch, + onSetSelectedThreadPushRemote, onRunSelectedThreadGitAction, }; } diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 2db95349f1021..be1d78f55bcd1 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -58,9 +58,11 @@ import * as VcsProjectConfig from "../vcs/VcsProjectConfig.ts"; import * as GitWorkflowService from "./GitWorkflowService.ts"; import type { SourceControlProvider, + SourceControlProviderContext, SourceControlRefSelector, } from "@t3tools/source-control-core/server/SourceControlProvider"; import * as SourceControlProviderRegistry from "../sourceControl/SourceControlProviderRegistry.ts"; +import { sourceControlDefinitions } from "../sourceControl/sourceControlDefinitions.ts"; import * as ServerConfig from "../config.ts"; import * as ProjectionStore from "../orchestration-v2/ProjectionStore.ts"; import * as ProjectStore from "../orchestration-v2/ProjectStore.ts"; @@ -607,6 +609,7 @@ function runStackedAction( commitMessage?: string; featureBranch?: boolean; filePaths?: readonly string[]; + pushRemote?: string; }, options?: Parameters[1], ) { @@ -629,6 +632,10 @@ function preparePullRequestThread( function makeManager(input?: { providerScenario?: FakeProviderScenario; sourceControlProvider?: SourceControlProvider["Service"]; + /** Served when a caller resolves the provider for a specific remote. */ + remoteSourceControlProvider?: SourceControlProvider["Service"]; + /** The checkout-wide context the default provider was resolved from. */ + sourceControlContext?: SourceControlProviderContext; textGeneration?: Partial; serverSettings?: Parameters[0]; setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]; @@ -726,7 +733,11 @@ function makeManager(input?: { SourceControlProviderRegistry.SourceControlProviderRegistry.of({ resolveLink: (input) => provider.resolveLink?.(input), get: () => Effect.succeed(provider), - resolveHandle: () => Effect.succeed({ provider, context: null }), + resolveHandle: (request) => + Effect.succeed({ + provider: (request.context && input?.remoteSourceControlProvider) || provider, + context: request.context ?? input?.sourceControlContext ?? null, + }), resolve: () => Effect.succeed(provider), discover: Effect.succeed([]), }), @@ -1024,6 +1035,146 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); + it.effect.each([ + { label: "the branch's push remote", key: "branch.feature/status-push-remote-host.pushRemote" }, + { label: "the repository's default push remote", key: "remote.pushDefault" }, + ])("status detects the host from $label before the first push", ({ key }) => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/status-push-remote-host"]); + yield* runGit(repoDir, ["remote", "add", "hub", "https://github.com/acme/repo.git"]); + yield* runGit(repoDir, ["remote", "add", "lab", "https://gitlab.com/acme/repo.git"]); + yield* runGit(repoDir, ["config", key, "lab"]); + + const { manager } = yield* makeManager(); + + const status = yield* manager.status({ cwd: repoDir }); + + expect(status.sourceControlProvider?.kind).toBe("gitlab"); + }), + ); + + it.effect("status finds the PR on the host of the remote the branch is pushed to", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/status-mixed-hosts"]); + const remoteDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "hub", "https://github.com/acme/repo.git"]); + yield* runGit(repoDir, ["remote", "add", "lab", "https://gitlab.com/acme/repo.git"]); + yield* runGit(repoDir, ["config", "remote.lab.pushurl", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "lab", "feature/status-mixed-hosts"]); + + const gitlab = createFakeSourceControlProvider({ + kind: "gitlab", + prListSequence: [ + [ + { + number: 21, + title: "Mixed hosts", + url: "https://gitlab.com/acme/repo/-/merge_requests/21", + baseRefName: "main", + headRefName: "feature/status-mixed-hosts", + isCrossRepository: true, + headRepositoryNameWithOwner: "acme/repo", + headRepositoryOwnerLogin: "acme", + }, + ], + ], + }); + const { manager } = yield* makeManager({ remoteSourceControlProvider: gitlab.service }); + + const status = yield* manager.status({ cwd: repoDir }); + + expect(status.pr?.url).toBe("https://gitlab.com/acme/repo/-/merge_requests/21"); + }), + ); + + it.effect("create_pr reads the base from the branch remote when only it has that base", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const originDir = yield* createBareRemote(); + const labDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "origin", originDir]); + yield* runGit(repoDir, ["push", "origin", "HEAD:main"]); + yield* runGit(repoDir, ["remote", "add", "lab", "https://gitlab.com/acme/repo.git"]); + yield* runGit(repoDir, ["config", "remote.lab.pushurl", labDir]); + yield* runGit(repoDir, ["update-ref", "refs/remotes/lab/develop", "HEAD"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/lab-base"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "lab.txt"), "lab\n"); + yield* runGit(repoDir, ["add", "lab.txt"]); + yield* runGit(repoDir, ["commit", "-m", "Lab base"]); + yield* runGit(repoDir, ["push", "-u", "lab", "feature/lab-base"]); + + const gitlab = createFakeSourceControlProvider({ + kind: "gitlab", + defaultBranch: "develop", + prListSequence: [ + [], + [ + { + number: 8, + title: "Lab base", + url: "https://gitlab.com/acme/repo/-/merge_requests/8", + baseRefName: "develop", + headRefName: "feature/lab-base", + }, + ], + ], + }); + const { manager } = yield* makeManager({ remoteSourceControlProvider: gitlab.service }); + + const result = yield* runStackedAction(manager, { cwd: repoDir, action: "create_pr" }); + + expect(result.pr.status).toBe("created"); + expect(created(gitlab.calls).map((call) => call.baseRefName)).toEqual(["develop"]); + }), + ); + + it.effect("status finds the PR on the branch remote's host when both hosts are GitHub", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + yield* runGit(repoDir, ["checkout", "-b", "feature/status-mixed-github-hosts"]); + const remoteDir = yield* createBareRemote(); + const originUrl = "https://github.com/acme/repo.git"; + const originProvider = sourceControlDefinitions.recognizeRemote(originUrl); + if (!originProvider) throw new Error("github.com is not recognized"); + yield* runGit(repoDir, ["remote", "add", "origin", originUrl]); + yield* runGit(repoDir, ["remote", "add", "ghe", "https://acme.ghe.com/acme/repo.git"]); + yield* runGit(repoDir, ["config", "remote.ghe.pushurl", remoteDir]); + yield* runGit(repoDir, ["push", "-u", "ghe", "feature/status-mixed-github-hosts"]); + + const enterprise = createFakeSourceControlProvider({ + prListSequence: [ + [ + { + number: 5, + title: "Enterprise host", + url: "https://acme.ghe.com/acme/repo/pull/5", + baseRefName: "main", + headRefName: "feature/status-mixed-github-hosts", + }, + ], + ], + }); + const { manager } = yield* makeManager({ + remoteSourceControlProvider: enterprise.service, + sourceControlContext: { + provider: originProvider, + remoteName: "origin", + remoteUrl: originUrl, + }, + }); + + const status = yield* manager.status({ cwd: repoDir }); + + expect(status.pr?.url).toBe("https://acme.ghe.com/acme/repo/pull/5"); + }), + ); + it.effect("status returns an explicit non-repo result for deleted directories", () => Effect.gen(function* () { const rootDir = yield* makeTempDir("t3code-git-manager-missing-dir-"); @@ -3854,6 +4005,49 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); + it.effect("commit_push_pr pushes to the chosen remote when none is named origin", () => + Effect.gen(function* () { + const repoDir = yield* makeTempDir("t3code-git-manager-"); + yield* initRepo(repoDir); + const forkDir = yield* createBareRemote(); + const upstreamDir = yield* createBareRemote(); + yield* runGit(repoDir, ["remote", "add", "fork", forkDir]); + yield* runGit(repoDir, ["remote", "add", "upstream", upstreamDir]); + yield* runGit(repoDir, ["push", "upstream", "HEAD:main"]); + yield* runGit(repoDir, ["checkout", "-b", "feature/chosen-remote"]); + NodeFS.writeFileSync(NodePath.join(repoDir, "chosen.txt"), "chosen\n"); + + const { manager } = yield* makeManager({ + providerScenario: { + prListSequence: [ + [], + [ + { + number: 61, + title: "Chosen remote", + url: "https://github.com/pingdotgg/codething-mvp/pull/61", + baseRefName: "main", + headRefName: "feature/chosen-remote", + }, + ], + ], + }, + }); + + const result = yield* runStackedAction(manager, { + cwd: repoDir, + action: "commit_push_pr", + pushRemote: "fork", + }); + + expect(result.push.status).toBe("pushed"); + expect(result.pr.status).toBe("created"); + expect( + (yield* runGit(repoDir, ["rev-parse", "--abbrev-ref", "@{upstream}"])).stdout.trim(), + ).toBe("fork/feature/chosen-remote"); + }), + ); + it.effect.each([ { localBranch: "develop" }, // Pushes to origin's develop under a name that differs from the base. diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 92d693a17382b..3fb15f91f0b8a 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -717,6 +717,9 @@ function toPullRequestHeadRemoteInfo(pr: { }; } +const sameBaseUrlHost = (left: string, right: string) => + URL.canParse(left) && URL.canParse(right) && new URL(left).host === new URL(right).host; + export const make = Effect.gen(function* () { const gitCore = yield* GitVcsDriver.GitVcsDriver; const sourceControlProviders = yield* SourceControlProviderRegistry.SourceControlProviderRegistry; @@ -738,6 +741,32 @@ export const make = Effect.gen(function* () { allowNonZeroExit: true, }) .pipe(Effect.map((result) => result.stdout.split("\n", 1)[0]?.trim() || null)); + // The checkout's provider follows origin, but a branch's change requests live on the host + // of the remote it was pushed to, which can differ when a repository mixes hosts. + const branchSourceControlProvider = Effect.fn("GitManager.branchSourceControlProvider")( + function* (cwd: string, remoteName: string | null) { + const fallback = yield* sourceControlProviders.resolveHandle({ cwd }); + const remoteUrl = remoteName + ? yield* readRemoteUrl(cwd, remoteName).pipe(Effect.orElseSucceed(() => null)) + : null; + const provider = remoteUrl ? sourceControlDefinitions.recognizeRemote(remoteUrl) : null; + if ( + !remoteName || + !remoteUrl || + !provider || + (provider.kind === fallback.provider.kind && + fallback.context !== null && + sameBaseUrlHost(provider.baseUrl, fallback.context.provider.baseUrl)) + ) { + return fallback.provider; + } + const handle = yield* sourceControlProviders.resolveHandle({ + cwd, + context: { provider, remoteName, remoteUrl }, + }); + return handle.provider; + }, + ); const changeRequestTermsFor = Effect.fn("GitManager.changeRequestTermsFor")(function* ( cwd: string, ) { @@ -816,8 +845,11 @@ export const make = Effect.gen(function* () { const pushCurrentBranch = Effect.fn("GitManager.pushCurrentBranch")(function* ( cwd: string, branch: string | null, + remoteName?: string, ) { - const result = yield* gitCore.pushCurrentBranch(cwd, branch); + const result = yield* gitCore.pushCurrentBranch(cwd, branch, { + remoteName: remoteName ?? null, + }); if (result.status === "pushed" && result.branch) { yield* shareBranchBase(cwd, result.branch); } @@ -1218,13 +1250,16 @@ export const make = Effect.gen(function* () { Effect.catchIf(isNotGitRepositoryError, () => Effect.succeed(nonRepositoryStatusDetails)), ); const hostingProvider = details.isRepo - ? yield* resolveHostingProvider(cwd, details.branch) + ? yield* resolveHostingProvider(cwd, details.branch, details.pushRemote ?? null) : null; return { isRepo: details.isRepo, ...(hostingProvider ? { sourceControlProvider: hostingProvider } : {}), hasPrimaryRemote: details.hasOriginRemote, + ...(details.remotes ? { remotes: details.remotes } : {}), + ...(details.pushRemote ? { pushRemote: details.pushRemote } : {}), + ...(details.pushRemoteChoices ? { pushRemoteChoices: details.pushRemoteChoices } : {}), isDefaultRef: details.isDefaultBranch, refName: details.branch, hasWorkingTreeChanges: details.hasWorkingTreeChanges, @@ -1585,11 +1620,13 @@ export const make = Effect.gen(function* () { const resolveHostingProvider = Effect.fn("resolveHostingProvider")(function* ( cwd: string, branch: string | null, + pushRemote: string | null, ) { const preferredRemoteName = - branch === null + pushRemote ?? + (branch === null ? "origin" - : ((yield* readConfigValueNullable(cwd, `branch.${branch}.remote`)) ?? "origin"); + : ((yield* readConfigValueNullable(cwd, `branch.${branch}.remote`)) ?? "origin")); const remoteUrl = (yield* readRemoteUrlNullable(cwd, preferredRemoteName)) ?? (yield* readRemoteUrlNullable(cwd, "origin")); @@ -1979,10 +2016,11 @@ export const make = Effect.gen(function* () { | "headRepositoryNameWithOwner" | "headRepositoryOwnerLogin" | "isCrossRepository" + | "remoteName" >, ownerHeadSelector?: string, ) { - const provider = yield* sourceControlProvider(cwd); + const provider = yield* branchSourceControlProvider(cwd, headContext.remoteName); const probe = headBranchProbe(provider, headContext.headSelectors, "open", ownerHeadSelector); for (const headSelector of probe.headSelectors) { const pullRequests = yield* provider.listChangeRequests({ @@ -2015,7 +2053,7 @@ export const make = Effect.gen(function* () { ) { const parsedByNumber = new Map(); - const provider = yield* sourceControlProvider(cwd); + const provider = yield* branchSourceControlProvider(cwd, headContext.remoteName); const probe = headBranchProbe( provider, headContext.headSelectors, @@ -2198,7 +2236,10 @@ export const make = Effect.gen(function* () { } } - const defaultFromProvider = yield* sourceControlProvider(cwd).pipe( + const defaultFromProvider = yield* branchSourceControlProvider( + cwd, + headContext.remoteName, + ).pipe( Effect.flatMap((provider) => provider.getDefaultBranch({ cwd })), Effect.orElseSucceed(() => null), ); @@ -2217,26 +2258,29 @@ export const make = Effect.gen(function* () { return { baseBranch: "main", baseRef: "main" }; }); + // A base read from the branch remote's host can exist only on that remote. const resolveBaseRangeRef = Effect.fn("resolveBaseRangeRef")(function* ( cwd: string, baseBranch: string, + branchRemoteName: string | null, ) { if (baseBranch.startsWith("refs/")) return baseBranch; - const remoteName = yield* gitCore + const primaryRemoteName = yield* gitCore .resolvePrimaryRemoteName(cwd) .pipe(Effect.orElseSucceed(() => null)); - if (!remoteName) return baseBranch; - - return yield* gitCore - .resolveRemoteTrackingCommit({ - cwd, - refName: baseBranch, - fallbackRemoteName: remoteName, - }) - .pipe( - Effect.map((resolved) => resolved.commitSha), - Effect.orElseSucceed(() => baseBranch), - ); + const remoteNames = [...new Set([primaryRemoteName, branchRemoteName])].filter( + (remoteName) => remoteName !== null, + ); + for (const remoteName of remoteNames) { + const commitSha = yield* gitCore + .resolveRemoteTrackingCommit({ cwd, refName: baseBranch, fallbackRemoteName: remoteName }) + .pipe( + Effect.map((resolved) => resolved.commitSha), + Effect.orElseSucceed(() => null), + ); + if (commitSha) return commitSha; + } + return baseBranch; }); const resolveCommitAndBranchSuggestion = Effect.fn("resolveCommitAndBranchSuggestion")( @@ -2413,8 +2457,9 @@ export const make = Effect.gen(function* () { cwd: string, status: { branch: string; upstreamRef: string | null; hasUpstream: boolean }, changeRequestLabel: string, + pushRemote: string | undefined, ) { - const target = yield* gitCore.resolvePushTarget(cwd, status); + const target = yield* gitCore.resolvePushTarget(cwd, { ...status, remoteName: pushRemote }); if (!target) return; const headContext = yield* resolveBranchHeadContext(cwd, { branch: status.branch, @@ -2445,8 +2490,6 @@ export const make = Effect.gen(function* () { emit: GitActionProgressEmitter, pushRemoteRef?: { readonly remoteName: string; readonly upstreamRef: string }, ) { - const provider = yield* sourceControlProvider(cwd); - const terms = sourceControlDefinitions.get(provider.kind).changeRequest; const details = yield* gitCore.statusDetails(cwd); const branch = details.branch ?? fallbackBranch; if (!branch) { @@ -2470,6 +2513,8 @@ export const make = Effect.gen(function* () { cwd, pushRemoteRef ? { branch, ...pushRemoteRef } : { branch, upstreamRef: details.upstreamRef }, ); + const provider = yield* branchSourceControlProvider(cwd, headContext.remoteName); + const terms = sourceControlDefinitions.get(provider.kind).changeRequest; const existing = yield* findOpenPr(cwd, headContext); if (existing) { @@ -2494,7 +2539,7 @@ export const make = Effect.gen(function* () { phase: "pr", label: `Generating ${terms.shortLabel} content...`, }); - const baseRangeRef = yield* resolveBaseRangeRef(cwd, baseRef); + const baseRangeRef = yield* resolveBaseRangeRef(cwd, baseRef, headContext.remoteName); const rangeContext = yield* gitCore.readRangeContext(cwd, baseRangeRef); const policy = yield* resolveStylePolicy(cwd, settings); const changeRequestTemplate = @@ -3616,6 +3661,7 @@ export const make = Effect.gen(function* () { input.cwd, { ...initialStatus, branch: initialStatus.branch }, changeRequestTerms.shortLabelWithArticle, + input.pushRemote, ); } @@ -3646,7 +3692,7 @@ export const make = Effect.gen(function* () { }) .pipe( Effect.tap(() => Ref.set(currentPhase, Option.some("push"))), - Effect.flatMap(() => pushCurrentBranch(input.cwd, currentBranch)), + Effect.flatMap(() => pushCurrentBranch(input.cwd, currentBranch, input.pushRemote)), ) : { status: "skipped_not_requested" as const }; diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 287207840ad6a..998da812b178d 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -6,6 +6,7 @@ import * as FileSystem from "effect/FileSystem"; import { GitManagerError, GitCommandError, + type VcsSetPushRemoteInput, type VcsSwitchRefInput, type VcsSwitchRefResult, type VcsCreateRefInput, @@ -142,6 +143,7 @@ export class GitWorkflowService extends Context.Service< readonly switchRef: ( input: VcsSwitchRefInput, ) => Effect.Effect; + readonly setPushRemote: (input: VcsSetPushRemoteInput) => Effect.Effect; readonly renameBranch: (input: { readonly exactName?: boolean; readonly cwd: string; @@ -542,6 +544,11 @@ export const make = Effect.gen(function* () { ), ); }), + setPushRemote: Effect.fn("GitWorkflowService.setPushRemote")(function (input) { + return ensureGitCommand("GitWorkflowService.setPushRemote", input.cwd).pipe( + Effect.andThen(git.setPushRemote(input)), + ); + }), renameBranch: Effect.fn("GitWorkflowService.renameBranch")(function (input) { return ensureGit("GitWorkflowService.renameBranch", input.cwd).pipe( Effect.andThen(git.renameBranch(input)), diff --git a/apps/server/src/observability/RpcInstrumentation.ts b/apps/server/src/observability/RpcInstrumentation.ts index 4549ee8a33c10..44745e7a056e9 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -196,6 +196,7 @@ const RPC_AGGREGATES = { [WS_METHODS.vcsRemoveWorktree]: "vcs", [WS_METHODS.vcsCreateRef]: "vcs", [WS_METHODS.vcsSwitchRef]: "vcs", + [WS_METHODS.vcsSetPushRemote]: "vcs", [WS_METHODS.vcsInit]: "vcs", [WS_METHODS.reviewGetBranchCommits]: "review", [WS_METHODS.reviewGetDiffPreview]: "review", diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index d1e9dae66f1fa..81be8b6e5aa82 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -16,6 +16,7 @@ import { VcsCheckpointStorageError, VcsProcessExitError, VcsUnsupportedOperationError, + type VcsSetPushRemoteInput, type VcsSwitchRefInput, type VcsSwitchRefResult, type VcsCreateRefInput, @@ -78,6 +79,9 @@ export interface GitStatusDetails { isRepo: boolean; sourceControlProvider?: VcsStatusResult["sourceControlProvider"]; hasOriginRemote: boolean; + remotes?: VcsStatusResult["remotes"]; + pushRemote?: VcsStatusResult["pushRemote"]; + pushRemoteChoices?: VcsStatusResult["pushRemoteChoices"]; isDefaultBranch: boolean; branch: string | null; upstreamRef: string | null; @@ -358,7 +362,11 @@ export class GitVcsDriver extends Context.Service< /** Where `pushCurrentBranch` would push the checked-out branch; null with no remote or when Git picks. */ readonly resolvePushTarget: ( cwd: string, - current: { readonly branch: string; readonly hasUpstream: boolean }, + current: { + readonly branch: string; + readonly hasUpstream: boolean; + readonly remoteName?: string | undefined; + }, ) => Effect.Effect; readonly readRangeContext: ( cwd: string, @@ -469,6 +477,7 @@ export class GitVcsDriver extends Context.Service< readonly switchRef: ( input: VcsSwitchRefInput, ) => Effect.Effect; + readonly setPushRemote: (input: VcsSetPushRemoteInput) => Effect.Effect; readonly initRepo: (input: VcsInitInput) => Effect.Effect; /** Drops cached refs after something outside this driver changed them, such as a clone into `cwd`. */ readonly invalidateRefs: (cwd: string) => Effect.Effect; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 9e5b20205fefb..9ad207db54b7e 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -413,6 +413,25 @@ const commitInitialRepo = (cwd: string, initArgs: ReadonlyArray = []) => yield* git(cwd, ["commit", "-m", "initial commit"]); }); +/** Remotes `fork` and `upstream` (holding the initial branch), no origin, on `feature/remote`. */ +const makeForkWorkflowRepo = Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const cwd = yield* makeTmpDir(); + const forkRemote = yield* makeTmpDir("git-remote-"); + const upstreamRemote = yield* makeTmpDir("git-remote-"); + const { initialBranch } = yield* initRepoWithCommit(cwd); + yield* git(forkRemote, ["init", "--bare"]); + yield* git(upstreamRemote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "fork", forkRemote]); + yield* git(cwd, ["remote", "add", "upstream", upstreamRemote]); + yield* git(cwd, ["push", "upstream", initialBranch]); + yield* git(cwd, ["checkout", "-b", "feature/remote"]); + yield* writeTextFile(cwd, "feature.txt", "feature\n"); + yield* git(cwd, ["add", "-A"]); + yield* driver.commit(cwd, "Add feature", ""); + return { cwd, initialBranch }; +}); + // Most tests start from the same one-commit repository; they copy it instead of spawning git. let repoTemplate: string | undefined; afterAll(() => { @@ -9456,6 +9475,115 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + it.effect("asks which remote to push to when several remotes have no origin", () => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const { cwd } = yield* makeForkWorkflowRepo; + // A push remote that was since removed is no choice at all. + yield* git(cwd, ["config", "branch.feature/remote.pushRemote", "removed"]); + + const status = yield* driver.statusDetailsLocal(cwd); + assert.deepEqual(status.pushRemoteChoices, ["fork", "upstream"]); + assert.isUndefined(status.pushRemote); + const refused = yield* driver.pushCurrentBranch(cwd, null).pipe(Effect.flip); + assert.include(refused.message, "Choose which remote"); + + // A push re-reads the cached remotes, so the configured default shows up after it. + yield* git(cwd, ["config", "remote.pushDefault", "fork"]); + yield* driver.pushCurrentBranch(cwd, null, { remoteName: "upstream" }); + assert.equal( + yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]), + "upstream/feature/remote", + ); + assert.isUndefined((yield* driver.statusDetailsLocal(cwd)).pushRemoteChoices); + }), + ); + + it.effect("asks for a push remote while the upstream is only the branch's base", () => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const { cwd, initialBranch } = yield* makeForkWorkflowRepo; + yield* git(cwd, ["branch", "--set-upstream-to", `upstream/${initialBranch}`]); + + const status = yield* driver.statusDetailsLocal(cwd); + assert.isTrue(status.hasUpstream); + assert.isUndefined(status.pushRemote); + const refused = yield* driver.pushCurrentBranch(cwd, null).pipe(Effect.flip); + assert.include(refused.message, "Choose which remote"); + const unknown = yield* driver + .pushCurrentBranch(cwd, null, { remoteName: cwd }) + .pipe(Effect.flip); + assert.include(unknown.message, "does not exist"); + + yield* driver.pushCurrentBranch(cwd, null, { remoteName: "fork" }); + assert.equal( + yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]), + "fork/feature/remote", + ); + assert.equal( + yield* git(cwd, ["config", "branch.feature/remote.t3-merge-base-ref"]), + `refs/remotes/upstream/${initialBranch}`, + ); + }), + ); + + it.effect("selecting a push remote keeps the branch's tracking", () => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const { cwd } = yield* makeForkWorkflowRepo; + const refName = "feature/remote"; + + const unset = yield* driver.statusDetailsLocal(cwd); + assert.deepEqual(unset.remotes, ["fork", "upstream"]); + assert.isUndefined(unset.pushRemote); + + yield* driver.setPushRemote({ cwd, refName, remoteName: "fork" }); + assert.equal((yield* driver.statusDetailsLocal(cwd)).pushRemote, "fork"); + yield* driver.pushCurrentBranch(cwd, null); + assert.equal( + yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]), + `fork/${refName}`, + ); + + // A fork workflow pulls from `upstream` and pushes to `fork`; picking a remote keeps that. + yield* git(cwd, ["push", "upstream", refName]); + yield* git(cwd, ["branch", `--set-upstream-to=upstream/${refName}`]); + yield* driver.setPushRemote({ cwd, refName, remoteName: "fork" }); + assert.equal( + yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]), + `upstream/${refName}`, + ); + yield* writeTextFile(cwd, "fork.txt", "fork\n"); + yield* git(cwd, ["add", "-A"]); + yield* driver.commit(cwd, "Add fork update", ""); + assert.deepInclude(yield* driver.pushCurrentBranch(cwd, null), { + status: "pushed", + upstreamBranch: `fork/${refName}`, + pushRemote: "fork", + setUpstream: false, + }); + assert.equal( + yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]), + `upstream/${refName}`, + ); + + const unknown = yield* driver + .setPushRemote({ cwd, refName, remoteName: "missing" }) + .pipe(Effect.flip); + assert.include(unknown.message, "missing"); + const unknownBranch = yield* driver + .setPushRemote({ cwd, refName: "feature/renamed", remoteName: "fork" }) + .pipe(Effect.flip); + assert.include(unknownBranch.message, "does not exist"); + assert.equal( + yield* git(cwd, ["config", "--get-regexp", "^branch\\.feature/renamed\\."]).pipe( + Effect.orElseSucceed(() => ""), + ), + "", + ); + }), + ); + it.effect.each(["branch.topic.pushRemote", "remote.pushDefault"] as const)( "pushes a branch tracking its upstream head to the %s remote", (configKey) => diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index da1794188b0b2..46944c80ac3fa 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -136,7 +136,7 @@ const LIST_REFS_SNAPSHOT_CACHE_TTL = Duration.minutes(2); const LIST_REFS_REFRESH_COALESCE_TTL = Duration.seconds(5); const LIST_REFS_REFRESH_FAILURE_COOLDOWN = Duration.seconds(30); const STATUS_DEFAULT_BRANCH_CACHE_TTL = Duration.minutes(5); -const STATUS_ORIGIN_EXISTS_CACHE_TTL = Duration.minutes(5); +const STATUS_REMOTES_CACHE_TTL = Duration.minutes(5); const STATUS_UPSTREAM_REFRESH_ENV = Object.freeze({ GCM_INTERACTIVE: "never", GIT_ASKPASS: "", @@ -458,6 +458,8 @@ function parseUpstreamRefByFirstSeparator( }; } +// Differently named upstreams are bases, not publish targets: pushing HEAD there would write +// features to shared branches. function parseTrackingBranchByUpstreamRef(stdout: string, upstreamRef: string): string | null { for (const line of stdout.split("\n")) { const trimmedLine = line.trim(); @@ -1676,23 +1678,55 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }), }, ); - const primaryRemoteExistsCache = yield* Cache.makeWith( + // Repo-wide, so cached per common dir; per-branch config is read by `resolveStatusPushRemote`. + const readStatusRemotes = (cwd: string, gitArgs: ReadonlyArray = []) => + Effect.all( + [ + executeGit("GitVcsDriver.statusDetails.remotes", cwd, [...gitArgs, "remote"], { + allowNonZeroExit: true, + }), + executeGit( + "GitVcsDriver.statusDetails.remotePushDefault", + cwd, + [...gitArgs, "config", "--get", "remote.pushDefault"], + { allowNonZeroExit: true }, + ), + ], + { concurrency: "unbounded" }, + ).pipe( + Effect.map(([remotes, pushDefault]) => { + const names = remotes.exitCode === 0 ? parseRemoteNamesInGitOrder(remotes.stdout) : []; + const defaultPushRemote = pushDefault.stdout.trim(); + const needsChoice = + names.length > 1 && !names.includes("origin") && !names.includes(defaultPushRemote); + return { + hasRemote: names.length > 0, + ...(names.length > 1 ? { names } : {}), + ...(needsChoice ? { choices: names } : {}), + ...(names.includes(defaultPushRemote) ? { defaultPushRemote } : {}), + }; + }), + ); + const NO_STATUS_REMOTES: { + hasRemote: boolean; + names?: ReadonlyArray; + choices?: ReadonlyArray; + defaultPushRemote?: string; + } = { + hasRemote: false, + }; + const remotesCache = yield* Cache.makeWith( (gitCommonDir: string) => Effect.gen(function* () { const path = yield* Path.Path; const fetchCwd = path.basename(gitCommonDir) === ".git" ? path.dirname(gitCommonDir) : gitCommonDir; - return yield* executeGit( - "GitVcsDriver.statusDetails.primaryRemoteExists", - fetchCwd, - ["--git-dir", gitCommonDir, "remote"], - { allowNonZeroExit: true }, - ).pipe(Effect.map((result) => result.exitCode === 0 && result.stdout.trim().length > 0)); + return yield* readStatusRemotes(fetchCwd, ["--git-dir", gitCommonDir]); }), { capacity: 2_048, timeToLive: Exit.match({ - onSuccess: () => STATUS_ORIGIN_EXISTS_CACHE_TTL, + onSuccess: () => STATUS_REMOTES_CACHE_TTL, onFailure: () => Duration.zero, }), }, @@ -1707,7 +1741,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* : gitCommonDir; const cacheKey = commonDir ?? normalizeRepositoryPathsCacheKey(cwd); yield* Cache.invalidate(defaultBranchCache, cacheKey); - yield* Cache.invalidate(primaryRemoteExistsCache, cacheKey); + yield* Cache.invalidate(remotesCache, cacheKey); }, ); @@ -1941,13 +1975,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* cwd: string, refName: string, ) { + // A configured remote that was since removed or renamed is ignored, as status ignores it. + const remoteNames: ReadonlyArray = yield* listRemoteNames(cwd).pipe( + Effect.orElseSucceed(() => []), + ); const branchPushRemote = yield* runGitStdout( "GitVcsDriver.resolvePushRemoteName.branchPushRemote", cwd, ["config", "--get", `branch.${refName}.pushRemote`], true, ).pipe(Effect.map((stdout) => stdout.trim())); - if (branchPushRemote.length > 0) { + if (remoteNames.includes(branchPushRemote)) { return branchPushRemote; } @@ -1957,7 +1995,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ["config", "--get", "remote.pushDefault"], true, ).pipe(Effect.map((stdout) => stdout.trim())); - return pushDefaultRemote.length > 0 ? pushDefaultRemote : null; + return remoteNames.includes(pushDefaultRemote) ? pushDefaultRemote : null; }); const resolvePushRemoteName = Effect.fn("resolvePushRemoteName")(function* ( @@ -1974,9 +2012,73 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return fallbackRemoteName; } + // With several remotes and no `origin`, the first remote is a guess: clients ask the + // user (`pushRemoteChoices`) and pass the pick as `remoteName` instead. + const remoteNames: ReadonlyArray = yield* listRemoteNames(cwd).pipe( + Effect.orElseSucceed(() => []), + ); + if (remoteNames.length > 1 && !remoteNames.includes("origin")) { + return yield* new GitCommandError({ + ...gitCommandContext({ + operation: "GitVcsDriver.resolvePushRemoteName", + cwd, + args: ["push"], + }), + detail: "Choose which remote to push this branch to.", + }); + } return yield* resolvePrimaryRemoteName(cwd).pipe(Effect.orElseSucceed(() => null)); }); + // Publishing retargets the upstream, so a branch tracking its base keeps that base first. + const preserveUpstreamAsBase = Effect.fn("preserveUpstreamAsBase")(function* ( + cwd: string, + branch: string, + upstream: { remoteName: string; upstreamRef: string; branchName: string }, + ) { + if ((yield* readBranchBase(cwd, branch)) !== null) return; + yield* writeConfigValue({ cwd, key: branchBaseConfigKey(branch), value: upstream.branchName }); + if (!(yield* readConfigValue(cwd, `branch.${branch}.t3-merge-base-ref`))) { + yield* writeConfigValue({ + cwd, + key: `branch.${branch}.t3-merge-base-ref`, + value: + upstream.remoteName === "." + ? `refs/heads/${upstream.branchName}` + : `refs/remotes/${upstream.upstreamRef}`, + }); + } + }); + + // Mirrors where `pushCurrentBranch` sends this branch; undefined while the choice is unclear. + const resolveStatusPushRemote = Effect.fn("resolveStatusPushRemote")(function* ( + cwd: string, + refName: string, + upstreamRef: string | null, + remoteNames: ReadonlyArray, + defaultPushRemote: string | undefined, + ) { + const upstream = upstreamRef + ? (parseUpstreamRefWithRemoteNames(upstreamRef, remoteNames) ?? + parseUpstreamRefByFirstSeparator(upstreamRef)) + : null; + const branchPushRemote = yield* runGitStdout( + "GitVcsDriver.statusDetails.branchPushRemote", + cwd, + ["config", "--get", `branch.${refName}.pushRemote`], + true, + ).pipe( + Effect.map((stdout) => stdout.trim()), + Effect.orElseSucceed(() => ""), + ); + const configuredPushRemote = + (remoteNames.includes(branchPushRemote) ? branchPushRemote : undefined) ?? defaultPushRemote; + if (upstream && isAliasOfUpstreamHead(refName, upstream.branchName, upstream.upstreamRef)) { + return configuredPushRemote ?? upstream.remoteName; + } + return configuredPushRemote ?? (remoteNames.includes("origin") ? "origin" : undefined); + }); + const remoteRefContainsHead = (cwd: string, remoteRef: string) => executeGit( "GitVcsDriver.pushCurrentBranch.publishContainsHead", @@ -2414,7 +2516,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* { allowNonZeroExit: true, maxOutputBytes: REVIEW_METADATA_MAX_OUTPUT_BYTES }, ); // diff-index does not read status output, so both spawn together; a non-repository discards it. - const [statusResult, numstatResult, defaultBranch, hasPrimaryRemote] = yield* Effect.all( + const [statusResult, numstatResult, defaultBranch, remotes] = yield* Effect.all( [ readStatus, readNumstat("HEAD").pipe( @@ -2444,13 +2546,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ? Cache.get(defaultBranchCache, statusCacheKey).pipe(Effect.orElseSucceed(() => null)) : resolvePrimaryDefaultBranchName(cwd).pipe(Effect.orElseSucceed(() => null)), statusCacheKey - ? Cache.get(primaryRemoteExistsCache, statusCacheKey).pipe( - Effect.orElseSucceed(() => false), + ? Cache.get(remotesCache, statusCacheKey).pipe( + Effect.orElseSucceed(() => NO_STATUS_REMOTES), ) - : listRemoteNames(cwd).pipe( - Effect.map((remoteNames) => remoteNames.length > 0), - Effect.orElseSucceed(() => false), - ), + : readStatusRemotes(cwd).pipe(Effect.orElseSucceed(() => NO_STATUS_REMOTES)), ], { concurrency: "unbounded" }, ); @@ -2589,9 +2688,23 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* } files.sort((a, b) => a.path.localeCompare(b.path)); + const pushRemote = + remotes.names && refName + ? yield* resolveStatusPushRemote( + cwd, + refName, + upstreamRef, + remotes.names, + remotes.defaultPushRemote, + ) + : null; + return { isRepo: true, - hasOriginRemote: hasPrimaryRemote, + hasOriginRemote: remotes.hasRemote, + ...(remotes.names ? { remotes: remotes.names } : {}), + ...(pushRemote ? { pushRemote } : {}), + ...(remotes.choices ? { pushRemoteChoices: remotes.choices } : {}), isDefaultBranch, branch: refName, upstreamRef, @@ -2648,6 +2761,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.map((details) => ({ isRepo: details.isRepo, hasPrimaryRemote: details.hasOriginRemote, + ...(details.remotes ? { remotes: details.remotes } : {}), + ...(details.pushRemote ? { pushRemote: details.pushRemote } : {}), + ...(details.pushRemoteChoices ? { pushRemoteChoices: details.pushRemoteChoices } : {}), isDefaultRef: details.isDefaultBranch, refName: details.branch, hasWorkingTreeChanges: details.hasWorkingTreeChanges, @@ -3111,6 +3227,23 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ), ); + // Clients send remote names; git would also accept a URL or path as a push target. + const requireRemote = Effect.fn("GitVcsDriver.requireRemote")(function* ( + cwd: string, + remoteName: string, + operation: string, + args: ReadonlyArray, + ) { + const remoteNames = yield* listRemoteNames(cwd); + if (!remoteNames.includes(remoteName)) { + return yield* new GitCommandError({ + ...gitCommandContext({ operation, cwd, args: [...args] }), + detail: `Remote '${remoteName}' does not exist.`, + }); + } + return remoteNames; + }); + // `publishes` is false only when HEAD goes to the head of its current `upstream`. const resolvePushDestination = Effect.fn("resolvePushDestination")(function* ( cwd: string, @@ -3192,9 +3325,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* pushRemote, }; } - const publishRemoteName = yield* resolvePushRemoteName(cwd, branch).pipe( - Effect.orElseSucceed(() => null), - ); + const publishRemoteName = yield* resolvePushRemoteName(cwd, branch); const branchName = yield* resolvePublishBranchName(cwd, branch); return { remoteName: publishRemoteName ?? upstream.remoteName, @@ -3208,6 +3339,14 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const resolvePushTarget: GitVcsDriver.GitVcsDriver["Service"]["resolvePushTarget"] = Effect.fn( "resolvePushTarget", )(function* (cwd, current) { + const requestedRemoteName = current.remoteName?.trim(); + if (requestedRemoteName) { + return { + remoteName: requestedRemoteName, + branchName: yield* resolvePublishBranchName(cwd, current.branch), + viaPushRemote: false, + }; + } const destination = yield* resolvePushDestination(cwd, current.branch, current.hasUpstream); return destination ? { @@ -3218,6 +3357,26 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* : null; }); + const setPushRemote: GitVcsDriver.GitVcsDriver["Service"]["setPushRemote"] = Effect.fn( + "setPushRemote", + )(function* ({ cwd, refName, remoteName }) { + yield* requireRemote(cwd, remoteName, "GitVcsDriver.setPushRemote", ["config"]); + const branchExists = yield* executeGit( + "GitVcsDriver.setPushRemote.branchExists", + cwd, + ["show-ref", "--verify", "--quiet", `refs/heads/${refName}`], + { allowNonZeroExit: true }, + ).pipe(Effect.map((result) => result.exitCode === 0)); + if (!branchExists) { + return yield* new GitCommandError({ + ...gitCommandContext({ operation: "GitVcsDriver.setPushRemote", cwd, args: ["config"] }), + detail: `Branch '${refName}' does not exist.`, + }); + } + // Tracking stays put: pushes follow `pushRemote`, and a fork branch keeps pulling from its upstream. + yield* writeConfigValue({ cwd, key: `branch.${refName}.pushRemote`, value: remoteName }); + }); + const pushCurrentBranch: GitVcsDriver.GitVcsDriver["Service"]["pushCurrentBranch"] = Effect.fn( "pushCurrentBranch", )(function* (cwd, fallbackBranch, options) { @@ -3236,7 +3395,17 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const requestedRemoteName = options?.remoteName?.trim() || null; if (requestedRemoteName) { + yield* requireRemote(cwd, requestedRemoteName, "GitVcsDriver.pushCurrentBranch", ["push"]); const publishBranch = yield* resolvePublishBranchName(cwd, branch); + const currentUpstream = details.hasUpstream + ? yield* resolveCurrentUpstream(cwd).pipe(Effect.orElseSucceed(() => null)) + : null; + if ( + currentUpstream && + !isAliasOfUpstreamHead(branch, currentUpstream.branchName, currentUpstream.upstreamRef) + ) { + yield* preserveUpstreamAsBase(cwd, branch, currentUpstream); + } yield* executeGitWithStableDiagnostics( "GitVcsDriver.pushCurrentBranch.pushWithRequestedRemote", cwd, @@ -3330,24 +3499,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* if (destination?.publishes) { const { remoteName, branchName: publishBranch, upstream } = destination; - // Publishing retargets the upstream, so preserve its comparison base first. - if (upstream && (yield* readBranchBase(cwd, branch)) === null) { - yield* writeConfigValue({ - cwd, - key: branchBaseConfigKey(branch), - value: upstream.branchName, - }); - if (!(yield* readConfigValue(cwd, `branch.${branch}.t3-merge-base-ref`))) { - yield* writeConfigValue({ - cwd, - key: `branch.${branch}.t3-merge-base-ref`, - value: - upstream.remoteName === "." - ? `refs/heads/${upstream.branchName}` - : `refs/remotes/${upstream.upstreamRef}`, - }); - } - } + if (upstream) yield* preserveUpstreamAsBase(cwd, branch, upstream); yield* executeGitWithStableDiagnostics( upstream ? "GitVcsDriver.pushCurrentBranch.pushOwnBranch" @@ -6195,6 +6347,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return yield* withListRefsInvalidation(input.cwd, createBranch(input)); }), writeConfigValue, + setPushRemote: Effect.fnUntraced(function* (input) { + return yield* withListRefsInvalidation(input.cwd, setPushRemote(input)); + }), removeWorktree: Effect.fnUntraced(function* (input) { return yield* withListRefsInvalidation(input.cwd, removeWorktree(input)); }), diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 8e8b30bab3270..45e30e3d07012 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -3200,6 +3200,8 @@ export const layerWsRpc = ( gitWorkflow .switchRef(input) .pipe(Effect.tap(() => vcsStatusBroadcaster.refreshAfterCheckout(input.cwd))), + [WS_METHODS.vcsSetPushRemote]: (input) => + gitWorkflow.setPushRemote(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), [WS_METHODS.vcsInit]: (input) => vcsProvisioning.initRepository(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), [WS_METHODS.reviewGetBranchCommits]: (input) => review.getBranchCommits(input), diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index eae9e43459f42..2b5e339b2f9f3 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -2,6 +2,7 @@ import { writeTextToClipboard } from "../hooks/useCopyToClipboard"; import { formatFilePathForClipboard } from "@t3tools/client-runtime/media-actions"; import { readLocalApi } from "../localApi"; import { stackedThreadToast, toastManager } from "./ui/toast"; +import { BranchToolbarRemoteSelector } from "./BranchToolbarRemoteSelector"; import { ComposerContextLabel } from "./ComposerContextLabel"; import { scopeProjectRef, scopeThreadRef } from "@t3tools/client-runtime/environment"; import type { EnvironmentId, ProjectId, ThreadId } from "@t3tools/contracts"; @@ -620,6 +621,8 @@ export const BranchToolbar = memo(function BranchToolbar({ const activeWorktreePath = forceNewWorktree ? null : worktreePath; const effectiveEnvMode = forceNewWorktree ? "worktree" : envMode; const envModeLocked = envLocked || (isServerThread && activeWorktreePath !== null); + // The branch a new worktree will get does not exist yet, so it has no remote to select. + const startsInNewWorktree = effectiveEnvMode === "worktree" && activeWorktreePath === null; const canUsePreviousWorktree = !isServerThread && !envModeLocked && !forceNewWorktree; const projectRefsForWorktreeLookup = canUsePreviousWorktree ? [activeProjectRef] : []; @@ -724,6 +727,13 @@ export const BranchToolbar = memo(function BranchToolbar({ {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})} /> ) : null} + {panelSection !== "workspace" && !startsInNewWorktree ? ( + + ) : null} ); } @@ -819,6 +829,12 @@ export const BranchToolbar = memo(function BranchToolbar({ {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})} /> ) : null} + {showGitControls && !startsInNewWorktree ? ( + + ) : null} {trailing} ); diff --git a/apps/web/src/components/BranchToolbarRemoteSelector.tsx b/apps/web/src/components/BranchToolbarRemoteSelector.tsx new file mode 100644 index 0000000000000..608a64f1e4922 --- /dev/null +++ b/apps/web/src/components/BranchToolbarRemoteSelector.tsx @@ -0,0 +1,103 @@ +import { + isAtomCommandInterrupted, + squashAtomCommandFailure, +} from "@t3tools/client-runtime/state/runtime"; +import { AuthSourceControlWriteScope, type EnvironmentId } from "@t3tools/contracts"; +import { CloudIcon } from "lucide-react"; + +import { cn } from "../lib/utils"; +import { useEnvironmentQuery } from "../state/query"; +import { useEnvironmentScope } from "../state/session"; +import { useAtomCommand } from "../state/use-atom-command"; +import { vcsEnvironment } from "../state/vcs"; +import { ComposerControl } from "./chat/ComposerControl"; +import { useComposerMenuProps } from "./chat/composerEventScope"; +import { ThreadDetailsControl } from "./chat/ThreadDetailsControl"; +import { THREAD_DETAILS_PANEL_ICON_CLASS } from "./chat/threadDetailsPanelStyles"; +import { ComposerContextLabel } from "./ComposerContextLabel"; +import { + Menu, + MenuGroup, + MenuGroupLabel, + MenuPopup, + MenuRadioGroup, + MenuRadioItem, + MenuTrigger, +} from "./ui/menu"; +import { stackedThreadToast, toastManager } from "./ui/toast"; + +export function BranchToolbarRemoteSelector({ + cwd, + displayMode = "toolbar", + environmentId, +}: { + cwd: string; + displayMode?: "toolbar" | "panel"; + environmentId: EnvironmentId; +}) { + const isPanel = displayMode === "panel"; + const composerFloatingLayerProps = useComposerMenuProps(); + const canWriteSourceControl = useEnvironmentScope(environmentId, AuthSourceControlWriteScope); + const statusQuery = useEnvironmentQuery(vcsEnvironment.status({ environmentId, input: { cwd } })); + const setPushRemote = useAtomCommand(vcsEnvironment.setPushRemote, { reportFailure: false }); + + const { remotes, pushRemote, refName } = statusQuery.data ?? {}; + if (!remotes || !refName) return null; + + const selectRemote = async (remoteName: string) => { + if (remoteName === pushRemote) return; + const result = await setPushRemote({ + environmentId, + input: { cwd, refName, remoteName }, + }); + if (result._tag === "Success") { + statusQuery.refresh(); + } else if (!isAtomCommandInterrupted(result)) { + const cause = squashAtomCommandFailure(result); + toastManager.add( + stackedThreadToast({ + type: "error", + title: "Failed to change push remote.", + description: cause instanceof Error ? cause.message : "An error occurred.", + }), + ); + } + }; + + return ( + + : } + className={isPanel ? undefined : "min-w-0 flex-initial"} + disabled={!canWriteSourceControl} + aria-label={`Push remote: ${pushRemote ?? "not chosen"}`} + data-composer-context-control + > + + + {pushRemote ?? "Choose remote"} + + + + + Push to + void selectRemote(remoteName)} + > + {remotes.map((remoteName) => ( + + {remoteName} + + ))} + + + + + ); +} diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 77ab241966e3f..f0d7d31c80df1 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -79,6 +79,13 @@ import { } from "~/components/ui/dialog"; import { Input } from "~/components/ui/input"; import { Menu, MenuItem, MenuItemLabel, MenuPopup, MenuTrigger } from "~/components/ui/menu"; +import { + Select, + SelectItem, + SelectPopup, + SelectTrigger, + SelectValue, +} from "~/components/ui/select"; import { Popover, PopoverPopup, PopoverTrigger } from "~/components/ui/popover"; import { ScrollArea } from "~/components/ui/scroll-area"; import { Textarea } from "~/components/ui/textarea"; @@ -128,6 +135,7 @@ import { import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { scopeProjectRef } from "@t3tools/client-runtime/environment"; import { formatDiffCount } from "@t3tools/client-runtime/diff-count"; +import { pushRemoteChoicesFor } from "@t3tools/client-runtime/state/vcs"; import { useOpenLink } from "~/browser/useOpenLink"; interface GitActionsControlProps { @@ -158,6 +166,7 @@ interface RunGitActionWithToastInput { statusOverride?: VcsStatusResult | null; featureBranch?: boolean; filePaths?: string[]; + pushRemote?: string; } interface InlineGitActionSuccess { @@ -913,6 +922,69 @@ export function PublishRepositoryDialog(props: PublishRepositoryDialogProps) { ); } +function PushRemoteDialog({ + choices, + canPush, + onClose, + onPush, +}: { + choices: ReadonlyArray | null; + canPush: boolean; + onClose: () => void; + onPush: (remoteName: string) => void; +}) { + const [remoteName, setRemoteName] = useState(null); + return ( + { + if (!open) onClose(); + }} + onOpenChangeComplete={(open) => { + if (!open) setRemoteName(null); + }} + > + + + Push to which remote? + + This repository has several remotes and the branch has none chosen yet. It will track + the one you pick. + + + + + + + + + + + + ); +} + export default function GitActionsControl({ gitCwd, activeThreadRef, @@ -966,6 +1038,10 @@ export default function GitActionsControl({ const [inlineSuccess, setInlineSuccess] = useState(null); const [pendingDefaultBranchAction, setPendingDefaultBranchAction] = useState(null); + const [pendingPushRemoteAction, setPendingPushRemoteAction] = useState<{ + input: RunGitActionWithToastInput; + choices: ReadonlyArray; + } | null>(null); const sourceControlScope = { environmentId: activeEnvironmentId, cwd: gitCwd }; const vcsActionState = useAtomValue(vcsActionManager.stateAtom(sourceControlScope)); const inlineSuccessExpired = useDeadlinePassed(inlineSuccess?.expiresAtMs ?? null); @@ -1198,15 +1274,17 @@ export default function GitActionsControl({ ), ); - async function runGitActionWithToast({ - action, - commitMessage, - onConfirmed, - skipDefaultBranchPrompt = false, - statusOverride, - featureBranch = false, - filePaths, - }: RunGitActionWithToastInput) { + async function runGitActionWithToast(input: RunGitActionWithToastInput) { + const { + action, + commitMessage, + onConfirmed, + skipDefaultBranchPrompt = false, + statusOverride, + featureBranch = false, + filePaths, + pushRemote, + } = input; if ( activeEnvironmentId === null || !readEnvironmentScope(activeEnvironmentId, AuthSourceControlWriteScope) || @@ -1246,6 +1324,17 @@ export default function GitActionsControl({ }); return false; } + const pushRemoteChoices = pushRemoteChoicesFor( + { action, featureBranch, ...(pushRemote ? { pushRemote } : {}) }, + actionStatus ?? null, + ); + if (pushRemoteChoices) { + setPendingPushRemoteAction({ + input, + choices: pushRemoteChoices, + }); + return false; + } onConfirmed?.(); setInlineSuccess(null); @@ -1258,6 +1347,7 @@ export default function GitActionsControl({ ...(commitMessage ? { commitMessage } : {}), ...(featureBranch ? { featureBranch } : {}), ...(filePaths ? { filePaths } : {}), + ...(pushRemote ? { pushRemote } : {}), ...(activeServerThread ? { threadId: activeServerThread.id } : {}), ...(activeDraftThread ? { projectId: activeDraftThread.projectId } : {}), }); @@ -1988,6 +2078,18 @@ export default function GitActionsControl({ + + setPendingPushRemoteAction(null)} + onPush={(remoteName) => { + if (!pendingPushRemoteAction) return; + const { input } = pendingPushRemoteAction; + setPendingPushRemoteAction(null); + void runGitActionWithToast({ ...input, pushRemote: remoteName }); + }} + /> ); } diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 2f068d8827494..6279710fe8028 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -181,6 +181,12 @@ Use a thread's Git actions to commit, push, and create a pull request. T3 Code c messages, review titles, and descriptions from your changes. Descriptions also draw on the work in the thread, including the checks it ran. +If a repository has several remotes, choose where a branch pushes with the remote selector next +to the branch selector. On mobile it is under the thread's Git options. The branch keeps pulling +from the remote it tracks, so a fork branch can pull from `origin` and push to your fork. If a branch +has no remote chosen and none is named `origin` or set as `remote.pushDefault`, T3 Code asks before +pushing it. + An existing fork PR can appear beside the branch even if you pushed without setting an upstream. If its badge is missing because several remotes hold that branch, set the branch's upstream to the intended remote with `git branch --set-upstream-to=/`. diff --git a/packages/client-runtime/src/state/gitActions.test.ts b/packages/client-runtime/src/state/gitActions.test.ts index 3fa84580614eb..2801b28aa9b60 100644 --- a/packages/client-runtime/src/state/gitActions.test.ts +++ b/packages/client-runtime/src/state/gitActions.test.ts @@ -5,6 +5,7 @@ import { sourceControlClients } from "../sourceControlClients.ts"; import { buildMenuItems, getGitActionDisabledReason, + pushRemoteChoicesFor, resolveDefaultBranchActionDialogCopy, resolveQuickAction, } from "./gitActions.ts"; @@ -94,3 +95,25 @@ describe("git action copy", () => { ).toBe("Commit, push & create MR"); }); }); + +describe("pushRemoteChoicesFor", () => { + const choices = ["fork", "upstream"]; + const unclear = { pushRemoteChoices: choices }; + + it("asks before a push when the remote is unclear", () => { + expect(pushRemoteChoicesFor({ action: "commit_push_pr" }, unclear)).toEqual(choices); + expect(pushRemoteChoicesFor({ action: "commit" }, unclear)).toBeNull(); + expect(pushRemoteChoicesFor({ action: "push", pushRemote: "fork" }, unclear)).toBeNull(); + expect(pushRemoteChoicesFor({ action: "push" }, {})).toBeNull(); + expect(pushRemoteChoicesFor({ action: "push" }, { ...unclear, pushRemote: "fork" })).toBeNull(); + }); + + it("asks for a new feature branch even when the current branch has a push remote", () => { + expect( + pushRemoteChoicesFor( + { action: "commit_push", featureBranch: true }, + { ...unclear, pushRemote: "fork" }, + ), + ).toEqual(choices); + }); +}); diff --git a/packages/client-runtime/src/state/gitActions.ts b/packages/client-runtime/src/state/gitActions.ts index fe5a2b77c4e6e..7f1c495c35f28 100644 --- a/packages/client-runtime/src/state/gitActions.ts +++ b/packages/client-runtime/src/state/gitActions.ts @@ -40,7 +40,7 @@ export type DefaultBranchConfirmableAction = export type GitActionRequestInput = Pick< GitRunStackedActionInput, - "action" | "commitMessage" | "featureBranch" | "filePaths" + "action" | "commitMessage" | "featureBranch" | "filePaths" | "pushRemote" >; export function changeRequestTerminologyFor( @@ -326,6 +326,20 @@ export function requiresDefaultBranchConfirmation( ); } +/** + * The remotes to ask the user between before running `input`, or null when it pushes nowhere, + * already names a remote, or git knows where the branch goes. + */ +export function pushRemoteChoicesFor( + input: GitActionRequestInput, + gitStatus: Pick | null, +): ReadonlyArray | null { + if (input.action === "commit" || input.pushRemote || !gitStatus?.pushRemoteChoices) return null; + // A new feature branch starts without the current branch's selection. + if (input.featureBranch) return gitStatus.pushRemoteChoices; + return gitStatus.pushRemote ? null : gitStatus.pushRemoteChoices; +} + export function resolveDefaultBranchActionDialogCopy(input: { action: DefaultBranchConfirmableAction; branchName: string; diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts index 310515ceb9915..a9079184488ff 100644 --- a/packages/client-runtime/src/state/vcs.ts +++ b/packages/client-runtime/src/state/vcs.ts @@ -385,6 +385,13 @@ export function createVcsEnvironmentAtoms( concurrency: vcsCommandConcurrency, onSettled: invalidateRefs, }), + setPushRemote: createEnvironmentRpcCommand(runtime, { + label: "environment-data:vcs:set-push-remote", + tag: WS_METHODS.vcsSetPushRemote, + scheduler: vcsCommandScheduler, + concurrency: vcsCommandConcurrency, + onSettled: invalidateRefs, + }), init: createEnvironmentRpcCommand(runtime, { label: "environment-data:vcs:init", tag: WS_METHODS.vcsInit, diff --git a/packages/client-runtime/src/state/vcsAction.test.ts b/packages/client-runtime/src/state/vcsAction.test.ts index 67e9253cba4f3..6e3b5362abf83 100644 --- a/packages/client-runtime/src/state/vcsAction.test.ts +++ b/packages/client-runtime/src/state/vcsAction.test.ts @@ -524,6 +524,7 @@ describe("vcsActionState", () => { manager.runStackedAction(targetKey).run(registry, { actionId: successfulActionId, action, + pushRemote: "fork", threadId, projectId, }), @@ -534,7 +535,14 @@ describe("vcsActionState", () => { expect(registry.get(state).revision).toBe(1); expect(removed).toEqual([`${environmentId}:*`]); expect(rpcInputs).toEqual([ - { actionId: successfulTransportActionId, cwd, action, threadId, projectId }, + { + actionId: successfulTransportActionId, + cwd, + action, + pushRemote: "fork", + threadId, + projectId, + }, ]); const failedResult = yield* Effect.promise(() => diff --git a/packages/client-runtime/src/state/vcsAction.ts b/packages/client-runtime/src/state/vcsAction.ts index 5382e61164472..c89c6aff63bb6 100644 --- a/packages/client-runtime/src/state/vcsAction.ts +++ b/packages/client-runtime/src/state/vcsAction.ts @@ -79,6 +79,7 @@ export interface RunVcsStackedActionInput { readonly commitMessage?: string; readonly featureBranch?: boolean; readonly filePaths?: ReadonlyArray; + readonly pushRemote?: string; readonly threadId?: ThreadId; readonly projectId?: ProjectId; readonly onProgress?: (event: GitActionProgressEvent) => void; @@ -472,6 +473,7 @@ export function createVcsActionManager( ...(input.commitMessage ? { commitMessage: input.commitMessage } : {}), ...(input.featureBranch ? { featureBranch: true } : {}), ...(input.filePaths?.length ? { filePaths: [...input.filePaths] } : {}), + ...(input.pushRemote ? { pushRemote: input.pushRemote } : {}), ...(input.threadId !== undefined ? { threadId: input.threadId } : {}), ...(input.projectId !== undefined ? { projectId: input.projectId } : {}), }; diff --git a/packages/contracts/src/clientRpcPermissions.ts b/packages/contracts/src/clientRpcPermissions.ts index 8681c1ea8abeb..318a219aa6442 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -60,6 +60,7 @@ export const CLIENT_GUARDED_RPC_SCOPES = { [WS_METHODS.vcsRemoveWorktree]: AuthSourceControlWriteScope, [WS_METHODS.vcsCreateRef]: AuthSourceControlWriteScope, [WS_METHODS.vcsSwitchRef]: AuthSourceControlWriteScope, + [WS_METHODS.vcsSetPushRemote]: AuthSourceControlWriteScope, [WS_METHODS.vcsInit]: AuthSourceControlWriteScope, // Starts an agent session on this environment that can read its logs and database. diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 45d68c5e9ca44..7a971141dad1e 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -137,6 +137,8 @@ export const GitRunStackedActionInput = Schema.Struct({ commitMessage: Schema.optional(TrimmedNonEmptyStringSchema.check(Schema.isMaxLength(10_000))), featureBranch: Schema.optional(Schema.Boolean), filePaths: Schema.optional(Schema.Array(GitFilePath).check(Schema.isMinLength(1))), + /** Remote for a branch without an upstream, picked from `pushRemoteChoices`. */ + pushRemote: Schema.optional(TrimmedNonEmptyStringSchema), threadId: Schema.optional(ThreadId), projectId: Schema.optional(ProjectId), }); @@ -203,6 +205,13 @@ export const VcsSwitchRefInput = Schema.Struct({ }); export type VcsSwitchRefInput = typeof VcsSwitchRefInput.Type; +export const VcsSetPushRemoteInput = Schema.Struct({ + cwd: TrimmedNonEmptyStringSchema, + refName: TrimmedNonEmptyStringSchema, + remoteName: TrimmedNonEmptyStringSchema, +}); +export type VcsSetPushRemoteInput = typeof VcsSetPushRemoteInput.Type; + export const VcsInitInput = Schema.Struct({ cwd: TrimmedNonEmptyStringSchema, kind: Schema.optional(VcsDriverKind), @@ -224,6 +233,15 @@ const VcsStatusLocalShape = { isRepo: Schema.Boolean, sourceControlProvider: Schema.optional(SourceControlProviderInfo), hasPrimaryRemote: Schema.Boolean, + /** Every remote, present only when there are at least two to select between. */ + remotes: Schema.optional(Schema.Array(TrimmedNonEmptyStringSchema)), + /** Where the current branch pushes. Absent with one remote, or while the choice is unclear. */ + pushRemote: Schema.optional(TrimmedNonEmptyStringSchema), + /** + * Remotes to ask between before pushing a branch without an upstream: present only when + * there are several, none is `origin`, and `remote.pushDefault` names none of them. + */ + pushRemoteChoices: Schema.optional(Schema.Array(TrimmedNonEmptyStringSchema)), isDefaultRef: Schema.Boolean, refName: Schema.NullOr(TrimmedNonEmptyStringSchema), hasWorkingTreeChanges: Schema.Boolean, diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index e1d392cd080b6..e0c99d9a823a7 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -127,6 +127,7 @@ import { } from "./worktreeSetup.ts"; import { GitActionProgressEvent, + VcsSetPushRemoteInput, VcsSwitchRefInput, VcsSwitchRefResult, GitCommandError, @@ -466,6 +467,7 @@ export const WS_METHODS = { vcsRemoveWorktree: "vcs.removeWorktree", vcsCreateRef: "vcs.createRef", vcsSwitchRef: "vcs.switchRef", + vcsSetPushRemote: "vcs.setPushRemote", vcsInit: "vcs.init", gitRunStackedAction: "git.runStackedAction", @@ -1615,6 +1617,11 @@ const WsVcsSwitchRefRpc = Rpc.make(WS_METHODS.vcsSwitchRef, { error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), }); +const WsVcsSetPushRemoteRpc = Rpc.make(WS_METHODS.vcsSetPushRemote, { + payload: VcsSetPushRemoteInput, + error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), +}); + const WsVcsInitRpc = Rpc.make(WS_METHODS.vcsInit, { payload: VcsInitInput, error: Schema.Union([VcsError, EnvironmentAuthorizationError]), @@ -2271,6 +2278,7 @@ export const WsRpcGroup = RpcGroup.make( WsVcsRemoveWorktreeRpc, WsVcsCreateRefRpc, WsVcsSwitchRefRpc, + WsVcsSetPushRemoteRpc, WsVcsInitRpc, WsReviewGetBranchCommitsRpc, WsReviewGetDiffPreviewRpc, diff --git a/packages/shared/src/git.ts b/packages/shared/src/git.ts index 7ef56875e34b5..86d3663e7eb93 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -371,6 +371,9 @@ function toLocalStatusPart(status: VcsStatusResult): VcsStatusLocalResult { ? { sourceControlProvider: status.sourceControlProvider } : {}), hasPrimaryRemote: status.hasPrimaryRemote, + ...(status.remotes ? { remotes: status.remotes } : {}), + ...(status.pushRemote ? { pushRemote: status.pushRemote } : {}), + ...(status.pushRemoteChoices ? { pushRemoteChoices: status.pushRemoteChoices } : {}), isDefaultRef: status.isDefaultRef, refName: status.refName, hasWorkingTreeChanges: status.hasWorkingTreeChanges,