From bae33c51c04aadb98a502d8dc53ddf9c46257950 Mon Sep 17 00:00:00 2001 From: TonybynMp4 <97451137+TonybynMp4@users.noreply.github.com> Date: Fri, 6 Nov 2026 02:54:24 +0100 Subject: [PATCH 01/11] fix(git): ask which remote to push to when none is named origin --- .../use-selected-thread-git-actions.test.ts | 6 +- .../state/use-selected-thread-git-actions.ts | 32 ++++++++ apps/server/src/git/GitManager.ts | 8 +- apps/server/src/vcs/GitVcsDriver.ts | 1 + apps/server/src/vcs/GitVcsDriverCore.test.ts | 33 ++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 74 ++++++++++++----- apps/web/src/components/GitActionsControl.tsx | 79 ++++++++++++++++--- docs/user/source-control.md | 3 + .../src/state/gitActions.test.ts | 21 +++++ .../client-runtime/src/state/gitActions.ts | 14 +++- packages/contracts/src/git.ts | 7 ++ packages/shared/src/git.ts | 1 + 12 files changed, 248 insertions(+), 31 deletions(-) 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..289aa74593316 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("../components/ui/Dialog", () => ({ alert: () => {} })); 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..2f3caaaf26b10 100644 --- a/apps/mobile/src/state/use-selected-thread-git-actions.ts +++ b/apps/mobile/src/state/use-selected-thread-git-actions.ts @@ -5,6 +5,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"; @@ -25,6 +26,7 @@ import { useBranches } from "./queries"; import { threadEnvironment } from "./threads"; import { vcsActionManager, vcsEnvironment } from "./vcs"; import { uuidv4 } from "../lib/uuid"; +import * as Dialog from "../components/ui/Dialog"; import { appAtomRegistry } from "./atom-registry"; import { readEnvironmentScope, useEnvironmentScope } from "./session"; import { @@ -359,6 +361,35 @@ export function useSelectedThreadGitActions() { const onRunSelectedThreadGitAction = async ( input: GitActionRequestInput, ): Promise => { + // Every entry point lands here, after any default-branch confirmation. + const gitStatus = + selectedThread && selectedThreadCwd + ? AsyncResult.getOrElse( + appAtomRegistry.get( + vcsEnvironment.status({ + environmentId: selectedThread.environmentId, + input: { cwd: selectedThreadCwd }, + }), + ), + () => null, + ) + : null; + const pushRemoteChoices = pushRemoteChoicesFor(input, gitStatus); + if (pushRemoteChoices) { + // Android shows at most three buttons; extra remotes fall off the end. + Dialog.alert( + "Push to which remote?", + "This repository has several remotes and none is named origin. The branch will track the one you choose.", + [ + ...pushRemoteChoices.map((remoteName) => ({ + text: remoteName, + onPress: () => void onRunSelectedThreadGitAction({ ...input, pushRemote: remoteName }), + })), + { text: "Cancel", style: "cancel" as const }, + ], + ); + return null; + } const actionId = uuidv4(); return await runSelectedThreadGitMutation( "run_change_request", @@ -370,6 +401,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)) { diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index c8f107ee7060b..4efe417227032 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -744,8 +744,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); } @@ -1150,6 +1153,7 @@ export const make = Effect.gen(function* () { isRepo: details.isRepo, ...(hostingProvider ? { sourceControlProvider: hostingProvider } : {}), hasPrimaryRemote: details.hasOriginRemote, + ...(details.pushRemoteChoices ? { pushRemoteChoices: details.pushRemoteChoices } : {}), isDefaultRef: details.isDefaultBranch, refName: details.branch, hasWorkingTreeChanges: details.hasWorkingTreeChanges, @@ -3322,7 +3326,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/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index ff129690b7b24..6faf738995f73 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -76,6 +76,7 @@ export interface GitStatusDetails { isRepo: boolean; sourceControlProvider?: VcsStatusResult["sourceControlProvider"]; hasOriginRemote: boolean; + pushRemoteChoices?: VcsStatusResult["pushRemoteChoices"]; isDefaultBranch: boolean; branch: string | null; upstreamRef: string | null; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 70b2d50eda88e..65d63075f43ab 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -6808,6 +6808,39 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { assert.notEqual(originMain.exitCode, 0); }), ); + + 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* makeTmpDir(); + const forkRemote = yield* makeTmpDir("git-remote-"); + const upstreamRemote = yield* makeTmpDir("git-remote-"); + 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, ["checkout", "-b", "feature/remote"]); + yield* writeTextFile(cwd, "feature.txt", "feature\n"); + yield* git(cwd, ["add", "-A"]); + yield* driver.commit(cwd, "Add feature", ""); + + const status = yield* driver.statusDetailsLocal(cwd); + assert.isTrue(status.hasOriginRemote); + assert.deepEqual(status.pushRemoteChoices, ["fork", "upstream"]); + 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); + }), + ); }); }); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 3d8122f197971..66f0ea56564bc 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -111,7 +111,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: "", @@ -1576,23 +1576,47 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }), }, ); - const primaryRemoteExistsCache = yield* Cache.makeWith( + // Whether any remote exists, and the push remotes to ask between when git would have to + // guess (see `pushRemoteChoices`). A branch's own `branch..pushRemote` is ignored. + 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 needsChoice = + names.length > 1 && + !names.includes("origin") && + !names.includes(pushDefault.stdout.trim()); + return { hasRemote: names.length > 0, ...(needsChoice ? { choices: names } : {}) }; + }), + ); + const NO_STATUS_REMOTES: { hasRemote: boolean; choices?: ReadonlyArray } = { + 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, }), }, @@ -1607,7 +1631,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); }, ); @@ -1795,6 +1819,21 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return pushDefaultRemote; } + // 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)); }); @@ -2184,7 +2223,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }), ); // 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, // Porcelain diff rewrites indexes despite GIT_OPTIONAL_LOCKS=0; plumbing with -M preserves rename detection. @@ -2238,13 +2277,10 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ? Cache.get(defaultBranchCache, statusCacheKey).pipe(Effect.orElseSucceed(() => null)) : resolveDefaultBranchName(cwd, "origin").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" }, ); @@ -2372,7 +2408,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return { isRepo: true, - hasOriginRemote: hasPrimaryRemote, + hasOriginRemote: remotes.hasRemote, + ...(remotes.choices ? { pushRemoteChoices: remotes.choices } : {}), isDefaultBranch, branch: refName, upstreamRef, @@ -2429,6 +2466,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.map((details) => ({ isRepo: details.isRepo, hasPrimaryRemote: details.hasOriginRemote, + ...(details.pushRemoteChoices ? { pushRemoteChoices: details.pushRemoteChoices } : {}), isDefaultRef: details.isDefaultBranch, refName: details.branch, hasWorkingTreeChanges: details.hasWorkingTreeChanges, diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index a9193f8fd35d9..da36bd008c843 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -121,6 +121,7 @@ import { import { getSourceControlPresentation, sourceControlIcon } from "~/sourceControlPresentation"; import { sourceControlClients } from "@t3tools/client-runtime/source-control-clients"; import { formatDiffCount } from "@t3tools/client-runtime/diff-count"; +import { pushRemoteChoicesFor } from "@t3tools/client-runtime/state/vcs"; import { useOpenLink } from "~/browser/useOpenLink"; interface GitActionsControlProps { @@ -151,6 +152,7 @@ interface RunGitActionWithToastInput { statusOverride?: VcsStatusResult | null; featureBranch?: boolean; filePaths?: string[]; + pushRemote?: string; } interface InlineGitActionSuccess { @@ -987,6 +989,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); @@ -1209,15 +1215,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) || @@ -1257,6 +1265,17 @@ export default function GitActionsControl({ }); return; } + const pushRemoteChoices = pushRemoteChoicesFor( + { action, featureBranch, ...(pushRemote ? { pushRemote } : {}) }, + actionStatus ?? null, + ); + if (pushRemoteChoices) { + setPendingPushRemoteAction({ + input: { ...input, skipDefaultBranchPrompt: true }, + choices: pushRemoteChoices, + }); + return; + } onConfirmed?.(); setInlineSuccess(null); @@ -1269,6 +1288,7 @@ export default function GitActionsControl({ ...(commitMessage ? { commitMessage } : {}), ...(featureBranch ? { featureBranch } : {}), ...(filePaths ? { filePaths } : {}), + ...(pushRemote ? { pushRemote } : {}), ...(activeServerThread ? { threadId: activeServerThread.id } : {}), ...(activeDraftThread ? { projectId: activeDraftThread.projectId } : {}), }); @@ -1984,6 +2004,47 @@ export default function GitActionsControl({ + + { + if (!open) setPendingPushRemoteAction(null); + }} + > + + + Push to which remote? + + This repository has several remotes and none is named origin. The branch will track + the one you choose. + + + + + {pendingPushRemoteAction?.choices.map((remoteName) => ( + + ))} + + + ); } diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 8bbeb7e988cbe..ff26a4b11a574 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -179,6 +179,9 @@ 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, none named `origin`, and no `remote.pushDefault`, T3 Code +asks which remote to use the first time you push a branch. The branch then tracks that remote. + 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..798b3426d44af 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,23 @@ describe("git action copy", () => { ).toBe("Commit, push & create MR"); }); }); + +describe("pushRemoteChoicesFor", () => { + const choices = ["fork", "upstream"]; + const unclear = { hasUpstream: false, pushRemoteChoices: choices }; + + it("asks before the first push of a branch 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" }, { hasUpstream: false })).toBeNull(); + }); + + it("asks for a new feature branch even when the current branch tracks a remote", () => { + const tracking = { ...unclear, hasUpstream: true }; + expect(pushRemoteChoicesFor({ action: "push" }, tracking)).toBeNull(); + expect(pushRemoteChoicesFor({ action: "commit_push", featureBranch: true }, tracking)).toEqual( + choices, + ); + }); +}); diff --git a/packages/client-runtime/src/state/gitActions.ts b/packages/client-runtime/src/state/gitActions.ts index fe5a2b77c4e6e..b00c0dbcf5735 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,18 @@ 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; + return !gitStatus.hasUpstream || input.featureBranch ? gitStatus.pushRemoteChoices : null; +} + export function resolveDefaultBranchActionDialogCopy(input: { action: DefaultBranchConfirmableAction; branchName: string; diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index a3b5e34076219..534b04e5882ac 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -133,6 +133,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), }); @@ -220,6 +222,11 @@ const VcsStatusLocalShape = { isRepo: Schema.Boolean, sourceControlProvider: Schema.optional(SourceControlProviderInfo), hasPrimaryRemote: Schema.Boolean, + /** + * 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/shared/src/git.ts b/packages/shared/src/git.ts index 290b30934d518..3a6804a1f5a2e 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -341,6 +341,7 @@ function toLocalStatusPart(status: VcsStatusResult): VcsStatusLocalResult { ? { sourceControlProvider: status.sourceControlProvider } : {}), hasPrimaryRemote: status.hasPrimaryRemote, + ...(status.pushRemoteChoices ? { pushRemoteChoices: status.pushRemoteChoices } : {}), isDefaultRef: status.isDefaultRef, refName: status.refName, hasWorkingTreeChanges: status.hasWorkingTreeChanges, From c74c63c4058e8b2be696e5773d82eb1408042938 Mon Sep 17 00:00:00 2001 From: Tony <97451137+TonybynMp4@users.noreply.github.com> Date: Mon, 9 Nov 2026 19:17:11 +0100 Subject: [PATCH 02/11] feat(git): add push remote picker for branches - Show the push remote in the web branch toolbar and mobile Git overview, and let users change it. - Changing the remote moves the branch's upstream to a matching branch there, or clears tracking so the first push publishes to the new remote. - Git status reports the remote list and the resolved push remote, and host detection uses the branch's pushRemote. - Mobile replaces the Android alert with a Git remote sheet when a push needs a remote choice. --- apps/mobile/src/Stack.tsx | 6 + .../layout/workspace-overlay-routes.ts | 1 + .../features/threads/git/GitOverviewSheet.tsx | 16 ++ .../features/threads/git/GitRemoteSheet.tsx | 103 +++++++++++++ .../use-selected-thread-git-actions.test.ts | 2 +- .../state/use-selected-thread-git-actions.ts | 73 +++++---- apps/server/src/git/GitManager.test.ts | 21 +++ apps/server/src/git/GitManager.ts | 6 +- apps/server/src/git/GitWorkflowService.ts | 7 + .../src/observability/RpcInstrumentation.ts | 1 + apps/server/src/vcs/GitVcsDriver.ts | 4 + apps/server/src/vcs/GitVcsDriverCore.test.ts | 55 +++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 142 ++++++++++++++++-- apps/server/src/ws.ts | 2 + apps/web/src/components/BranchToolbar.tsx | 16 ++ .../BranchToolbarRemoteSelector.tsx | 103 +++++++++++++ apps/web/src/components/GitActionsControl.tsx | 119 ++++++++++----- docs/user/source-control.md | 6 +- .../src/state/gitActions.test.ts | 7 + .../client-runtime/src/state/gitActions.ts | 6 +- packages/client-runtime/src/state/vcs.ts | 7 + .../src/state/vcsAction.test.ts | 10 +- .../client-runtime/src/state/vcsAction.ts | 2 + .../contracts/src/clientRpcPermissions.ts | 1 + packages/contracts/src/git.ts | 11 ++ packages/contracts/src/rpc.ts | 8 + packages/shared/src/git.ts | 2 + 27 files changed, 652 insertions(+), 85 deletions(-) create mode 100644 apps/mobile/src/features/threads/git/GitRemoteSheet.tsx create mode 100644 apps/web/src/components/BranchToolbarRemoteSelector.tsx diff --git a/apps/mobile/src/Stack.tsx b/apps/mobile/src/Stack.tsx index b8ab00673db12..9e50b07a524fa 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"; @@ -697,6 +698,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 43e541083688f..bfe30a4ef7f1d 100644 --- a/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitOverviewSheet.tsx @@ -302,6 +302,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 289aa74593316..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 @@ -56,7 +56,7 @@ vi.mock("./use-atom-command", () => ({ useAtomCommand: (command: unknown) => com vi.mock("./atom-registry", () => ({ appAtomRegistry: { get: () => AsyncResult.initial() }, })); -vi.mock("../components/ui/Dialog", () => ({ alert: () => {} })); +vi.mock("@react-navigation/native", () => ({ useNavigation: () => ({ navigate: () => {} }) })); vi.mock("./use-remote-environment-registry", () => ({ environmentConnectedAtom: () => null, setPendingConnectionError: () => {}, 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 2f3caaaf26b10..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"; @@ -26,7 +27,6 @@ import { useBranches } from "./queries"; import { threadEnvironment } from "./threads"; import { vcsActionManager, vcsEnvironment } from "./vcs"; import { uuidv4 } from "../lib/uuid"; -import * as Dialog from "../components/ui/Dialog"; import { appAtomRegistry } from "./atom-registry"; import { readEnvironmentScope, useEnvironmentScope } from "./session"; import { @@ -55,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 }); @@ -358,36 +360,52 @@ 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 gitStatus = - selectedThread && selectedThreadCwd - ? AsyncResult.getOrElse( - appAtomRegistry.get( - vcsEnvironment.status({ - environmentId: selectedThread.environmentId, - input: { cwd: selectedThreadCwd }, - }), - ), - () => null, - ) - : null; - const pushRemoteChoices = pushRemoteChoicesFor(input, gitStatus); - if (pushRemoteChoices) { - // Android shows at most three buttons; extra remotes fall off the end. - Dialog.alert( - "Push to which remote?", - "This repository has several remotes and none is named origin. The branch will track the one you choose.", - [ - ...pushRemoteChoices.map((remoteName) => ({ - text: remoteName, - onPress: () => void onRunSelectedThreadGitAction({ ...input, pushRemote: remoteName }), - })), - { text: "Cancel", style: "cancel" as const }, - ], - ); + 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(); @@ -444,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 1eebcdeb4f93f..f9ad3f69f4914 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -945,6 +945,27 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); + it.effect("status detects the host from the branch's push remote before its first push", () => + 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", + "branch.feature/status-push-remote-host.pushRemote", + "lab", + ]); + + const { manager } = yield* makeManager(); + + const status = yield* manager.status({ cwd: repoDir }); + + expect(status.sourceControlProvider?.kind).toBe("gitlab"); + }), + ); + it.effect("status returns an explicit non-repo result for deleted directories", () => Effect.gen(function* () { const rootDir = yield* makeTempDir("t3code-git-manager-missing-dir-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 4efe417227032..1fc6754c81971 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -1153,6 +1153,8 @@ export const make = Effect.gen(function* () { 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, @@ -1507,7 +1509,9 @@ export const make = Effect.gen(function* () { const preferredRemoteName = branch === null ? "origin" - : ((yield* readConfigValueNullable(cwd, `branch.${branch}.remote`)) ?? "origin"); + : ((yield* readConfigValueNullable(cwd, `branch.${branch}.pushRemote`)) ?? + (yield* readConfigValueNullable(cwd, `branch.${branch}.remote`)) ?? + "origin"); const remoteUrl = (yield* readConfigValueNullable(cwd, `remote.${preferredRemoteName}.url`)) ?? (yield* readConfigValueNullable(cwd, "remote.origin.url")); diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 794131444814e..4845d6fa2c9d7 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, @@ -125,6 +126,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; @@ -487,6 +489,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 f9a861cca23fb..77b9d1795f672 100644 --- a/apps/server/src/observability/RpcInstrumentation.ts +++ b/apps/server/src/observability/RpcInstrumentation.ts @@ -192,6 +192,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 6faf738995f73..929d86923a0b7 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -14,6 +14,7 @@ import { GitCommandError, VcsProcessExitError, VcsUnsupportedOperationError, + type VcsSetPushRemoteInput, type VcsSwitchRefInput, type VcsSwitchRefResult, type VcsCreateRefInput, @@ -76,6 +77,8 @@ export interface GitStatusDetails { isRepo: boolean; sourceControlProvider?: VcsStatusResult["sourceControlProvider"]; hasOriginRemote: boolean; + remotes?: VcsStatusResult["remotes"]; + pushRemote?: VcsStatusResult["pushRemote"]; pushRemoteChoices?: VcsStatusResult["pushRemoteChoices"]; isDefaultBranch: boolean; branch: string | null; @@ -459,6 +462,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; readonly listLocalBranchNames: (cwd: string) => Effect.Effect; } diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 65d63075f43ab..5a9e1d42a2e3d 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -6841,6 +6841,61 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { assert.isUndefined((yield* driver.statusDetailsLocal(cwd)).pushRemoteChoices); }), ); + + it.effect("selecting a push remote moves the branch's tracking to it", () => + Effect.gen(function* () { + const driver = yield* GitVcsDriver.GitVcsDriver; + const cwd = yield* makeTmpDir(); + const forkRemote = yield* makeTmpDir("git-remote-"); + const upstreamRemote = yield* makeTmpDir("git-remote-"); + 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, ["checkout", "-b", "feature/remote"]); + yield* writeTextFile(cwd, "feature.txt", "feature\n"); + yield* git(cwd, ["add", "-A"]); + yield* driver.commit(cwd, "Add feature", ""); + 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}`, + ); + + // The new remote has no such branch yet: tracking clears, so the branch can publish there. + yield* driver.setPushRemote({ cwd, refName, remoteName: "upstream" }); + const moved = yield* driver.statusDetailsLocal(cwd); + assert.equal(moved.pushRemote, "upstream"); + assert.isFalse(moved.hasUpstream); + yield* driver.pushCurrentBranch(cwd, null); + assert.equal( + yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]), + `upstream/${refName}`, + ); + + // Going back finds the branch already on that remote and tracks it again. + yield* driver.setPushRemote({ cwd, refName, remoteName: "fork" }); + assert.equal( + yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]), + `fork/${refName}`, + ); + assert.equal((yield* driver.statusDetailsLocal(cwd)).pushRemote, "fork"); + + const unknown = yield* driver + .setPushRemote({ cwd, refName, remoteName: "missing" }) + .pipe(Effect.flip); + assert.include(unknown.message, "missing"); + }), + ); }); }); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 66f0ea56564bc..61d147691106d 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -402,6 +402,17 @@ function parseUpstreamRefByFirstSeparator( }; } +// Differently named upstreams are bases, not publish targets; pushing HEAD there would write features to shared branches. +function isUpstreamPublishTarget( + branch: string, + upstream: { upstreamRef: string; branchName: string }, +): boolean { + return ( + branch === upstream.branchName || + (branch.endsWith(`/${upstream.branchName}`) && upstream.upstreamRef.endsWith(`/${branch}`)) + ); +} + function parseTrackingBranchByUpstreamRef(stdout: string, upstreamRef: string): string | null { for (const line of stdout.split("\n")) { const trimmedLine = line.trim(); @@ -1576,8 +1587,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }), }, ); - // Whether any remote exists, and the push remotes to ask between when git would have to - // guess (see `pushRemoteChoices`). A branch's own `branch..pushRemote` is ignored. + // Repo-wide, so cached per common dir: which remotes exist, and the ones to ask between when + // git would have to guess (see `pushRemoteChoices`). Per-branch config is read by + // `resolveStatusPushRemote`. const readStatusRemotes = (cwd: string, gitArgs: ReadonlyArray = []) => Effect.all( [ @@ -1595,14 +1607,23 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ).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(pushDefault.stdout.trim()); - return { hasRemote: names.length > 0, ...(needsChoice ? { choices: names } : {}) }; + 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; choices?: ReadonlyArray } = { + const NO_STATUS_REMOTES: { + hasRemote: boolean; + names?: ReadonlyArray; + choices?: ReadonlyArray; + defaultPushRemote?: string; + } = { hasRemote: false, }; const remotesCache = yield* Cache.makeWith( @@ -1837,6 +1858,37 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return yield* resolvePrimaryRemoteName(cwd).pipe(Effect.orElseSucceed(() => null)); }); + // 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; + if (upstream && isUpstreamPublishTarget(refName, upstream)) { + return upstream.remoteName; + } + const branchPushRemote = yield* runGitStdout( + "GitVcsDriver.statusDetails.branchPushRemote", + cwd, + ["config", "--get", `branch.${refName}.pushRemote`], + true, + ).pipe( + Effect.map((stdout) => stdout.trim()), + Effect.orElseSucceed(() => ""), + ); + return ( + branchPushRemote || + defaultPushRemote || + (remoteNames.includes("origin") ? "origin" : upstream?.remoteName) + ); + }); + const ensureRemote: GitVcsDriver.GitVcsDriver["Service"]["ensureRemote"] = Effect.fn( "ensureRemote", )(function* (input) { @@ -2406,9 +2458,22 @@ 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: remotes.hasRemote, + ...(remotes.names ? { remotes: remotes.names } : {}), + ...(pushRemote ? { pushRemote } : {}), ...(remotes.choices ? { pushRemoteChoices: remotes.choices } : {}), isDefaultBranch, branch: refName, @@ -2466,6 +2531,8 @@ 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, @@ -2724,11 +2791,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const upstream = yield* resolveCurrentUpstream(cwd).pipe(Effect.orElseSucceed(() => null)); if (!upstream) return null; - // Differently named upstreams are bases, not publish targets; pushing HEAD there would write features to shared branches. - const isAliasOfUpstreamHead = - branch === upstream.branchName || - (branch.endsWith(`/${upstream.branchName}`) && upstream.upstreamRef.endsWith(`/${branch}`)); - if (isAliasOfUpstreamHead) { + if (isUpstreamPublishTarget(branch, upstream)) { return { remoteName: upstream.remoteName, branchName: upstream.branchName, @@ -2757,6 +2820,60 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* : null; }); + const setPushRemote: GitVcsDriver.GitVcsDriver["Service"]["setPushRemote"] = Effect.fn( + "setPushRemote", + )(function* ({ cwd, refName, remoteName }) { + const remoteNames = yield* listRemoteNames(cwd); + if (!remoteNames.includes(remoteName)) { + return yield* new GitCommandError({ + ...gitCommandContext({ operation: "GitVcsDriver.setPushRemote", cwd, args: ["config"] }), + detail: `Remote '${remoteName}' does not exist.`, + }); + } + yield* writeConfigValue({ cwd, key: `branch.${refName}.pushRemote`, value: remoteName }); + + const upstreamRef = yield* runGitStdout( + "GitVcsDriver.setPushRemote.upstream", + cwd, + ["for-each-ref", "--format=%(upstream:short)", `refs/heads/${refName}`], + true, + ).pipe(Effect.map((stdout) => stdout.trim())); + const upstream = upstreamRef + ? (parseUpstreamRefWithRemoteNames(upstreamRef, remoteNames) ?? + parseUpstreamRefByFirstSeparator(upstreamRef)) + : null; + if ( + !upstream || + upstream.remoteName === remoteName || + !isUpstreamPublishTarget(refName, upstream) + ) { + return; + } + + // Pushes follow a publishing upstream, so it moves too, which keeps ahead/behind honest. + const remoteBranchExists = yield* executeGit( + "GitVcsDriver.setPushRemote.remoteBranchExists", + cwd, + ["show-ref", "--verify", "--quiet", `refs/remotes/${remoteName}/${upstream.branchName}`], + { allowNonZeroExit: true }, + ).pipe(Effect.map((result) => result.exitCode === 0)); + if (remoteBranchExists) { + yield* setBranchUpstream({ + cwd, + branch: refName, + remoteName, + remoteBranch: upstream.branchName, + }); + return; + } + yield* executeGitWithStableDiagnostics( + "GitVcsDriver.setPushRemote.unsetUpstream", + cwd, + ["branch", "--unset-upstream", refName], + { writesSharedConfig: true }, + ).pipe(retryWhileConfigLocked); + }); + const pushCurrentBranch: GitVcsDriver.GitVcsDriver["Service"]["pushCurrentBranch"] = Effect.fn( "pushCurrentBranch", )(function* (cwd, fallbackBranch, options) { @@ -5292,6 +5409,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return yield* withListRefsInvalidation(input.cwd, createBranch(input)); }), writeConfigValue, + setPushRemote, 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 08a2756bfbe6c..88bcbbf117b6f 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -3078,6 +3078,8 @@ export const layerWsRpc = ( gitWorkflow.createRef(input).pipe(Effect.tap(() => refreshGitStatus(input.cwd))), [WS_METHODS.vcsSwitchRef]: (input) => gitWorkflow.switchRef(input).pipe(Effect.tap(() => refreshGitStatus(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 93249bcf0c9f2..2fb9e01492bdd 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"; @@ -628,6 +629,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] : []; @@ -732,6 +735,13 @@ export const BranchToolbar = memo(function BranchToolbar({ {...(onComposerFocusRequest ? { onComposerFocusRequest } : {})} /> ) : null} + {panelSection !== "workspace" && !startsInNewWorktree ? ( + + ) : null} ); } @@ -827,6 +837,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..210faf6b30606 --- /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 da36bd008c843..66a39d970fe6a 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -78,6 +78,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"; @@ -937,6 +944,69 @@ 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 none is named origin. The branch will track the + one you choose. + + + + + + + + + + + + ); +} + export default function GitActionsControl({ gitCwd, activeThreadRef, @@ -2005,46 +2075,17 @@ export default function GitActionsControl({ - { - if (!open) setPendingPushRemoteAction(null); + setPendingPushRemoteAction(null)} + onPush={(remoteName) => { + if (!pendingPushRemoteAction) return; + const { input } = pendingPushRemoteAction; + setPendingPushRemoteAction(null); + void runGitActionWithToast({ ...input, pushRemote: remoteName }); }} - > - - - Push to which remote? - - This repository has several remotes and none is named origin. The branch will track - the one you choose. - - - - - {pendingPushRemoteAction?.choices.map((remoteName) => ( - - ))} - - - + /> ); } diff --git a/docs/user/source-control.md b/docs/user/source-control.md index ff26a4b11a574..8bed980ccb5d4 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -179,8 +179,10 @@ 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, none named `origin`, and no `remote.pushDefault`, T3 Code -asks which remote to use the first time you push a branch. The branch then tracks that remote. +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. Changing the remote also +moves the branch's tracking to it. If no remote is chosen and none is named `origin` or set as +`remote.pushDefault`, T3 Code asks when you first push the branch. 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 diff --git a/packages/client-runtime/src/state/gitActions.test.ts b/packages/client-runtime/src/state/gitActions.test.ts index 798b3426d44af..d7ab2a745b609 100644 --- a/packages/client-runtime/src/state/gitActions.test.ts +++ b/packages/client-runtime/src/state/gitActions.test.ts @@ -105,6 +105,13 @@ describe("pushRemoteChoicesFor", () => { expect(pushRemoteChoicesFor({ action: "commit" }, unclear)).toBeNull(); expect(pushRemoteChoicesFor({ action: "push", pushRemote: "fork" }, unclear)).toBeNull(); expect(pushRemoteChoicesFor({ action: "push" }, { hasUpstream: false })).toBeNull(); + expect(pushRemoteChoicesFor({ action: "push" }, { ...unclear, pushRemote: "fork" })).toBeNull(); + expect( + pushRemoteChoicesFor( + { action: "commit_push", featureBranch: true }, + { ...unclear, pushRemote: "fork" }, + ), + ).toEqual(choices); }); it("asks for a new feature branch even when the current branch tracks a remote", () => { diff --git a/packages/client-runtime/src/state/gitActions.ts b/packages/client-runtime/src/state/gitActions.ts index b00c0dbcf5735..49b580b610606 100644 --- a/packages/client-runtime/src/state/gitActions.ts +++ b/packages/client-runtime/src/state/gitActions.ts @@ -332,10 +332,12 @@ export function requiresDefaultBranchConfirmation( */ export function pushRemoteChoicesFor( input: GitActionRequestInput, - gitStatus: Pick | null, + gitStatus: Pick | null, ): ReadonlyArray | null { if (input.action === "commit" || input.pushRemote || !gitStatus?.pushRemoteChoices) return null; - return !gitStatus.hasUpstream || input.featureBranch ? gitStatus.pushRemoteChoices : null; + // A new feature branch starts without the current branch's selection. + if (input.featureBranch) return gitStatus.pushRemoteChoices; + return gitStatus.hasUpstream || gitStatus.pushRemote ? null : gitStatus.pushRemoteChoices; } export function resolveDefaultBranchActionDialogCopy(input: { diff --git a/packages/client-runtime/src/state/vcs.ts b/packages/client-runtime/src/state/vcs.ts index 12d55a7477058..a5f13ae53fd52 100644 --- a/packages/client-runtime/src/state/vcs.ts +++ b/packages/client-runtime/src/state/vcs.ts @@ -378,6 +378,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 d4a1355fb4ff9..399fba4e833da 100644 --- a/packages/client-runtime/src/state/vcsAction.test.ts +++ b/packages/client-runtime/src/state/vcsAction.test.ts @@ -528,6 +528,7 @@ describe("vcsActionState", () => { manager.runStackedAction(targetKey).run(registry, { actionId: successfulActionId, action, + pushRemote: "fork", threadId, projectId, }), @@ -538,7 +539,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 8b4a7d48d279c..1ae3e19b6286d 100644 --- a/packages/contracts/src/clientRpcPermissions.ts +++ b/packages/contracts/src/clientRpcPermissions.ts @@ -59,6 +59,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 534b04e5882ac..72ff8d52c7585 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -201,6 +201,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), @@ -222,6 +229,10 @@ 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. diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 0f6af19188e18..d76a5dcddfb61 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -125,6 +125,7 @@ import { } from "./worktreeSetup.ts"; import { GitActionProgressEvent, + VcsSetPushRemoteInput, VcsSwitchRefInput, VcsSwitchRefResult, GitCommandError, @@ -464,6 +465,7 @@ export const WS_METHODS = { vcsRemoveWorktree: "vcs.removeWorktree", vcsCreateRef: "vcs.createRef", vcsSwitchRef: "vcs.switchRef", + vcsSetPushRemote: "vcs.setPushRemote", vcsInit: "vcs.init", gitRunStackedAction: "git.runStackedAction", @@ -1576,6 +1578,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]), @@ -2222,6 +2229,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 3a6804a1f5a2e..d1c9a5dff5250 100644 --- a/packages/shared/src/git.ts +++ b/packages/shared/src/git.ts @@ -341,6 +341,8 @@ 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, From 4907cc0d5c299344b5256874b45e6a3d7836e836 Mon Sep 17 00:00:00 2001 From: Tony <97451137+TonybynMp4@users.noreply.github.com> Date: Mon, 9 Nov 2026 21:32:32 +0100 Subject: [PATCH 03/11] fix(git): ask for a push remote when upstream is only a base - Treat a branch's upstream as its comparison base, not its publish target, so pushing still asks for a remote - Find the PR on the host of the remote the branch pushes to, which can differ from origin's host - Stop falling back to the upstream's remote when no origin exists - Keep the upstream as the base when publishing to a chosen remote - Reword the push remote prompt on web and mobile to match --- .../features/threads/git/GitRemoteSheet.tsx | 2 +- apps/server/src/git/GitManager.test.ts | 55 +++++++++++++-- apps/server/src/git/GitManager.ts | 43 +++++++++--- apps/server/src/vcs/GitVcsDriverCore.test.ts | 70 ++++++++++++------- apps/server/src/vcs/GitVcsDriverCore.ts | 56 ++++++++------- apps/web/src/components/GitActionsControl.tsx | 4 +- docs/user/source-control.md | 4 +- .../src/state/gitActions.test.ts | 17 ++--- .../client-runtime/src/state/gitActions.ts | 4 +- 9 files changed, 171 insertions(+), 84 deletions(-) diff --git a/apps/mobile/src/features/threads/git/GitRemoteSheet.tsx b/apps/mobile/src/features/threads/git/GitRemoteSheet.tsx index 5603d4803df2f..177fc50638451 100644 --- a/apps/mobile/src/features/threads/git/GitRemoteSheet.tsx +++ b/apps/mobile/src/features/threads/git/GitRemoteSheet.tsx @@ -78,7 +78,7 @@ export function GitRemoteSheet(props: GitRemoteSheetProps) { {...(action ? { footer: - "This repository has several remotes and none is named origin. The branch will track the one you choose.", + "This repository has several remotes and the branch has none chosen yet. It will track the one you pick.", } : {})} > diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index f9ad3f69f4914..1dff2a45c7faa 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -599,6 +599,8 @@ function preparePullRequestThread( function makeManager(input?: { providerScenario?: FakeProviderScenario; sourceControlProvider?: SourceControlProvider["Service"]; + /** Served when a caller resolves the provider for a specific remote. */ + remoteSourceControlProvider?: SourceControlProvider["Service"]; textGeneration?: Partial; serverSettings?: Parameters[0]; setupScriptRunner?: ProjectSetupScriptRunner.ProjectSetupScriptRunner["Service"]; @@ -696,7 +698,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: null, + }), resolve: () => Effect.succeed(provider), discover: Effect.succeed([]), }), @@ -945,18 +951,17 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); - it.effect("status detects the host from the branch's push remote before its first push", () => + 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", - "branch.feature/status-push-remote-host.pushRemote", - "lab", - ]); + yield* runGit(repoDir, ["config", key, "lab"]); const { manager } = yield* makeManager(); @@ -966,6 +971,42 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); + 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("status returns an explicit non-repo result for deleted directories", () => Effect.gen(function* () { const rootDir = yield* makeTempDir("t3code-git-manager-missing-dir-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 1fc6754c81971..c95b9d7e72c3a 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -666,6 +666,25 @@ export const make = Effect.gen(function* () { const path = yield* Path.Path; const sourceControlProvider = (cwd: string) => sourceControlProviders.resolve({ cwd }); + // 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* sourceControlProvider(cwd); + const remoteUrl = remoteName + ? yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`) + : null; + const provider = remoteUrl ? sourceControlDefinitions.recognizeRemote(remoteUrl) : null; + if (!remoteName || !remoteUrl || !provider || provider.kind === fallback.kind) { + return fallback; + } + const handle = yield* sourceControlProviders.resolveHandle({ + cwd, + context: { provider, remoteName, remoteUrl }, + }); + return handle.provider; + }, + ); const changeRequestTermsFor = Effect.fn("GitManager.changeRequestTermsFor")(function* ( cwd: string, ) { @@ -1146,7 +1165,7 @@ 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 { @@ -1505,13 +1524,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}.pushRemote`)) ?? - (yield* readConfigValueNullable(cwd, `branch.${branch}.remote`)) ?? - "origin"); + : ((yield* readConfigValueNullable(cwd, `branch.${branch}.remote`)) ?? "origin")); const remoteUrl = (yield* readConfigValueNullable(cwd, `remote.${preferredRemoteName}.url`)) ?? (yield* readConfigValueNullable(cwd, "remote.origin.url")); @@ -1891,9 +1910,10 @@ export const make = Effect.gen(function* () { | "headRepositoryNameWithOwner" | "headRepositoryOwnerLogin" | "isCrossRepository" + | "remoteName" >, ) { - const provider = yield* sourceControlProvider(cwd); + const provider = yield* branchSourceControlProvider(cwd, headContext.remoteName); const probe = headBranchProbe(provider, headContext.headSelectors, "open"); for (const headSelector of probe.headSelectors) { const pullRequests = yield* provider.listChangeRequests({ @@ -1925,7 +1945,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, "all"); for (const headSelector of probe.headSelectors) { const pullRequests = yield* provider.listChangeRequests({ @@ -2086,7 +2106,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), ); @@ -2328,8 +2351,6 @@ export const make = Effect.gen(function* () { threadId: ThreadId | undefined, emit: GitActionProgressEmitter, ) { - 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) { @@ -2351,6 +2372,8 @@ export const make = Effect.gen(function* () { 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) { diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 5a9e1d42a2e3d..9eb658231d014 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -345,6 +345,25 @@ const initRepoWithCommit = ( return { initialBranch }; }); +/** 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 }; +}); + it.effect.each(["root", "nested", "worktree"] as const)( "skips clean filters while the %s index is locked and resumes after unlock", (location) => @@ -6812,21 +6831,9 @@ 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* makeTmpDir(); - const forkRemote = yield* makeTmpDir("git-remote-"); - const upstreamRemote = yield* makeTmpDir("git-remote-"); - 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, ["checkout", "-b", "feature/remote"]); - yield* writeTextFile(cwd, "feature.txt", "feature\n"); - yield* git(cwd, ["add", "-A"]); - yield* driver.commit(cwd, "Add feature", ""); + const { cwd } = yield* makeForkWorkflowRepo; const status = yield* driver.statusDetailsLocal(cwd); - assert.isTrue(status.hasOriginRemote); assert.deepEqual(status.pushRemoteChoices, ["fork", "upstream"]); const refused = yield* driver.pushCurrentBranch(cwd, null).pipe(Effect.flip); assert.include(refused.message, "Choose which remote"); @@ -6842,21 +6849,34 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); + 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"); + + 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 moves the branch's tracking to it", () => Effect.gen(function* () { const driver = yield* GitVcsDriver.GitVcsDriver; - const cwd = yield* makeTmpDir(); - const forkRemote = yield* makeTmpDir("git-remote-"); - const upstreamRemote = yield* makeTmpDir("git-remote-"); - 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, ["checkout", "-b", "feature/remote"]); - yield* writeTextFile(cwd, "feature.txt", "feature\n"); - yield* git(cwd, ["add", "-A"]); - yield* driver.commit(cwd, "Add feature", ""); + const { cwd } = yield* makeForkWorkflowRepo; const refName = "feature/remote"; const unset = yield* driver.statusDetailsLocal(cwd); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 61d147691106d..28849768beff8 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -402,7 +402,8 @@ function parseUpstreamRefByFirstSeparator( }; } -// Differently named upstreams are bases, not publish targets; pushing HEAD there would write features to shared branches. +// Differently named upstreams are bases, not publish targets: pushing HEAD there would write +// features to shared branches. function isUpstreamPublishTarget( branch: string, upstream: { upstreamRef: string; branchName: string }, @@ -1587,9 +1588,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }), }, ); - // Repo-wide, so cached per common dir: which remotes exist, and the ones to ask between when - // git would have to guess (see `pushRemoteChoices`). Per-branch config is read by - // `resolveStatusPushRemote`. + // Repo-wide, so cached per common dir; per-branch config is read by `resolveStatusPushRemote`. const readStatusRemotes = (cwd: string, gitArgs: ReadonlyArray = []) => Effect.all( [ @@ -1859,6 +1858,23 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* }); // Mirrors where `pushCurrentBranch` sends this branch; undefined while the choice is unclear. + // 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: { 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: `refs/remotes/${upstream.upstreamRef}`, + }); + } + }); + const resolveStatusPushRemote = Effect.fn("resolveStatusPushRemote")(function* ( cwd: string, refName: string, @@ -1885,7 +1901,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return ( branchPushRemote || defaultPushRemote || - (remoteNames.includes("origin") ? "origin" : upstream?.remoteName) + (remoteNames.includes("origin") ? "origin" : undefined) ); }); @@ -2799,9 +2815,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* upstream, }; } - 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, @@ -2893,6 +2907,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const requestedRemoteName = options?.remoteName?.trim() || null; if (requestedRemoteName) { const publishBranch = yield* resolvePublishBranchName(cwd, branch); + const currentUpstream = details.hasUpstream + ? yield* resolveCurrentUpstream(cwd).pipe(Effect.orElseSucceed(() => null)) + : null; + if (currentUpstream && !isUpstreamPublishTarget(branch, currentUpstream)) { + yield* preserveUpstreamAsBase(cwd, branch, currentUpstream); + } yield* runGit( "GitVcsDriver.pushCurrentBranch.pushWithRequestedRemote", cwd, @@ -2966,21 +2986,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: `refs/remotes/${upstream.upstreamRef}`, - }); - } - } + if (upstream) yield* preserveUpstreamAsBase(cwd, branch, upstream); yield* runGit( upstream ? "GitVcsDriver.pushCurrentBranch.pushOwnBranch" @@ -5409,7 +5415,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* return yield* withListRefsInvalidation(input.cwd, createBranch(input)); }), writeConfigValue, - setPushRemote, + 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/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 66a39d970fe6a..e8c2be61758d8 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -970,8 +970,8 @@ function PushRemoteDialog({ Push to which remote? - This repository has several remotes and none is named origin. The branch will track the - one you choose. + This repository has several remotes and the branch has none chosen yet. It will track + the one you pick. diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 8bed980ccb5d4..bba1bd90962d0 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -181,8 +181,8 @@ 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. Changing the remote also -moves the branch's tracking to it. If no remote is chosen and none is named `origin` or set as -`remote.pushDefault`, T3 Code asks when you first push the branch. +moves the branch's tracking to it. 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 diff --git a/packages/client-runtime/src/state/gitActions.test.ts b/packages/client-runtime/src/state/gitActions.test.ts index d7ab2a745b609..2801b28aa9b60 100644 --- a/packages/client-runtime/src/state/gitActions.test.ts +++ b/packages/client-runtime/src/state/gitActions.test.ts @@ -98,14 +98,17 @@ describe("git action copy", () => { describe("pushRemoteChoicesFor", () => { const choices = ["fork", "upstream"]; - const unclear = { hasUpstream: false, pushRemoteChoices: choices }; + const unclear = { pushRemoteChoices: choices }; - it("asks before the first push of a branch when the remote is unclear", () => { + 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" }, { hasUpstream: false })).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 }, @@ -113,12 +116,4 @@ describe("pushRemoteChoicesFor", () => { ), ).toEqual(choices); }); - - it("asks for a new feature branch even when the current branch tracks a remote", () => { - const tracking = { ...unclear, hasUpstream: true }; - expect(pushRemoteChoicesFor({ action: "push" }, tracking)).toBeNull(); - expect(pushRemoteChoicesFor({ action: "commit_push", featureBranch: true }, tracking)).toEqual( - choices, - ); - }); }); diff --git a/packages/client-runtime/src/state/gitActions.ts b/packages/client-runtime/src/state/gitActions.ts index 49b580b610606..7f1c495c35f28 100644 --- a/packages/client-runtime/src/state/gitActions.ts +++ b/packages/client-runtime/src/state/gitActions.ts @@ -332,12 +332,12 @@ export function requiresDefaultBranchConfirmation( */ export function pushRemoteChoicesFor( input: GitActionRequestInput, - gitStatus: Pick | null, + 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.hasUpstream || gitStatus.pushRemote ? null : gitStatus.pushRemoteChoices; + return gitStatus.pushRemote ? null : gitStatus.pushRemoteChoices; } export function resolveDefaultBranchActionDialogCopy(input: { From dbadfb5afbf7028d1179750c6c6a1dc1ae70856c Mon Sep 17 00:00:00 2001 From: Tony <97451137+TonybynMp4@users.noreply.github.com> Date: Mon, 9 Nov 2026 22:01:54 +0100 Subject: [PATCH 04/11] fix(web): push remote menu closes after picking a remote --- apps/web/src/components/BranchToolbarRemoteSelector.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/BranchToolbarRemoteSelector.tsx b/apps/web/src/components/BranchToolbarRemoteSelector.tsx index 210faf6b30606..608a64f1e4922 100644 --- a/apps/web/src/components/BranchToolbarRemoteSelector.tsx +++ b/apps/web/src/components/BranchToolbarRemoteSelector.tsx @@ -91,7 +91,7 @@ export function BranchToolbarRemoteSelector({ onValueChange={(remoteName: string) => void selectRemote(remoteName)} > {remotes.map((remoteName) => ( - + {remoteName} ))} From ee465b793043c443f57089ac920f96cb0a76d0bc Mon Sep 17 00:00:00 2001 From: Tony <97451137+TonybynMp4@users.noreply.github.com> Date: Mon, 9 Nov 2026 23:16:47 +0100 Subject: [PATCH 05/11] fix(git): validate push remote names and bind PRs to the branch host Refuse a requested push remote that is not a configured remote, since git would otherwise accept a URL or path as the push target. Resolve the branch's provider from its push remote when that remote is on a different host than origin, even when both hosts are the same provider kind. --- apps/server/src/git/GitManager.test.ts | 52 +++++++++++++++++++- apps/server/src/git/GitManager.ts | 16 ++++-- apps/server/src/vcs/GitVcsDriverCore.test.ts | 4 ++ apps/server/src/vcs/GitVcsDriverCore.ts | 28 ++++++++--- 4 files changed, 88 insertions(+), 12 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 1dff2a45c7faa..a610b1765e601 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -55,8 +55,12 @@ import * as VcsProcess from "../vcs/VcsProcess.ts"; import * as VcsDriverRegistry from "../vcs/VcsDriverRegistry.ts"; import * as VcsProjectConfig from "../vcs/VcsProjectConfig.ts"; import * as GitWorkflowService from "./GitWorkflowService.ts"; -import type { SourceControlProvider } from "@t3tools/source-control-core/server/SourceControlProvider"; +import type { + SourceControlProvider, + SourceControlProviderContext, +} 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"; @@ -601,6 +605,8 @@ function makeManager(input?: { 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"]; @@ -701,7 +707,7 @@ function makeManager(input?: { resolveHandle: (request) => Effect.succeed({ provider: (request.context && input?.remoteSourceControlProvider) || provider, - context: null, + context: request.context ?? input?.sourceControlContext ?? null, }), resolve: () => Effect.succeed(provider), discover: Effect.succeed([]), @@ -1007,6 +1013,48 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); + 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-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index c95b9d7e72c3a..4f494adb18790 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -655,6 +655,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; @@ -670,13 +673,20 @@ export const make = Effect.gen(function* () { // 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* sourceControlProvider(cwd); + const fallback = yield* sourceControlProviders.resolveHandle({ cwd }); const remoteUrl = remoteName ? yield* readConfigValueNullable(cwd, `remote.${remoteName}.url`) : null; const provider = remoteUrl ? sourceControlDefinitions.recognizeRemote(remoteUrl) : null; - if (!remoteName || !remoteUrl || !provider || provider.kind === fallback.kind) { - return fallback; + 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, diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 9eb658231d014..fdff2d4f6cf15 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -6860,6 +6860,10 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { 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( diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 28849768beff8..ae347b0307233 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2792,6 +2792,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, @@ -2837,13 +2854,9 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const setPushRemote: GitVcsDriver.GitVcsDriver["Service"]["setPushRemote"] = Effect.fn( "setPushRemote", )(function* ({ cwd, refName, remoteName }) { - const remoteNames = yield* listRemoteNames(cwd); - if (!remoteNames.includes(remoteName)) { - return yield* new GitCommandError({ - ...gitCommandContext({ operation: "GitVcsDriver.setPushRemote", cwd, args: ["config"] }), - detail: `Remote '${remoteName}' does not exist.`, - }); - } + const remoteNames = yield* requireRemote(cwd, remoteName, "GitVcsDriver.setPushRemote", [ + "config", + ]); yield* writeConfigValue({ cwd, key: `branch.${refName}.pushRemote`, value: remoteName }); const upstreamRef = yield* runGitStdout( @@ -2906,6 +2919,7 @@ 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)) From 686ef3dad6d3e52451271f4a2210428d4a227682 Mon Sep 17 00:00:00 2001 From: Antony <97451137+TonybynMp4@users.noreply.github.com> Date: Tue, 10 Nov 2026 00:48:42 +0100 Subject: [PATCH 06/11] fix: Resolve the branch remote when the fallback context is null Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- apps/server/src/git/GitManager.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 4f494adb18790..394b28ae2d0b4 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -683,8 +683,8 @@ export const make = Effect.gen(function* () { !remoteUrl || !provider || (provider.kind === fallback.provider.kind && - (fallback.context === null || - sameBaseUrlHost(provider.baseUrl, fallback.context.provider.baseUrl))) + fallback.context !== null && + sameBaseUrlHost(provider.baseUrl, fallback.context.provider.baseUrl)) ) { return fallback.provider; } From 06d180e8635857ea29c7939e3a8e377d56ed570c Mon Sep 17 00:00:00 2001 From: Tony <97451137+TonybynMp4@users.noreply.github.com> Date: Wed, 11 Nov 2026 16:22:15 +0100 Subject: [PATCH 07/11] fix(git): the PR base check uses the chosen push remote The check that a PR head is not its base resolves where the branch will be pushed. It ignored the remote picked in the client, so with several remotes and no origin it asked for a remote even though one was chosen. --- apps/server/src/git/GitManager.test.ts | 44 +++++++++++++++++++++++++ apps/server/src/git/GitManager.ts | 4 ++- apps/server/src/vcs/GitVcsDriver.ts | 6 +++- apps/server/src/vcs/GitVcsDriverCore.ts | 7 ++++ 4 files changed, 59 insertions(+), 2 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index a610b1765e601..807ba9e5bc836 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -581,6 +581,7 @@ function runStackedAction( commitMessage?: string; featureBranch?: boolean; filePaths?: readonly string[]; + pushRemote?: string; }, options?: Parameters[1], ) { @@ -3653,6 +3654,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 394b28ae2d0b4..57573f995b57d 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -2330,8 +2330,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, @@ -3333,6 +3334,7 @@ export const make = Effect.gen(function* () { input.cwd, { ...initialStatus, branch: initialStatus.branch }, changeRequestTerms.shortLabelWithArticle, + input.pushRemote, ); } diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index 929d86923a0b7..6ab830ae8c9b5 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -351,7 +351,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, diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index ae347b0307233..a6925c82a6659 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -2845,6 +2845,13 @@ 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), + }; + } const destination = yield* resolvePushDestination(cwd, current.branch, current.hasUpstream); return destination ? { remoteName: destination.remoteName, branchName: destination.branchName } From 44217bd33e0417d447c500ff506e80371ddbc7af Mon Sep 17 00:00:00 2001 From: Tony <97451137+TonybynMp4@users.noreply.github.com> Date: Wed, 11 Nov 2026 17:08:02 +0100 Subject: [PATCH 08/11] fix(git): ignore stale push remotes and unknown branches A branch push remote or remote.pushDefault that names a removed remote no longer counts as chosen, so the client asks again instead of the push failing. Setting a push remote for a branch that does not exist is refused. --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 13 ++++++++++ apps/server/src/vcs/GitVcsDriverCore.ts | 27 +++++++++++++++----- 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index fdff2d4f6cf15..1fcee74470097 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -6832,9 +6832,12 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { 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"); @@ -6918,6 +6921,16 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { .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(() => ""), + ), + "", + ); }), ); }); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index a6925c82a6659..551cbe50acd98 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -1819,13 +1819,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; } @@ -1835,15 +1839,12 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* ["config", "--get", "remote.pushDefault"], true, ).pipe(Effect.map((stdout) => stdout.trim())); - if (pushDefaultRemote.length > 0) { + if (remoteNames.includes(pushDefaultRemote)) { return pushDefaultRemote; } // 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({ @@ -1899,8 +1900,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* Effect.orElseSucceed(() => ""), ); return ( - branchPushRemote || - defaultPushRemote || + (remoteNames.includes(branchPushRemote) ? branchPushRemote : undefined) ?? + defaultPushRemote ?? (remoteNames.includes("origin") ? "origin" : undefined) ); }); @@ -2864,6 +2865,18 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const remoteNames = 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.`, + }); + } yield* writeConfigValue({ cwd, key: `branch.${refName}.pushRemote`, value: remoteName }); const upstreamRef = yield* runGitStdout( From 5c1f9df24ea41569b2414b2d800a327b72c085ec Mon Sep 17 00:00:00 2001 From: Tony <97451137+TonybynMp4@users.noreply.github.com> Date: Wed, 11 Nov 2026 17:09:48 +0100 Subject: [PATCH 09/11] fix(git): read a PR's base from the branch remote when only it has it The provider bound to the branch's remote can report a default branch that only that remote has. The commit range then looked only at the primary remote and PR creation failed, so it now falls back to the branch's remote. --- apps/server/src/git/GitManager.test.ts | 42 ++++++++++++++++++++++++++ apps/server/src/git/GitManager.ts | 31 ++++++++++--------- 2 files changed, 59 insertions(+), 14 deletions(-) diff --git a/apps/server/src/git/GitManager.test.ts b/apps/server/src/git/GitManager.test.ts index 807ba9e5bc836..3e9b787edbbed 100644 --- a/apps/server/src/git/GitManager.test.ts +++ b/apps/server/src/git/GitManager.test.ts @@ -1014,6 +1014,48 @@ it.layer(layerGitManagerTest)("GitManager", (it) => { }), ); + 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-"); diff --git a/apps/server/src/git/GitManager.ts b/apps/server/src/git/GitManager.ts index 57573f995b57d..2c81c81ae20b4 100644 --- a/apps/server/src/git/GitManager.ts +++ b/apps/server/src/git/GitManager.ts @@ -2138,26 +2138,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")( @@ -2409,7 +2412,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 = From a7eecd7dc63ca1baa717a058664933066608f58a Mon Sep 17 00:00:00 2001 From: Tony <97451137+TonybynMp4@users.noreply.github.com> Date: Wed, 11 Nov 2026 18:19:09 +0100 Subject: [PATCH 10/11] fix(web): picking a push remote no longer skips the default branch prompt The pending push kept a forced skip of the default branch confirmation, so switching to the default branch while the remote picker was open pushed there without asking. It now keeps the caller's own choice. --- apps/web/src/components/GitActionsControl.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index e8c2be61758d8..d2e7851bf6b1a 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -1341,7 +1341,7 @@ export default function GitActionsControl({ ); if (pushRemoteChoices) { setPendingPushRemoteAction({ - input: { ...input, skipDefaultBranchPrompt: true }, + input, choices: pushRemoteChoices, }); return; From d0fb2908b0770094383c7d5e46beddaa12171b55 Mon Sep 17 00:00:00 2001 From: Tony <97451137+TonybynMp4@users.noreply.github.com> Date: Sun, 15 Nov 2026 17:51:25 +0100 Subject: [PATCH 11/11] fix(git): picking a push remote keeps the branch's tracking Co-Authored-By: Claude Opus 5.5 --- apps/server/src/vcs/GitVcsDriverCore.test.ts | 27 +++++++----- apps/server/src/vcs/GitVcsDriverCore.ts | 46 +------------------- docs/user/source-control.md | 7 +-- 3 files changed, 21 insertions(+), 59 deletions(-) diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index 554c75b3ffd5b..1268b6950bc7d 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -9093,7 +9093,7 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { }), ); - it.effect("selecting a push remote moves the branch's tracking to it", () => + it.effect("selecting a push remote keeps the branch's tracking", () => Effect.gen(function* () { const driver = yield* GitVcsDriver.GitVcsDriver; const { cwd } = yield* makeForkWorkflowRepo; @@ -9111,24 +9111,27 @@ it.layer(layerTest)("GitVcsDriver core integration", (it) => { `fork/${refName}`, ); - // The new remote has no such branch yet: tracking clears, so the branch can publish there. - yield* driver.setPushRemote({ cwd, refName, remoteName: "upstream" }); - const moved = yield* driver.statusDetailsLocal(cwd); - assert.equal(moved.pushRemote, "upstream"); - assert.isFalse(moved.hasUpstream); - yield* driver.pushCurrentBranch(cwd, null); + // 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}`, ); - - // Going back finds the branch already on that remote and tracks it again. - yield* driver.setPushRemote({ cwd, refName, remoteName: "fork" }); + 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}"]), - `fork/${refName}`, + `upstream/${refName}`, ); - assert.equal((yield* driver.statusDetailsLocal(cwd)).pushRemote, "fork"); const unknown = yield* driver .setPushRemote({ cwd, refName, remoteName: "missing" }) diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 035dd75e719bb..765d34687e0d1 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -3278,9 +3278,7 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* const setPushRemote: GitVcsDriver.GitVcsDriver["Service"]["setPushRemote"] = Effect.fn( "setPushRemote", )(function* ({ cwd, refName, remoteName }) { - const remoteNames = yield* requireRemote(cwd, remoteName, "GitVcsDriver.setPushRemote", [ - "config", - ]); + yield* requireRemote(cwd, remoteName, "GitVcsDriver.setPushRemote", ["config"]); const branchExists = yield* executeGit( "GitVcsDriver.setPushRemote.branchExists", cwd, @@ -3293,48 +3291,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* 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 upstreamRef = yield* runGitStdout( - "GitVcsDriver.setPushRemote.upstream", - cwd, - ["for-each-ref", "--format=%(upstream:short)", `refs/heads/${refName}`], - true, - ).pipe(Effect.map((stdout) => stdout.trim())); - const upstream = upstreamRef - ? (parseUpstreamRefWithRemoteNames(upstreamRef, remoteNames) ?? - parseUpstreamRefByFirstSeparator(upstreamRef)) - : null; - if ( - !upstream || - upstream.remoteName === remoteName || - !isAliasOfUpstreamHead(refName, upstream.branchName, upstream.upstreamRef) - ) { - return; - } - - // Pushes follow a publishing upstream, so it moves too, which keeps ahead/behind honest. - const remoteBranchExists = yield* executeGit( - "GitVcsDriver.setPushRemote.remoteBranchExists", - cwd, - ["show-ref", "--verify", "--quiet", `refs/remotes/${remoteName}/${upstream.branchName}`], - { allowNonZeroExit: true }, - ).pipe(Effect.map((result) => result.exitCode === 0)); - if (remoteBranchExists) { - yield* setBranchUpstream({ - cwd, - branch: refName, - remoteName, - remoteBranch: upstream.branchName, - }); - return; - } - yield* executeGitWithStableDiagnostics( - "GitVcsDriver.setPushRemote.unsetUpstream", - cwd, - ["branch", "--unset-upstream", refName], - { writesSharedConfig: true }, - ).pipe(retryWhileConfigLocked); }); const pushCurrentBranch: GitVcsDriver.GitVcsDriver["Service"]["pushCurrentBranch"] = Effect.fn( diff --git a/docs/user/source-control.md b/docs/user/source-control.md index 5d61d77a2f2fa..e3c748bdf0056 100644 --- a/docs/user/source-control.md +++ b/docs/user/source-control.md @@ -182,9 +182,10 @@ messages, review titles, and descriptions from your changes. Descriptions also d 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. Changing the remote also -moves the branch's tracking to it. If a branch has no remote chosen and none is named `origin` or -set as `remote.pushDefault`, T3 Code asks before pushing it. +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