diff --git a/notes/host-context-implementation-checklist.md b/notes/host-context-implementation-checklist.md index 5a3e7324bbb..e8041f0f536 100644 --- a/notes/host-context-implementation-checklist.md +++ b/notes/host-context-implementation-checklist.md @@ -285,3 +285,4 @@ Done means: reviewers and future agents can understand the vision, implementatio - [x] 2026-06-13: Fed desktop-owned GitHub/GitLab preflight into Tasks source availability for local and SSH project sources, so missing `gh`/`glab` or missing CLI auth disables the affected provider with source-tool/auth-specific copy. Runtime-owned sources intentionally skip desktop preflight and continue to rely on runtime capability/fetch ownership. Verified with provider availability tests, Tasks source summary tests, TaskPage source-boundary tests, targeted oxlint, and full `pnpm run typecheck`. - [x] 2026-06-13: Electron-verified a local project with local GitHub credentials: added the local Orca worktree to the running dev app, confirmed `gh` auth for `Jinwoo-H`, fetched 10 GitHub work items through explicit `TaskSourceContext { provider: github, hostId: local }`, verified the local source-scoped cache key, and captured the rendered Tasks page showing GitHub issues for the local project. - [x] 2026-06-13: Snapshotted Source Control PR-generation runtime ownership and reused that owner for generation, cancellation, and post-generation git refresh, preventing async PR generation from following a later focused-host switch. Verified with focused Source Control host-boundary tests, targeted oxlint, and `git diff --check`. +- [x] 2026-06-13: Extended Source Control git remote operations and upstream refresh to accept explicit runtime-owner settings, then wired SourceControl and ChecksPanel to pass the repo owner host for push, pull, fetch, sync, fast-forward, rebase, publish, and PR-generation follow-up refreshes. Verified with focused editor/git-status tests, targeted oxlint, `git diff --check`, and full `pnpm run typecheck`. diff --git a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx index 487d86fe07b..25fce2066fe 100644 --- a/src/renderer/src/components/right-sidebar/ChecksPanel.tsx +++ b/src/renderer/src/components/right-sidebar/ChecksPanel.tsx @@ -694,8 +694,16 @@ export default function ChecksPanel(): React.JSX.Element { // moved, the embedded composer should push before creating the review. setCreatePrPushFirst(true) const connectionId = activeConnectionId ?? undefined - await fetchUpstreamStatus(activeWorktreeId, activeWorktree.path, connectionId) - }, [activeConnectionId, activeWorktree?.path, activeWorktreeId, fetchUpstreamStatus]) + await fetchUpstreamStatus(activeWorktreeId, activeWorktree.path, connectionId, undefined, { + runtimeTargetSettings: ownerSettings + }) + }, [ + activeConnectionId, + activeWorktree?.path, + activeWorktreeId, + fetchUpstreamStatus, + ownerSettings + ]) const prCreationDefaults = useMemo(() => { if (!settings) { return DEFAULT_SOURCE_CONTROL_AI_PR_CREATION_DEFAULTS @@ -2528,14 +2536,24 @@ export default function ChecksPanel(): React.JSX.Element { activeWorktree.path, false, connectionId, - activeWorktree.pushTarget + activeWorktree.pushTarget, + { runtimeTargetSettings: ownerSettings } ) - await fetchUpstreamStatus(activeWorktreeId, activeWorktree.path, connectionId) + await fetchUpstreamStatus(activeWorktreeId, activeWorktree.path, connectionId, undefined, { + runtimeTargetSettings: ownerSettings + }) return true } catch { return false } - }, [activeConnectionId, activeWorktree, activeWorktreeId, fetchUpstreamStatus, pushBranch]) + }, [ + activeConnectionId, + activeWorktree, + activeWorktreeId, + fetchUpstreamStatus, + ownerSettings, + pushBranch + ]) const handlePublishBranch = useCallback(async (): Promise => { if ( @@ -2554,13 +2572,15 @@ export default function ChecksPanel(): React.JSX.Element { activeWorktree.path, true, connectionId, - activeWorktree.pushTarget + activeWorktree.pushTarget, + { runtimeTargetSettings: ownerSettings } ) await fetchUpstreamStatus( activeWorktreeId, activeWorktree.path, connectionId, - activeWorktree.pushTarget + activeWorktree.pushTarget, + { runtimeTargetSettings: ownerSettings } ) } catch { // Store remote actions already surface the publish failure toast. @@ -2577,6 +2597,7 @@ export default function ChecksPanel(): React.JSX.Element { fetchUpstreamStatus, isPublishingBranch, isRemoteOperationActive, + ownerSettings, pushBranch ]) diff --git a/src/renderer/src/components/right-sidebar/SourceControl.tsx b/src/renderer/src/components/right-sidebar/SourceControl.tsx index 12f4c91313d..9b461448ab3 100644 --- a/src/renderer/src/components/right-sidebar/SourceControl.tsx +++ b/src/renderer/src/components/right-sidebar/SourceControl.tsx @@ -1631,7 +1631,8 @@ function SourceControlInner(): React.JSX.Element { worktreePath, true, connectionId, - activeWorktree?.pushTarget + activeWorktree?.pushTarget, + { runtimeTargetSettings: activeRepoSettings } ) return } @@ -1643,7 +1644,9 @@ function SourceControlInner(): React.JSX.Element { false, connectionId, activeWorktree?.pushTarget, - forceWithLease ? { forceWithLease: true } : undefined + forceWithLease + ? { forceWithLease: true, runtimeTargetSettings: activeRepoSettings } + : { runtimeTargetSettings: activeRepoSettings } ) return } @@ -1654,12 +1657,20 @@ function SourceControlInner(): React.JSX.Element { false, connectionId, activeWorktree?.pushTarget, - { forceWithLease: true } + { forceWithLease: true, runtimeTargetSettings: activeRepoSettings } ) return } if (kind === 'pull') { - await pullBranch(activeWorktreeId, worktreePath, connectionId, activeWorktree?.pushTarget) + await pullBranch( + activeWorktreeId, + worktreePath, + connectionId, + activeWorktree?.pushTarget, + { + runtimeTargetSettings: activeRepoSettings + } + ) return } if (kind === 'fast_forward') { @@ -1667,7 +1678,8 @@ function SourceControlInner(): React.JSX.Element { activeWorktreeId, worktreePath, connectionId, - activeWorktree?.pushTarget + activeWorktree?.pushTarget, + { runtimeTargetSettings: activeRepoSettings } ) return } @@ -1676,7 +1688,10 @@ function SourceControlInner(): React.JSX.Element { activeWorktreeId, worktreePath, connectionId, - activeWorktree?.pushTarget + activeWorktree?.pushTarget, + { + runtimeTargetSettings: activeRepoSettings + } ) return } @@ -1689,11 +1704,14 @@ function SourceControlInner(): React.JSX.Element { worktreePath, effectiveBaseRef, connectionId, - activeWorktree?.pushTarget + activeWorktree?.pushTarget, + { runtimeTargetSettings: activeRepoSettings } ) return } - await syncBranch(activeWorktreeId, worktreePath, connectionId, activeWorktree?.pushTarget) + await syncBranch(activeWorktreeId, worktreePath, connectionId, activeWorktree?.pushTarget, { + runtimeTargetSettings: activeRepoSettings + }) setRemoteActionErrors((prev) => ({ ...prev, [activeWorktreeId]: null })) } catch (error) { // Why: remote action failures are surfaced by editor-slice actions to keep @@ -1716,6 +1734,7 @@ function SourceControlInner(): React.JSX.Element { } }, [ + activeRepoSettings, activeWorktree?.pushTarget, activeWorktreeId, fetchBranch, @@ -3159,9 +3178,11 @@ function SourceControlInner(): React.JSX.Element { activeWorktreeId, worktreePath, connectionId, - activeWorktree?.pushTarget + activeWorktree?.pushTarget, + { runtimeTargetSettings: activeRepoSettings } ) }, [ + activeRepoSettings, activeWorktree?.pushTarget, activeWorktreeId, fetchUpstreamStatus, diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts b/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts index bba4521ae80..1613f3c8b54 100644 --- a/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts +++ b/src/renderer/src/components/right-sidebar/git-status-refresh.test.ts @@ -76,7 +76,9 @@ describe('refreshGitStatusForWorktree', () => { }) expect(deps.setUpstreamStatus).not.toHaveBeenCalled() - expect(deps.fetchUpstreamStatus).toHaveBeenCalledWith('wt-1', '/repo', undefined) + expect(deps.fetchUpstreamStatus).toHaveBeenCalledWith('wt-1', '/repo', undefined, undefined, { + runtimeTargetSettings: undefined + }) }) it('falls back to explicit upstream refresh for legacy status payloads', async () => { @@ -103,7 +105,9 @@ describe('refreshGitStatusForWorktree', () => { branch: 'refs/heads/main' }) expect(deps.setUpstreamStatus).not.toHaveBeenCalled() - expect(deps.fetchUpstreamStatus).toHaveBeenCalledWith('wt-2', '/repo', 'ssh-2') + expect(deps.fetchUpstreamStatus).toHaveBeenCalledWith('wt-2', '/repo', 'ssh-2', undefined, { + runtimeTargetSettings: undefined + }) }) it('leaves ignored-file discovery to the File Explorer instead of status polling', async () => { diff --git a/src/renderer/src/components/right-sidebar/git-status-refresh.ts b/src/renderer/src/components/right-sidebar/git-status-refresh.ts index 17d25d58bf5..79c3408da9e 100644 --- a/src/renderer/src/components/right-sidebar/git-status-refresh.ts +++ b/src/renderer/src/components/right-sidebar/git-status-refresh.ts @@ -17,7 +17,8 @@ export type GitStatusRefreshDeps = { worktreeId: string, worktreePath: string, connectionId?: string, - pushTarget?: GitPushTarget + pushTarget?: GitPushTarget, + options?: { runtimeTargetSettings?: Pick | null } ) => Promise } @@ -56,7 +57,9 @@ export async function refreshGitStatusForWorktree({ // Why: porcelain status reports Git's configured upstream. Source Control // actions for PR-created worktrees must instead reconcile with Orca's // explicit publish target. - await deps.fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget) + await deps.fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget, { + runtimeTargetSettings: settings + }) return } if (status.upstreamStatus) { @@ -68,11 +71,15 @@ export async function refreshGitStatusForWorktree({ // Why: porcelain status has counts but cannot tell stale post-rebase // upstream commits from real remote work. Writing it first makes the // primary action flicker between Sync and Force Push on every poll. - await deps.fetchUpstreamStatus(worktreeId, worktreePath, connectionId) + await deps.fetchUpstreamStatus(worktreeId, worktreePath, connectionId, undefined, { + runtimeTargetSettings: settings + }) return } deps.setUpstreamStatus(worktreeId, status.upstreamStatus) return } - await deps.fetchUpstreamStatus(worktreeId, worktreePath, connectionId) + await deps.fetchUpstreamStatus(worktreeId, worktreePath, connectionId, undefined, { + runtimeTargetSettings: settings + }) } diff --git a/src/renderer/src/store/slices/editor.test.ts b/src/renderer/src/store/slices/editor.test.ts index 11d38269717..f9774e42dfa 100644 --- a/src/renderer/src/store/slices/editor.test.ts +++ b/src/renderer/src/store/slices/editor.test.ts @@ -2098,6 +2098,28 @@ describe('createEditorSlice remote branch actions', () => { expect(toastErrorMock).not.toHaveBeenCalled() }) + it('routes git operations through the explicit runtime owner instead of ambient focus', async () => { + const store = createEditorStore() + store.setState({ settings: { activeRuntimeEnvironmentId: 'focused-runtime' } as never }) + + await store.getState().pushBranch('wt-1', '/repo', false, undefined, undefined, { + runtimeTargetSettings: { activeRuntimeEnvironmentId: null } + }) + + expect(gitPushMock).toHaveBeenCalledWith({ + worktreePath: '/repo', + publish: false, + connectionId: undefined, + pushTarget: undefined, + forceWithLease: undefined + }) + expect(gitUpstreamStatusMock).toHaveBeenCalledWith({ + worktreePath: '/repo', + connectionId: undefined, + pushTarget: undefined + }) + }) + it('runs rebase from base and refreshes upstream on success', async () => { const store = createEditorStore() const pushTarget = { remoteName: 'fork', branchName: 'feature' } diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index ed5b75804b8..334818b20f6 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -16,6 +16,7 @@ import type { GitConflictOperation, GitConflictResolutionStatus, GitConflictStatusSource, + GlobalSettings, GitPushTarget, GitStatusEntry, GitStatusResult, @@ -240,6 +241,10 @@ type EditorOpenTargetOptions = { runtimeEnvironmentId?: string | null } +type GitRuntimeOperationOptions = { + runtimeTargetSettings?: Pick | null +} + export type PendingEditorReveal = { filePath: string fileId?: string @@ -512,7 +517,8 @@ export type EditorSlice = { worktreeId: string, worktreePath: string, connectionId?: string, - pushTarget?: GitPushTarget + pushTarget?: GitPushTarget, + options?: GitRuntimeOperationOptions ) => Promise pushBranch: ( worktreeId: string, @@ -520,38 +526,43 @@ export type EditorSlice = { publish?: boolean, connectionId?: string, pushTarget?: GitPushTarget, - options?: { forceWithLease?: boolean } + options?: GitRuntimeOperationOptions & { forceWithLease?: boolean } ) => Promise pullBranch: ( worktreeId: string, worktreePath: string, connectionId?: string, - pushTarget?: GitPushTarget + pushTarget?: GitPushTarget, + options?: GitRuntimeOperationOptions ) => Promise fastForwardBranch: ( worktreeId: string, worktreePath: string, connectionId?: string, - pushTarget?: GitPushTarget + pushTarget?: GitPushTarget, + options?: GitRuntimeOperationOptions ) => Promise syncBranch: ( worktreeId: string, worktreePath: string, connectionId?: string, - pushTarget?: GitPushTarget + pushTarget?: GitPushTarget, + options?: GitRuntimeOperationOptions ) => Promise rebaseFromBase: ( worktreeId: string, worktreePath: string, baseRef: string, connectionId?: string, - pushTarget?: GitPushTarget + pushTarget?: GitPushTarget, + options?: GitRuntimeOperationOptions ) => Promise fetchBranch: ( worktreeId: string, worktreePath: string, connectionId?: string, - pushTarget?: GitPushTarget + pushTarget?: GitPushTarget, + options?: GitRuntimeOperationOptions ) => Promise gitBranchChangesByWorktree: Record gitBranchCompareSummaryByWorktree: Record @@ -3385,11 +3396,12 @@ export const createEditorSlice: StateCreator = (s inFlightRemoteOpKind: next > 0 ? s.inFlightRemoteOpKind : null } }), - fetchUpstreamStatus: async (worktreeId, worktreePath, connectionId, pushTarget) => { + fetchUpstreamStatus: async (worktreeId, worktreePath, connectionId, pushTarget, options) => { try { + const runtimeSettings = options?.runtimeTargetSettings ?? get().settings const status = await getRuntimeGitUpstreamStatus( { - settings: get().settings, + settings: runtimeSettings, worktreeId, worktreePath, connectionId @@ -3428,9 +3440,10 @@ export const createEditorSlice: StateCreator = (s publish ? 'publish' : options.forceWithLease === true ? 'force_push' : 'push' ) let shouldRefreshAfterRejectedPush = false + const runtimeSettings = options.runtimeTargetSettings ?? get().settings try { await pushRuntimeGit( - { settings: get().settings, worktreeId, worktreePath, connectionId }, + { settings: runtimeSettings, worktreeId, worktreePath, connectionId }, { publish, pushTarget, forceWithLease: options.forceWithLease } ) } catch (error) { @@ -3446,26 +3459,33 @@ export const createEditorSlice: StateCreator = (s } finally { get().endRemoteOperation() if (shouldRefreshAfterRejectedPush) { - const context = { settings: get().settings, worktreeId, worktreePath, connectionId } + const context = { settings: runtimeSettings, worktreeId, worktreePath, connectionId } // Why: the rejected push proved the publish branch moved. Fetch first // so legacy base-tracking worktrees can discover origin/, then // refresh ahead/behind so Pull/Sync become actionable immediately. void fetchRuntimeGit(context, pushTarget) .catch(() => undefined) - .then(() => get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget)) + .then(() => + get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget, { + runtimeTargetSettings: runtimeSettings + }) + ) } } - void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget) + void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget, { + runtimeTargetSettings: runtimeSettings + }) const refreshGitHubForWorktree = get().refreshGitHubForWorktree if (typeof refreshGitHubForWorktree === 'function') { refreshGitHubForWorktree(worktreeId) } }, - pullBranch: async (worktreeId, worktreePath, connectionId, pushTarget) => { + pullBranch: async (worktreeId, worktreePath, connectionId, pushTarget, options) => { get().beginRemoteOperation('pull') + const runtimeSettings = options?.runtimeTargetSettings ?? get().settings try { await pullRuntimeGit( - { settings: get().settings, worktreeId, worktreePath, connectionId }, + { settings: runtimeSettings, worktreeId, worktreePath, connectionId }, pushTarget ) } catch (error) { @@ -3474,17 +3494,20 @@ export const createEditorSlice: StateCreator = (s } finally { get().endRemoteOperation() } - void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget) + void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget, { + runtimeTargetSettings: runtimeSettings + }) const refreshGitHubForWorktree = get().refreshGitHubForWorktree if (typeof refreshGitHubForWorktree === 'function') { refreshGitHubForWorktree(worktreeId) } }, - fastForwardBranch: async (worktreeId, worktreePath, connectionId, pushTarget) => { + fastForwardBranch: async (worktreeId, worktreePath, connectionId, pushTarget, options) => { get().beginRemoteOperation('fast_forward') + const runtimeSettings = options?.runtimeTargetSettings ?? get().settings try { await fastForwardRuntimeGit( - { settings: get().settings, worktreeId, worktreePath, connectionId }, + { settings: runtimeSettings, worktreeId, worktreePath, connectionId }, pushTarget ) } catch (error) { @@ -3493,13 +3516,15 @@ export const createEditorSlice: StateCreator = (s } finally { get().endRemoteOperation() } - void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget) + void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget, { + runtimeTargetSettings: runtimeSettings + }) const refreshGitHubForWorktree = get().refreshGitHubForWorktree if (typeof refreshGitHubForWorktree === 'function') { refreshGitHubForWorktree(worktreeId) } }, - syncBranch: async (worktreeId, worktreePath, connectionId, pushTarget) => { + syncBranch: async (worktreeId, worktreePath, connectionId, pushTarget, options) => { // Why: same shape as pushBranch / pullBranch — fire-and-forget the // post-op upstream refresh after the busy flag clears so the primary // button label rotates immediately when the IPC resolves. @@ -3510,8 +3535,9 @@ export const createEditorSlice: StateCreator = (s // outer catch must then skip toasting to avoid a double-toast. let pushStageToastShown = false let pushed = false + const runtimeSettings = options?.runtimeTargetSettings ?? get().settings try { - const context = { settings: get().settings, worktreeId, worktreePath, connectionId } + const context = { settings: runtimeSettings, worktreeId, worktreePath, connectionId } await fetchRuntimeGit(context, pushTarget) const upstreamStatusBeforePull = await getRuntimeGitUpstreamStatus(context, pushTarget) if (shouldForcePushWithLeaseForUpstream(upstreamStatusBeforePull)) { @@ -3556,7 +3582,9 @@ export const createEditorSlice: StateCreator = (s } finally { get().endRemoteOperation() } - void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget) + void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget, { + runtimeTargetSettings: runtimeSettings + }) if (pushed) { const refreshGitHubForWorktree = get().refreshGitHubForWorktree if (typeof refreshGitHubForWorktree === 'function') { @@ -3564,11 +3592,12 @@ export const createEditorSlice: StateCreator = (s } } }, - rebaseFromBase: async (worktreeId, worktreePath, baseRef, connectionId, pushTarget) => { + rebaseFromBase: async (worktreeId, worktreePath, baseRef, connectionId, pushTarget, options) => { get().beginRemoteOperation('rebase') + const runtimeSettings = options?.runtimeTargetSettings ?? get().settings try { await rebaseRuntimeGitFromBase( - { settings: get().settings, worktreeId, worktreePath, connectionId }, + { settings: runtimeSettings, worktreeId, worktreePath, connectionId }, baseRef ) } catch (error) { @@ -3577,21 +3606,24 @@ export const createEditorSlice: StateCreator = (s } finally { get().endRemoteOperation() } - void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget) + void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget, { + runtimeTargetSettings: runtimeSettings + }) const refreshGitHubForWorktree = get().refreshGitHubForWorktree if (typeof refreshGitHubForWorktree === 'function') { refreshGitHubForWorktree(worktreeId) } }, - fetchBranch: async (worktreeId, worktreePath, connectionId, pushTarget) => { + fetchBranch: async (worktreeId, worktreePath, connectionId, pushTarget, options) => { // Why: same shape as pushBranch / pullBranch — fire-and-forget the // upstream refresh after the busy flag clears. Fetch updates the // remote refs only, so the visible signal we want is the new // ahead/behind counts on the upstream-status payload. get().beginRemoteOperation('fetch') + const runtimeSettings = options?.runtimeTargetSettings ?? get().settings try { await fetchRuntimeGit( - { settings: get().settings, worktreeId, worktreePath, connectionId }, + { settings: runtimeSettings, worktreeId, worktreePath, connectionId }, pushTarget ) } catch (error) { @@ -3600,7 +3632,9 @@ export const createEditorSlice: StateCreator = (s } finally { get().endRemoteOperation() } - void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget) + void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget, { + runtimeTargetSettings: runtimeSettings + }) }, gitBranchChangesByWorktree: {}, gitBranchCompareSummaryByWorktree: {},