diff --git a/apps/server/src/git/Layers/GitCore.test.ts b/apps/server/src/git/Layers/GitCore.test.ts index e4a4bf0054287ba361369d3852bcf094426ab293..f94bd7631fa82baccb85478ab5fdea9b156fb51c 100644 --- a/apps/server/src/git/Layers/GitCore.test.ts +++ b/apps/server/src/git/Layers/GitCore.test.ts @@ -1234,6 +1234,42 @@ it.layer(TestLayer)("git integration", (it) => { }), ); + it.effect( + "pushes with upstream setup when comparable base exists but remote branch is missing", + () => + Effect.gen(function* () { + const tmp = yield* makeTmpDir(); + const remote = yield* makeTmpDir(); + yield* git(remote, ["init", "--bare"]); + + yield* initRepoWithCommit(tmp); + const initialBranch = (yield* listGitBranches({ cwd: tmp })).branches.find( + (branch) => branch.current, + )!.name; + yield* git(tmp, ["remote", "add", "origin", remote]); + yield* git(tmp, ["push", "-u", "origin", initialBranch]); + + yield* writeTextFile(path.join(tmp, "default-ahead.txt"), "ahead on default\n"); + yield* git(tmp, ["add", "default-ahead.txt"]); + yield* git(tmp, ["commit", "-m", "default ahead"]); + + const featureBranch = "feature/publish-no-upstream"; + yield* git(tmp, ["checkout", "-b", featureBranch]); + + const core = yield* GitCore; + const pushed = yield* core.pushCurrentBranch(tmp, null); + expect(pushed.status).toBe("pushed"); + expect(pushed.setUpstream).toBe(true); + expect(pushed.upstreamBranch).toBe(`origin/${featureBranch}`); + expect(yield* git(tmp, ["rev-parse", "--abbrev-ref", "@{upstream}"])).toBe( + `origin/${featureBranch}`, + ); + expect(yield* git(tmp, ["ls-remote", "--heads", "origin", featureBranch])).toContain( + featureBranch, + ); + }), + ); + it.effect("includes command context when worktree removal fails", () => Effect.gen(function* () { const tmp = yield* makeTmpDir(); diff --git a/apps/server/src/git/Layers/GitCore.ts b/apps/server/src/git/Layers/GitCore.ts index e362765758b590074882227dcf788ef40ab0b413..2a2d358bd3811e38e660fa12d1e67c119b9e230c 100644 --- a/apps/server/src/git/Layers/GitCore.ts +++ b/apps/server/src/git/Layers/GitCore.ts @@ -381,6 +381,11 @@ const makeGitCore = Effect.gen(function* () { }, ).pipe(Effect.map((result) => result.code === 0)); + const originRemoteExists = (cwd: string): Effect.Effect => + executeGit("GitCore.originRemoteExists", cwd, ["remote", "get-url", "origin"], { + allowNonZeroExit: true, + }).pipe(Effect.map((result) => result.code === 0)); + const resolveBaseBranchForNoUpstream = ( cwd: string, branch: string, @@ -668,10 +673,25 @@ const makeGitCore = Effect.gen(function* () { Effect.catch(() => Effect.succeed(null)), ); if (comparableBaseBranch) { - return { - status: "skipped_up_to_date" as const, - branch, - }; + const hasOriginRemote = yield* originRemoteExists(cwd).pipe( + Effect.catch(() => Effect.succeed(false)), + ); + if (!hasOriginRemote) { + return { + status: "skipped_up_to_date" as const, + branch, + }; + } + + const hasRemoteBranch = yield* remoteBranchExists(cwd, branch).pipe( + Effect.catch(() => Effect.succeed(false)), + ); + if (hasRemoteBranch) { + return { + status: "skipped_up_to_date" as const, + branch, + }; + } } } diff --git a/apps/web/src/components/GitActionsControl.logic.ts b/apps/web/src/components/GitActionsControl.logic.ts index 7b454a50b8e8597abccf8e2edbc34f048f7f87f2..d1c91ba9424a251609a43b953ec9ce22f2564f2f 100644 --- a/apps/web/src/components/GitActionsControl.logic.ts +++ b/apps/web/src/components/GitActionsControl.logic.ts @@ -31,6 +31,8 @@ export interface DefaultBranchActionDialogCopy { continueLabel: string; } +export type DefaultBranchConfirmableAction = "commit_push" | "commit_push_pr"; + const SHORT_SHA_LENGTH = 7; const TOAST_DESCRIPTION_MAX = 72; const AUTO_FEATURE_BRANCH_PREFIX = "feature/stacked"; @@ -283,21 +285,13 @@ export function requiresDefaultBranchConfirmation( } export function resolveDefaultBranchActionDialogCopy(input: { - action: GitStackedAction; + action: DefaultBranchConfirmableAction; branchName: string; includesCommit: boolean; }): DefaultBranchActionDialogCopy { const branchLabel = input.branchName; const suffix = ` on "${branchLabel}". You can continue on this branch or create a feature branch and run the same action there.`; - if (input.action === "commit") { - return { - title: "Commit to default branch?", - description: `This action will commit changes${suffix}`, - continueLabel: `Commit to ${branchLabel}`, - }; - } - if (input.action === "commit_push") { if (input.includesCommit) { return { diff --git a/apps/web/src/components/GitActionsControl.tsx b/apps/web/src/components/GitActionsControl.tsx index 2f91edfa006d804547419b6299abf144f106f386..a60680118621d8041eda2d3c34bfebf215a03caa 100644 --- a/apps/web/src/components/GitActionsControl.tsx +++ b/apps/web/src/components/GitActionsControl.tsx @@ -9,6 +9,7 @@ import { type GitActionIconName, type GitActionMenuItem, type GitQuickAction, + type DefaultBranchConfirmableAction, requiresDefaultBranchConfirmation, resolveAutoFeatureBranchName, resolveDefaultBranchActionDialogCopy, @@ -50,7 +51,7 @@ interface GitActionsControlProps { } interface PendingDefaultBranchAction { - action: GitStackedAction; + action: DefaultBranchConfirmableAction; branchName: string; includesCommit: boolean; commitMessage?: string; @@ -268,6 +269,9 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions requiresDefaultBranchConfirmation(action, actionIsDefaultBranch) && actionBranch ) { + if (action !== "commit_push" && action !== "commit_push_pr") { + return; + } setPendingDefaultBranchAction({ action, branchName: actionBranch, @@ -578,11 +582,18 @@ export default function GitActionsControl({ gitCwd, activeThreadId }: GitActions if (!isCommitDialogOpen) return; const commitMessage = dialogCommitMessage.trim(); setIsCommitDialogOpen(false); + setDialogCommitMessage(""); void runGitActionWithToast({ action: "commit", ...(commitMessage ? { commitMessage } : {}), }); - }, [dialogCommitMessage, isCommitDialogOpen, runGitActionWithToast, setIsCommitDialogOpen]); + }, [ + dialogCommitMessage, + isCommitDialogOpen, + runGitActionWithToast, + setDialogCommitMessage, + setIsCommitDialogOpen, + ]); const openChangedFileInEditor = useCallback( (filePath: string) => {