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
34 changes: 32 additions & 2 deletions apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,15 +50,18 @@ export async function createWorktree(
mainRepoPath: string,
branch: string,
worktreePath: string,
startPoint = "origin/main",
): Promise<void> {
try {
const parentDir = join(worktreePath, "..");
await mkdir(parentDir, { recursive: true });

const git = simpleGit(mainRepoPath);
await git.raw(["worktree", "add", worktreePath, "-b", branch]);
await git.raw(["worktree", "add", worktreePath, "-b", branch, startPoint]);

console.log(`Created worktree at ${worktreePath} with branch ${branch}`);
console.log(
`Created worktree at ${worktreePath} with branch ${branch} from ${startPoint}`,
);
} catch (error) {
console.error(`Failed to create worktree: ${error}`);
throw new Error(`Failed to create worktree: ${error}`);
Expand Down Expand Up @@ -114,3 +117,30 @@ export async function worktreeExists(
throw error;
}
}

/**
* Fetches origin/main and returns the latest commit SHA
* @param mainRepoPath - Path to the main repository
* @returns The commit SHA of origin/main after fetch
*/
export async function fetchOriginMain(mainRepoPath: string): Promise<string> {
const git = simpleGit(mainRepoPath);
await git.fetch("origin", "main");
const commit = await git.revparse("origin/main");
return commit.trim();
}

/**
* Checks if a worktree's branch is behind origin/main
* @param worktreePath - Path to the worktree
* @returns true if the branch has commits on origin/main that it doesn't have
*/
export async function checkNeedsRebase(worktreePath: string): Promise<boolean> {
const git = simpleGit(worktreePath);
const behindCount = await git.raw([
"rev-list",
"--count",
"HEAD..origin/main",
]);
return Number.parseInt(behindCount.trim(), 10) > 0;
}
70 changes: 69 additions & 1 deletion apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@ import { SUPERSET_DIR_NAME, WORKTREES_DIR_NAME } from "shared/constants";
import { z } from "zod";
import { publicProcedure, router } from "../..";
import {
checkNeedsRebase,
createWorktree,
fetchOriginMain,
generateBranchName,
removeWorktree,
worktreeExists,
Expand Down Expand Up @@ -39,14 +41,31 @@ export const createWorkspacesRouter = () => {
branch,
);

await createWorktree(project.mainRepoPath, branch, worktreePath);
// Fetch origin/main to ensure we're branching from latest (best-effort)
try {
await fetchOriginMain(project.mainRepoPath);
} catch {
// Silently continue - origin/main still exists locally, just might be stale
}

await createWorktree(
project.mainRepoPath,
branch,
worktreePath,
"origin/main",
);

const worktree = {
id: nanoid(),
projectId: input.projectId,
path: worktreePath,
branch,
createdAt: Date.now(),
gitStatus: {
branch,
needsRebase: false, // Fresh off main, doesn't need rebase
lastRefreshed: Date.now(),
},
};

const projectWorkspaces = db.data.workspaces.filter(
Expand Down Expand Up @@ -426,6 +445,55 @@ export const createWorkspacesRouter = () => {

return { success: true };
}),

refreshGitStatus: publicProcedure
.input(z.object({ workspaceId: z.string() }))
.mutation(async ({ input }) => {
const workspace = db.data.workspaces.find(
(w) => w.id === input.workspaceId,
);
if (!workspace) {
throw new Error(`Workspace ${input.workspaceId} not found`);
}

const worktree = db.data.worktrees.find(
(wt) => wt.id === workspace.worktreeId,
);
if (!worktree) {
throw new Error(
`Worktree for workspace ${input.workspaceId} not found`,
);
}

const project = db.data.projects.find(
(p) => p.id === workspace.projectId,
);
if (!project) {
throw new Error(`Project ${workspace.projectId} not found`);
}

// Fetch origin/main to get latest
await fetchOriginMain(project.mainRepoPath);

// Check if worktree branch is behind origin/main
const needsRebase = await checkNeedsRebase(worktree.path);

const gitStatus = {
branch: worktree.branch,
needsRebase,
lastRefreshed: Date.now(),
};

// Update worktree in db
await db.update((data) => {
const wt = data.worktrees.find((w) => w.id === worktree.id);
if (wt) {
wt.gitStatus = gitStatus;
}
});

return { gitStatus };
}),
Comment on lines +449 to +496
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Add error handling and worktree existence validation.

The refreshGitStatus mutation has several reliability concerns:

  1. Line 476: fetchOriginMain is not wrapped in try-catch, unlike the best-effort approach in the create mutation (lines 45-49). Network failures will cause the entire operation to fail with an unclear error.

  2. Line 479: checkNeedsRebase can throw if git operations fail or the worktree is in a bad state, but is not wrapped in error handling.

  3. Missing validation: The mutation doesn't verify that the worktree exists on disk using worktreeExists before attempting git operations. If the worktree was manually deleted, git operations will fail. The delete mutation performs this check (lines 325-328).

These issues create a poor user experience with unclear error messages and inconsistent error handling across mutations.

Apply this diff to add error handling and validation:

 		refreshGitStatus: publicProcedure
 			.input(z.object({ workspaceId: z.string() }))
 			.mutation(async ({ input }) => {
 				const workspace = db.data.workspaces.find(
 					(w) => w.id === input.workspaceId,
 				);
 				if (!workspace) {
 					throw new Error(`Workspace ${input.workspaceId} not found`);
 				}
 
 				const worktree = db.data.worktrees.find(
 					(wt) => wt.id === workspace.worktreeId,
 				);
 				if (!worktree) {
 					throw new Error(
 						`Worktree for workspace ${input.workspaceId} not found`,
 					);
 				}
 
 				const project = db.data.projects.find(
 					(p) => p.id === workspace.projectId,
 				);
 				if (!project) {
 					throw new Error(`Project ${workspace.projectId} not found`);
 				}
+
+				// Verify worktree exists on disk
+				const exists = await worktreeExists(project.mainRepoPath, worktree.path);
+				if (!exists) {
+					throw new Error(
+						`Worktree at ${worktree.path} not found on disk. It may have been manually removed.`,
+					);
+				}
 
-				// Fetch origin/main to get latest
-				await fetchOriginMain(project.mainRepoPath);
+				// Fetch origin/main to get latest (best-effort)
+				try {
+					await fetchOriginMain(project.mainRepoPath);
+				} catch (error) {
+					console.warn('Failed to fetch origin/main:', error);
+					// Continue with stale origin/main - we can still check rebase status
+				}
 
-				// Check if worktree branch is behind origin/main
-				const needsRebase = await checkNeedsRebase(worktree.path);
+				// Check if worktree branch is behind origin/main
+				let needsRebase = false;
+				try {
+					needsRebase = await checkNeedsRebase(worktree.path);
+				} catch (error) {
+					console.error('Failed to check rebase status:', error);
+					throw new Error(
+						`Failed to check git status: ${error instanceof Error ? error.message : String(error)}`,
+					);
+				}
 
 				const gitStatus = {
 					branch: worktree.branch,
 					needsRebase,
 					lastRefreshed: Date.now(),
 				};
 
 				// Update worktree in db
 				await db.update((data) => {
 					const wt = data.worktrees.find((w) => w.id === worktree.id);
 					if (wt) {
 						wt.gitStatus = gitStatus;
 					}
 				});
 
 				return { gitStatus };
 			}),
🤖 Prompt for AI Agents
In apps/desktop/src/lib/trpc/routers/workspaces/workspaces.ts around lines
449-496, validate the worktree exists on disk before running git ops (call
worktreeExists and throw a clear error if missing), wrap fetchOriginMain in
try/catch and treat it as best-effort (log the error but continue), and wrap
checkNeedsRebase in try/catch so git failures don’t crash the mutation (on error
set needsRebase to false or a safe default and log the error); then update the
worktree.gitStatus and return as before. Ensure error messages are descriptive
and consistent with other mutations.

});
};

Expand Down
20 changes: 20 additions & 0 deletions apps/desktop/src/main/lib/db/schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,12 +8,32 @@ export interface Project {
createdAt: number;
}

export interface GitStatus {
branch: string;
needsRebase: boolean;
lastRefreshed: number;
}

export interface GitHubStatus {
pr: {
number: number;
title: string;
url: string;
state: "open" | "draft" | "merged" | "closed";
mergedAt?: number;
} | null;
repoUrl: string;
lastRefreshed: number;
}

export interface Worktree {
id: string;
projectId: string;
path: string;
branch: string;
createdAt: number;
gitStatus?: GitStatus;
githubStatus?: GitHubStatus;
}

export interface Workspace {
Expand Down
6 changes: 1 addition & 5 deletions apps/desktop/src/shared/ipc-channels/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,6 @@ import type { TabChannels } from "./tab";
import type { TerminalChannels } from "./terminal";
import type { UiChannels } from "./ui";
import type { WindowChannels } from "./window";
import type { WorkspaceChannels } from "./workspace";
import type { WorktreeChannels } from "./worktree";

// Re-export shared types
export type {
Expand All @@ -28,9 +26,7 @@ export type {
* Combine all channel definitions into a single interface
*/
export interface IpcChannels
extends WorkspaceChannels,
WorktreeChannels,
TabChannels,
extends TabChannels,
TerminalChannels,
ProxyChannels,
ExternalChannels,
Expand Down
Loading