Skip to content
Closed
71 changes: 49 additions & 22 deletions apps/desktop/src/lib/trpc/routers/projects/projects.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { basename, join } from "node:path";
import type { BrowserWindow } from "electron";
import { dialog } from "electron";
import { db } from "main/lib/db";
import type { Project } from "main/lib/db/schemas";
import type { Project, Workspace } from "main/lib/db/schemas";
import { nanoid } from "nanoid";
import { PROJECT_COLOR_VALUES } from "shared/constants/project-colors";
import simpleGit from "simple-git";
Expand All @@ -28,18 +28,40 @@ export type OpenNewResult =
| OpenNewNeedsGitInit
| OpenNewError;

/**
* Creates a main branch workspace for a project
*/
function createMainWorkspace(
projectId: string,
defaultBranch: string,
): Workspace {
return {
id: nanoid(),
projectId,
worktreeId: undefined,
type: "branch",
branch: defaultBranch,
name: defaultBranch,
tabOrder: 0,
createdAt: Date.now(),
updatedAt: Date.now(),
lastOpenedAt: Date.now(),
};
}

/**
* Creates or updates a project record in the database.
* If a project with the same mainRepoPath exists, updates lastOpenedAt.
* Otherwise, creates a new project.
* Otherwise, creates a new project with a main branch workspace.
*/
async function upsertProject(
mainRepoPath: string,
defaultBranch: string,
): Promise<Project> {
): Promise<{ project: Project; isNew: boolean }> {
const name = basename(mainRepoPath);

let project = db.data.projects.find((p) => p.mainRepoPath === mainRepoPath);
const isNew = !project;

if (project) {
await db.update((data) => {
Expand All @@ -61,13 +83,32 @@ async function upsertProject(
defaultBranch,
};

const mainWorkspace = createMainWorkspace(project.id, defaultBranch);

await db.update((data) => {
// biome-ignore lint/style/noNonNullAssertion: project is assigned above, TypeScript can't see it inside callback
data.projects.push(project!);
data.workspaces.push(mainWorkspace);
data.settings.lastActiveWorkspaceId = mainWorkspace.id;

// Activate the project
const activeProjects = data.projects.filter(
(proj) => proj.tabOrder !== null,
);
const maxProjectTabOrder =
activeProjects.length > 0
? // biome-ignore lint/style/noNonNullAssertion: filter guarantees tabOrder is not null
Math.max(...activeProjects.map((proj) => proj.tabOrder!))
: -1;
// biome-ignore lint/style/noNonNullAssertion: project is assigned above
const p = data.projects.find((p) => p.id === project!.id);
if (p) {
p.tabOrder = maxProjectTabOrder + 1;
}
});
}

return project;
return { project, isNew };
}

// Safe filename regex: letters, numbers, dots, underscores, hyphens, spaces, and common unicode
Expand Down Expand Up @@ -180,7 +221,7 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => {
}

const defaultBranch = await getDefaultBranch(mainRepoPath);
const project = await upsertProject(mainRepoPath, defaultBranch);
const { project } = await upsertProject(mainRepoPath, defaultBranch);

return {
canceled: false,
Expand Down Expand Up @@ -230,7 +271,7 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => {
const branchSummary = await git.branch();
const defaultBranch = branchSummary.current || "main";

const project = await upsertProject(input.path, defaultBranch);
const { project } = await upsertProject(input.path, defaultBranch);

return { project };
}),
Expand Down Expand Up @@ -334,23 +375,9 @@ export const createProjectsRouter = (getWindow: () => BrowserWindow | null) => {
const git = simpleGit();
await git.clone(input.url, clonePath);

// Create new project
const name = basename(clonePath);
// Create new project with main workspace
const defaultBranch = await getDefaultBranch(clonePath);
const project: Project = {
id: nanoid(),
mainRepoPath: clonePath,
name,
color: assignRandomColor(),
tabOrder: null,
lastOpenedAt: Date.now(),
createdAt: Date.now(),
defaultBranch,
};

await db.update((data) => {
data.projects.push(project);
});
const { project } = await upsertProject(clonePath, defaultBranch);

return {
canceled: false as const,
Expand Down
26 changes: 16 additions & 10 deletions apps/desktop/src/lib/trpc/routers/terminal/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { db } from "main/lib/db";
import { terminalManager } from "main/lib/terminal";
import { z } from "zod";
import { publicProcedure, router } from "../..";
import { getWorktreePath } from "../workspaces/utils/worktree";
import { getWorkspacePath } from "../workspaces/utils/worktree";
import { resolveCwd } from "./utils";

/**
Expand Down Expand Up @@ -47,12 +47,12 @@ export const createTerminalRouter = () => {
initialCommands,
} = input;

// Resolve cwd: absolute paths stay as-is, relative paths resolve against worktree
// Resolve cwd: absolute paths stay as-is, relative paths resolve against workspace path
const workspace = db.data.workspaces.find((w) => w.id === workspaceId);
const worktreePath = workspace
? getWorktreePath(workspace.worktreeId)
const workspacePath = workspace
? getWorkspacePath(workspace)
: undefined;
const cwd = resolveCwd(cwdOverride, worktreePath);
const cwd = resolveCwd(cwdOverride, workspacePath);

// Get project info for environment variables
const project = workspace
Expand All @@ -64,7 +64,7 @@ export const createTerminalRouter = () => {
tabId,
workspaceId,
workspaceName: workspace?.name,
workspacePath: worktreePath,
workspacePath,
rootPath: project?.mainRepoPath,
cwd,
cols,
Expand Down Expand Up @@ -126,6 +126,15 @@ export const createTerminalRouter = () => {
await terminalManager.kill(input);
}),

/**
* Kill all terminals for a workspace
*/
killByWorkspaceId: publicProcedure
.input(z.object({ workspaceId: z.string() }))
.mutation(async ({ input }) => {
return terminalManager.killByWorkspaceId(input.workspaceId);
}),

/**
* Detach from terminal (keep session alive)
*/
Expand Down Expand Up @@ -171,10 +180,7 @@ export const createTerminalRouter = () => {
return undefined;
}

const worktree = db.data.worktrees.find(
(wt) => wt.id === workspace.worktreeId,
);
return worktree?.path;
return getWorkspacePath(workspace);
}),

/**
Expand Down
184 changes: 184 additions & 0 deletions apps/desktop/src/lib/trpc/routers/workspaces/utils/git.ts
Original file line number Diff line number Diff line change
Expand Up @@ -444,3 +444,187 @@ export async function branchExistsOnRemote(
return false;
}
}

/**
* Lists all local and remote branches in a repository
* @param repoPath - Path to the repository
* @param options.fetch - Whether to fetch and prune remote refs first (default: false)
* @returns Object with local and remote branch arrays
*/
export async function listBranches(
repoPath: string,
options?: { fetch?: boolean },
): Promise<{ local: string[]; remote: string[] }> {
const git = simpleGit(repoPath);

// Optionally fetch and prune to get up-to-date remote refs
if (options?.fetch) {
try {
await git.fetch(["--prune"]);
} catch {
// Ignore fetch errors (e.g., offline)
}
}

// Get local branches
const localResult = await git.branchLocal();
const local = localResult.all;

// Get remote branches (strip "origin/" prefix)
const remoteResult = await git.branch(["-r"]);
const remote = remoteResult.all
.filter((b) => b.startsWith("origin/") && !b.includes("->"))
.map((b) => b.replace("origin/", ""));

return { local, remote };
}

/**
* Checks out a branch in a repository.
* If the branch only exists on remote, creates a local tracking branch.
* @param repoPath - Path to the repository
* @param branch - The branch name to checkout
*/
/**
* Result of pre-checkout safety checks
*/
export interface CheckoutSafetyResult {
safe: boolean;
error?: string;
hasUncommittedChanges?: boolean;
hasUntrackedFiles?: boolean;
}

/**
* Performs safety checks before a branch checkout:
* 1. Checks for uncommitted changes (staged/unstaged)
* 2. Checks for untracked files that might be overwritten
* 3. Runs git fetch --prune to clean up stale remote refs
* @param repoPath - Path to the repository
* @returns Safety check result indicating if checkout is safe
*/
export async function checkBranchCheckoutSafety(
repoPath: string,
): Promise<CheckoutSafetyResult> {
const git = simpleGit(repoPath);

try {
// Check for uncommitted changes
const status = await git.status();

const hasUncommittedChanges =
status.staged.length > 0 ||
status.modified.length > 0 ||
status.deleted.length > 0;

const hasUntrackedFiles = status.not_added.length > 0;

if (hasUncommittedChanges) {
return {
safe: false,
error:
"Cannot switch branches: you have uncommitted changes. Please commit or stash your changes first.",
hasUncommittedChanges: true,
hasUntrackedFiles,
};
}

// Fetch and prune stale remote refs (best-effort)
try {
await git.fetch(["--prune"]);
} catch {
// Ignore fetch errors (e.g., offline) - not critical for safety
}

return {
safe: true,
hasUncommittedChanges: false,
hasUntrackedFiles,
};
} catch (error) {
return {
safe: false,
error: `Failed to check repository status: ${error instanceof Error ? error.message : String(error)}`,
};
}
}

/**
* Gets the current branch name (HEAD)
* @param repoPath - Path to the repository
* @returns The current branch name, or null if in detached HEAD state
*/
export async function getCurrentBranch(
repoPath: string,
): Promise<string | null> {
const git = simpleGit(repoPath);
try {
const branch = await git.revparse(["--abbrev-ref", "HEAD"]);
const trimmed = branch.trim();
// "HEAD" means detached HEAD state
return trimmed === "HEAD" ? null : trimmed;
} catch {
return null;
}
}

export async function checkoutBranch(
repoPath: string,
branch: string,
): Promise<void> {
const git = simpleGit(repoPath);

// Check if branch exists locally
const localBranches = await git.branchLocal();
if (localBranches.all.includes(branch)) {
await git.checkout(branch);
return;
}

// Branch doesn't exist locally - check if it exists on remote and create tracking branch
const remoteBranches = await git.branch(["-r"]);
const remoteBranchName = `origin/${branch}`;
if (remoteBranches.all.includes(remoteBranchName)) {
// Create local branch tracking the remote
await git.checkout(["-b", branch, "--track", remoteBranchName]);
return;
}

// Branch doesn't exist anywhere - let git checkout fail with its normal error
await git.checkout(branch);
}

/**
* Safe branch checkout that performs safety checks first.
* This is the preferred method for branch workspaces.
* @param repoPath - Path to the repository
* @param branch - Branch to checkout
* @throws Error if safety checks fail or checkout fails
*/
export async function safeCheckoutBranch(
repoPath: string,
branch: string,
): Promise<void> {
// Check if we're already on the target branch - no checkout needed
const currentBranch = await getCurrentBranch(repoPath);
if (currentBranch === branch) {
return;
}

// Run safety checks before switching branches
const safety = await checkBranchCheckoutSafety(repoPath);
if (!safety.safe) {
throw new Error(safety.error);
}

// Proceed with checkout
await checkoutBranch(repoPath, branch);

// Verify we landed on the correct branch
const verifyBranch = await getCurrentBranch(repoPath);
if (verifyBranch !== branch) {
throw new Error(
`Branch checkout verification failed: expected "${branch}" but HEAD is on "${verifyBranch ?? "detached HEAD"}"`,
);
}
}
Loading