authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-09 11:00:24-08:00
committergravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-02-11 09:00:18-08:00
log7e201486f15575d587890dbe1fd637a5f552da91
treeba485a94d8a26ea42d0f1c51173ef401b28c62e2
parentf47dc3c78ca9496b80c4b4a2537c74bbd697fd9a

resource management


2 files changed, 131 insertions(+), 128 deletions(-)

apps/desktop/src/git.test.ts+130-127
......@@ -2,7 +2,7 @@ import { existsSync } from "node:fs";
22import { mkdtemp, rm, writeFile } from "node:fs/promises";
33import { tmpdir } from "node:os";
44import path from "node:path";
5import { afterEach, beforeEach, describe, expect, it } from "vitest";
5import { describe, expect, it } from "vitest";
66
77import {
88 checkoutGitBranch,
......@@ -29,6 +29,17 @@ async function git(cwd: string, command: string): Promise<string> {
2929 return result.stdout.trim();
3030}
3131
32/** Create a disposable temp directory that cleans up automatically. */
33async function makeTmpDir() {
34 const dir = await mkdtemp(path.join(tmpdir(), "git-test-"));
35 return {
36 path: dir,
37 [Symbol.asyncDispose]: async () => {
38 await rm(dir, { recursive: true, force: true });
39 },
40 };
41}
42
3243/** Create a repo with an initial commit so branches work. */
3344async function initRepoWithCommit(cwd: string): Promise<void> {
3445 await initGitRepo({ cwd });
......@@ -42,27 +53,19 @@ async function initRepoWithCommit(cwd: string): Promise<void> {
4253// ── Tests ──
4354
4455describe("git integration", () => {
45 let tmpDir: string;
46
47 beforeEach(async () => {
48 tmpDir = await mkdtemp(path.join(tmpdir(), "git-test-"));
49 });
50
51 afterEach(async () => {
52 await rm(tmpDir, { recursive: true, force: true });
53 });
54
5556 // ── initGitRepo ──
5657
5758 describe("initGitRepo", () => {
5859 it("creates a valid git repo", async () => {
59 await initGitRepo({ cwd: tmpDir });
60 expect(existsSync(path.join(tmpDir, ".git"))).toBe(true);
60 await using tmp = await makeTmpDir();
61 await initGitRepo({ cwd: tmp.path });
62 expect(existsSync(path.join(tmp.path, ".git"))).toBe(true);
6163 });
6264
6365 it("listGitBranches reports isRepo: true after init + commit", async () => {
64 await initRepoWithCommit(tmpDir);
65 const result = await listGitBranches({ cwd: tmpDir });
66 await using tmp = await makeTmpDir();
67 await initRepoWithCommit(tmp.path);
68 const result = await listGitBranches({ cwd: tmp.path });
6669 expect(result.isRepo).toBe(true);
6770 expect(result.branches.length).toBeGreaterThanOrEqual(1);
6871 });
......@@ -72,42 +75,47 @@ describe("git integration", () => {
7275
7376 describe("listGitBranches", () => {
7477 it("returns isRepo: false for non-git directory", async () => {
75 const result = await listGitBranches({ cwd: tmpDir });
78 await using tmp = await makeTmpDir();
79 const result = await listGitBranches({ cwd: tmp.path });
7680 expect(result.isRepo).toBe(false);
7781 expect(result.branches).toEqual([]);
7882 });
7983
8084 it("returns the current branch with current: true", async () => {
81 await initRepoWithCommit(tmpDir);
82 const result = await listGitBranches({ cwd: tmpDir });
85 await using tmp = await makeTmpDir();
86 await initRepoWithCommit(tmp.path);
87 const result = await listGitBranches({ cwd: tmp.path });
8388 const current = result.branches.find((b) => b.current);
8489 expect(current).toBeDefined();
8590 expect(current!.current).toBe(true);
8691 });
8792
8893 it("sorts current branch first", async () => {
89 await initRepoWithCommit(tmpDir);
90 await createGitBranch({ cwd: tmpDir, branch: "aaa-first-alpha" });
91 await createGitBranch({ cwd: tmpDir, branch: "zzz-last" });
94 await using tmp = await makeTmpDir();
95 await initRepoWithCommit(tmp.path);
96 await createGitBranch({ cwd: tmp.path, branch: "aaa-first-alpha" });
97 await createGitBranch({ cwd: tmp.path, branch: "zzz-last" });
9298
93 const result = await listGitBranches({ cwd: tmpDir });
99 const result = await listGitBranches({ cwd: tmp.path });
94100 expect(result.branches[0]!.current).toBe(true);
95101 });
96102
97103 it("lists multiple branches after creating them", async () => {
98 await initRepoWithCommit(tmpDir);
99 await createGitBranch({ cwd: tmpDir, branch: "feature-a" });
100 await createGitBranch({ cwd: tmpDir, branch: "feature-b" });
104 await using tmp = await makeTmpDir();
105 await initRepoWithCommit(tmp.path);
106 await createGitBranch({ cwd: tmp.path, branch: "feature-a" });
107 await createGitBranch({ cwd: tmp.path, branch: "feature-b" });
101108
102 const result = await listGitBranches({ cwd: tmpDir });
109 const result = await listGitBranches({ cwd: tmp.path });
103110 const names = result.branches.map((b) => b.name);
104111 expect(names).toContain("feature-a");
105112 expect(names).toContain("feature-b");
106113 });
107114
108115 it("isDefault is false when no remote exists", async () => {
109 await initRepoWithCommit(tmpDir);
110 const result = await listGitBranches({ cwd: tmpDir });
116 await using tmp = await makeTmpDir();
117 await initRepoWithCommit(tmp.path);
118 const result = await listGitBranches({ cwd: tmp.path });
111119 expect(result.branches.every((b) => b.isDefault === false)).toBe(true);
112120 });
113121 });
......@@ -116,57 +124,50 @@ describe("git integration", () => {
116124
117125 describe("checkoutGitBranch", () => {
118126 it("checks out an existing branch", async () => {
119 await initRepoWithCommit(tmpDir);
120 await createGitBranch({ cwd: tmpDir, branch: "feature" });
127 await using tmp = await makeTmpDir();
128 await initRepoWithCommit(tmp.path);
129 await createGitBranch({ cwd: tmp.path, branch: "feature" });
121130
122 await checkoutGitBranch({ cwd: tmpDir, branch: "feature" });
131 await checkoutGitBranch({ cwd: tmp.path, branch: "feature" });
123132
124 const result = await listGitBranches({ cwd: tmpDir });
133 const result = await listGitBranches({ cwd: tmp.path });
125134 const current = result.branches.find((b) => b.current);
126135 expect(current!.name).toBe("feature");
127136 });
128137
129138 it("throws when branch does not exist", async () => {
130 await initRepoWithCommit(tmpDir);
131 await expect(
132 checkoutGitBranch({ cwd: tmpDir, branch: "nonexistent" }),
133 ).rejects.toThrow();
139 await using tmp = await makeTmpDir();
140 await initRepoWithCommit(tmp.path);
141 await expect(checkoutGitBranch({ cwd: tmp.path, branch: "nonexistent" })).rejects.toThrow();
134142 });
135143
136144 it("throws when checkout would overwrite uncommitted changes", async () => {
137 await initRepoWithCommit(tmpDir);
138 await createGitBranch({ cwd: tmpDir, branch: "other" });
145 await using tmp = await makeTmpDir();
146 await initRepoWithCommit(tmp.path);
147 await createGitBranch({ cwd: tmp.path, branch: "other" });
139148
140149 // Create a conflicting change: modify README on current branch
141 await writeFile(path.join(tmpDir, "README.md"), "modified\n");
142 await git(tmpDir, "add README.md");
143
144 // On the other branch, README has original content — checkout should conflict
145 // Actually git checkout with staged changes can sometimes work, so let's
146 // create a real conflict: commit different content on other branch first.
147 // Simpler: write to a tracked file without committing, then checkout a branch
148 // where that file differs.
150 await writeFile(path.join(tmp.path, "README.md"), "modified\n");
151 await git(tmp.path, "add README.md");
149152
150153 // First, checkout other branch cleanly
151 await git(tmpDir, "stash");
152 await checkoutGitBranch({ cwd: tmpDir, branch: "other" });
153 await writeFile(path.join(tmpDir, "README.md"), "other content\n");
154 await git(tmpDir, "add .");
155 await git(tmpDir, "commit -m 'other change'");
154 await git(tmp.path, "stash");
155 await checkoutGitBranch({ cwd: tmp.path, branch: "other" });
156 await writeFile(path.join(tmp.path, "README.md"), "other content\n");
157 await git(tmp.path, "add .");
158 await git(tmp.path, "commit -m 'other change'");
156159
157160 // Go back to default branch
158 const defaultBranch = (
159 await listGitBranches({ cwd: tmpDir })
160 ).branches.find((b) => !b.current)!.name;
161 await checkoutGitBranch({ cwd: tmpDir, branch: defaultBranch });
161 const defaultBranch = (await listGitBranches({ cwd: tmp.path })).branches.find(
162 (b) => !b.current,
163 )!.name;
164 await checkoutGitBranch({ cwd: tmp.path, branch: defaultBranch });
162165
163166 // Make uncommitted changes to the same file
164 await writeFile(path.join(tmpDir, "README.md"), "conflicting local\n");
167 await writeFile(path.join(tmp.path, "README.md"), "conflicting local\n");
165168
166169 // Checkout should fail due to uncommitted changes
167 await expect(
168 checkoutGitBranch({ cwd: tmpDir, branch: "other" }),
169 ).rejects.toThrow();
170 await expect(checkoutGitBranch({ cwd: tmp.path, branch: "other" })).rejects.toThrow();
170171 });
171172 });
172173
......@@ -174,19 +175,19 @@ describe("git integration", () => {
174175
175176 describe("createGitBranch", () => {
176177 it("creates a new branch visible in listGitBranches", async () => {
177 await initRepoWithCommit(tmpDir);
178 await createGitBranch({ cwd: tmpDir, branch: "new-feature" });
178 await using tmp = await makeTmpDir();
179 await initRepoWithCommit(tmp.path);
180 await createGitBranch({ cwd: tmp.path, branch: "new-feature" });
179181
180 const result = await listGitBranches({ cwd: tmpDir });
182 const result = await listGitBranches({ cwd: tmp.path });
181183 expect(result.branches.some((b) => b.name === "new-feature")).toBe(true);
182184 });
183185
184186 it("throws when branch already exists", async () => {
185 await initRepoWithCommit(tmpDir);
186 await createGitBranch({ cwd: tmpDir, branch: "dupe" });
187 await expect(
188 createGitBranch({ cwd: tmpDir, branch: "dupe" }),
189 ).rejects.toThrow();
187 await using tmp = await makeTmpDir();
188 await initRepoWithCommit(tmp.path);
189 await createGitBranch({ cwd: tmp.path, branch: "dupe" });
190 await expect(createGitBranch({ cwd: tmp.path, branch: "dupe" })).rejects.toThrow();
190191 });
191192 });
192193
......@@ -194,12 +195,13 @@ describe("git integration", () => {
194195
195196 describe("createGitWorktree", () => {
196197 it("creates a worktree directory at the expected path", async () => {
197 await initRepoWithCommit(tmpDir);
198 await createGitBranch({ cwd: tmpDir, branch: "wt-branch" });
198 await using tmp = await makeTmpDir();
199 await initRepoWithCommit(tmp.path);
200 await createGitBranch({ cwd: tmp.path, branch: "wt-branch" });
199201
200 const wtPath = path.join(tmpDir, "worktree-out");
202 const wtPath = path.join(tmp.path, "worktree-out");
201203 const result = await createGitWorktree({
202 cwd: tmpDir,
204 cwd: tmp.path,
203205 branch: "wt-branch",
204206 path: wtPath,
205207 });
......@@ -209,17 +211,18 @@ describe("git integration", () => {
209211 expect(existsSync(wtPath)).toBe(true);
210212 expect(existsSync(path.join(wtPath, "README.md"))).toBe(true);
211213
212 // Clean up worktree
213 await removeGitWorktree({ cwd: tmpDir, path: wtPath });
214 // Clean up worktree before tmp dir disposal
215 await removeGitWorktree({ cwd: tmp.path, path: wtPath });
214216 });
215217
216218 it("worktree has the correct branch checked out", async () => {
217 await initRepoWithCommit(tmpDir);
218 await createGitBranch({ cwd: tmpDir, branch: "wt-check" });
219 await using tmp = await makeTmpDir();
220 await initRepoWithCommit(tmp.path);
221 await createGitBranch({ cwd: tmp.path, branch: "wt-check" });
219222
220 const wtPath = path.join(tmpDir, "wt-check-dir");
223 const wtPath = path.join(tmp.path, "wt-check-dir");
221224 await createGitWorktree({
222 cwd: tmpDir,
225 cwd: tmp.path,
223226 branch: "wt-check",
224227 path: wtPath,
225228 });
......@@ -228,19 +231,20 @@ describe("git integration", () => {
228231 const branchOutput = await git(wtPath, "branch --show-current");
229232 expect(branchOutput).toBe("wt-check");
230233
231 await removeGitWorktree({ cwd: tmpDir, path: wtPath });
234 await removeGitWorktree({ cwd: tmp.path, path: wtPath });
232235 });
233236
234237 it("throws when branch is already checked out in main worktree", async () => {
235 await initRepoWithCommit(tmpDir);
238 await using tmp = await makeTmpDir();
239 await initRepoWithCommit(tmp.path);
236240 // Try to create a worktree for the current branch (already checked out)
237 const branches = await listGitBranches({ cwd: tmpDir });
241 const branches = await listGitBranches({ cwd: tmp.path });
238242 const currentBranch = branches.branches.find((b) => b.current)!.name;
239243
240 const wtPath = path.join(tmpDir, "wt-conflict");
244 const wtPath = path.join(tmp.path, "wt-conflict");
241245 await expect(
242246 createGitWorktree({
243 cwd: tmpDir,
247 cwd: tmp.path,
244248 branch: currentBranch,
245249 path: wtPath,
246250 }),
......@@ -248,18 +252,19 @@ describe("git integration", () => {
248252 });
249253
250254 it("removeGitWorktree cleans up the worktree", async () => {
251 await initRepoWithCommit(tmpDir);
252 await createGitBranch({ cwd: tmpDir, branch: "wt-remove" });
255 await using tmp = await makeTmpDir();
256 await initRepoWithCommit(tmp.path);
257 await createGitBranch({ cwd: tmp.path, branch: "wt-remove" });
253258
254 const wtPath = path.join(tmpDir, "wt-remove-dir");
259 const wtPath = path.join(tmp.path, "wt-remove-dir");
255260 await createGitWorktree({
256 cwd: tmpDir,
261 cwd: tmp.path,
257262 branch: "wt-remove",
258263 path: wtPath,
259264 });
260265 expect(existsSync(wtPath)).toBe(true);
261266
262 await removeGitWorktree({ cwd: tmpDir, path: wtPath });
267 await removeGitWorktree({ cwd: tmp.path, path: wtPath });
263268 expect(existsSync(wtPath)).toBe(false);
264269 });
265270 });
......@@ -268,11 +273,12 @@ describe("git integration", () => {
268273
269274 describe("full flow: local branch checkout", () => {
270275 it("init → commit → create branch → checkout → verify current", async () => {
271 await initRepoWithCommit(tmpDir);
272 await createGitBranch({ cwd: tmpDir, branch: "feature-login" });
273 await checkoutGitBranch({ cwd: tmpDir, branch: "feature-login" });
276 await using tmp = await makeTmpDir();
277 await initRepoWithCommit(tmp.path);
278 await createGitBranch({ cwd: tmp.path, branch: "feature-login" });
279 await checkoutGitBranch({ cwd: tmp.path, branch: "feature-login" });
274280
275 const result = await listGitBranches({ cwd: tmpDir });
281 const result = await listGitBranches({ cwd: tmp.path });
276282 const current = result.branches.find((b) => b.current);
277283 expect(current!.name).toBe("feature-login");
278284 });
......@@ -282,12 +288,13 @@ describe("git integration", () => {
282288
283289 describe("full flow: worktree creation", () => {
284290 it("creates worktree from a non-current branch", async () => {
285 await initRepoWithCommit(tmpDir);
286 await createGitBranch({ cwd: tmpDir, branch: "feature-wt" });
291 await using tmp = await makeTmpDir();
292 await initRepoWithCommit(tmp.path);
293 await createGitBranch({ cwd: tmp.path, branch: "feature-wt" });
287294
288 const wtPath = path.join(tmpDir, "my-worktree");
295 const wtPath = path.join(tmp.path, "my-worktree");
289296 const result = await createGitWorktree({
290 cwd: tmpDir,
297 cwd: tmp.path,
291298 branch: "feature-wt",
292299 path: wtPath,
293300 });
......@@ -296,7 +303,7 @@ describe("git integration", () => {
296303 expect(existsSync(result.worktree.path)).toBe(true);
297304
298305 // Main repo still on original branch
299 const mainBranches = await listGitBranches({ cwd: tmpDir });
306 const mainBranches = await listGitBranches({ cwd: tmp.path });
300307 const mainCurrent = mainBranches.branches.find((b) => b.current);
301308 expect(mainCurrent!.name).not.toBe("feature-wt");
302309
......@@ -304,7 +311,7 @@ describe("git integration", () => {
304311 const wtBranch = await git(wtPath, "branch --show-current");
305312 expect(wtBranch).toBe("feature-wt");
306313
307 await removeGitWorktree({ cwd: tmpDir, path: wtPath });
314 await removeGitWorktree({ cwd: tmp.path, path: wtPath });
308315 });
309316 });
310317
......@@ -312,23 +319,24 @@ describe("git integration", () => {
312319
313320 describe("full flow: thread switching (checkout toggling)", () => {
314321 it("checkout a → checkout b → checkout a → current matches", async () => {
315 await initRepoWithCommit(tmpDir);
316 await createGitBranch({ cwd: tmpDir, branch: "branch-a" });
317 await createGitBranch({ cwd: tmpDir, branch: "branch-b" });
322 await using tmp = await makeTmpDir();
323 await initRepoWithCommit(tmp.path);
324 await createGitBranch({ cwd: tmp.path, branch: "branch-a" });
325 await createGitBranch({ cwd: tmp.path, branch: "branch-b" });
318326
319327 // Simulate switching to thread A's branch
320 await checkoutGitBranch({ cwd: tmpDir, branch: "branch-a" });
321 let branches = await listGitBranches({ cwd: tmpDir });
328 await checkoutGitBranch({ cwd: tmp.path, branch: "branch-a" });
329 let branches = await listGitBranches({ cwd: tmp.path });
322330 expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a");
323331
324332 // Simulate switching to thread B's branch
325 await checkoutGitBranch({ cwd: tmpDir, branch: "branch-b" });
326 branches = await listGitBranches({ cwd: tmpDir });
333 await checkoutGitBranch({ cwd: tmp.path, branch: "branch-b" });
334 branches = await listGitBranches({ cwd: tmp.path });
327335 expect(branches.branches.find((b) => b.current)!.name).toBe("branch-b");
328336
329337 // Switch back to thread A
330 await checkoutGitBranch({ cwd: tmpDir, branch: "branch-a" });
331 branches = await listGitBranches({ cwd: tmpDir });
338 await checkoutGitBranch({ cwd: tmp.path, branch: "branch-a" });
339 branches = await listGitBranches({ cwd: tmp.path });
332340 expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a");
333341 });
334342 });
......@@ -337,42 +345,37 @@ describe("git integration", () => {
337345
338346 describe("full flow: checkout conflict", () => {
339347 it("uncommitted changes prevent checkout to a diverged branch", async () => {
340 await initRepoWithCommit(tmpDir);
341 await createGitBranch({ cwd: tmpDir, branch: "diverged" });
348 await using tmp = await makeTmpDir();
349 await initRepoWithCommit(tmp.path);
350 await createGitBranch({ cwd: tmp.path, branch: "diverged" });
342351
343352 // Make diverged branch have different file content
344 await checkoutGitBranch({ cwd: tmpDir, branch: "diverged" });
345 await writeFile(path.join(tmpDir, "README.md"), "diverged content\n");
346 await git(tmpDir, "add .");
347 await git(tmpDir, "commit -m 'diverge'");
353 await checkoutGitBranch({ cwd: tmp.path, branch: "diverged" });
354 await writeFile(path.join(tmp.path, "README.md"), "diverged content\n");
355 await git(tmp.path, "add .");
356 await git(tmp.path, "commit -m 'diverge'");
348357
349358 // Go back to default branch
350 const defaultBranch = (
351 await git(tmpDir, "rev-parse --abbrev-ref HEAD")
352 ).includes("diverged")
359 const defaultBranch = (await git(tmp.path, "rev-parse --abbrev-ref HEAD")).includes("diverged")
353360 ? // we're on diverged, need to find the other one
354 (await listGitBranches({ cwd: tmpDir })).branches.find(
361 (await listGitBranches({ cwd: tmp.path })).branches.find(
355362 (b) => !b.current && b.name !== "diverged",
356363 )!.name
357 : (await git(tmpDir, "rev-parse --abbrev-ref HEAD"));
364 : await git(tmp.path, "rev-parse --abbrev-ref HEAD");
358365
359366 // Actually, let's just get back to the initial branch explicitly
360 const allBranches = await listGitBranches({ cwd: tmpDir });
361 const initialBranch = allBranches.branches.find(
362 (b) => b.name !== "diverged",
363 )!.name;
364 await checkoutGitBranch({ cwd: tmpDir, branch: initialBranch });
367 const allBranches = await listGitBranches({ cwd: tmp.path });
368 const initialBranch = allBranches.branches.find((b) => b.name !== "diverged")!.name;
369 await checkoutGitBranch({ cwd: tmp.path, branch: initialBranch });
365370
366371 // Make local uncommitted changes to the same file
367 await writeFile(path.join(tmpDir, "README.md"), "local uncommitted\n");
372 await writeFile(path.join(tmp.path, "README.md"), "local uncommitted\n");
368373
369374 // Attempt checkout should fail
370 await expect(
371 checkoutGitBranch({ cwd: tmpDir, branch: "diverged" }),
372 ).rejects.toThrow();
375 await expect(checkoutGitBranch({ cwd: tmp.path, branch: "diverged" })).rejects.toThrow();
373376
374377 // Current branch should still be the initial one
375 const result = await listGitBranches({ cwd: tmpDir });
378 const result = await listGitBranches({ cwd: tmp.path });
376379 expect(result.branches.find((b) => b.current)!.name).toBe(initialBranch);
377380 });
378381 });
apps/desktop/tsconfig.json+1-1
......@@ -3,7 +3,7 @@
33 "compilerOptions": {
44 "composite": true,
55 "types": ["node", "electron"],
6 "lib": ["ES2023", "DOM"]
6 "lib": ["ES2023", "DOM", "esnext.disposable"]
77 },
88 "include": ["src", "tsup.config.ts"]
99}