Route git operations by owner host

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-06-13 01:36:33 -07:00
co-authored by Orca
parent 64b726f33d
commit 0b232dbb3c
7 changed files with 160 additions and 50 deletions
@@ -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`.
@@ -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<void> => {
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
])
@@ -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,
@@ -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 () => {
@@ -17,7 +17,8 @@ export type GitStatusRefreshDeps = {
worktreeId: string,
worktreePath: string,
connectionId?: string,
pushTarget?: GitPushTarget
pushTarget?: GitPushTarget,
options?: { runtimeTargetSettings?: Pick<GlobalSettings, 'activeRuntimeEnvironmentId'> | null }
) => Promise<void>
}
@@ -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
})
}
@@ -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' }
+62 -28
View File
@@ -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<GlobalSettings, 'activeRuntimeEnvironmentId'> | 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<void>
pushBranch: (
worktreeId: string,
@@ -520,38 +526,43 @@ export type EditorSlice = {
publish?: boolean,
connectionId?: string,
pushTarget?: GitPushTarget,
options?: { forceWithLease?: boolean }
options?: GitRuntimeOperationOptions & { forceWithLease?: boolean }
) => Promise<void>
pullBranch: (
worktreeId: string,
worktreePath: string,
connectionId?: string,
pushTarget?: GitPushTarget
pushTarget?: GitPushTarget,
options?: GitRuntimeOperationOptions
) => Promise<void>
fastForwardBranch: (
worktreeId: string,
worktreePath: string,
connectionId?: string,
pushTarget?: GitPushTarget
pushTarget?: GitPushTarget,
options?: GitRuntimeOperationOptions
) => Promise<void>
syncBranch: (
worktreeId: string,
worktreePath: string,
connectionId?: string,
pushTarget?: GitPushTarget
pushTarget?: GitPushTarget,
options?: GitRuntimeOperationOptions
) => Promise<void>
rebaseFromBase: (
worktreeId: string,
worktreePath: string,
baseRef: string,
connectionId?: string,
pushTarget?: GitPushTarget
pushTarget?: GitPushTarget,
options?: GitRuntimeOperationOptions
) => Promise<void>
fetchBranch: (
worktreeId: string,
worktreePath: string,
connectionId?: string,
pushTarget?: GitPushTarget
pushTarget?: GitPushTarget,
options?: GitRuntimeOperationOptions
) => Promise<void>
gitBranchChangesByWorktree: Record<string, GitBranchChangeEntry[]>
gitBranchCompareSummaryByWorktree: Record<string, GitBranchCompareSummary | null>
@@ -3385,11 +3396,12 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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/<branch>, 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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (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<AppState, [], [], EditorSlice> = (s
} finally {
get().endRemoteOperation()
}
void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget)
void get().fetchUpstreamStatus(worktreeId, worktreePath, connectionId, pushTarget, {
runtimeTargetSettings: runtimeSettings
})
},
gitBranchChangesByWorktree: {},
gitBranchCompareSummaryByWorktree: {},