diff --git a/apps/server/src/git/GitWorkflowService.ts b/apps/server/src/git/GitWorkflowService.ts index 74064450fcb..18c77ffd8b8 100644 --- a/apps/server/src/git/GitWorkflowService.ts +++ b/apps/server/src/git/GitWorkflowService.ts @@ -5,6 +5,11 @@ import * as Layer from "effect/Layer"; import { GitManagerError, GitCommandError, + type GitDivergedError, + type VcsFetchResult, + type VcsPushResult, + type VcsSyncInput, + type VcsSyncResult, type VcsSwitchRefInput, type VcsSwitchRefResult, type VcsCreateRefInput, @@ -46,6 +51,12 @@ export interface GitWorkflowServiceShape { readonly invalidateRemoteStatus: (cwd: string) => Effect.Effect; readonly invalidateStatus: (cwd: string) => Effect.Effect; readonly pullCurrentBranch: (cwd: string) => Effect.Effect; + readonly fetchCurrentBranch: (cwd: string) => Effect.Effect; + readonly pushCurrentBranch: (cwd: string) => Effect.Effect; + readonly syncCurrentBranch: ( + cwd: string, + options?: { readonly mode?: VcsSyncInput["mode"] }, + ) => Effect.Effect; readonly runStackedAction: ( input: GitRunStackedActionInput, options?: GitRunStackedActionOptions, @@ -272,6 +283,28 @@ export const make = Effect.fn("makeGitWorkflowService")(function* () { ensureGitCommand("GitWorkflowService.pullCurrentBranch", cwd).pipe( Effect.andThen(git.pullCurrentBranch(cwd)), ), + fetchCurrentBranch: (cwd) => + ensureGitCommand("GitWorkflowService.fetchCurrentBranch", cwd).pipe( + Effect.andThen(git.fetchCurrentBranch(cwd)), + ), + pushCurrentBranch: (cwd) => + ensureGitCommand("GitWorkflowService.pushCurrentBranch", cwd).pipe( + // Reuse the exact driver call the stacked-action push path uses so the + // standalone push and the bundled push share upstream-setting logic. + Effect.andThen(git.pushCurrentBranch(cwd, null)), + Effect.map( + (result): VcsPushResult => ({ + status: result.status, + refName: result.branch, + upstreamRef: result.upstreamBranch ?? null, + setUpstream: result.setUpstream ?? false, + }), + ), + ), + syncCurrentBranch: (cwd, options) => + ensureGitCommand("GitWorkflowService.syncCurrentBranch", cwd).pipe( + Effect.andThen(git.syncCurrentBranch(cwd, options)), + ), runStackedAction: (input, options) => ensureGit("GitWorkflowService.runStackedAction", input.cwd).pipe( Effect.andThen(gitManager.runStackedAction(input, options)), diff --git a/apps/server/src/rpcRequiredScope.test.ts b/apps/server/src/rpcRequiredScope.test.ts new file mode 100644 index 00000000000..89a78dfab8c --- /dev/null +++ b/apps/server/src/rpcRequiredScope.test.ts @@ -0,0 +1,22 @@ +import { WsRpcGroup } from "@t3tools/contracts"; +import { describe, expect, it } from "vite-plus/test"; + +import { RPC_REQUIRED_SCOPE } from "./rpcRequiredScope.ts"; + +// The RPC dispatch layer in ws.ts throws at runtime when a served method has no +// entry in RPC_REQUIRED_SCOPE. These tests turn that latent runtime failure into +// a test failure: the map must match the set of methods WsRpcGroup actually serves. +describe("RPC_REQUIRED_SCOPE", () => { + const servedMethods = [...WsRpcGroup.requests.keys()]; + + it("declares an authorization scope for every served WsRpcGroup method", () => { + const missing = servedMethods.filter((method) => !RPC_REQUIRED_SCOPE.has(method)); + expect(missing).toEqual([]); + }); + + it("does not declare scopes for methods that are not served", () => { + const served = new Set(servedMethods); + const stale = [...RPC_REQUIRED_SCOPE.keys()].filter((method) => !served.has(method)); + expect(stale).toEqual([]); + }); +}); diff --git a/apps/server/src/rpcRequiredScope.ts b/apps/server/src/rpcRequiredScope.ts new file mode 100644 index 00000000000..16481e6384a --- /dev/null +++ b/apps/server/src/rpcRequiredScope.ts @@ -0,0 +1,79 @@ +import { + AuthOrchestrationOperateScope, + AuthOrchestrationReadScope, + AuthReviewWriteScope, + AuthRelayWriteScope, + AuthTerminalOperateScope, + AuthAccessReadScope, + type AuthEnvironmentScope, + ORCHESTRATION_WS_METHODS, + WS_METHODS, +} from "@t3tools/contracts"; + +/** + * Authorization scope required to invoke each WebSocket RPC method. + * + * Every method served by `WsRpcGroup` must have an entry here — the RPC dispatch + * layer in ws.ts throws at runtime ("RPC method X has no declared authorization + * scope.") when a method is missing. `rpcRequiredScope.test.ts` asserts this map + * stays complete against `WsRpcGroup.requests`, so a newly added RPC fails in + * tests rather than on a live call. + */ +export const RPC_REQUIRED_SCOPE = new Map([ + [ORCHESTRATION_WS_METHODS.dispatchCommand, AuthOrchestrationOperateScope], + [ORCHESTRATION_WS_METHODS.getTurnDiff, AuthOrchestrationReadScope], + [ORCHESTRATION_WS_METHODS.getFullThreadDiff, AuthOrchestrationReadScope], + [ORCHESTRATION_WS_METHODS.replayEvents, AuthOrchestrationReadScope], + [ORCHESTRATION_WS_METHODS.subscribeShell, AuthOrchestrationReadScope], + [ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, AuthOrchestrationReadScope], + [ORCHESTRATION_WS_METHODS.subscribeThread, AuthOrchestrationReadScope], + [WS_METHODS.serverGetConfig, AuthOrchestrationReadScope], + [WS_METHODS.serverRefreshProviders, AuthOrchestrationOperateScope], + [WS_METHODS.serverUpdateProvider, AuthOrchestrationOperateScope], + [WS_METHODS.serverUpsertKeybinding, AuthOrchestrationOperateScope], + [WS_METHODS.serverRemoveKeybinding, AuthOrchestrationOperateScope], + [WS_METHODS.serverGetSettings, AuthOrchestrationReadScope], + [WS_METHODS.serverUpdateSettings, AuthOrchestrationOperateScope], + [WS_METHODS.serverDiscoverSourceControl, AuthOrchestrationReadScope], + [WS_METHODS.serverGetTraceDiagnostics, AuthOrchestrationReadScope], + [WS_METHODS.serverGetProcessDiagnostics, AuthOrchestrationReadScope], + [WS_METHODS.serverGetProcessResourceHistory, AuthOrchestrationReadScope], + [WS_METHODS.serverSignalProcess, AuthOrchestrationOperateScope], + [WS_METHODS.cloudGetRelayClientStatus, AuthRelayWriteScope], + [WS_METHODS.cloudInstallRelayClient, AuthRelayWriteScope], + [WS_METHODS.sourceControlLookupRepository, AuthOrchestrationReadScope], + [WS_METHODS.sourceControlCloneRepository, AuthOrchestrationOperateScope], + [WS_METHODS.sourceControlPublishRepository, AuthOrchestrationOperateScope], + [WS_METHODS.projectsSearchEntries, AuthOrchestrationReadScope], + [WS_METHODS.projectsWriteFile, AuthOrchestrationOperateScope], + [WS_METHODS.shellOpenInEditor, AuthOrchestrationOperateScope], + [WS_METHODS.filesystemBrowse, AuthOrchestrationReadScope], + [WS_METHODS.subscribeVcsStatus, AuthOrchestrationReadScope], + [WS_METHODS.vcsRefreshStatus, AuthOrchestrationReadScope], + [WS_METHODS.vcsPull, AuthOrchestrationOperateScope], + [WS_METHODS.vcsFetch, AuthOrchestrationOperateScope], + [WS_METHODS.vcsPush, AuthOrchestrationOperateScope], + [WS_METHODS.vcsSync, AuthOrchestrationOperateScope], + [WS_METHODS.gitRunStackedAction, AuthOrchestrationOperateScope], + [WS_METHODS.gitResolvePullRequest, AuthOrchestrationOperateScope], + [WS_METHODS.gitPreparePullRequestThread, AuthOrchestrationOperateScope], + [WS_METHODS.vcsListRefs, AuthOrchestrationReadScope], + [WS_METHODS.vcsCreateWorktree, AuthOrchestrationOperateScope], + [WS_METHODS.vcsRemoveWorktree, AuthOrchestrationOperateScope], + [WS_METHODS.vcsCreateRef, AuthOrchestrationOperateScope], + [WS_METHODS.vcsSwitchRef, AuthOrchestrationOperateScope], + [WS_METHODS.vcsInit, AuthOrchestrationOperateScope], + [WS_METHODS.reviewGetDiffPreview, AuthReviewWriteScope], + [WS_METHODS.terminalOpen, AuthTerminalOperateScope], + [WS_METHODS.terminalAttach, AuthTerminalOperateScope], + [WS_METHODS.terminalWrite, AuthTerminalOperateScope], + [WS_METHODS.terminalResize, AuthTerminalOperateScope], + [WS_METHODS.terminalClear, AuthTerminalOperateScope], + [WS_METHODS.terminalRestart, AuthTerminalOperateScope], + [WS_METHODS.terminalClose, AuthTerminalOperateScope], + [WS_METHODS.subscribeTerminalEvents, AuthTerminalOperateScope], + [WS_METHODS.subscribeTerminalMetadata, AuthTerminalOperateScope], + [WS_METHODS.subscribeServerConfig, AuthOrchestrationReadScope], + [WS_METHODS.subscribeServerLifecycle, AuthOrchestrationReadScope], + [WS_METHODS.subscribeAuthAccess, AuthAccessReadScope], +]); diff --git a/apps/server/src/vcs/GitVcsDriver.ts b/apps/server/src/vcs/GitVcsDriver.ts index adf991556d4..ae1465b5e9f 100644 --- a/apps/server/src/vcs/GitVcsDriver.ts +++ b/apps/server/src/vcs/GitVcsDriver.ts @@ -11,6 +11,7 @@ import { ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError, + GitDivergedError, VcsProcessExitError, type VcsSwitchRefInput, type VcsSwitchRefResult, @@ -23,7 +24,10 @@ import { type VcsInitInput, type VcsListRefsInput, type VcsListRefsResult, + type VcsFetchResult, type VcsPullResult, + type VcsSyncInput, + type VcsSyncResult, type VcsRemoveWorktreeInput, type VcsStatusInput, type VcsStatusResult, @@ -204,6 +208,11 @@ export interface GitVcsDriverShape { ) => Effect.Effect; readonly listRefs: (input: VcsListRefsInput) => Effect.Effect; readonly pullCurrentBranch: (cwd: string) => Effect.Effect; + readonly fetchCurrentBranch: (cwd: string) => Effect.Effect; + readonly syncCurrentBranch: ( + cwd: string, + options?: { readonly mode?: VcsSyncInput["mode"] }, + ) => Effect.Effect; readonly createWorktree: ( input: VcsCreateWorktreeInput, ) => Effect.Effect; diff --git a/apps/server/src/vcs/GitVcsDriverCore.test.ts b/apps/server/src/vcs/GitVcsDriverCore.test.ts index c0e0f1876c4..08125de1800 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.test.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.test.ts @@ -512,4 +512,164 @@ it.layer(TestLayer)("GitVcsDriver core integration", (it) => { }), ); }); + + describe("branch sync", () => { + // Helper: a repo on `main` tracking a bare `origin`, both at the initial commit. + const initRepoWithRemote = (cwd: string, remote: string) => + Effect.gen(function* () { + yield* initRepoWithCommit(cwd); + yield* git(cwd, ["branch", "-M", "main"]); + yield* git(remote, ["init", "--bare"]); + yield* git(cwd, ["remote", "add", "origin", remote]); + yield* git(cwd, ["push", "-u", "origin", "main"]); + }); + + it.effect("pushes when the branch is ahead of its upstream", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithRemote(cwd, remote); + yield* writeTextFile(cwd, "ahead.txt", "ahead\n"); + yield* git(cwd, ["add", "ahead.txt"]); + yield* git(cwd, ["commit", "-m", "ahead commit"]); + + const result = yield* (yield* GitVcsDriver.GitVcsDriver).syncCurrentBranch(cwd); + + assert.deepStrictEqual(result, { + refName: "main", + fetched: true, + pull: "skipped", + push: "pushed", + setUpstream: false, + }); + assert.equal(yield* git(remote, ["log", "-1", "--pretty=%s", "main"]), "ahead commit"); + }), + ); + + it.effect("fast-forward pulls when the branch is behind its upstream", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithRemote(cwd, remote); + // Advance the upstream, then rewind the local branch so it is purely behind. + yield* writeTextFile(cwd, "second.txt", "second\n"); + yield* git(cwd, ["add", "second.txt"]); + yield* git(cwd, ["commit", "-m", "second commit"]); + yield* git(cwd, ["push", "origin", "main"]); + yield* git(cwd, ["reset", "--hard", "HEAD~1"]); + + const result = yield* (yield* GitVcsDriver.GitVcsDriver).syncCurrentBranch(cwd); + + assert.deepStrictEqual(result, { + refName: "main", + fetched: true, + pull: "pulled", + push: "skipped", + setUpstream: false, + }); + assert.equal(yield* git(cwd, ["log", "-1", "--pretty=%s"]), "second commit"); + }), + ); + + it.effect("fails with GitDivergedError when history has diverged", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithRemote(cwd, remote); + // Upstream gains a commit the local branch never sees... + yield* writeTextFile(cwd, "upstream.txt", "upstream\n"); + yield* git(cwd, ["add", "upstream.txt"]); + yield* git(cwd, ["commit", "-m", "upstream commit"]); + yield* git(cwd, ["push", "origin", "main"]); + yield* git(cwd, ["reset", "--hard", "HEAD~1"]); + // ...while the local branch grows its own commit. + yield* writeTextFile(cwd, "local.txt", "local\n"); + yield* git(cwd, ["add", "local.txt"]); + yield* git(cwd, ["commit", "-m", "local commit"]); + + const error = yield* (yield* GitVcsDriver.GitVcsDriver) + .syncCurrentBranch(cwd) + .pipe(Effect.flip); + + assert.equal(error._tag, "GitDivergedError"); + if (error._tag === "GitDivergedError") { + assert.equal(error.refName, "main"); + assert.isAtLeast(error.aheadCount, 1); + assert.isAtLeast(error.behindCount, 1); + } + // The working tree must be left clean — no half-finished rebase/merge. + assert.equal(yield* git(cwd, ["log", "-1", "--pretty=%s"]), "local commit"); + }), + ); + + it.effect("rebases and pushes when the diverged sync opts into rebase", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithRemote(cwd, remote); + yield* writeTextFile(cwd, "upstream.txt", "upstream\n"); + yield* git(cwd, ["add", "upstream.txt"]); + yield* git(cwd, ["commit", "-m", "upstream commit"]); + yield* git(cwd, ["push", "origin", "main"]); + yield* git(cwd, ["reset", "--hard", "HEAD~1"]); + yield* writeTextFile(cwd, "local.txt", "local\n"); + yield* git(cwd, ["add", "local.txt"]); + yield* git(cwd, ["commit", "-m", "local commit"]); + + const result = yield* (yield* GitVcsDriver.GitVcsDriver).syncCurrentBranch(cwd, { + mode: "rebase", + }); + + assert.deepStrictEqual(result, { + refName: "main", + fetched: true, + pull: "rebased", + push: "pushed", + setUpstream: false, + }); + // Local commit replayed on top of the upstream commit and pushed back. + assert.equal(yield* git(remote, ["log", "-1", "--pretty=%s", "main"]), "local commit"); + }), + ); + + it.effect("publishes a branch that has no upstream", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithRemote(cwd, remote); + const driver = yield* GitVcsDriver.GitVcsDriver; + yield* driver.createRef({ cwd, refName: "feature/publish-sync" }); + yield* driver.switchRef({ cwd, refName: "feature/publish-sync" }); + yield* writeTextFile(cwd, "feature.txt", "feature\n"); + yield* git(cwd, ["add", "feature.txt"]); + yield* git(cwd, ["commit", "-m", "feature commit"]); + + const result = yield* driver.syncCurrentBranch(cwd); + + assert.deepStrictEqual(result, { + refName: "feature/publish-sync", + fetched: true, + pull: "skipped", + push: "pushed", + setUpstream: true, + }); + assert.equal( + yield* git(cwd, ["rev-parse", "--abbrev-ref", "@{upstream}"]), + "origin/feature/publish-sync", + ); + }), + ); + + it.effect("reports the branch and upstream when fetching", () => + Effect.gen(function* () { + const cwd = yield* makeTmpDir(); + const remote = yield* makeTmpDir("git-remote-"); + yield* initRepoWithRemote(cwd, remote); + + const result = yield* (yield* GitVcsDriver.GitVcsDriver).fetchCurrentBranch(cwd); + + assert.deepStrictEqual(result, { refName: "main", hasUpstream: true }); + }), + ); + }); }); diff --git a/apps/server/src/vcs/GitVcsDriverCore.ts b/apps/server/src/vcs/GitVcsDriverCore.ts index 33d009b9dc2..e33302f10b7 100644 --- a/apps/server/src/vcs/GitVcsDriverCore.ts +++ b/apps/server/src/vcs/GitVcsDriverCore.ts @@ -21,9 +21,12 @@ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; import { GitCommandError, + GitDivergedError, type ReviewDiffPreviewInput, type ReviewDiffPreviewSource, + type VcsFetchResult, type VcsRef, + type VcsSyncResult, } from "@t3tools/contracts"; import { dedupeRemoteBranchesWithLocalMatches } from "@t3tools/shared/git"; import { compactTraceAttributes } from "@t3tools/shared/observability"; @@ -2223,6 +2226,152 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* input.branch, ]); + // Force an immediate fetch (independent of the cached status refresh) so the + // remote-tracking ref behind @{u} reflects the latest upstream commits. + const fetchCurrentBranch: GitVcsDriver.GitVcsDriverShape["fetchCurrentBranch"] = Effect.fn( + "fetchCurrentBranch", + )(function* (cwd) { + const details = yield* statusDetails(cwd); + const upstream = yield* resolveCurrentUpstream(cwd).pipe(Effect.orElseSucceed(() => null)); + if (upstream) { + // Update exactly the tracking ref the ahead/behind counts are measured against. + yield* fetchRemoteTrackingBranch({ + cwd, + remoteName: upstream.remoteName, + remoteBranch: upstream.branchName, + }); + } else if (details.branch) { + // No upstream yet: fetch the branch's configured push remote so a newly + // published upstream becomes visible. Skip quietly when no remote exists. + const remoteName = yield* resolvePushRemoteName(cwd, details.branch).pipe( + Effect.orElseSucceed(() => null), + ); + if (remoteName) { + yield* runGit("GitVcsDriver.fetchCurrentBranch.fetchRemote", cwd, [ + "fetch", + "--quiet", + "--no-tags", + remoteName, + ]); + } + } + return { + refName: details.branch, + hasUpstream: details.hasUpstream, + } satisfies VcsFetchResult; + }); + + // `git pull --rebase` that never leaves the tree mid-rebase: on conflict it + // aborts and surfaces a clear error so the user can resolve manually. + const rebaseOntoUpstream = Effect.fn("rebaseOntoUpstream")(function* (cwd: string) { + const result = yield* executeGit( + "GitVcsDriver.syncCurrentBranch.rebase", + cwd, + ["pull", "--rebase"], + { allowNonZeroExit: true, timeoutMs: 30_000 }, + ); + if (result.exitCode !== 0) { + yield* executeGit( + "GitVcsDriver.syncCurrentBranch.rebaseAbort", + cwd, + ["rebase", "--abort"], + { allowNonZeroExit: true }, + ); + return yield* createGitCommandError( + "GitVcsDriver.syncCurrentBranch.rebase", + cwd, + ["pull", "--rebase"], + "Rebase hit conflicts and was aborted. Resolve the divergence manually, then sync again.", + ); + } + }); + + // One-click sync: fetch, then fast-forward pull and/or push as needed, pushing + // to the branch's configured push remote. Diverged history is reported as a + // typed GitDivergedError unless the caller opts into a rebase. + const syncCurrentBranch: GitVcsDriver.GitVcsDriverShape["syncCurrentBranch"] = Effect.fn( + "syncCurrentBranch", + )(function* (cwd, options) { + const mode = options?.mode ?? "ff"; + yield* fetchCurrentBranch(cwd); + const details = yield* statusDetails(cwd); + const refName = details.branch; + if (!refName) { + return yield* createGitCommandError( + "GitVcsDriver.syncCurrentBranch", + cwd, + ["sync"], + "Cannot sync from detached HEAD.", + ); + } + + // No upstream yet: publish the branch (push -u sets the upstream). + if (!details.hasUpstream) { + const published = yield* pushCurrentBranch(cwd, refName); + return { + refName, + fetched: true, + pull: "skipped" as const, + push: published.status === "pushed" ? ("pushed" as const) : ("skipped" as const), + setUpstream: published.setUpstream ?? false, + } satisfies VcsSyncResult; + } + + const ahead = details.aheadCount; + const behind = details.behindCount; + + if (ahead > 0 && behind > 0) { + if (mode !== "rebase") { + return yield* new GitDivergedError({ + operation: "GitVcsDriver.syncCurrentBranch", + cwd, + refName, + aheadCount: ahead, + behindCount: behind, + }); + } + yield* rebaseOntoUpstream(cwd); + const pushed = yield* pushCurrentBranch(cwd, refName); + return { + refName, + fetched: true, + pull: "rebased" as const, + push: pushed.status === "pushed" ? ("pushed" as const) : ("skipped" as const), + setUpstream: pushed.setUpstream ?? false, + } satisfies VcsSyncResult; + } + + if (behind > 0) { + const pulled = yield* pullCurrentBranch(cwd); + return { + refName, + fetched: true, + pull: pulled.status === "pulled" ? ("pulled" as const) : ("skipped_up_to_date" as const), + push: "skipped" as const, + setUpstream: false, + } satisfies VcsSyncResult; + } + + if (ahead > 0) { + const pushed = yield* pushCurrentBranch(cwd, refName); + return { + refName, + fetched: true, + pull: "skipped" as const, + push: pushed.status === "pushed" ? ("pushed" as const) : ("skipped" as const), + setUpstream: pushed.setUpstream ?? false, + } satisfies VcsSyncResult; + } + + return { + refName, + fetched: true, + pull: "skipped_up_to_date" as const, + push: "skipped" as const, + setUpstream: false, + } satisfies VcsSyncResult; + }); + const removeWorktree: GitVcsDriver.GitVcsDriverShape["removeWorktree"] = Effect.fn( "removeWorktree", )(function* (input) { @@ -2399,6 +2548,8 @@ export const makeGitVcsDriverCore = Effect.fn("makeGitVcsDriverCore")(function* commit, pushCurrentBranch, pullCurrentBranch, + fetchCurrentBranch, + syncCurrentBranch, readRangeContext, getReviewDiffPreview, readConfigValue, diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index 0f2a8f790bf..73f26c2a504 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -11,12 +11,6 @@ import * as Schema from "effect/Schema"; import * as Stream from "effect/Stream"; import { DEFAULT_AUTOMATIC_GIT_FETCH_INTERVAL, - AuthOrchestrationOperateScope, - AuthOrchestrationReadScope, - AuthReviewWriteScope, - AuthRelayWriteScope, - AuthTerminalOperateScope, - AuthAccessReadScope, AuthAccessStreamError, type AuthAccessStreamEvent, type AuthEnvironmentScope, @@ -54,6 +48,7 @@ import { RpcSerialization, RpcServer } from "effect/unstable/rpc"; import { CheckpointDiffQuery } from "./checkpointing/Services/CheckpointDiffQuery.ts"; import { ServerConfig } from "./config.ts"; +import { RPC_REQUIRED_SCOPE } from "./rpcRequiredScope.ts"; import { Keybindings } from "./keybindings.ts"; import * as ExternalLauncher from "./process/externalLauncher.ts"; import { normalizeDispatchCommand } from "./orchestration/Normalizer.ts"; @@ -129,62 +124,6 @@ function isThreadDetailEvent(event: OrchestrationEvent): event is Extract< const PROVIDER_STATUS_DEBOUNCE_MS = 200; -const RPC_REQUIRED_SCOPE = new Map([ - [ORCHESTRATION_WS_METHODS.dispatchCommand, AuthOrchestrationOperateScope], - [ORCHESTRATION_WS_METHODS.getTurnDiff, AuthOrchestrationReadScope], - [ORCHESTRATION_WS_METHODS.getFullThreadDiff, AuthOrchestrationReadScope], - [ORCHESTRATION_WS_METHODS.replayEvents, AuthOrchestrationReadScope], - [ORCHESTRATION_WS_METHODS.subscribeShell, AuthOrchestrationReadScope], - [ORCHESTRATION_WS_METHODS.getArchivedShellSnapshot, AuthOrchestrationReadScope], - [ORCHESTRATION_WS_METHODS.subscribeThread, AuthOrchestrationReadScope], - [WS_METHODS.serverGetConfig, AuthOrchestrationReadScope], - [WS_METHODS.serverRefreshProviders, AuthOrchestrationOperateScope], - [WS_METHODS.serverUpdateProvider, AuthOrchestrationOperateScope], - [WS_METHODS.serverUpsertKeybinding, AuthOrchestrationOperateScope], - [WS_METHODS.serverRemoveKeybinding, AuthOrchestrationOperateScope], - [WS_METHODS.serverGetSettings, AuthOrchestrationReadScope], - [WS_METHODS.serverUpdateSettings, AuthOrchestrationOperateScope], - [WS_METHODS.serverDiscoverSourceControl, AuthOrchestrationReadScope], - [WS_METHODS.serverGetTraceDiagnostics, AuthOrchestrationReadScope], - [WS_METHODS.serverGetProcessDiagnostics, AuthOrchestrationReadScope], - [WS_METHODS.serverGetProcessResourceHistory, AuthOrchestrationReadScope], - [WS_METHODS.serverSignalProcess, AuthOrchestrationOperateScope], - [WS_METHODS.cloudGetRelayClientStatus, AuthRelayWriteScope], - [WS_METHODS.cloudInstallRelayClient, AuthRelayWriteScope], - [WS_METHODS.sourceControlLookupRepository, AuthOrchestrationReadScope], - [WS_METHODS.sourceControlCloneRepository, AuthOrchestrationOperateScope], - [WS_METHODS.sourceControlPublishRepository, AuthOrchestrationOperateScope], - [WS_METHODS.projectsSearchEntries, AuthOrchestrationReadScope], - [WS_METHODS.projectsWriteFile, AuthOrchestrationOperateScope], - [WS_METHODS.shellOpenInEditor, AuthOrchestrationOperateScope], - [WS_METHODS.filesystemBrowse, AuthOrchestrationReadScope], - [WS_METHODS.subscribeVcsStatus, AuthOrchestrationReadScope], - [WS_METHODS.vcsRefreshStatus, AuthOrchestrationReadScope], - [WS_METHODS.vcsPull, AuthOrchestrationOperateScope], - [WS_METHODS.gitRunStackedAction, AuthOrchestrationOperateScope], - [WS_METHODS.gitResolvePullRequest, AuthOrchestrationOperateScope], - [WS_METHODS.gitPreparePullRequestThread, AuthOrchestrationOperateScope], - [WS_METHODS.vcsListRefs, AuthOrchestrationReadScope], - [WS_METHODS.vcsCreateWorktree, AuthOrchestrationOperateScope], - [WS_METHODS.vcsRemoveWorktree, AuthOrchestrationOperateScope], - [WS_METHODS.vcsCreateRef, AuthOrchestrationOperateScope], - [WS_METHODS.vcsSwitchRef, AuthOrchestrationOperateScope], - [WS_METHODS.vcsInit, AuthOrchestrationOperateScope], - [WS_METHODS.reviewGetDiffPreview, AuthReviewWriteScope], - [WS_METHODS.terminalOpen, AuthTerminalOperateScope], - [WS_METHODS.terminalAttach, AuthTerminalOperateScope], - [WS_METHODS.terminalWrite, AuthTerminalOperateScope], - [WS_METHODS.terminalResize, AuthTerminalOperateScope], - [WS_METHODS.terminalClear, AuthTerminalOperateScope], - [WS_METHODS.terminalRestart, AuthTerminalOperateScope], - [WS_METHODS.terminalClose, AuthTerminalOperateScope], - [WS_METHODS.subscribeTerminalEvents, AuthTerminalOperateScope], - [WS_METHODS.subscribeTerminalMetadata, AuthTerminalOperateScope], - [WS_METHODS.subscribeServerConfig, AuthOrchestrationReadScope], - [WS_METHODS.subscribeServerLifecycle, AuthOrchestrationReadScope], - [WS_METHODS.subscribeAuthAccess, AuthAccessReadScope], -]); - function toAuthAccessStreamEvent( change: PairingGrantStore.BootstrapCredentialChange | SessionStore.SessionCredentialChange, revision: number, @@ -1214,6 +1153,47 @@ const makeWsRpcLayer = (currentSession: AuthenticatedSession) => ), { "rpc.aggregate": "git" }, ), + [WS_METHODS.vcsFetch]: (input) => + observeRpcEffect( + WS_METHODS.vcsFetch, + gitWorkflow.fetchCurrentBranch(input.cwd).pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => Effect.failCause(cause), + onSuccess: (result) => + refreshGitStatus(input.cwd).pipe(Effect.ignore({ log: true }), Effect.as(result)), + }), + ), + { "rpc.aggregate": "git" }, + ), + [WS_METHODS.vcsPush]: (input) => + observeRpcEffect( + WS_METHODS.vcsPush, + gitWorkflow.pushCurrentBranch(input.cwd).pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => Effect.failCause(cause), + onSuccess: (result) => + refreshGitStatus(input.cwd).pipe(Effect.ignore({ log: true }), Effect.as(result)), + }), + ), + { "rpc.aggregate": "git" }, + ), + [WS_METHODS.vcsSync]: (input) => + observeRpcEffect( + WS_METHODS.vcsSync, + gitWorkflow + .syncCurrentBranch(input.cwd, input.mode ? { mode: input.mode } : undefined) + .pipe( + Effect.matchCauseEffect({ + onFailure: (cause) => Effect.failCause(cause), + onSuccess: (result) => + refreshGitStatus(input.cwd).pipe( + Effect.ignore({ log: true }), + Effect.as(result), + ), + }), + ), + { "rpc.aggregate": "git" }, + ), [WS_METHODS.gitRunStackedAction]: (input) => observeRpcStream( WS_METHODS.gitRunStackedAction, diff --git a/apps/web/src/components/BranchToolbarBranchSelector.tsx b/apps/web/src/components/BranchToolbarBranchSelector.tsx index 72391f714fc..9c1c6346681 100644 --- a/apps/web/src/components/BranchToolbarBranchSelector.tsx +++ b/apps/web/src/components/BranchToolbarBranchSelector.tsx @@ -16,6 +16,7 @@ import { import { useComposerDraftStore, type DraftId } from "../composerDraftStore"; import { readEnvironmentApi } from "../environmentApi"; +import { useSourceControlActionRunning } from "../lib/sourceControlActions"; import { useVcsStatus } from "../lib/vcsStatusState"; import { useVcsRefs, vcsRefManager } from "../lib/vcsRefState"; import { newCommandId } from "../lib/utils"; @@ -32,6 +33,7 @@ import { resolveEffectiveEnvMode, shouldIncludeBranchPickerItem, } from "./BranchToolbar.logic"; +import { GitSyncControl, SYNC_BUSY_ACTIONS } from "./GitSyncControl"; import { Button } from "./ui/button"; import { Combobox, @@ -202,6 +204,10 @@ export function BranchToolbarBranchSelector({ const deferredBranchQuery = useDeferredValue(branchQuery); const branchStatusQuery = useVcsStatus({ environmentId, cwd: branchCwd }); + // Block ref switches/creates while a sync action (fetch/push/pull/sync) is in + // flight on the same cwd — checking out mid-operation would race the git op. + const syncScope = useMemo(() => ({ environmentId, cwd: branchCwd }), [environmentId, branchCwd]); + const isSyncBusy = useSourceControlActionRunning(syncScope, SYNC_BUSY_ACTIONS); const trimmedBranchQuery = branchQuery.trim(); const deferredTrimmedBranchQuery = deferredBranchQuery.trim(); const branchRefTarget = useMemo( @@ -592,88 +598,95 @@ export function BranchToolbarBranchSelector({ } return ( - { - if (!isBranchMenuOpen || eventDetails.index < 0 || eventDetails.reason !== "keyboard") { - return; - } - branchListRef.current?.scrollIndexIntoView?.({ - index: eventDetails.index, - animated: false, - }); - }} - onOpenChange={handleOpenChange} - open={isBranchMenuOpen} - value={resolvedActiveBranch} - > - } - className={cn("min-w-0 text-muted-foreground/70 hover:text-foreground/80", className)} - disabled={isInitialBranchesLoadPending || isBranchActionPending} +
+ { + if (!isBranchMenuOpen || eventDetails.index < 0 || eventDetails.reason !== "keyboard") { + return; + } + branchListRef.current?.scrollIndexIntoView?.({ + index: eventDetails.index, + animated: false, + }); + }} + onOpenChange={handleOpenChange} + open={isBranchMenuOpen} + value={resolvedActiveBranch} > - - {triggerLabel} - - - -
-
-
-
-
- No refs found. -
- - - ref={branchListRef} - data={filteredBranchPickerItems} - keyExtractor={(item) => item} - renderItem={({ item, index }) => renderPickerItem(item, index)} - estimatedItemSize={28} - drawDistance={336} - onEndReached={() => { - if (hasNextPage && !isFetchingNextPage) { - fetchNextBranchPage(); - } - }} - onLayout={() => { - updateBranchListScrollFades(); - maybeFetchNextBranchPage(); - }} - onScroll={() => { - updateBranchListScrollFades(); - maybeFetchNextBranchPage(); - }} - className={cn( - "scrollbar-gutter-stable overflow-x-hidden overscroll-y-contain ps-1 pe-0 pt-2 pb-1 [--fade-size:1.5rem]", - showTopBranchScrollFade && "mask-t-from-[calc(100%-var(--fade-size))]", - showBottomBranchScrollFade && "mask-b-from-[calc(100%-var(--fade-size))]", - )} - style={{ maxHeight: "14rem" }} + } + className="min-w-0 text-muted-foreground/70 hover:text-foreground/80" + disabled={isInitialBranchesLoadPending || isBranchActionPending || isSyncBusy} + > + + {triggerLabel} + + + +
+
+
- {branchStatusText ? {branchStatusText} : null} -
- - +
+ No refs found. +
+ + + ref={branchListRef} + data={filteredBranchPickerItems} + keyExtractor={(item) => item} + renderItem={({ item, index }) => renderPickerItem(item, index)} + estimatedItemSize={28} + drawDistance={336} + onEndReached={() => { + if (hasNextPage && !isFetchingNextPage) { + fetchNextBranchPage(); + } + }} + onLayout={() => { + updateBranchListScrollFades(); + maybeFetchNextBranchPage(); + }} + onScroll={() => { + updateBranchListScrollFades(); + maybeFetchNextBranchPage(); + }} + className={cn( + "scrollbar-gutter-stable overflow-x-hidden overscroll-y-contain ps-1 pe-0 pt-2 pb-1 [--fade-size:1.5rem]", + showTopBranchScrollFade && "mask-t-from-[calc(100%-var(--fade-size))]", + showBottomBranchScrollFade && "mask-b-from-[calc(100%-var(--fade-size))]", + )} + style={{ maxHeight: "14rem" }} + /> + +
+ {branchStatusText ? {branchStatusText} : null} +
+ + + +
); } diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 6a8dc53fbca..07c64f70824 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -128,7 +128,14 @@ interface RunGitActionWithToastInput { } const GIT_STATUS_WINDOW_REFRESH_DEBOUNCE_MS = 250; -const RUNNING_SOURCE_CONTROL_ACTIONS = ["runStackedAction", "pull", "publishRepository"] as const; +const RUNNING_SOURCE_CONTROL_ACTIONS = [ + "runStackedAction", + "pull", + "fetch", + "push", + "sync", + "publishRepository", +] as const; const PUBLISH_PROVIDER_OPTIONS = [ { diff --git a/apps/web/src/components/GitSyncControl.tsx b/apps/web/src/components/GitSyncControl.tsx new file mode 100644 index 00000000000..229c52a0697 --- /dev/null +++ b/apps/web/src/components/GitSyncControl.tsx @@ -0,0 +1,271 @@ +import type { EnvironmentId, VcsStatusResult } from "@t3tools/contracts"; +import { ArrowDownIcon, ArrowUpIcon, CheckIcon, RefreshCwIcon, UploadIcon } from "lucide-react"; +import { useCallback, useMemo, type ReactNode } from "react"; + +import { + useSourceControlActionRunning, + useVcsFetchAction, + useVcsPullAction, + useVcsPushAction, + useVcsSyncAction, +} from "../lib/sourceControlActions"; +import { cn } from "../lib/utils"; +import { Button } from "./ui/button"; +import { Spinner } from "./ui/spinner"; +import { stackedThreadToast, toastManager } from "./ui/toast"; + +interface GitSyncControlProps { + environmentId: EnvironmentId; + cwd: string | null; + /** Latest VCS status (reused from the branch selector — no extra subscription). */ + status: VcsStatusResult | null; + className?: string; +} + +// Kept in sync with the action kinds this control can launch so any one of them +// (including a concurrent stacked action) disables the whole control. Exported so +// the branch picker can disable ref switches while a sync op is in flight. +export const SYNC_BUSY_ACTIONS = ["runStackedAction", "pull", "fetch", "push", "sync"] as const; + +function errorMessage(error: unknown): string { + return error instanceof Error && error.message.trim().length > 0 + ? error.message + : "An error occurred."; +} + +function isDivergedError( + error: unknown, +): error is { _tag: "GitDivergedError"; refName: string; aheadCount: number; behindCount: number } { + return ( + typeof error === "object" && + error !== null && + "_tag" in error && + (error as { _tag?: unknown })._tag === "GitDivergedError" + ); +} + +/** + * VS Code-style ahead/behind indicator + one-click sync, shown next to the + * branch in the composer toolbar. States: publish (no upstream), pull (behind), + * push (ahead), sync (both), or up-to-date. A standalone Fetch button forces an + * immediate refresh of the ahead/behind counts. + */ +export function GitSyncControl({ environmentId, cwd, status, className }: GitSyncControlProps) { + const scope = useMemo(() => ({ environmentId, cwd }), [environmentId, cwd]); + const fetchAction = useVcsFetchAction(scope); + const pullAction = useVcsPullAction(scope); + const pushAction = useVcsPushAction(scope); + const syncAction = useVcsSyncAction(scope); + const isRunning = useSourceControlActionRunning(scope, SYNC_BUSY_ACTIONS); + + const onFetch = useCallback(() => { + const promise = fetchAction.run(); + void toastManager.promise>>(promise, { + loading: { title: "Fetching..." }, + // vcs.fetch only updates remote-tracking refs; the branch may now be ahead + // or behind, so don't claim "Up to date" here — the badge reflects the state. + success: () => ({ title: "Fetched", description: "Updated remote-tracking refs." }), + error: (err) => ({ title: "Fetch failed", description: errorMessage(err) }), + }); + void promise.catch(() => undefined); + }, [fetchAction]); + + const onPull = useCallback(() => { + const promise = pullAction.run(); + void toastManager.promise>>(promise, { + loading: { title: "Pulling..." }, + success: (result) => ({ + title: result.status === "pulled" ? "Pulled" : "Already up to date", + description: + result.status === "pulled" + ? `Updated ${result.refName} from ${result.upstreamRef ?? "upstream"}` + : `${result.refName} is already synchronized.`, + }), + error: (err) => ({ title: "Pull failed", description: errorMessage(err) }), + }); + void promise.catch(() => undefined); + }, [pullAction]); + + const onPush = useCallback( + (publish: boolean) => { + const promise = pushAction.run(); + void toastManager.promise>>(promise, { + loading: { title: publish ? "Publishing branch..." : "Pushing..." }, + success: (result) => ({ + title: result.setUpstream + ? "Branch published" + : result.status === "pushed" + ? "Pushed" + : "Already up to date", + description: result.upstreamRef + ? `${result.refName} → ${result.upstreamRef}` + : result.refName, + }), + error: (err) => ({ + title: publish ? "Publish failed" : "Push failed", + description: errorMessage(err), + }), + }); + void promise.catch(() => undefined); + }, + [pushAction], + ); + + const onSync = useCallback( + (mode?: "rebase") => { + const promise = syncAction.run(mode ? { mode } : undefined); + void toastManager.promise>>(promise, { + loading: { title: mode === "rebase" ? "Rebasing..." : "Syncing..." }, + success: (result) => ({ + title: "Synced", + description: describeSyncResult(result), + }), + error: (err) => + isDivergedError(err) + ? stackedThreadToast({ + type: "error", + title: "Branch has diverged", + description: `Local and upstream both changed (${err.aheadCount} ahead, ${err.behindCount} behind). A fast-forward isn't possible.`, + data: { + secondaryActionProps: { + children: "Rebase & sync", + onClick: () => onSync("rebase"), + }, + secondaryActionVariant: "outline", + }, + }) + : { title: "Sync failed", description: errorMessage(err) }, + }); + void promise.catch(() => undefined); + }, + [syncAction], + ); + + // Only meaningful inside a git repo that has a remote to sync against. + if (!cwd || !status?.isRepo || !status.hasPrimaryRemote) { + return null; + } + + const hasUpstream = status.hasUpstream; + const ahead = status.aheadCount; + const behind = status.behindCount; + const busy = + isRunning || + fetchAction.isPending || + pullAction.isPending || + pushAction.isPending || + syncAction.isPending; + + let primary: { content: ReactNode; title: string; onClick: () => void } | null = null; + if (!hasUpstream) { + primary = { + content: ( + <> + {busy ? : } + Publish + + ), + title: "Publish branch (push and set upstream)", + onClick: () => onPush(true), + }; + } else if (ahead > 0 && behind > 0) { + primary = { + content: ( + <> + {busy ? : } + + + ), + title: `Sync: pull ${behind} and push ${ahead}`, + onClick: () => onSync(), + }; + } else if (behind > 0) { + primary = { + content: ( + <> + {busy ? : } + {behind} + + ), + title: `Pull ${behind} commit${behind === 1 ? "" : "s"} from upstream`, + onClick: onPull, + }; + } else if (ahead > 0) { + primary = { + content: ( + <> + {busy ? : } + {ahead} + + ), + title: `Push ${ahead} commit${ahead === 1 ? "" : "s"} to upstream`, + onClick: () => onPush(false), + }; + } + + return ( +
+ {primary ? ( + + ) : ( + + + + )} + +
+ ); +} + +function SyncCounts({ ahead, behind }: { ahead: number; behind: number }) { + return ( + + + + {behind} + + + + {ahead} + + + ); +} + +function describeSyncResult(result: { + pull: "pulled" | "rebased" | "skipped_up_to_date" | "skipped"; + push: "pushed" | "skipped"; + setUpstream: boolean; +}): string { + const parts: string[] = []; + if (result.pull === "pulled") parts.push("pulled"); + else if (result.pull === "rebased") parts.push("rebased"); + if (result.push === "pushed") parts.push(result.setUpstream ? "published" : "pushed"); + return parts.length > 0 ? `Branch ${parts.join(" and ")}.` : "Already up to date."; +} diff --git a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts index 675a4868032..f0cd8bfefda 100644 --- a/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts +++ b/apps/web/src/environments/runtime/service.threadSubscriptions.test.ts @@ -121,6 +121,9 @@ vi.mock("@t3tools/client-runtime", async (importOriginal) => { }, vcs: { pull: vi.fn(), + fetch: vi.fn(), + push: vi.fn(), + sync: vi.fn(), refreshStatus: vi.fn(), onStatus: vi.fn(() => () => undefined), listRefs: vi.fn(), diff --git a/apps/web/src/lib/sourceControlActions.ts b/apps/web/src/lib/sourceControlActions.ts index 917b8c3a9b2..6cab0fce9df 100644 --- a/apps/web/src/lib/sourceControlActions.ts +++ b/apps/web/src/lib/sourceControlActions.ts @@ -18,7 +18,11 @@ import { type SourceControlPublishRepositoryResult, type SourceControlRepositoryVisibility, type ThreadId, + type VcsFetchResult, type VcsPullResult, + type VcsPushResult, + type VcsSyncInput, + type VcsSyncResult, } from "@t3tools/contracts"; import { useCallback, @@ -38,6 +42,9 @@ import { vcsRefManager } from "./vcsRefState"; type SourceControlActionKind = | "init" | "pull" + | "fetch" + | "push" + | "sync" | "publishRepository" | "runStackedAction" | "preparePullRequestThread"; @@ -115,6 +122,12 @@ function getVcsActionOperationForKind(kind: SourceControlActionKind): VcsActionO return "init"; case "pull": return "pull"; + case "fetch": + return "fetch"; + case "push": + return "push"; + case "sync": + return "sync"; case "runStackedAction": return "run_change_request"; case "publishRepository": @@ -332,6 +345,51 @@ export function useVcsPullAction(scope: SourceControlActionScope) { }); } +export function useVcsFetchAction(scope: SourceControlActionScope) { + const action = useCallback(async (): Promise => { + if (!scope.cwd || !scope.environmentId) throw new Error("Git fetch is unavailable."); + return vcsActionManager.fetch(scope); + }, [scope]); + + return useVcsManagerAction({ + operation: "fetch", + scope, + unavailableMessage: "Git fetch is unavailable.", + action, + }); +} + +export function useVcsPushAction(scope: SourceControlActionScope) { + const action = useCallback(async (): Promise => { + if (!scope.cwd || !scope.environmentId) throw new Error("Git push is unavailable."); + return vcsActionManager.push(scope); + }, [scope]); + + return useVcsManagerAction({ + operation: "push", + scope, + unavailableMessage: "Git push is unavailable.", + action, + }); +} + +export function useVcsSyncAction(scope: SourceControlActionScope) { + const action = useCallback( + async (input?: Omit): Promise => { + if (!scope.cwd || !scope.environmentId) throw new Error("Git sync is unavailable."); + return vcsActionManager.sync(scope, input); + }, + [scope], + ); + + return useVcsManagerAction({ + operation: "sync", + scope, + unavailableMessage: "Git sync is unavailable.", + action, + }); +} + export function useSourceControlPublishRepositoryAction(scope: SourceControlActionScope) { const action = useCallback( async (args: { diff --git a/packages/client-runtime/src/vcsActionState.test.ts b/packages/client-runtime/src/vcsActionState.test.ts index f653b26b34f..63029a97d22 100644 --- a/packages/client-runtime/src/vcsActionState.test.ts +++ b/packages/client-runtime/src/vcsActionState.test.ts @@ -4,7 +4,10 @@ import { type GitRunStackedActionResult, type VcsCreateRefResult, type VcsCreateWorktreeResult, + type VcsFetchResult, type VcsPullResult, + type VcsPushResult, + type VcsSyncResult, type VcsStatusResult, type VcsSwitchRefResult, } from "@t3tools/contracts"; @@ -122,6 +125,9 @@ function createActionFinishedEvent(): Extract(); const pullDeferred = createDeferred(); + const fetchDeferred = createDeferred(); + const pushDeferred = createDeferred(); + const syncDeferred = createDeferred(); const switchRefDeferred = createDeferred(); const createRefDeferred = createDeferred(); const createWorktreeDeferred = createDeferred(); @@ -132,6 +138,9 @@ function createMockClient() { const client: VcsActionClient = { refreshStatus: vi.fn(() => refreshDeferred.promise), pull: vi.fn(() => pullDeferred.promise), + fetch: vi.fn(() => fetchDeferred.promise), + push: vi.fn(() => pushDeferred.promise), + sync: vi.fn(() => syncDeferred.promise), switchRef: vi.fn(() => switchRefDeferred.promise), createRef: vi.fn(() => createRefDeferred.promise), createWorktree: vi.fn(() => createWorktreeDeferred.promise), @@ -146,6 +155,9 @@ function createMockClient() { client, refreshDeferred, pullDeferred, + fetchDeferred, + pushDeferred, + syncDeferred, switchRefDeferred, createRefDeferred, createWorktreeDeferred, diff --git a/packages/client-runtime/src/vcsActionState.ts b/packages/client-runtime/src/vcsActionState.ts index 5ff545b4596..e77e4dd8816 100644 --- a/packages/client-runtime/src/vcsActionState.ts +++ b/packages/client-runtime/src/vcsActionState.ts @@ -8,8 +8,14 @@ import type { VcsCreateRefResult, VcsCreateWorktreeInput, VcsCreateWorktreeResult, + VcsFetchInput, + VcsFetchResult, VcsPullInput, VcsPullResult, + VcsPushInput, + VcsPushResult, + VcsSyncInput, + VcsSyncResult, VcsStatusResult, VcsSwitchRefInput, VcsSwitchRefResult, @@ -24,6 +30,9 @@ export type VcsActionOperation = | "refresh_status" | "run_change_request" | "pull" + | "fetch" + | "push" + | "sync" | "switch_ref" | "create_ref" | "create_worktree" @@ -50,7 +59,15 @@ export interface VcsActionTarget { export type VcsActionClient = Pick< WsRpcClient["vcs"], - "refreshStatus" | "pull" | "switchRef" | "createRef" | "createWorktree" | "init" + | "refreshStatus" + | "pull" + | "fetch" + | "push" + | "sync" + | "switchRef" + | "createRef" + | "createWorktree" + | "init" > & { readonly runChangeRequest: WsRpcClient["git"]["runStackedAction"]; }; @@ -322,6 +339,50 @@ export function createVcsActionManager(config: VcsActionManagerConfig) { }); } + async function fetch( + target: VcsActionTarget, + client?: VcsActionClient, + options?: { readonly label?: string }, + ): Promise { + return runOperation(target, { + operation: "fetch", + label: options?.label ?? "Fetching from remote", + client, + execute: (resolved) => resolved.fetch({ cwd: target.cwd! } satisfies VcsFetchInput), + }); + } + + async function push( + target: VcsActionTarget, + client?: VcsActionClient, + options?: { readonly label?: string }, + ): Promise { + return runOperation(target, { + operation: "push", + label: options?.label ?? "Pushing to remote", + client, + execute: (resolved) => resolved.push({ cwd: target.cwd! } satisfies VcsPushInput), + }); + } + + async function sync( + target: VcsActionTarget, + input?: Omit, + client?: VcsActionClient, + options?: { readonly label?: string }, + ): Promise { + return runOperation(target, { + operation: "sync", + label: options?.label ?? "Syncing with remote", + client, + execute: (resolved) => + resolved.sync({ + cwd: target.cwd!, + ...(input?.mode ? { mode: input.mode } : {}), + } satisfies VcsSyncInput), + }); + } + async function switchRef( target: VcsActionTarget, input: Omit, @@ -448,6 +509,9 @@ export function createVcsActionManager(config: VcsActionManagerConfig) { getSnapshot, refreshStatus, pull, + fetch, + push, + sync, switchRef, createRef, createWorktree, diff --git a/packages/client-runtime/src/wsRpcClient.ts b/packages/client-runtime/src/wsRpcClient.ts index c1c683616b2..05e7119dcab 100644 --- a/packages/client-runtime/src/wsRpcClient.ts +++ b/packages/client-runtime/src/wsRpcClient.ts @@ -99,6 +99,9 @@ export interface WsRpcClient { }; readonly vcs: { readonly pull: RpcUnaryMethod; + readonly fetch: RpcUnaryMethod; + readonly push: RpcUnaryMethod; + readonly sync: RpcUnaryMethod; readonly refreshStatus: RpcUnaryMethod; readonly onStatus: ( input: RpcInput, @@ -235,6 +238,9 @@ export function createWsRpcClient( }, vcs: { pull: (input) => transport.request((client) => client[WS_METHODS.vcsPull](input)), + fetch: (input) => transport.request((client) => client[WS_METHODS.vcsFetch](input)), + push: (input) => transport.request((client) => client[WS_METHODS.vcsPush](input)), + sync: (input) => transport.request((client) => client[WS_METHODS.vcsSync](input)), refreshStatus: (input) => transport.request((client) => client[WS_METHODS.vcsRefreshStatus](input)), onStatus: (input, listener, options) => { diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index e8e9a4ecc1a..45cc2ac1cf6 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -109,6 +109,27 @@ export const VcsPullInput = Schema.Struct({ }); export type VcsPullInput = typeof VcsPullInput.Type; +export const VcsFetchInput = Schema.Struct({ + cwd: TrimmedNonEmptyStringSchema, +}); +export type VcsFetchInput = typeof VcsFetchInput.Type; + +export const VcsPushInput = Schema.Struct({ + cwd: TrimmedNonEmptyStringSchema, +}); +export type VcsPushInput = typeof VcsPushInput.Type; + +export const VcsSyncMode = Schema.Literals(["ff", "rebase"]); +export type VcsSyncMode = typeof VcsSyncMode.Type; + +export const VcsSyncInput = Schema.Struct({ + cwd: TrimmedNonEmptyStringSchema, + // "ff" (default) refuses to integrate diverged history; "rebase" replays local + // commits onto the upstream after the user explicitly confirms it. + mode: Schema.optional(VcsSyncMode), +}); +export type VcsSyncInput = typeof VcsSyncInput.Type; + export const GitRunStackedActionInput = Schema.Struct({ actionId: TrimmedNonEmptyStringSchema, cwd: TrimmedNonEmptyStringSchema, @@ -316,6 +337,31 @@ export const VcsPullResult = Schema.Struct({ }); export type VcsPullResult = typeof VcsPullResult.Type; +export const VcsFetchResult = Schema.Struct({ + refName: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), + hasUpstream: Schema.Boolean, +}); +export type VcsFetchResult = typeof VcsFetchResult.Type; + +export const VcsPushResult = Schema.Struct({ + status: Schema.Literals(["pushed", "skipped_up_to_date"]), + refName: TrimmedNonEmptyStringSchema, + upstreamRef: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), + // True when this push created the upstream (publish / set -u) rather than + // pushing to an already-configured upstream. + setUpstream: Schema.Boolean, +}); +export type VcsPushResult = typeof VcsPushResult.Type; + +export const VcsSyncResult = Schema.Struct({ + refName: TrimmedNonEmptyStringSchema, + fetched: Schema.Boolean, + pull: Schema.Literals(["pulled", "rebased", "skipped_up_to_date", "skipped"]), + push: Schema.Literals(["pushed", "skipped"]), + setUpstream: Schema.Boolean, +}); +export type VcsSyncResult = typeof VcsSyncResult.Type; + // RPC / domain errors export class GitCommandError extends Schema.TaggedErrorClass()("GitCommandError", { operation: Schema.String, @@ -329,6 +375,23 @@ export class GitCommandError extends Schema.TaggedErrorClass()( } } +// Raised when `vcs.sync` cannot fast-forward because local and upstream history +// have diverged. The client surfaces this distinctly to offer an explicit rebase. +export class GitDivergedError extends Schema.TaggedErrorClass()( + "GitDivergedError", + { + operation: Schema.String, + cwd: Schema.String, + refName: Schema.String, + aheadCount: NonNegativeInt, + behindCount: NonNegativeInt, + }, +) { + override get message(): string { + return `Branch '${this.refName}' has diverged from its upstream (${this.aheadCount} ahead, ${this.behindCount} behind). Rebase or merge before syncing.`; + } +} + export class TextGenerationError extends Schema.TaggedErrorClass()( "TextGenerationError", { diff --git a/packages/contracts/src/rpc.ts b/packages/contracts/src/rpc.ts index 5a145f3f657..18e072e3f0b 100644 --- a/packages/contracts/src/rpc.ts +++ b/packages/contracts/src/rpc.ts @@ -18,6 +18,13 @@ import { VcsSwitchRefInput, VcsSwitchRefResult, GitCommandError, + GitDivergedError, + VcsFetchInput, + VcsFetchResult, + VcsPushInput, + VcsPushResult, + VcsSyncInput, + VcsSyncResult, VcsCreateRefInput, VcsCreateRefResult, VcsCreateWorktreeInput, @@ -132,6 +139,9 @@ export const WS_METHODS = { // VCS methods vcsPull: "vcs.pull", + vcsFetch: "vcs.fetch", + vcsPush: "vcs.push", + vcsSync: "vcs.sync", vcsRefreshStatus: "vcs.refreshStatus", vcsListRefs: "vcs.listRefs", vcsCreateWorktree: "vcs.createWorktree", @@ -345,6 +355,24 @@ export const WsVcsPullRpc = Rpc.make(WS_METHODS.vcsPull, { error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), }); +export const WsVcsFetchRpc = Rpc.make(WS_METHODS.vcsFetch, { + payload: VcsFetchInput, + success: VcsFetchResult, + error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), +}); + +export const WsVcsPushRpc = Rpc.make(WS_METHODS.vcsPush, { + payload: VcsPushInput, + success: VcsPushResult, + error: Schema.Union([GitCommandError, EnvironmentAuthorizationError]), +}); + +export const WsVcsSyncRpc = Rpc.make(WS_METHODS.vcsSync, { + payload: VcsSyncInput, + success: VcsSyncResult, + error: Schema.Union([GitCommandError, GitDivergedError, EnvironmentAuthorizationError]), +}); + export const WsVcsRefreshStatusRpc = Rpc.make(WS_METHODS.vcsRefreshStatus, { payload: VcsStatusInput, success: VcsStatusResult, @@ -569,6 +597,9 @@ export const WsRpcGroup = RpcGroup.make( WsFilesystemBrowseRpc, WsSubscribeVcsStatusRpc, WsVcsPullRpc, + WsVcsFetchRpc, + WsVcsPushRpc, + WsVcsSyncRpc, WsVcsRefreshStatusRpc, WsGitRunStackedActionRpc, WsGitResolvePullRequestRpc,