diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index b5a3a3fb3..c52922219 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -1823,20 +1823,58 @@ it.layer(TestLayer)("git integration", (it) => { yield* git(clone, ["push", "origin", initialBranch]); const core = yield* GitCore; - const pulled = yield* core.pullCurrentBranch(source); + const pulled = yield* core.syncCurrentBranch(source); expect(pulled.status).toBe("pulled"); + expect(pulled.strategy).toBe("pull"); expect((yield* core.statusDetails(source)).behindCount).toBe(0); - const skipped = yield* core.pullCurrentBranch(source); + const skipped = yield* core.syncCurrentBranch(source); expect(skipped.status).toBe("skipped_up_to_date"); }), ); + it.effect("rebases diverged branches and reports conflicts", () => + Effect.gen(function* () { + const remote = yield* makeTmpDir(); + const source = yield* makeTmpDir(); + const clone = yield* makeTmpDir(); + yield* git(remote, ["init", "--bare"]); + + yield* initRepoWithCommit(source); + const initialBranch = (yield* (yield* GitCore).listBranches({ cwd: source })).branches.find( + (branch) => branch.current, + )!.name; + yield* git(source, ["remote", "add", "origin", remote]); + yield* git(source, ["push", "-u", "origin", initialBranch]); + + yield* writeTextFile(path.join(source, "README.md"), "local change\n"); + yield* git(source, ["add", "README.md"]); + yield* git(source, ["commit", "-m", "local update"]); + + yield* git(clone, ["clone", remote, "."]); + yield* git(clone, ["config", "user.email", "test@test.com"]); + yield* git(clone, ["config", "user.name", "Test"]); + yield* writeTextFile(path.join(clone, "README.md"), "remote change\n"); + yield* git(clone, ["add", "README.md"]); + yield* git(clone, ["commit", "-m", "remote update"]); + yield* git(clone, ["push", "origin", initialBranch]); + + const core = yield* GitCore; + const result = yield* core.syncCurrentBranch(source); + + expect(result.status).toBe("conflicted"); + expect(result.strategy).toBe("rebase"); + expect(result.hasConflicts).toBe(true); + expect(result.conflictedFiles).toContain("README.md"); + expect((yield* core.statusDetails(source)).hasConflicts).toBe(true); + }), + ); + it.effect("top-level pullGitBranch rejects when no upstream exists", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); yield* initRepoWithCommit(tmp); - const result = yield* Effect.result((yield* GitCore).pullCurrentBranch(tmp)); + const result = yield* Effect.result((yield* GitCore).syncCurrentBranch(tmp)); expect(result._tag).toBe("Failure"); if (result._tag === "Failure") { expect(result.failure.message.toLowerCase()).toContain("no upstream"); diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index e1d721447..97d065dd1 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -1421,38 +1421,74 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute" }; }); - const pullCurrentBranch: GitCoreShape["pullCurrentBranch"] = (cwd) => + const syncCurrentBranch: GitCoreShape["syncCurrentBranch"] = (cwd) => Effect.gen(function* () { const details = yield* statusDetails(cwd); const branch = details.branch; if (!branch) { return yield* createGitCommandError( - "GitCore.pullCurrentBranch", + "GitCore.syncCurrentBranch", cwd, ["pull", "--ff-only"], - "Cannot pull from detached HEAD.", + "Cannot sync from detached HEAD.", ); } if (!details.hasUpstream) { return yield* createGitCommandError( - "GitCore.pullCurrentBranch", + "GitCore.syncCurrentBranch", cwd, ["pull", "--ff-only"], "Current branch has no upstream configured. Push with upstream first.", ); } + if (details.hasConflicts) { + return yield* createGitCommandError( + "GitCore.syncCurrentBranch", + cwd, + ["status", "--porcelain=v2", "--branch"], + "Resolve existing merge conflicts before syncing this branch.", + ); + } const beforeSha = yield* runGitStdout( - "GitCore.pullCurrentBranch.beforeSha", + "GitCore.syncCurrentBranch.beforeSha", cwd, ["rev-parse", "HEAD"], true, ).pipe(Effect.map((stdout) => stdout.trim())); - yield* executeGit("GitCore.pullCurrentBranch.pull", cwd, ["pull", "--ff-only"], { + const strategy = details.aheadCount > 0 && details.behindCount > 0 ? "rebase" : "pull"; + const args = + strategy === "rebase" + ? (["pull", "--rebase"] as const) + : (["pull", "--ff-only"] as const); + const syncResult = yield* executeGit("GitCore.syncCurrentBranch.pull", cwd, args, { timeoutMs: 30_000, + allowNonZeroExit: true, fallbackErrorMessage: "git pull failed", }); + if (syncResult.code !== 0) { + const refreshed = yield* statusDetails(cwd).pipe( + Effect.catch(() => Effect.succeed(details)), + ); + if (strategy === "rebase" && refreshed.hasConflicts) { + return { + status: "conflicted" as const, + strategy, + cwd, + branch, + upstreamBranch: refreshed.upstreamRef, + hasConflicts: true, + conflictedFiles: refreshed.conflictedFiles, + }; + } + return yield* createGitCommandError( + "GitCore.syncCurrentBranch", + cwd, + args, + syncResult.stderr.trim().length > 0 ? syncResult.stderr.trim() : "git pull failed", + ); + } const afterSha = yield* runGitStdout( - "GitCore.pullCurrentBranch.afterSha", + "GitCore.syncCurrentBranch.afterSha", cwd, ["rev-parse", "HEAD"], true, @@ -1460,9 +1496,18 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute" const refreshed = yield* statusDetails(cwd); return { - status: beforeSha.length > 0 && beforeSha === afterSha ? "skipped_up_to_date" : "pulled", + status: + beforeSha.length > 0 && beforeSha === afterSha + ? ("skipped_up_to_date" as const) + : strategy === "rebase" + ? ("rebased" as const) + : ("pulled" as const), + strategy, + cwd, branch, upstreamBranch: refreshed.upstreamRef, + hasConflicts: refreshed.hasConflicts, + conflictedFiles: refreshed.conflictedFiles, }; }); @@ -1994,7 +2039,7 @@ export const makeGitCore = (options?: { executeOverride?: GitCoreShape["execute" prepareCommitContext, commit, pushCurrentBranch, - pullCurrentBranch, + syncCurrentBranch, readRangeContext, readConfigValue, listBranches, diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts index 20460c9e2..c72b741dc 100644 --- a/apps/server/src/git/Services/GitCore.ts +++ b/apps/server/src/git/Services/GitCore.ts @@ -199,9 +199,10 @@ export interface GitCoreShape { ) => Effect.Effect; /** - * Pull current branch from upstream using fast-forward only. + * Synchronize the current branch with its upstream using fast-forward pull + * when cleanly behind and rebase when the branch has diverged. */ - readonly pullCurrentBranch: (cwd: string) => Effect.Effect; + readonly syncCurrentBranch: (cwd: string) => Effect.Effect; /** * Create a worktree and branch from a base branch. diff --git a/apps/server/src/wsServer.test.ts b/apps/server/src/wsServer.test.ts index 0b56e1e29..d87563e21 100644 --- a/apps/server/src/wsServer.test.ts +++ b/apps/server/src/wsServer.test.ts @@ -500,7 +500,7 @@ describe("WebSocket Server", () => { providerHealth?: ProviderHealthShape; open?: OpenShape; gitManager?: GitManagerShape; - gitCore?: Pick; + gitCore?: Pick; terminalManager?: TerminalManagerShape; } = {}, ): Promise { @@ -1805,10 +1805,10 @@ describe("WebSocket Server", () => { }), ); const initRepo = vi.fn(() => Effect.void); - const pullCurrentBranch = vi.fn(() => + const syncCurrentBranch = vi.fn(() => Effect.fail( new GitCommandError({ - operation: "GitCore.test.pullCurrentBranch", + operation: "GitCore.test.syncCurrentBranch", detail: "No upstream configured", command: "git pull", cwd: "/repo/path", @@ -1822,7 +1822,7 @@ describe("WebSocket Server", () => { gitCore: { listBranches, initRepo, - pullCurrentBranch, + syncCurrentBranch, }, }); const addr = server.address(); @@ -1843,7 +1843,7 @@ describe("WebSocket Server", () => { const pullResponse = await sendRequest(ws, WS_METHODS.gitPull, { cwd: "/repo/path" }); expect(pullResponse.result).toBeUndefined(); expect(pullResponse.error?.message).toContain("No upstream configured"); - expect(pullCurrentBranch).toHaveBeenCalledWith("/repo/path"); + expect(syncCurrentBranch).toHaveBeenCalledWith("/repo/path"); }); it("supports git.status over websocket", async () => { diff --git a/apps/server/src/wsServer.ts b/apps/server/src/wsServer.ts index 18eeb5749..ec7768d5d 100644 --- a/apps/server/src/wsServer.ts +++ b/apps/server/src/wsServer.ts @@ -1133,7 +1133,7 @@ export const createServer = Effect.fn(function* (): Effect.fn.Return< const snapshot = yield* projectionReadModelQuery.getSnapshot(); const gitEnv = yield* resolveRuntimeEnvironment({ cwd: body.cwd, readModel: snapshot }); return yield* git - .pullCurrentBranch(body.cwd) + .syncCurrentBranch(body.cwd) .pipe(Effect.provideService(RuntimeEnv, gitEnv)); } diff --git a/apps/web/src/components/BranchToolbar.tsx b/apps/web/src/components/BranchToolbar.tsx index d81676aa3..d3be9e54f 100644 --- a/apps/web/src/components/BranchToolbar.tsx +++ b/apps/web/src/components/BranchToolbar.tsx @@ -122,8 +122,10 @@ export default function BranchToolbar({ const queryClient = useQueryClient(); const gitCwd = activeWorktreePath ?? activeProject?.cwd ?? null; const gitStatus = useQuery(gitStatusQueryOptions(gitCwd)); + const aheadCount = gitStatus.data?.aheadCount ?? 0; const behindCount = gitStatus.data?.behindCount ?? 0; - const isBehindUpstream = behindCount > 0 && !hasServerThread; + const isDiverged = aheadCount > 0 && behindCount > 0; + const needsSync = behindCount > 0 && !hasServerThread; const pullMutation = useMutation(gitPullMutationOptions({ cwd: gitCwd, queryClient })); // Force a fresh git-status fetch when a draft thread mounts so we catch @@ -139,12 +141,25 @@ export default function BranchToolbar({ void promise .then((result) => { toastManager.add({ - type: "success", - title: result.status === "pulled" ? "Pulled" : "Already up to date", + type: result.status === "conflicted" ? "warning" : "success", + title: + result.status === "pulled" + ? "Pulled" + : result.status === "rebased" + ? "Rebased" + : result.status === "conflicted" + ? "Rebase stopped with conflicts" + : "Already up to date", description: result.status === "pulled" ? `Updated ${result.branch} from ${result.upstreamBranch ?? "upstream"}.` - : "Branch is already up to date.", + : result.status === "rebased" + ? `Rebased ${result.branch} onto ${result.upstreamBranch ?? "upstream"}.` + : result.status === "conflicted" + ? result.conflictedFiles.length > 0 + ? `Resolve ${result.conflictedFiles.length} conflicted file${result.conflictedFiles.length === 1 ? "" : "s"} in ${result.cwd}.` + : `Resolve conflicts in ${result.cwd} before continuing.` + : "Branch is already up to date.", }); }) .catch((error) => { @@ -210,7 +225,7 @@ export default function BranchToolbar({
- {isBehindUpstream ? ( + {needsSync ? ( )} - Pull + {isDiverged ? "Rebase" : "Pull"} - Local branch is {behindCount} commit{behindCount !== 1 ? "s" : ""} behind upstream. - Pull to update before starting a new thread. + {isDiverged + ? `Local branch has diverged from upstream (${aheadCount} ahead, ${behindCount} behind). Rebase before starting a new thread.` + : `Local branch is ${behindCount} commit${behindCount !== 1 ? "s" : ""} behind upstream. Pull to update before starting a new thread.`} ) : null} diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index d39321827..e56d3b97d 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -422,13 +422,13 @@ describe("when: branch is behind upstream", () => { }); describe("when: branch has diverged from upstream", () => { - it("resolveQuickAction returns a disabled sync hint", () => { + it("resolveQuickAction offers a sync action", () => { const quick = resolveQuickAction(status({ aheadCount: 2, behindCount: 1 }), false); assert.deepEqual(quick, { label: "Sync branch", - disabled: true, - kind: "show_hint", - hint: "Branch has diverged from upstream. Rebase/merge first.", + disabled: false, + kind: "run_pull", + hint: "Branch has diverged from upstream. Rebase onto the remote branch.", }); }); }); @@ -453,13 +453,13 @@ describe("sync button state", () => { }); }); - it("returns a disabled sync hint when the branch has diverged from upstream", () => { + it("returns a rebase sync action when the branch has diverged from upstream", () => { const syncAction = resolveSyncAction(status({ aheadCount: 2, behindCount: 1 }), false); assert.deepEqual(syncAction, { label: "Sync branch", - disabled: true, - kind: "show_hint", - hint: "Branch has diverged from upstream. Rebase/merge first.", + disabled: false, + kind: "run_pull", + hint: "Branch has diverged from upstream. Rebase onto the remote branch.", }); }); diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index e9b381839..73ec01e35 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -339,9 +339,9 @@ export function resolveQuickAction( if (isDiverged) { return { label: "Sync branch", - disabled: true, - kind: "show_hint", - hint: "Branch has diverged from upstream. Rebase/merge first.", + disabled: false, + kind: "run_pull", + hint: "Branch has diverged from upstream. Rebase onto the remote branch.", }; } @@ -414,9 +414,9 @@ export function resolveSyncAction( if (isAhead && isBehind) { return { label: "Sync branch", - disabled: true, - kind: "show_hint", - hint: "Branch has diverged from upstream. Rebase/merge first.", + disabled: false, + kind: "run_pull", + hint: "Branch has diverged from upstream. Rebase onto the remote branch.", }; } diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 491bd07ac..9ed685b71 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -56,6 +56,7 @@ import { import { Group, GroupSeparator } from "~/components/ui/group"; import { Menu, + MenuGroup, MenuGroupLabel, MenuItem, MenuPopup, @@ -998,30 +999,57 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions (messages?: { loadingTitle?: string; pulledTitle?: string; + rebasedTitle?: string; + conflictedTitle?: string; skippedTitle?: string; errorTitle?: string; }) => { - const promise = pullMutation.mutateAsync(); - toastManager.promise(promise, { - loading: { title: messages?.loadingTitle ?? "Pulling...", data: threadToastData }, - success: (result) => ({ - title: - result.status === "pulled" - ? (messages?.pulledTitle ?? "Pulled") - : (messages?.skippedTitle ?? "Already up to date"), - description: - result.status === "pulled" - ? `Updated ${result.branch} from ${result.upstreamBranch ?? "upstream"}` - : `${result.branch} is already synchronized.`, - data: threadToastData, - }), - error: (err) => ({ - title: messages?.errorTitle ?? "Pull failed", - description: err instanceof Error ? err.message : "An error occurred.", - data: threadToastData, - }), + const loadingToastId = toastManager.add({ + type: "loading", + title: messages?.loadingTitle ?? "Pulling...", + timeout: 0, + data: threadToastData, }); - void promise.catch(() => undefined); + void pullMutation + .mutateAsync() + .then((result) => { + if (result.status === "conflicted") { + toastManager.update(loadingToastId, { + type: "warning", + title: messages?.conflictedTitle ?? "Rebase stopped with conflicts", + description: + result.conflictedFiles.length > 0 + ? `Resolve ${result.conflictedFiles.length} conflicted file${result.conflictedFiles.length === 1 ? "" : "s"} in ${result.cwd}, then continue from the conflict controls.` + : `Resolve conflicts in ${result.cwd} before continuing.`, + data: threadToastData, + }); + return; + } + toastManager.update(loadingToastId, { + type: "success", + title: + result.status === "pulled" + ? (messages?.pulledTitle ?? "Pulled") + : result.status === "rebased" + ? (messages?.rebasedTitle ?? "Rebased") + : (messages?.skippedTitle ?? "Already up to date"), + description: + result.status === "pulled" + ? `Updated ${result.branch} from ${result.upstreamBranch ?? "upstream"}` + : result.status === "rebased" + ? `Rebased ${result.branch} onto ${result.upstreamBranch ?? "upstream"}` + : `${result.branch} is already synchronized.`, + data: threadToastData, + }); + }) + .catch((err) => { + toastManager.update(loadingToastId, { + type: "error", + title: messages?.errorTitle ?? "Pull failed", + description: err instanceof Error ? err.message : "An error occurred.", + data: threadToastData, + }); + }); }, [pullMutation, threadToastData], ); @@ -1034,6 +1062,8 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions runPullWithToast({ loadingTitle: "Syncing...", pulledTitle: "Synced branch", + rebasedTitle: "Rebased branch", + conflictedTitle: "Sync stopped with conflicts", skippedTitle: "Already up to date", errorTitle: "Sync failed", }); diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts index 5ee4012d9..6ae704c95 100644 --- a/packages/contracts/src/git.ts +++ b/packages/contracts/src/git.ts @@ -302,9 +302,13 @@ export const GitRunStackedActionResult = Schema.Struct({ export type GitRunStackedActionResult = typeof GitRunStackedActionResult.Type; export const GitPullResult = Schema.Struct({ - status: Schema.Literals(["pulled", "skipped_up_to_date"]), + status: Schema.Literals(["pulled", "rebased", "skipped_up_to_date", "conflicted"]), + strategy: Schema.Literals(["pull", "rebase"]), + cwd: TrimmedNonEmptyStringSchema, branch: TrimmedNonEmptyStringSchema, upstreamBranch: TrimmedNonEmptyStringSchema.pipe(Schema.NullOr), + hasConflicts: Schema.Boolean, + conflictedFiles: Schema.Array(TrimmedNonEmptyStringSchema), }); export type GitPullResult = typeof GitPullResult.Type;