Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 51 additions & 13 deletions apps/desktop/src/lib/trpc/routers/changes/git-operations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { TRPCError } from "@trpc/server";
import type { RemoteWithRefs, SimpleGit } from "simple-git";
import { z } from "zod";
import { publicProcedure, router } from "../..";
import { getCurrentBranch } from "../workspaces/utils/git";
import {
execGitWithShellPath,
getSimpleGitWithShellPath,
Expand Down Expand Up @@ -61,10 +62,16 @@ async function getTrackingRemote(git: SimpleGit): Promise<string> {
return trackingRef?.remoteName ?? "origin";
}

async function fetchCurrentBranch(git: SimpleGit): Promise<void> {
const localBranch = (await git.revparse(["--abbrev-ref", "HEAD"])).trim();
async function fetchCurrentBranch(
git: SimpleGit,
worktreePath: string,
): Promise<void> {
const localBranch = await getCurrentBranch(worktreePath);
const trackingRef = await getTrackingRef(git);
const branch = trackingRef?.branchName ?? localBranch;
if (!branch) {
return;
}
const remote = trackingRef?.remoteName ?? resolveTrackingRemoteName(null);
try {
await git.fetch([remote, branch]);
Expand Down Expand Up @@ -498,7 +505,14 @@ export const createGitOperationsRouter = () => {
const hasUpstream = await hasUpstreamBranch(git);

if (input.setUpstream && !hasUpstream) {
const localBranch = await git.revparse(["--abbrev-ref", "HEAD"]);
const localBranch = await getCurrentBranch(input.worktreePath);
if (!localBranch) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Cannot push from detached HEAD. Please checkout a branch and try again.",
});
}
await pushWithResolvedUpstream({
git,
worktreePath: input.worktreePath,
Expand All @@ -511,7 +525,14 @@ export const createGitOperationsRouter = () => {
const message =
error instanceof Error ? error.message : String(error);
if (shouldRetryPushWithUpstream(message)) {
const localBranch = await git.revparse(["--abbrev-ref", "HEAD"]);
const localBranch = await getCurrentBranch(input.worktreePath);
if (!localBranch) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Cannot push from detached HEAD. Please checkout a branch and try again.",
});
}
await pushWithResolvedUpstream({
git,
worktreePath: input.worktreePath,
Expand All @@ -522,7 +543,8 @@ export const createGitOperationsRouter = () => {
}
}
}
await fetchCurrentBranch(git);

await fetchCurrentBranch(git, input.worktreePath);
clearStatusCacheForWorktree(input.worktreePath);
return { success: true };
}),
Expand Down Expand Up @@ -569,20 +591,28 @@ export const createGitOperationsRouter = () => {
const message =
error instanceof Error ? error.message : String(error);
if (isUpstreamMissingError(message)) {
const localBranch = await git.revparse(["--abbrev-ref", "HEAD"]);
const localBranch = await getCurrentBranch(input.worktreePath);
if (!localBranch) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Cannot push from detached HEAD. Please checkout a branch and try again.",
});
}
await pushWithResolvedUpstream({
git,
worktreePath: input.worktreePath,
localBranch,
});
await fetchCurrentBranch(git);
await fetchCurrentBranch(git, input.worktreePath);
clearStatusCacheForWorktree(input.worktreePath);
return { success: true };
}
throw error;
}

await git.push();
await fetchCurrentBranch(git);
await fetchCurrentBranch(git, input.worktreePath);
clearStatusCacheForWorktree(input.worktreePath);
return { success: true };
}),
Expand All @@ -592,7 +622,7 @@ export const createGitOperationsRouter = () => {
.mutation(async ({ input }): Promise<{ success: boolean }> => {
assertRegisteredWorktree(input.worktreePath);
const git = await getGitWithShellPath(input.worktreePath);
await fetchCurrentBranch(git);
await fetchCurrentBranch(git, input.worktreePath);
clearStatusCacheForWorktree(input.worktreePath);
return { success: true };
}),
Expand All @@ -609,7 +639,15 @@ export const createGitOperationsRouter = () => {
assertRegisteredWorktree(input.worktreePath);

const git = await getGitWithShellPath(input.worktreePath);
const branch = (await git.revparse(["--abbrev-ref", "HEAD"])).trim();
const branch = await getCurrentBranch(input.worktreePath);
if (!branch) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Cannot create a pull request from detached HEAD. Please checkout a branch and try again.",
});
}

const trackingStatus = await getTrackingBranchStatus(git);
const hasUpstream = trackingStatus.hasUpstream;
const isBehindUpstream =
Expand Down Expand Up @@ -664,7 +702,7 @@ export const createGitOperationsRouter = () => {

const existingPRUrl = await findExistingOpenPRUrl(input.worktreePath);
if (existingPRUrl) {
await fetchCurrentBranch(git);
await fetchCurrentBranch(git, input.worktreePath);
clearWorktreeStatusCaches(input.worktreePath);
return { success: true, url: existingPRUrl };
}
Expand All @@ -675,7 +713,7 @@ export const createGitOperationsRouter = () => {
git,
branch,
);
await fetchCurrentBranch(git);
await fetchCurrentBranch(git, input.worktreePath);
clearWorktreeStatusCaches(input.worktreePath);

return { success: true, url };
Expand All @@ -686,7 +724,7 @@ export const createGitOperationsRouter = () => {
input.worktreePath,
);
if (recoveredPRUrl) {
await fetchCurrentBranch(git);
await fetchCurrentBranch(git, input.worktreePath);
clearWorktreeStatusCaches(input.worktreePath);
return { success: true, url: recoveredPRUrl };
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { runWithPostCheckoutHookTolerance } from "../../utils/git-hook-tolerance";
import { getCurrentBranch } from "../../workspaces/utils/git";
import { getSimpleGitWithShellPath } from "../../workspaces/utils/git-client";
import {
assertRegisteredWorktree,
Expand Down Expand Up @@ -29,8 +30,7 @@ async function isCurrentBranch({
expectedBranch: string;
}): Promise<boolean> {
try {
const git = await getGitWithShellPath(worktreePath);
const currentBranch = (await git.revparse(["--abbrev-ref", "HEAD"])).trim();
const currentBranch = await getCurrentBranch(worktreePath);
return currentBranch === expectedBranch;
} catch {
return false;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
import { afterAll, beforeEach, describe, expect, mock, test } from "bun:test";

const getCurrentBranchMock = mock(
(async () => null) as (...args: unknown[]) => Promise<string | null>,
);
const execGitWithShellPathMock = mock((async () => ({
stdout: "",
stderr: "",
})) as (...args: unknown[]) => Promise<{ stdout: string; stderr: string }>);
const getRepoContextMock = mock(
(async () => null) as (...args: unknown[]) => Promise<{
isFork: boolean;
repoUrl: string;
upstreamUrl: string;
} | null>,
);
const getPRForBranchMock = mock(
(async () => null) as (...args: unknown[]) => Promise<{
number: number;
state: "open" | "closed" | "merged";
} | null>,
);
const getPullRequestRepoArgsMock = mock(() => [] as string[]);
const execWithShellEnvMock = mock(
(async () => undefined) as (...args: unknown[]) => Promise<void>,
);
const isNoPullRequestFoundMessageMock = mock(() => false);
const clearWorktreeStatusCachesMock = mock(() => undefined);

mock.module("../../workspaces/utils/git", () => ({
getCurrentBranch: getCurrentBranchMock,
}));

mock.module("../../workspaces/utils/git-client", () => ({
execGitWithShellPath: execGitWithShellPathMock,
}));

mock.module("../../workspaces/utils/github", () => ({
getPRForBranch: getPRForBranchMock,
getPullRequestRepoArgs: getPullRequestRepoArgsMock,
getRepoContext: getRepoContextMock,
}));

mock.module("../../workspaces/utils/shell-env", () => ({
execWithShellEnv: execWithShellEnvMock,
}));

mock.module("../git-utils", () => ({
isNoPullRequestFoundMessage: isNoPullRequestFoundMessageMock,
}));

mock.module("./worktree-status-caches", () => ({
clearWorktreeStatusCaches: clearWorktreeStatusCachesMock,
}));

const { mergePullRequest } = await import("./merge-pull-request");

describe("mergePullRequest", () => {
beforeEach(() => {
getCurrentBranchMock.mockReset();
getCurrentBranchMock.mockResolvedValue(null);
execGitWithShellPathMock.mockReset();
execGitWithShellPathMock.mockResolvedValue({
stdout: "abc123\n",
stderr: "",
});
getRepoContextMock.mockReset();
getRepoContextMock.mockResolvedValue({
isFork: false,
repoUrl: "https://github.com/superset-sh/superset",
upstreamUrl: "https://github.com/superset-sh/superset",
});
getPRForBranchMock.mockReset();
getPRForBranchMock.mockResolvedValue(null);
getPullRequestRepoArgsMock.mockReset();
getPullRequestRepoArgsMock.mockReturnValue([]);
execWithShellEnvMock.mockReset();
execWithShellEnvMock.mockResolvedValue(undefined);
isNoPullRequestFoundMessageMock.mockReset();
isNoPullRequestFoundMessageMock.mockReturnValue(false);
clearWorktreeStatusCachesMock.mockReset();
});

test("falls back to legacy gh merge when HEAD is detached", async () => {
const result = await mergePullRequest({
worktreePath: "/tmp/detached-worktree",
strategy: "squash",
});

expect(getRepoContextMock).toHaveBeenCalledWith("/tmp/detached-worktree");
expect(getCurrentBranchMock).toHaveBeenCalledWith("/tmp/detached-worktree");
expect(execGitWithShellPathMock).not.toHaveBeenCalled();
expect(getPRForBranchMock).not.toHaveBeenCalled();
expect(execWithShellEnvMock).toHaveBeenCalledWith(
"gh",
["pr", "merge", "--squash"],
{ cwd: "/tmp/detached-worktree" },
);
expect(clearWorktreeStatusCachesMock).toHaveBeenCalledWith(
"/tmp/detached-worktree",
);
expect(result.success).toBe(true);
expect(Number.isNaN(Date.parse(result.mergedAt))).toBe(false);
});

test("resolves the PR by branch when HEAD has no commit yet", async () => {
getCurrentBranchMock.mockResolvedValue("feature/unborn");
execGitWithShellPathMock.mockRejectedValueOnce(
new Error("fatal: ambiguous argument 'HEAD'"),
);
getPRForBranchMock.mockResolvedValue({
number: 42,
state: "open",
});

const result = await mergePullRequest({
worktreePath: "/tmp/unborn-worktree",
strategy: "rebase",
});

expect(execWithShellEnvMock).toHaveBeenCalledWith(
"gh",
["pr", "merge", "42", "--rebase"],
{ cwd: "/tmp/unborn-worktree" },
);
expect(getPRForBranchMock).toHaveBeenCalledWith(
"/tmp/unborn-worktree",
"feature/unborn",
{
isFork: false,
repoUrl: "https://github.com/superset-sh/superset",
upstreamUrl: "https://github.com/superset-sh/superset",
},
undefined,
);
expect(result.success).toBe(true);
});

test("falls back to legacy merge on unexpected HEAD lookup failures", async () => {
getCurrentBranchMock.mockResolvedValue("feature/branch");
execGitWithShellPathMock.mockRejectedValueOnce(
new Error("fatal: permission denied"),
);

const result = await mergePullRequest({
worktreePath: "/tmp/broken-worktree",
strategy: "merge",
});

expect(getPRForBranchMock).not.toHaveBeenCalled();
expect(execWithShellEnvMock).toHaveBeenCalledWith(
"gh",
["pr", "merge", "--merge"],
{ cwd: "/tmp/broken-worktree" },
);
expect(result.success).toBe(true);
});
});

afterAll(() => {
mock.restore();
});
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import {
getCurrentBranch,
isUnbornHeadError,
} from "../../workspaces/utils/git";
import { execGitWithShellPath } from "../../workspaces/utils/git-client";
import {
getPRForBranch,
Expand Down Expand Up @@ -36,15 +40,20 @@ export async function mergePullRequest({

let pr: Awaited<ReturnType<typeof getPRForBranch>> = null;
try {
const [{ stdout: branchOutput }, { stdout: headOutput }] =
await Promise.all([
execGitWithShellPath(["rev-parse", "--abbrev-ref", "HEAD"], {
cwd: worktreePath,
}),
execGitWithShellPath(["rev-parse", "HEAD"], { cwd: worktreePath }),
]);
const localBranch = branchOutput.trim();
const headSha = headOutput.trim();
const localBranch = await getCurrentBranch(worktreePath);
if (!localBranch) {
return runMerge(legacyMergeArgs);
}
const { stdout: headOutput } = await execGitWithShellPath(
["rev-parse", "HEAD"],
{ cwd: worktreePath },
).catch((error) => {
if (isUnbornHeadError(error)) {
return { stdout: "", stderr: "" };
}
throw error;
});
const headSha = headOutput.trim() || undefined;

pr = await getPRForBranch(worktreePath, localBranch, repoContext, headSha);
} catch (error) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,12 @@ export const createStatusProcedures = () => {
.mutation(({ input }) => {
const { workspaceId, branch } = input;

if (!branch || branch === "HEAD") {
if (
!branch ||
branch === "HEAD" ||
branch.startsWith("[") ||
branch.includes(" ")
) {
return { success: false as const, reason: "invalid-branch" as const };
}

Expand Down
Loading
Loading