diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index 0a11abab5b..fad3ba8c0b 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -1424,6 +1424,20 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { }, ); + const resetToUpstream: GitCoreShape["resetToUpstream"] = (cwd) => + Effect.gen(function* () { + const upstream = yield* runGitStdout( + "GitCore.resetToUpstream.upstream", + cwd, + ["rev-parse", "--abbrev-ref", "@{upstream}"], + ); + yield* runGit("GitCore.resetToUpstream.reset", cwd, [ + "reset", + "--hard", + upstream.trim(), + ]); + }); + const readRangeContext: GitCoreShape["readRangeContext"] = Effect.fn("readRangeContext")( function* (cwd, baseBranch) { const range = `${baseBranch}..HEAD`; @@ -1966,6 +1980,7 @@ export const makeGitCore = Effect.fn("makeGitCore")(function* (options?: { commit, pushCurrentBranch, pullCurrentBranch, + resetToUpstream, readRangeContext, readConfigValue, isInsideWorkTree, diff --git a/apps/server/src/git/Layers/GitHubCli.ts b/apps/server/src/git/Layers/GitHubCli.ts index 280679e337..a4207c2f62 100644 --- a/apps/server/src/git/Layers/GitHubCli.ts +++ b/apps/server/src/git/Layers/GitHubCli.ts @@ -247,6 +247,7 @@ const makeGitHubCli = Effect.sync(() => { args: [ "pr", "create", + ...(input.repo ? ["--repo", input.repo] : []), "--base", input.baseBranch, "--head", diff --git a/apps/server/src/git/Layers/GitManager.ts b/apps/server/src/git/Layers/GitManager.ts index ca9d562c03..12e8985b2a 100644 --- a/apps/server/src/git/Layers/GitManager.ts +++ b/apps/server/src/git/Layers/GitManager.ts @@ -74,6 +74,8 @@ interface BranchHeadContext { remoteName: string | null; headRepositoryNameWithOwner: string | null; headRepositoryOwnerLogin: string | null; + /** The origin remote's owner/repo — used to target the correct repo for PR creation in forks. */ + originRepositoryNameWithOwner: string | null; isCrossRepository: boolean; } @@ -808,6 +810,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { remoteName, headRepositoryNameWithOwner: remoteRepository.repositoryNameWithOwner, headRepositoryOwnerLogin: remoteRepository.ownerLogin, + originRepositoryNameWithOwner: originRepository.repositoryNameWithOwner, isCrossRepository, } satisfies BranchHeadContext; }); @@ -1261,6 +1264,7 @@ export const makeGitManager = Effect.fn("makeGitManager")(function* () { headSelector: headContext.preferredHeadSelector, title: generated.title, bodyFile, + repo: headContext.originRepositoryNameWithOwner, }) .pipe(Effect.ensuring(fileSystem.remove(bodyFile).pipe(Effect.catch(() => Effect.void)))); diff --git a/apps/server/src/git/Services/GitCore.ts b/apps/server/src/git/Services/GitCore.ts index d7a28d1763..c72d0010f9 100644 --- a/apps/server/src/git/Services/GitCore.ts +++ b/apps/server/src/git/Services/GitCore.ts @@ -230,6 +230,11 @@ export interface GitCoreShape { */ readonly pullCurrentBranch: (cwd: string) => Effect.Effect; + /** + * Hard-reset the current branch to its upstream tracking ref. + */ + readonly resetToUpstream: (cwd: string) => Effect.Effect; + /** * Create a worktree and branch from a base branch. */ diff --git a/apps/server/src/git/Services/GitHubCli.ts b/apps/server/src/git/Services/GitHubCli.ts index 38afdd5f92..d8920e6f0a 100644 --- a/apps/server/src/git/Services/GitHubCli.ts +++ b/apps/server/src/git/Services/GitHubCli.ts @@ -76,6 +76,8 @@ export interface GitHubCliShape { readonly headSelector: string; readonly title: string; readonly bodyFile: string; + /** Optional owner/repo to target. Without this, `gh` defaults to the upstream parent for forks. */ + readonly repo?: string | null; }) => Effect.Effect; /** diff --git a/apps/server/src/ws.ts b/apps/server/src/ws.ts index cff7e26efa..3e433be884 100644 --- a/apps/server/src/ws.ts +++ b/apps/server/src/ws.ts @@ -222,7 +222,34 @@ const WsRpcLayer = WsRpcGroup.toLayer( ), [WS_METHODS.shellOpenInEditor]: (input) => open.openInEditor(input), [WS_METHODS.gitStatus]: (input) => gitManager.status(input), - [WS_METHODS.gitPull]: (input) => git.pullCurrentBranch(input.cwd), + [WS_METHODS.gitPull]: (input) => + git.pullCurrentBranch(input.cwd).pipe( + Effect.catch((pullError) => + Effect.gen(function* () { + // Only attempt reset-to-upstream when the branch has actually diverged. + // Re-throw for network errors, auth failures, missing upstream, etc. + const details = yield* git.statusDetails(input.cwd); + if (details.aheadCount === 0 || details.behindCount === 0) { + return yield* Effect.fail(pullError); + } + if (details.hasWorkingTreeChanges) { + return yield* Effect.fail( + new GitManagerServiceError({ + message: + "Branch has diverged and has local changes. Stash or commit changes before syncing.", + }), + ); + } + yield* git.resetToUpstream(input.cwd); + const refreshed = yield* git.statusDetails(input.cwd); + return { + status: "pulled" as const, + branch: refreshed.branch ?? "unknown", + upstreamBranch: refreshed.upstreamRef, + }; + }), + ), + ), [WS_METHODS.gitRunStackedAction]: (input) => Stream.callback((queue) => gitManager diff --git a/apps/web/src/components/GitActionsControl.logic.test.ts b/apps/web/src/components/GitActionsControl.logic.test.ts index f21c924c79..90153f52f9 100644 --- a/apps/web/src/components/GitActionsControl.logic.test.ts +++ b/apps/web/src/components/GitActionsControl.logic.test.ts @@ -69,6 +69,13 @@ describe("when: branch is clean and has an open PR", () => { kind: "open_dialog", dialogAction: "commit", }, + { + id: "pull", + label: "Pull", + disabled: true, + icon: "pull", + kind: "run_pull", + }, { id: "push", label: "Push", @@ -110,6 +117,13 @@ describe("when: actions are busy", () => { kind: "open_dialog", dialogAction: "commit", }, + { + id: "pull", + label: "Pull", + disabled: true, + icon: "pull", + kind: "run_pull", + }, { id: "push", label: "Push", @@ -190,6 +204,13 @@ describe("when: branch is clean, ahead, and has an open PR", () => { kind: "open_dialog", dialogAction: "commit", }, + { + id: "pull", + label: "Pull", + disabled: true, + icon: "pull", + kind: "run_pull", + }, { id: "push", label: "Push", @@ -230,6 +251,13 @@ describe("when: branch is clean, ahead, and has no open PR", () => { kind: "open_dialog", dialogAction: "commit", }, + { + id: "pull", + label: "Pull", + disabled: true, + icon: "pull", + kind: "run_pull", + }, { id: "push", label: "Push", @@ -270,6 +298,13 @@ describe("when: branch is clean, up to date, and has no open PR", () => { kind: "open_dialog", dialogAction: "commit", }, + { + id: "pull", + label: "Pull", + disabled: true, + icon: "pull", + kind: "run_pull", + }, { id: "push", label: "Push", @@ -296,7 +331,7 @@ describe("when: branch is behind upstream", () => { assert.deepInclude(quick, { kind: "run_pull", label: "Pull", disabled: false }); }); - it("buildMenuItems disables push and create PR", () => { + it("buildMenuItems enables pull and disables push and create PR", () => { const items = buildMenuItems(status({ behindCount: 1, pr: null }), false); assert.deepEqual(items, [ { @@ -307,6 +342,13 @@ describe("when: branch is behind upstream", () => { kind: "open_dialog", dialogAction: "commit", }, + { + id: "pull", + label: "Pull", + disabled: false, + icon: "pull", + kind: "run_pull", + }, { id: "push", label: "Push", @@ -328,13 +370,12 @@ describe("when: branch is behind upstream", () => { }); describe("when: branch has diverged from upstream", () => { - it("resolveQuickAction returns a disabled sync hint", () => { + it("resolveQuickAction enables sync when working tree is clean", () => { 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", }); }); }); @@ -397,6 +438,13 @@ describe("when: working tree has local changes", () => { kind: "open_dialog", dialogAction: "commit", }, + { + id: "pull", + label: "Pull", + disabled: true, + icon: "pull", + kind: "run_pull", + }, { id: "push", label: "Push", @@ -471,6 +519,13 @@ describe("when: working tree has local changes and branch is behind upstream", ( kind: "open_dialog", dialogAction: "commit", }, + { + id: "pull", + label: "Pull", + disabled: false, + icon: "pull", + kind: "run_pull", + }, { id: "push", label: "Push", @@ -500,7 +555,7 @@ describe("when: HEAD is detached and there are no local changes", () => { assert.deepInclude(quick, { kind: "show_hint", label: "Commit", disabled: true }); }); - it("buildMenuItems keeps commit, push, and PR disabled", () => { + it("buildMenuItems keeps commit, pull, push, and PR disabled", () => { const items = buildMenuItems(status({ branch: null, hasWorkingTreeChanges: false }), false); assert.deepEqual(items, [ { @@ -511,6 +566,13 @@ describe("when: HEAD is detached and there are no local changes", () => { kind: "open_dialog", dialogAction: "commit", }, + { + id: "pull", + label: "Pull", + disabled: true, + icon: "pull", + kind: "run_pull", + }, { id: "push", label: "Push", @@ -603,6 +665,13 @@ describe("when: branch has no upstream configured", () => { kind: "open_dialog", dialogAction: "commit", }, + { + id: "pull", + label: "Pull", + disabled: true, + icon: "pull", + kind: "run_pull", + }, { id: "push", label: "Push", @@ -669,6 +738,13 @@ describe("when: branch has no upstream configured", () => { kind: "open_dialog", dialogAction: "commit", }, + { + id: "pull", + label: "Pull", + disabled: true, + icon: "pull", + kind: "run_pull", + }, { id: "push", label: "Push", @@ -703,6 +779,13 @@ describe("when: branch has no upstream configured", () => { kind: "open_dialog", dialogAction: "commit", }, + { + id: "pull", + label: "Pull", + disabled: true, + icon: "pull", + kind: "run_pull", + }, { id: "push", label: "Push", @@ -779,6 +862,13 @@ describe("when: branch has no upstream configured", () => { kind: "open_dialog", dialogAction: "commit", }, + { + id: "pull", + label: "Pull", + disabled: true, + icon: "pull", + kind: "run_pull", + }, { id: "push", label: "Push", diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 80906a982b..5ba3daaef3 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -4,16 +4,16 @@ import type { GitStatusResult, } from "@t3tools/contracts"; -export type GitActionIconName = "commit" | "push" | "pr"; +export type GitActionIconName = "commit" | "push" | "pull" | "pr"; export type GitDialogAction = "commit" | "push" | "create_pr"; export interface GitActionMenuItem { - id: "commit" | "push" | "pr"; + id: "commit" | "push" | "pull" | "pr"; label: string; disabled: boolean; icon: GitActionIconName; - kind: "open_dialog" | "open_pr"; + kind: "open_dialog" | "open_pr" | "run_pull"; dialogAction?: GitDialogAction; } @@ -80,22 +80,25 @@ export function buildMenuItems( const hasBranch = gitStatus.branch !== null; const hasChanges = gitStatus.hasWorkingTreeChanges; const hasOpenPr = gitStatus.pr?.state === "open"; + const isAhead = gitStatus.aheadCount > 0; const isBehind = gitStatus.behindCount > 0; const canPushWithoutUpstream = hasOriginRemote && !gitStatus.hasUpstream; const canCommit = !isBusy && hasChanges; + const canPull = + !isBusy && hasBranch && gitStatus.hasUpstream && isBehind; const canPush = !isBusy && hasBranch && !hasChanges && !isBehind && - gitStatus.aheadCount > 0 && + isAhead && (gitStatus.hasUpstream || canPushWithoutUpstream); const canCreatePr = !isBusy && hasBranch && !hasChanges && !hasOpenPr && - gitStatus.aheadCount > 0 && + isAhead && !isBehind && (gitStatus.hasUpstream || canPushWithoutUpstream); const canOpenPr = !isBusy && hasOpenPr; @@ -109,6 +112,13 @@ export function buildMenuItems( kind: "open_dialog", dialogAction: "commit", }, + { + id: "pull", + label: isAhead && isBehind ? "Sync (force pull)" : "Pull", + disabled: !canPull, + icon: "pull", + kind: "run_pull", + }, { id: "push", label: "Push", @@ -226,12 +236,15 @@ export function resolveQuickAction( } if (isDiverged) { - return { - label: "Sync branch", - disabled: true, - kind: "show_hint", - hint: "Branch has diverged from upstream. Rebase/merge first.", - }; + if (hasChanges) { + return { + label: "Sync branch", + disabled: true, + kind: "show_hint", + hint: "Stash or commit local changes before syncing.", + }; + } + return { label: "Sync branch", disabled: false, kind: "run_pull" }; } if (isBehind) { diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 6e811e6f4b..2075011b94 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -7,7 +7,13 @@ import type { } from "@t3tools/contracts"; import { useIsMutating, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useCallback, useEffect, useEffectEvent, useMemo, useRef, useState } from "react"; -import { ChevronDownIcon, CloudUploadIcon, GitCommitIcon, InfoIcon } from "lucide-react"; +import { + ArrowDownToLineIcon, + ChevronDownIcon, + CloudUploadIcon, + GitCommitIcon, + InfoIcon, +} from "lucide-react"; import { GitHubIcon } from "./Icons"; import { buildGitActionProgressStages, @@ -141,6 +147,19 @@ function getMenuActionDisabledReason({ return "Commit is currently unavailable."; } + if (item.id === "pull") { + if (!hasBranch) { + return "Detached HEAD: checkout a branch before pulling."; + } + if (!gitStatus.hasUpstream) { + return "No upstream configured. Push with upstream first."; + } + if (!isBehind) { + return "Branch is already up to date with upstream."; + } + return "Pull is currently unavailable."; + } + if (item.id === "push") { if (!hasBranch) { return "Detached HEAD: checkout a branch before pushing."; @@ -187,6 +206,7 @@ const COMMIT_DIALOG_DESCRIPTION = function GitActionItemIcon({ icon }: { icon: GitActionIconName }) { if (icon === "commit") return ; + if (icon === "pull") return ; if (icon === "push") return ; return ; } @@ -194,7 +214,7 @@ function GitActionItemIcon({ icon }: { icon: GitActionIconName }) { function GitQuickActionIcon({ quickAction }: { quickAction: GitQuickAction }) { const iconClassName = "size-3.5"; if (quickAction.kind === "open_pr") return ; - if (quickAction.kind === "run_pull") return ; + if (quickAction.kind === "run_pull") return ; if (quickAction.kind === "run_action") { if (quickAction.action === "commit") return ; if (quickAction.action === "push" || quickAction.action === "commit_push") { @@ -702,6 +722,27 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions void openExistingPr(); return; } + if (item.kind === "run_pull") { + const promise = pullMutation.mutateAsync(); + toastManager.promise(promise, { + loading: { title: "Pulling...", data: threadToastData }, + success: (result) => ({ + title: result.status === "pulled" ? "Pulled" : "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: "Pull failed", + description: err instanceof Error ? err.message : "An error occurred.", + data: threadToastData, + }), + }); + void promise.catch(() => undefined); + return; + } if (item.dialogAction === "push") { void runGitActionWithToast({ action: "push" }); return; @@ -714,7 +755,7 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions setIsEditingFiles(false); setIsCommitDialogOpen(true); }, - [openExistingPr, setIsCommitDialogOpen], + [openExistingPr, pullMutation, runGitActionWithToast, setIsCommitDialogOpen, threadToastData, toastManager], ); const runDialogAction = useCallback(() => {