Skip to content
Closed
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
126 changes: 91 additions & 35 deletions apps/desktop/src/lib/trpc/routers/changes/git-operations.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { TRPCError } from "@trpc/server";
import { shell } from "electron";
import simpleGit from "simple-git";
import { z } from "zod";
import { publicProcedure, router } from "../..";
import {
GIT_TIMEOUT_NETWORK,
GIT_TIMEOUT_NETWORK_HEAVY,
wrapTimeoutError,
} from "../workspaces/utils/git-timeouts";
import { execWithShellEnv } from "../workspaces/utils/shell-env";
import { isUpstreamMissingError } from "./git-utils";
import { assertRegisteredWorktree } from "./security";

const execFileAsync = promisify(execFile);

export { isUpstreamMissingError };

async function hasUpstreamBranch(
Expand All @@ -20,17 +29,22 @@ async function hasUpstreamBranch(
}
}

async function fetchCurrentBranch(
git: ReturnType<typeof simpleGit>,
): Promise<void> {
async function fetchCurrentBranch(worktreePath: string): Promise<void> {
const git = simpleGit(worktreePath);
const branch = (await git.revparse(["--abbrev-ref", "HEAD"])).trim();
try {
await git.fetch(["origin", branch]);
await execFileAsync(
"git",
["-C", worktreePath, "fetch", "origin", branch],
{ timeout: GIT_TIMEOUT_NETWORK },
);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (isUpstreamMissingError(message)) {
try {
await git.fetch(["origin"]);
await execFileAsync("git", ["-C", worktreePath, "fetch", "origin"], {
timeout: GIT_TIMEOUT_NETWORK,
});
} catch (fallbackError) {
const fallbackMessage =
fallbackError instanceof Error
Expand All @@ -41,20 +55,20 @@ async function fetchCurrentBranch(
`[git/fetch] failed fallback fetch for branch ${branch}:`,
fallbackError,
);
throw fallbackError;
throw wrapTimeoutError(fallbackError, "Fetch");
}
}
return;
}
throw error;
throw wrapTimeoutError(error, "Fetch");
}
}

async function pushWithSetUpstream({
git,
worktreePath,
branch,
}: {
git: ReturnType<typeof simpleGit>;
worktreePath: string;
branch: string;
}): Promise<void> {
const trimmedBranch = branch.trim();
Expand All @@ -68,11 +82,22 @@ async function pushWithSetUpstream({

// Use HEAD refspec to avoid resolving the branch name as a local ref.
// This is more reliable for worktrees where upstream tracking isn't set yet.
await git.push([
"--set-upstream",
"origin",
`HEAD:refs/heads/${trimmedBranch}`,
]);
try {
await execFileAsync(
"git",
[
"-C",
worktreePath,
"push",
"--set-upstream",
"origin",
`HEAD:refs/heads/${trimmedBranch}`,
],
{ timeout: GIT_TIMEOUT_NETWORK_HEAVY },
);
} catch (error) {
throw wrapTimeoutError(error, "Push");
}
}

function shouldRetryPushWithUpstream(message: string): boolean {
Expand Down Expand Up @@ -125,22 +150,30 @@ export const createGitOperationsRouter = () => {

if (input.setUpstream && !hasUpstream) {
const branch = await git.revparse(["--abbrev-ref", "HEAD"]);
await pushWithSetUpstream({ git, branch });
await pushWithSetUpstream({
worktreePath: input.worktreePath,
branch,
});
} else {
try {
await git.push();
await execFileAsync("git", ["-C", input.worktreePath, "push"], {
timeout: GIT_TIMEOUT_NETWORK_HEAVY,
});
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
if (shouldRetryPushWithUpstream(message)) {
const branch = await git.revparse(["--abbrev-ref", "HEAD"]);
await pushWithSetUpstream({ git, branch });
await pushWithSetUpstream({
worktreePath: input.worktreePath,
branch,
});
} else {
throw error;
throw wrapTimeoutError(error, "Push");
}
}
}
await fetchCurrentBranch(git);
await fetchCurrentBranch(input.worktreePath);
return { success: true };
}),

Expand All @@ -153,9 +186,12 @@ export const createGitOperationsRouter = () => {
.mutation(async ({ input }): Promise<{ success: boolean }> => {
assertRegisteredWorktree(input.worktreePath);

const git = simpleGit(input.worktreePath);
try {
await git.pull(["--rebase"]);
await execFileAsync(
"git",
["-C", input.worktreePath, "pull", "--rebase"],
{ timeout: GIT_TIMEOUT_NETWORK_HEAVY },
);
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
Expand All @@ -164,7 +200,7 @@ export const createGitOperationsRouter = () => {
"No upstream branch to pull from. The remote branch may have been deleted.",
);
}
throw error;
throw wrapTimeoutError(error, "Pull");
}
return { success: true };
}),
Expand All @@ -180,29 +216,41 @@ export const createGitOperationsRouter = () => {

const git = simpleGit(input.worktreePath);
try {
await git.pull(["--rebase"]);
await execFileAsync(
"git",
["-C", input.worktreePath, "pull", "--rebase"],
{ timeout: GIT_TIMEOUT_NETWORK_HEAVY },
);
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
if (isUpstreamMissingError(message)) {
const branch = await git.revparse(["--abbrev-ref", "HEAD"]);
await pushWithSetUpstream({ git, branch });
await fetchCurrentBranch(git);
await pushWithSetUpstream({
worktreePath: input.worktreePath,
branch,
});
await fetchCurrentBranch(input.worktreePath);
return { success: true };
}
throw error;
throw wrapTimeoutError(error, "Sync");
}
try {
await execFileAsync("git", ["-C", input.worktreePath, "push"], {
timeout: GIT_TIMEOUT_NETWORK_HEAVY,
});
} catch (error) {
throw wrapTimeoutError(error, "Push");
}
await git.push();
await fetchCurrentBranch(git);
await fetchCurrentBranch(input.worktreePath);
return { success: true };
}),

fetch: publicProcedure
.input(z.object({ worktreePath: z.string() }))
.mutation(async ({ input }): Promise<{ success: boolean }> => {
assertRegisteredWorktree(input.worktreePath);
const git = simpleGit(input.worktreePath);
await fetchCurrentBranch(git);
await fetchCurrentBranch(input.worktreePath);
return { success: true };
}),

Expand All @@ -222,18 +270,26 @@ export const createGitOperationsRouter = () => {

// Ensure branch is pushed first
if (!hasUpstream) {
await pushWithSetUpstream({ git, branch });
await pushWithSetUpstream({
worktreePath: input.worktreePath,
branch,
});
} else {
// Push any unpushed commits
try {
await git.push();
await execFileAsync("git", ["-C", input.worktreePath, "push"], {
timeout: GIT_TIMEOUT_NETWORK_HEAVY,
});
} catch (error) {
const message =
error instanceof Error ? error.message : String(error);
if (shouldRetryPushWithUpstream(message)) {
await pushWithSetUpstream({ git, branch });
await pushWithSetUpstream({
worktreePath: input.worktreePath,
branch,
});
} else {
throw error;
throw wrapTimeoutError(error, "Push");
}
}
}
Expand All @@ -252,7 +308,7 @@ export const createGitOperationsRouter = () => {
const url = `https://github.com/${repo}/compare/${branch}?expand=1`;

await shell.openExternal(url);
await fetchCurrentBranch(git);
await fetchCurrentBranch(input.worktreePath);

return { success: true, url };
},
Expand Down
28 changes: 28 additions & 0 deletions apps/desktop/src/lib/trpc/routers/workspaces/utils/git-timeouts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/** 10s — config reads, rev-parse, local-only commands */
export const GIT_TIMEOUT_LOCAL = 10_000;

/** 30s — fetch single branch, ls-remote, gh API calls */
export const GIT_TIMEOUT_NETWORK = 30_000;

/** 60s — push, pull (significant data transfer) */
export const GIT_TIMEOUT_NETWORK_HEAVY = 60_000;

/** 120s — worktree creation, large fetches (e.g. fork PRs) */
export const GIT_TIMEOUT_LONG = 120_000;

export function isTimeoutError(error: unknown): boolean {
return (
error instanceof Error &&
"killed" in error &&
(error as any).killed === true
);
}

export function wrapTimeoutError(error: unknown, operation: string): Error {
if (isTimeoutError(error)) {
return new Error(
`${operation} timed out. Check your network connection and try again.`,
);
}
return error instanceof Error ? error : new Error(String(error));
}
Loading
Loading