authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-09 13:28:10-08:00
committergravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-11 09:00:18-08:00
log2a610f92b9c6254119d13bbc83b50c9ca7d288b4
tree10281b5dd3234660650135241758ee5892b21ad6
parent7e201486f15575d587890dbe1fd637a5f552da91

kewl


4 files changed, 61 insertions(+), 23 deletions(-)

apps/desktop/src/git.test.ts+41-20
......@@ -194,15 +194,19 @@ describe("git integration", () => {
194194 // ── createGitWorktree + removeGitWorktree ──
195195
196196 describe("createGitWorktree", () => {
197 it("creates a worktree directory at the expected path", async () => {
197 it("creates a worktree with a new branch from the base branch", async () => {
198198 await using tmp = await makeTmpDir();
199199 await initRepoWithCommit(tmp.path);
200 await createGitBranch({ cwd: tmp.path, branch: "wt-branch" });
201200
202201 const wtPath = path.join(tmp.path, "worktree-out");
202 const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find(
203 (b) => b.current,
204 )!.name;
205
203206 const result = await createGitWorktree({
204207 cwd: tmp.path,
205 branch: "wt-branch",
208 branch: currentBranch,
209 newBranch: "wt-branch",
206210 path: wtPath,
207211 });
208212
......@@ -215,37 +219,44 @@ describe("git integration", () => {
215219 await removeGitWorktree({ cwd: tmp.path, path: wtPath });
216220 });
217221
218 it("worktree has the correct branch checked out", async () => {
222 it("worktree has the new branch checked out", async () => {
219223 await using tmp = await makeTmpDir();
220224 await initRepoWithCommit(tmp.path);
221 await createGitBranch({ cwd: tmp.path, branch: "wt-check" });
222225
223226 const wtPath = path.join(tmp.path, "wt-check-dir");
227 const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find(
228 (b) => b.current,
229 )!.name;
230
224231 await createGitWorktree({
225232 cwd: tmp.path,
226 branch: "wt-check",
233 branch: currentBranch,
234 newBranch: "wt-check",
227235 path: wtPath,
228236 });
229237
230 // Verify the worktree is on the right branch
238 // Verify the worktree is on the new branch
231239 const branchOutput = await git(wtPath, "branch --show-current");
232240 expect(branchOutput).toBe("wt-check");
233241
234242 await removeGitWorktree({ cwd: tmp.path, path: wtPath });
235243 });
236244
237 it("throws when branch is already checked out in main worktree", async () => {
245 it("throws when new branch name already exists", async () => {
238246 await using tmp = await makeTmpDir();
239247 await initRepoWithCommit(tmp.path);
240 // Try to create a worktree for the current branch (already checked out)
241 const branches = await listGitBranches({ cwd: tmp.path });
242 const currentBranch = branches.branches.find((b) => b.current)!.name;
248 await createGitBranch({ cwd: tmp.path, branch: "existing" });
243249
244250 const wtPath = path.join(tmp.path, "wt-conflict");
251 const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find(
252 (b) => b.current,
253 )!.name;
254
245255 await expect(
246256 createGitWorktree({
247257 cwd: tmp.path,
248258 branch: currentBranch,
259 newBranch: "existing",
249260 path: wtPath,
250261 }),
251262 ).rejects.toThrow();
......@@ -254,12 +265,16 @@ describe("git integration", () => {
254265 it("removeGitWorktree cleans up the worktree", async () => {
255266 await using tmp = await makeTmpDir();
256267 await initRepoWithCommit(tmp.path);
257 await createGitBranch({ cwd: tmp.path, branch: "wt-remove" });
258268
259269 const wtPath = path.join(tmp.path, "wt-remove-dir");
270 const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find(
271 (b) => b.current,
272 )!.name;
273
260274 await createGitWorktree({
261275 cwd: tmp.path,
262 branch: "wt-remove",
276 branch: currentBranch,
277 newBranch: "wt-remove",
263278 path: wtPath,
264279 });
265280 expect(existsSync(wtPath)).toBe(true);
......@@ -284,18 +299,22 @@ describe("git integration", () => {
284299 });
285300 });
286301
287 // ── Full flow: worktree creation from selected branch ──
302 // ── Full flow: worktree creation from base branch ──
288303
289304 describe("full flow: worktree creation", () => {
290 it("creates worktree from a non-current branch", async () => {
305 it("creates worktree with new branch from current branch", async () => {
291306 await using tmp = await makeTmpDir();
292307 await initRepoWithCommit(tmp.path);
293 await createGitBranch({ cwd: tmp.path, branch: "feature-wt" });
308
309 const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find(
310 (b) => b.current,
311 )!.name;
294312
295313 const wtPath = path.join(tmp.path, "my-worktree");
296314 const result = await createGitWorktree({
297315 cwd: tmp.path,
298 branch: "feature-wt",
316 branch: currentBranch,
317 newBranch: "feature-wt",
299318 path: wtPath,
300319 });
301320
......@@ -305,9 +324,9 @@ describe("git integration", () => {
305324 // Main repo still on original branch
306325 const mainBranches = await listGitBranches({ cwd: tmp.path });
307326 const mainCurrent = mainBranches.branches.find((b) => b.current);
308 expect(mainCurrent!.name).not.toBe("feature-wt");
327 expect(mainCurrent!.name).toBe(currentBranch);
309328
310 // Worktree is on feature-wt
329 // Worktree is on the new branch
311330 const wtBranch = await git(wtPath, "branch --show-current");
312331 expect(wtBranch).toBe("feature-wt");
313332
......@@ -356,7 +375,9 @@ describe("git integration", () => {
356375 await git(tmp.path, "commit -m 'diverge'");
357376
358377 // Go back to default branch
359 const defaultBranch = (await git(tmp.path, "rev-parse --abbrev-ref HEAD")).includes("diverged")
378 const defaultBranch = (await git(tmp.path, "rev-parse --abbrev-ref HEAD")).includes(
379 "diverged",
380 )
360381 ? // we're on diverged, need to find the other one
361382 (await listGitBranches({ cwd: tmp.path })).branches.find(
362383 (b) => !b.current && b.name !== "diverged",
apps/desktop/src/git.ts+5-3
......@@ -123,14 +123,16 @@ export async function listGitBranches(
123123export async function createGitWorktree(
124124 input: GitCreateWorktreeInput,
125125): Promise<GitCreateWorktreeResult> {
126 const sanitizedBranch = input.branch.replace(/\//g, "-");
126 const esc = (s: string) => s.replace(/'/g, "'\\''");
127 const sanitizedBranch = input.newBranch.replace(/\//g, "-");
127128 const repoName = path.basename(input.cwd);
128129 const worktreePath =
129130 input.path ??
130131 path.join(input.cwd, "..", `${repoName}-worktrees`, sanitizedBranch);
131132
133 // Create a new branch from the base branch in a new worktree
132134 const result = await runTerminalCommand({
133 command: `git worktree add '${worktreePath.replace(/'/g, "'\\''")}' '${input.branch.replace(/'/g, "'\\''")}'`,
135 command: `git worktree add -b '${esc(input.newBranch)}' '${esc(worktreePath)}' '${esc(input.branch)}'`,
134136 cwd: input.cwd,
135137 timeoutMs: 30_000,
136138 });
......@@ -142,7 +144,7 @@ export async function createGitWorktree(
142144 return {
143145 worktree: {
144146 path: worktreePath,
145 branch: input.branch,
147 branch: input.newBranch,
146148 },
147149 };
148150}
apps/web/src/hooks/useGitState.ts created+12
......@@ -0,0 +1,12 @@
1import { queryOptions, skipToken } from "@tanstack/react-query";
2import { readNativeApi } from "../session-logic";
3
4export const listBranchesQuery = (cwd: string | undefined) => {
5 const api = readNativeApi();
6
7 return queryOptions({
8 queryKey: ["git-branches", cwd],
9 queryFn: api && cwd ? () => api.git.listBranches({ cwd }) : skipToken,
10 refetchOnWindowFocus: true,
11 });
12};
packages/contracts/src/git.ts+3
......@@ -8,7 +8,10 @@ export const gitListBranchesInputSchema = z.object({
88
99export const gitCreateWorktreeInputSchema = z.object({
1010 cwd: z.string().min(1),
11 /** Base branch to create the worktree from. */
1112 branch: z.string().min(1),
13 /** New branch name to create in the worktree. */
14 newBranch: z.string().min(1),
1215 path: z.string().min(1).optional(),
1316});
1417