Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
bae33c5
fix(git): ask which remote to push to when none is named origin
TonybynMp4 Nov 6, 2026
c74c63c
feat(git): add push remote picker for branches
TonybynMp4 Nov 9, 2026
4907cc0
fix(git): ask for a push remote when upstream is only a base
TonybynMp4 Nov 9, 2026
dbadfb5
fix(web): push remote menu closes after picking a remote
TonybynMp4 Nov 9, 2026
ee465b7
fix(git): validate push remote names and bind PRs to the branch host
TonybynMp4 Nov 9, 2026
686ef3d
fix: Resolve the branch remote when the fallback context is null
TonybynMp4 Nov 9, 2026
06d180e
fix(git): the PR base check uses the chosen push remote
TonybynMp4 Nov 11, 2026
44217bd
fix(git): ignore stale push remotes and unknown branches
TonybynMp4 Nov 11, 2026
5c1f9df
fix(git): read a PR's base from the branch remote when only it has it
TonybynMp4 Nov 11, 2026
a7eecd7
fix(web): picking a push remote no longer skips the default branch pr…
TonybynMp4 Nov 11, 2026
58cdeb0
Merge upstream/main into feat/push-remote-picker
TonybynMp4 Nov 11, 2026
2fa730d
Merge upstream/main into feat/push-remote-picker
TonybynMp4 Nov 13, 2026
a24caba
Merge upstream/main into feat/push-remote-picker
TonybynMp4 Nov 14, 2026
628290f
Merge branch 'main' into feat/push-remote-picker
TonybynMp4 Nov 14, 2026
d0fb290
fix(git): picking a push remote keeps the branch's tracking
TonybynMp4 Nov 15, 2026
12618ca
Merge upstream/main into feat/push-remote-picker
TonybynMp4 Nov 15, 2026
b597057
Merge remote-tracking branch 'origin/feat/push-remote-picker' into fe…
TonybynMp4 Nov 15, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/mobile/src/Stack.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -703,6 +704,11 @@ const RootStackConfig = createWorkspaceStackNavigator({
linking: `${THREAD_LINKING_PREFIX}/git/branches`,
options: sheetRoute({ detent: 0.55, androidFit: true }),
}),
GitRemote: createNativeStackScreen({
screen: GitRemoteSheet,
linking: `${THREAD_LINKING_PREFIX}/git/remote`,
options: sheetRoute({ detent: 0.55, androidFit: true }),
}),
GitConfirm: createNativeStackScreen({
screen: GitConfirmSheet,
linking: `${THREAD_LINKING_PREFIX}/git-confirm`,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const LISTED_OVERLAY_ROUTES: ReadonlySet<string> = new Set([
"GitBranches",
"GitCommit",
"GitConfirm",
"GitRemote",
"GitOverview",
"NewTaskProject",
"NewTaskFiles",
Expand Down
16 changes: 16 additions & 0 deletions apps/mobile/src/features/threads/git/GitOverviewSheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,22 @@ function GitOverviewSheetContent(props: GitOverviewSheetProps) {
)
}
/>
{gitStatus.data?.remotes && gitStatus.data.refName ? (
<ListRow
icon="cloud"
label="Push remote"
description={gitStatus.data.pushRemote ?? "Not chosen"}
disabled={busy}
onPress={() =>
presentRoute(() =>
navigation.navigate("GitRemote", {
environmentId: String(environmentId),
threadId: String(threadId),
}),
)
}
/>
) : null}
{linkedPrActions.canLink ? (
<ListRow
icon="link"
Expand Down
103 changes: 103 additions & 0 deletions apps/mobile/src/features/threads/git/GitRemoteSheet.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import type { GitStackedAction } from "@t3tools/contracts";
import { StackActions, useNavigation, type StaticScreenProps } from "@react-navigation/native";
import { Platform, ScrollView, useWindowDimensions, View } from "react-native";
import { useSafeAreaInsets } from "react-native-safe-area-context";

import { ListChoiceRow } from "../../../components/ui/ListChoiceRow";
import { ListSection } from "../../../components/ui/ListSection";
import { ScreenContent } from "../../../components/ui/ScreenContent";
import { SheetHeader } from "../../../components/ui/SheetHeader";
import { useEnvironmentQuery } from "../../../state/query";
import { useSelectedThreadGitActions } from "../../../state/use-selected-thread-git-actions";
import { useSelectedThreadWorktree } from "../../../state/use-selected-thread-worktree";
import { useThreadSelection } from "../../../state/use-thread-selection";
import { vcsEnvironment } from "../../../state/vcs";

type GitRemoteSheetProps = StaticScreenProps<{
readonly environmentId: string;
readonly threadId: string;
/** Set when a git action is waiting on the choice; it runs once a remote is picked. */
readonly action?: GitStackedAction;
readonly commitMessage?: string;
readonly featureBranch?: string;
readonly filePaths?: string;
}>;

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 (
<View
collapsable={false}
className="bg-sheet ios:flex-1 android:flex-col-reverse"
style={Platform.OS === "android" ? { maxHeight: windowHeight * 0.92 } : undefined}
>
<ScreenContent fitToContents>
<ScrollView
nestedScrollEnabled
className="android:shrink android:grow-0 ios:flex-1"
contentInsetAdjustmentBehavior="automatic"
showsVerticalScrollIndicator={false}
contentInset={{ bottom: Math.max(insets.bottom, 18) + 18 }}
contentContainerClassName="android:gap-2 android:p-2 ios:gap-4 ios:px-5 ios:pt-2"
>
<ListSection
{...(action
? {
footer:
"This repository has several remotes and the branch has none chosen yet. It will track the one you pick.",
}
: {})}
>
{remotes.map((remoteName) => (
<ListChoiceRow
key={remoteName}
label={remoteName}
selected={remoteName === pushRemote}
disabled={!gitActions.canWriteSourceControl}
onPress={() => void selectRemote(remoteName)}
/>
))}
</ListSection>
</ScrollView>
</ScreenContent>
<SheetHeader
title={action ? "Push to which remote?" : "Push remote"}
onClose={() => navigation.goBack()}
/>
</View>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,10 @@ vi.mock("./queries", () => ({
useBranches: () => ({ data: { refs: state.cachedRefs }, refresh: () => {} }),
}));
vi.mock("./use-atom-command", () => ({ useAtomCommand: (command: unknown) => command }));
vi.mock("./atom-registry", () => ({ appAtomRegistry: {} }));
vi.mock("./atom-registry", () => ({
appAtomRegistry: { get: () => AsyncResult.initial() },
}));
vi.mock("@react-navigation/native", () => ({ useNavigation: () => ({ navigate: () => {} }) }));
vi.mock("./use-remote-environment-registry", () => ({
environmentConnectedAtom: () => null,
setPendingConnectionError: () => {},
Expand Down Expand Up @@ -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 });
Expand Down
51 changes: 51 additions & 0 deletions apps/mobile/src/state/use-selected-thread-git-actions.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { useNavigation } from "@react-navigation/native";
import { useAtomMount } from "@effect/atom-react";

import { EnvironmentProject, EnvironmentThreadShell } from "@t3tools/client-runtime/state/shell";
import type { AtomCommandResult } from "@t3tools/client-runtime/state/runtime";
import {
createVcsStatusRevalidationAtomFamily,
type GitActionRequestInput,
pushRemoteChoicesFor,
type VcsActionOperation,
type VcsRef,
} from "@t3tools/client-runtime/state/vcs";
Expand Down Expand Up @@ -53,12 +55,14 @@ const selectedThreadStatusRevalidation = createVcsStatusRevalidationAtomFamily({
});

export function useSelectedThreadGitActions() {
const navigation = useNavigation();
const updateThreadMetadata = useAtomCommand(threadEnvironment.updateMetadata, {
reportFailure: false,
});
const readRefs = useAtomCommand(vcsEnvironment.readRefs, { reportFailure: false });
const refreshStatus = useAtomCommand(vcsEnvironment.refreshStatus, { reportFailure: false });
const switchRef = useAtomCommand(vcsEnvironment.switchRef, { reportFailure: false });
const setPushRemote = useAtomCommand(vcsEnvironment.setPushRemote, { reportFailure: false });
const createRef = useAtomCommand(vcsEnvironment.createRef, { reportFailure: false });
const createWorktree = useAtomCommand(vcsEnvironment.createWorktree, { reportFailure: false });
const pull = useAtomCommand(vcsEnvironment.pull, { reportFailure: false });
Expand Down Expand Up @@ -356,9 +360,54 @@ export function useSelectedThreadGitActions() {
);
};

const selectedThreadGitStatus = () =>
selectedThread && selectedThreadCwd
? AsyncResult.getOrElse(
appAtomRegistry.get(
vcsEnvironment.status({
environmentId: selectedThread.environmentId,
input: { cwd: selectedThreadCwd },
}),
),
() => null,
)
: null;

const onSetSelectedThreadPushRemote = async (remoteName: string) => {
const refName = selectedThreadGitStatus()?.refName;
if (!refName) return null;
return runSelectedThreadGitMutation(
"switch_ref",
"Changing push remote",
async ({ thread, cwd }) => {
const result = await setPushRemote({
environmentId: thread.environmentId,
input: { cwd, refName, remoteName },
});
if (AsyncResult.isSuccess(result)) {
await refreshSelectedThreadGitStatus({ quiet: true, cwd });
}
return result;
},
);
};

const onRunSelectedThreadGitAction = async (
input: GitActionRequestInput,
): Promise<GitRunStackedActionResult | null> => {
// Every entry point lands here, after any default-branch confirmation.
const pushRemoteChoices = pushRemoteChoicesFor(input, selectedThreadGitStatus());
if (pushRemoteChoices && selectedThread) {
navigation.navigate("GitRemote", {
environmentId: String(selectedThread.environmentId),
threadId: String(selectedThread.id),
action: input.action,
...(input.commitMessage ? { commitMessage: input.commitMessage } : {}),
...(input.featureBranch ? { featureBranch: "true" } : {}),
...(input.filePaths?.length ? { filePaths: JSON.stringify(input.filePaths) } : {}),
});
return null;
}
const actionId = uuidv4();
return await runSelectedThreadGitMutation(
"run_change_request",
Expand All @@ -370,6 +419,7 @@ export function useSelectedThreadGitActions() {
...(input.commitMessage ? { commitMessage: input.commitMessage } : {}),
...(input.featureBranch ? { featureBranch: input.featureBranch } : {}),
...(input.filePaths?.length ? { filePaths: [...input.filePaths] } : {}),
...(input.pushRemote ? { pushRemote: input.pushRemote } : {}),
threadId: thread.id,
});
if (AsyncResult.isFailure(result)) {
Expand Down Expand Up @@ -412,6 +462,7 @@ export function useSelectedThreadGitActions() {
onCreateSelectedThreadBranch,
onCreateSelectedThreadWorktree,
onPullSelectedThreadBranch,
onSetSelectedThreadPushRemote,
onRunSelectedThreadGitAction,
};
}
Loading
Loading