From d64f78a9498ae017db7efde4cc5fbbfd07091a79 Mon Sep 17 00:00:00 2001 From: Julius Marminge Date: Sun, 8 Feb 2026 21:27:34 -0800 Subject: [PATCH] Add git integration: branch picker + worktrees Allows users to right-click "+ New thread" to create threads from specific git branches, with optional worktree creation for isolated work. Includes branch sorting (current/default first), graceful handling of non-git repos, and ability to initialize git repos. - New git IPC contract (listBranches, createWorktree, removeWorktree, init) - Branch context menu with worktree sub-option in Sidebar - Thread model extended with branch/worktreePath fields - Session cwd uses worktree path when available - Branch sorting: current > main/master > alphabetical Co-Authored-By: Claude Haiku 4.5 --- apps/web/src/persistenceSchema.ts | 6 ++++ apps/web/src/types.ts | 2 ++ packages/contracts/src/git.ts | 56 +++++++++++++++++++++++++++++++ packages/contracts/src/index.ts | 1 + packages/contracts/src/ipc.ts | 14 ++++++++ 5 files changed, 79 insertions(+) create mode 100644 packages/contracts/src/git.ts diff --git a/apps/web/src/persistenceSchema.ts b/apps/web/src/persistenceSchema.ts index e6b4983a21353f7e865bc9c7fdcddf346524bd7c..eb4514b7f8a9b49357243655567ca46900f13759 100644 --- a/apps/web/src/persistenceSchema.ts +++ b/apps/web/src/persistenceSchema.ts @@ -34,6 +34,8 @@ const persistedThreadSchema = z.object({ model: z.string().min(1), messages: z.array(persistedMessageSchema), createdAt: z.string().min(1), + branch: z.string().min(1).nullable().optional(), + worktreePath: z.string().min(1).nullable().optional(), }); const persistedStateBodySchema = z.object({ @@ -112,6 +114,8 @@ function hydrateThread( events: [], error: null, createdAt: thread.createdAt, + branch: thread.branch ?? null, + worktreePath: thread.worktreePath ?? null, }; } @@ -170,6 +174,8 @@ export function toPersistedState( model: thread.model, messages: thread.messages, createdAt: thread.createdAt, + branch: thread.branch, + worktreePath: thread.worktreePath, })), activeThreadId: state.activeThreadId, runtimeMode: state.runtimeMode, diff --git a/apps/web/src/types.ts b/apps/web/src/types.ts index ebe3731d121be92b304c7e0e4c050b604802e482..66746532b57e5b072266b624303f54aa375b852f 100644 --- a/apps/web/src/types.ts +++ b/apps/web/src/types.ts @@ -35,4 +35,6 @@ export interface Thread { latestTurnStartedAt?: string | undefined; latestTurnCompletedAt?: string | undefined; latestTurnDurationMs?: number | undefined; + branch: string | null; + worktreePath: string | null; } diff --git a/packages/contracts/src/git.ts b/packages/contracts/src/git.ts new file mode 100644 index 0000000000000000000000000000000000000000..aa2f72e67a9b4d7eba90d0090279138b1ff9fcf4 --- /dev/null +++ b/packages/contracts/src/git.ts @@ -0,0 +1,56 @@ +import { z } from "zod"; + +// ── Input schemas ── + +export const gitListBranchesInputSchema = z.object({ + cwd: z.string().min(1), +}); + +export const gitCreateWorktreeInputSchema = z.object({ + cwd: z.string().min(1), + branch: z.string().min(1), + path: z.string().min(1).optional(), +}); + +export const gitRemoveWorktreeInputSchema = z.object({ + cwd: z.string().min(1), + path: z.string().min(1), +}); + +export const gitInitInputSchema = z.object({ + cwd: z.string().min(1), +}); + +// ── Output schemas ── + +export const gitBranchSchema = z.object({ + name: z.string().min(1), + current: z.boolean(), +}); + +export const gitWorktreeSchema = z.object({ + path: z.string().min(1), + branch: z.string().min(1), +}); + +// ── Types ── + +export type GitListBranchesInput = z.infer; +export type GitBranch = z.infer; +export type GitCreateWorktreeInput = z.infer< + typeof gitCreateWorktreeInputSchema +>; +export type GitWorktree = z.infer; +export type GitRemoveWorktreeInput = z.infer< + typeof gitRemoveWorktreeInputSchema +>; +export type GitInitInput = z.infer; + +export interface GitListBranchesResult { + branches: GitBranch[]; + isRepo: boolean; +} + +export interface GitCreateWorktreeResult { + worktree: GitWorktree; +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts index ec007fc29fb1c84b4ca9137b4c8b965b8b8f2f6f..430f4fd84077d4e439b8f5165571e6be7c87baa3 100644 --- a/packages/contracts/src/index.ts +++ b/packages/contracts/src/index.ts @@ -6,3 +6,4 @@ export * from "./provider"; export * from "./model"; export * from "./ws"; export * from "./project"; +export * from "./git"; diff --git a/packages/contracts/src/ipc.ts b/packages/contracts/src/ipc.ts index 4138750510ceb684c46626ae3ba4dbe92583d4d5..45aa5f1d63bbbd64ac4006aab7a906a7adf680ee 100644 --- a/packages/contracts/src/ipc.ts +++ b/packages/contracts/src/ipc.ts @@ -1,4 +1,12 @@ import type { AgentConfig, AgentExit, OutputChunk } from "./agent"; +import type { + GitCreateWorktreeInput, + GitCreateWorktreeResult, + GitInitInput, + GitListBranchesInput, + GitListBranchesResult, + GitRemoveWorktreeInput, +} from "./git"; import type { ProviderEvent, ProviderInterruptTurnInput, @@ -62,4 +70,10 @@ export interface NativeApi { shell: { openInEditor: (cwd: string, editor: EditorId) => Promise; }; + git: { + listBranches: (input: GitListBranchesInput) => Promise; + createWorktree: (input: GitCreateWorktreeInput) => Promise; + removeWorktree: (input: GitRemoveWorktreeInput) => Promise; + init: (input: GitInitInput) => Promise; + }; } -- 2.54.0