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", () => {...@@ -194,15 +194,19 @@ describe("git integration", () => {
194 // ── createGitWorktree + removeGitWorktree ──194 // ── createGitWorktree + removeGitWorktree ──
195195
196 describe("createGitWorktree", () => {196 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 () => {
198 await using tmp = await makeTmpDir();198 await using tmp = await makeTmpDir();
199 await initRepoWithCommit(tmp.path);199 await initRepoWithCommit(tmp.path);
200 await createGitBranch({ cwd: tmp.path, branch: "wt-branch" });
201200
202 const wtPath = path.join(tmp.path, "worktree-out");201 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
203 const result = await createGitWorktree({206 const result = await createGitWorktree({
204 cwd: tmp.path,207 cwd: tmp.path,
205 branch: "wt-branch",208 branch: currentBranch,
209 newBranch: "wt-branch",
206 path: wtPath,210 path: wtPath,
207 });211 });
208212
...@@ -215,37 +219,44 @@ describe("git integration", () => {...@@ -215,37 +219,44 @@ describe("git integration", () => {
215 await removeGitWorktree({ cwd: tmp.path, path: wtPath });219 await removeGitWorktree({ cwd: tmp.path, path: wtPath });
216 });220 });
217221
218 it("worktree has the correct branch checked out", async () => {222 it("worktree has the new branch checked out", async () => {
219 await using tmp = await makeTmpDir();223 await using tmp = await makeTmpDir();
220 await initRepoWithCommit(tmp.path);224 await initRepoWithCommit(tmp.path);
221 await createGitBranch({ cwd: tmp.path, branch: "wt-check" });
222225
223 const wtPath = path.join(tmp.path, "wt-check-dir");226 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
224 await createGitWorktree({231 await createGitWorktree({
225 cwd: tmp.path,232 cwd: tmp.path,
226 branch: "wt-check",233 branch: currentBranch,
234 newBranch: "wt-check",
227 path: wtPath,235 path: wtPath,
228 });236 });
229237
230 // Verify the worktree is on the right branch238 // Verify the worktree is on the new branch
231 const branchOutput = await git(wtPath, "branch --show-current");239 const branchOutput = await git(wtPath, "branch --show-current");
232 expect(branchOutput).toBe("wt-check");240 expect(branchOutput).toBe("wt-check");
233241
234 await removeGitWorktree({ cwd: tmp.path, path: wtPath });242 await removeGitWorktree({ cwd: tmp.path, path: wtPath });
235 });243 });
236244
237 it("throws when branch is already checked out in main worktree", async () => {245 it("throws when new branch name already exists", async () => {
238 await using tmp = await makeTmpDir();246 await using tmp = await makeTmpDir();
239 await initRepoWithCommit(tmp.path);247 await initRepoWithCommit(tmp.path);
240 // Try to create a worktree for the current branch (already checked out)248 await createGitBranch({ cwd: tmp.path, branch: "existing" });
241 const branches = await listGitBranches({ cwd: tmp.path });
242 const currentBranch = branches.branches.find((b) => b.current)!.name;
243249
244 const wtPath = path.join(tmp.path, "wt-conflict");250 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
245 await expect(255 await expect(
246 createGitWorktree({256 createGitWorktree({
247 cwd: tmp.path,257 cwd: tmp.path,
248 branch: currentBranch,258 branch: currentBranch,
259 newBranch: "existing",
249 path: wtPath,260 path: wtPath,
250 }),261 }),
251 ).rejects.toThrow();262 ).rejects.toThrow();
...@@ -254,12 +265,16 @@ describe("git integration", () => {...@@ -254,12 +265,16 @@ describe("git integration", () => {
254 it("removeGitWorktree cleans up the worktree", async () => {265 it("removeGitWorktree cleans up the worktree", async () => {
255 await using tmp = await makeTmpDir();266 await using tmp = await makeTmpDir();
256 await initRepoWithCommit(tmp.path);267 await initRepoWithCommit(tmp.path);
257 await createGitBranch({ cwd: tmp.path, branch: "wt-remove" });
258268
259 const wtPath = path.join(tmp.path, "wt-remove-dir");269 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
260 await createGitWorktree({274 await createGitWorktree({
261 cwd: tmp.path,275 cwd: tmp.path,
262 branch: "wt-remove",276 branch: currentBranch,
277 newBranch: "wt-remove",
263 path: wtPath,278 path: wtPath,
264 });279 });
265 expect(existsSync(wtPath)).toBe(true);280 expect(existsSync(wtPath)).toBe(true);
...@@ -284,18 +299,22 @@ describe("git integration", () => {...@@ -284,18 +299,22 @@ describe("git integration", () => {
284 });299 });
285 });300 });
286301
287 // ── Full flow: worktree creation from selected branch ──302 // ── Full flow: worktree creation from base branch ──
288303
289 describe("full flow: worktree creation", () => {304 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 () => {
291 await using tmp = await makeTmpDir();306 await using tmp = await makeTmpDir();
292 await initRepoWithCommit(tmp.path);307 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
295 const wtPath = path.join(tmp.path, "my-worktree");313 const wtPath = path.join(tmp.path, "my-worktree");
296 const result = await createGitWorktree({314 const result = await createGitWorktree({
297 cwd: tmp.path,315 cwd: tmp.path,
298 branch: "feature-wt",316 branch: currentBranch,
317 newBranch: "feature-wt",
299 path: wtPath,318 path: wtPath,
300 });319 });
301320
...@@ -305,9 +324,9 @@ describe("git integration", () => {...@@ -305,9 +324,9 @@ describe("git integration", () => {
305 // Main repo still on original branch324 // Main repo still on original branch
306 const mainBranches = await listGitBranches({ cwd: tmp.path });325 const mainBranches = await listGitBranches({ cwd: tmp.path });
307 const mainCurrent = mainBranches.branches.find((b) => b.current);326 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-wt329 // Worktree is on the new branch
311 const wtBranch = await git(wtPath, "branch --show-current");330 const wtBranch = await git(wtPath, "branch --show-current");
312 expect(wtBranch).toBe("feature-wt");331 expect(wtBranch).toBe("feature-wt");
313332
...@@ -356,7 +375,9 @@ describe("git integration", () => {...@@ -356,7 +375,9 @@ describe("git integration", () => {
356 await git(tmp.path, "commit -m 'diverge'");375 await git(tmp.path, "commit -m 'diverge'");
357376
358 // Go back to default branch377 // 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 )
360 ? // we're on diverged, need to find the other one381 ? // we're on diverged, need to find the other one
361 (await listGitBranches({ cwd: tmp.path })).branches.find(382 (await listGitBranches({ cwd: tmp.path })).branches.find(
362 (b) => !b.current && b.name !== "diverged",383 (b) => !b.current && b.name !== "diverged",
apps/desktop/src/git.ts+5-3
...@@ -123,14 +123,16 @@ export async function listGitBranches(...@@ -123,14 +123,16 @@ export async function listGitBranches(
123export async function createGitWorktree(123export async function createGitWorktree(
124 input: GitCreateWorktreeInput,124 input: GitCreateWorktreeInput,
125): Promise<GitCreateWorktreeResult> {125): Promise<GitCreateWorktreeResult> {
126 const sanitizedBranch = input.branch.replace(/\//g, "-");126 const esc = (s: string) => s.replace(/'/g, "'\\''");
127 const sanitizedBranch = input.newBranch.replace(/\//g, "-");
127 const repoName = path.basename(input.cwd);128 const repoName = path.basename(input.cwd);
128 const worktreePath =129 const worktreePath =
129 input.path ??130 input.path ??
130 path.join(input.cwd, "..", `${repoName}-worktrees`, sanitizedBranch);131 path.join(input.cwd, "..", `${repoName}-worktrees`, sanitizedBranch);
131132
133 // Create a new branch from the base branch in a new worktree
132 const result = await runTerminalCommand({134 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)}'`,
134 cwd: input.cwd,136 cwd: input.cwd,
135 timeoutMs: 30_000,137 timeoutMs: 30_000,
136 });138 });
...@@ -142,7 +144,7 @@ export async function createGitWorktree(...@@ -142,7 +144,7 @@ export async function createGitWorktree(
142 return {144 return {
143 worktree: {145 worktree: {
144 path: worktreePath,146 path: worktreePath,
145 branch: input.branch,147 branch: input.newBranch,
146 },148 },
147 };149 };
148}150}
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({...@@ -8,7 +8,10 @@ export const gitListBranchesInputSchema = z.object({
88
9export const gitCreateWorktreeInputSchema = z.object({9export const gitCreateWorktreeInputSchema = z.object({
10 cwd: z.string().min(1),10 cwd: z.string().min(1),
11 /** Base branch to create the worktree from. */
11 branch: z.string().min(1),12 branch: z.string().min(1),
13 /** New branch name to create in the worktree. */
14 newBranch: z.string().min(1),
12 path: z.string().min(1).optional(),15 path: z.string().min(1).optional(),
13});16});
1417