| ... | @@ -0,0 +1,379 @@ |
| 1 | import { existsSync } from "node:fs"; |
| 2 | import { mkdtemp, rm, writeFile } from "node:fs/promises"; |
| 3 | import { tmpdir } from "node:os"; |
| 4 | import path from "node:path"; |
| 5 | import { afterEach, beforeEach, describe, expect, it } from "vitest"; |
| 6 | |
| 7 | import { |
| 8 | checkoutGitBranch, |
| 9 | createGitBranch, |
| 10 | createGitWorktree, |
| 11 | initGitRepo, |
| 12 | listGitBranches, |
| 13 | removeGitWorktree, |
| 14 | runTerminalCommand, |
| 15 | } from "./git"; |
| 16 | |
| 17 | // ── Helpers ── |
| 18 | |
| 19 | /** Run a raw git command for test setup (not under test). */ |
| 20 | async function git(cwd: string, command: string): Promise<string> { |
| 21 | const result = await runTerminalCommand({ |
| 22 | command: `git ${command}`, |
| 23 | cwd, |
| 24 | timeoutMs: 10_000, |
| 25 | }); |
| 26 | if (result.code !== 0) { |
| 27 | throw new Error(`git ${command} failed: ${result.stderr}`); |
| 28 | } |
| 29 | return result.stdout.trim(); |
| 30 | } |
| 31 | |
| 32 | /** Create a repo with an initial commit so branches work. */ |
| 33 | async function initRepoWithCommit(cwd: string): Promise<void> { |
| 34 | await initGitRepo({ cwd }); |
| 35 | await git(cwd, "config user.email 'test@test.com'"); |
| 36 | await git(cwd, "config user.name 'Test'"); |
| 37 | await writeFile(path.join(cwd, "README.md"), "# test\n"); |
| 38 | await git(cwd, "add ."); |
| 39 | await git(cwd, "commit -m 'initial commit'"); |
| 40 | } |
| 41 | |
| 42 | // ── Tests ── |
| 43 | |
| 44 | describe("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 | |
| 55 | // ── initGitRepo ── |
| 56 | |
| 57 | describe("initGitRepo", () => { |
| 58 | it("creates a valid git repo", async () => { |
| 59 | await initGitRepo({ cwd: tmpDir }); |
| 60 | expect(existsSync(path.join(tmpDir, ".git"))).toBe(true); |
| 61 | }); |
| 62 | |
| 63 | it("listGitBranches reports isRepo: true after init + commit", async () => { |
| 64 | await initRepoWithCommit(tmpDir); |
| 65 | const result = await listGitBranches({ cwd: tmpDir }); |
| 66 | expect(result.isRepo).toBe(true); |
| 67 | expect(result.branches.length).toBeGreaterThanOrEqual(1); |
| 68 | }); |
| 69 | }); |
| 70 | |
| 71 | // ── listGitBranches ── |
| 72 | |
| 73 | describe("listGitBranches", () => { |
| 74 | it("returns isRepo: false for non-git directory", async () => { |
| 75 | const result = await listGitBranches({ cwd: tmpDir }); |
| 76 | expect(result.isRepo).toBe(false); |
| 77 | expect(result.branches).toEqual([]); |
| 78 | }); |
| 79 | |
| 80 | it("returns the current branch with current: true", async () => { |
| 81 | await initRepoWithCommit(tmpDir); |
| 82 | const result = await listGitBranches({ cwd: tmpDir }); |
| 83 | const current = result.branches.find((b) => b.current); |
| 84 | expect(current).toBeDefined(); |
| 85 | expect(current!.current).toBe(true); |
| 86 | }); |
| 87 | |
| 88 | 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" }); |
| 92 | |
| 93 | const result = await listGitBranches({ cwd: tmpDir }); |
| 94 | expect(result.branches[0]!.current).toBe(true); |
| 95 | }); |
| 96 | |
| 97 | 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" }); |
| 101 | |
| 102 | const result = await listGitBranches({ cwd: tmpDir }); |
| 103 | const names = result.branches.map((b) => b.name); |
| 104 | expect(names).toContain("feature-a"); |
| 105 | expect(names).toContain("feature-b"); |
| 106 | }); |
| 107 | |
| 108 | it("isDefault is false when no remote exists", async () => { |
| 109 | await initRepoWithCommit(tmpDir); |
| 110 | const result = await listGitBranches({ cwd: tmpDir }); |
| 111 | expect(result.branches.every((b) => b.isDefault === false)).toBe(true); |
| 112 | }); |
| 113 | }); |
| 114 | |
| 115 | // ── checkoutGitBranch ── |
| 116 | |
| 117 | describe("checkoutGitBranch", () => { |
| 118 | it("checks out an existing branch", async () => { |
| 119 | await initRepoWithCommit(tmpDir); |
| 120 | await createGitBranch({ cwd: tmpDir, branch: "feature" }); |
| 121 | |
| 122 | await checkoutGitBranch({ cwd: tmpDir, branch: "feature" }); |
| 123 | |
| 124 | const result = await listGitBranches({ cwd: tmpDir }); |
| 125 | const current = result.branches.find((b) => b.current); |
| 126 | expect(current!.name).toBe("feature"); |
| 127 | }); |
| 128 | |
| 129 | it("throws when branch does not exist", async () => { |
| 130 | await initRepoWithCommit(tmpDir); |
| 131 | await expect( |
| 132 | checkoutGitBranch({ cwd: tmpDir, branch: "nonexistent" }), |
| 133 | ).rejects.toThrow(); |
| 134 | }); |
| 135 | |
| 136 | it("throws when checkout would overwrite uncommitted changes", async () => { |
| 137 | await initRepoWithCommit(tmpDir); |
| 138 | await createGitBranch({ cwd: tmpDir, branch: "other" }); |
| 139 | |
| 140 | // 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. |
| 149 | |
| 150 | // 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'"); |
| 156 | |
| 157 | // 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 }); |
| 162 | |
| 163 | // Make uncommitted changes to the same file |
| 164 | await writeFile(path.join(tmpDir, "README.md"), "conflicting local\n"); |
| 165 | |
| 166 | // Checkout should fail due to uncommitted changes |
| 167 | await expect( |
| 168 | checkoutGitBranch({ cwd: tmpDir, branch: "other" }), |
| 169 | ).rejects.toThrow(); |
| 170 | }); |
| 171 | }); |
| 172 | |
| 173 | // ── createGitBranch ── |
| 174 | |
| 175 | describe("createGitBranch", () => { |
| 176 | it("creates a new branch visible in listGitBranches", async () => { |
| 177 | await initRepoWithCommit(tmpDir); |
| 178 | await createGitBranch({ cwd: tmpDir, branch: "new-feature" }); |
| 179 | |
| 180 | const result = await listGitBranches({ cwd: tmpDir }); |
| 181 | expect(result.branches.some((b) => b.name === "new-feature")).toBe(true); |
| 182 | }); |
| 183 | |
| 184 | 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(); |
| 190 | }); |
| 191 | }); |
| 192 | |
| 193 | // ── createGitWorktree + removeGitWorktree ── |
| 194 | |
| 195 | describe("createGitWorktree", () => { |
| 196 | it("creates a worktree directory at the expected path", async () => { |
| 197 | await initRepoWithCommit(tmpDir); |
| 198 | await createGitBranch({ cwd: tmpDir, branch: "wt-branch" }); |
| 199 | |
| 200 | const wtPath = path.join(tmpDir, "worktree-out"); |
| 201 | const result = await createGitWorktree({ |
| 202 | cwd: tmpDir, |
| 203 | branch: "wt-branch", |
| 204 | path: wtPath, |
| 205 | }); |
| 206 | |
| 207 | expect(result.worktree.path).toBe(wtPath); |
| 208 | expect(result.worktree.branch).toBe("wt-branch"); |
| 209 | expect(existsSync(wtPath)).toBe(true); |
| 210 | expect(existsSync(path.join(wtPath, "README.md"))).toBe(true); |
| 211 | |
| 212 | // Clean up worktree |
| 213 | await removeGitWorktree({ cwd: tmpDir, path: wtPath }); |
| 214 | }); |
| 215 | |
| 216 | it("worktree has the correct branch checked out", async () => { |
| 217 | await initRepoWithCommit(tmpDir); |
| 218 | await createGitBranch({ cwd: tmpDir, branch: "wt-check" }); |
| 219 | |
| 220 | const wtPath = path.join(tmpDir, "wt-check-dir"); |
| 221 | await createGitWorktree({ |
| 222 | cwd: tmpDir, |
| 223 | branch: "wt-check", |
| 224 | path: wtPath, |
| 225 | }); |
| 226 | |
| 227 | // Verify the worktree is on the right branch |
| 228 | const branchOutput = await git(wtPath, "branch --show-current"); |
| 229 | expect(branchOutput).toBe("wt-check"); |
| 230 | |
| 231 | await removeGitWorktree({ cwd: tmpDir, path: wtPath }); |
| 232 | }); |
| 233 | |
| 234 | it("throws when branch is already checked out in main worktree", async () => { |
| 235 | await initRepoWithCommit(tmpDir); |
| 236 | // Try to create a worktree for the current branch (already checked out) |
| 237 | const branches = await listGitBranches({ cwd: tmpDir }); |
| 238 | const currentBranch = branches.branches.find((b) => b.current)!.name; |
| 239 | |
| 240 | const wtPath = path.join(tmpDir, "wt-conflict"); |
| 241 | await expect( |
| 242 | createGitWorktree({ |
| 243 | cwd: tmpDir, |
| 244 | branch: currentBranch, |
| 245 | path: wtPath, |
| 246 | }), |
| 247 | ).rejects.toThrow(); |
| 248 | }); |
| 249 | |
| 250 | it("removeGitWorktree cleans up the worktree", async () => { |
| 251 | await initRepoWithCommit(tmpDir); |
| 252 | await createGitBranch({ cwd: tmpDir, branch: "wt-remove" }); |
| 253 | |
| 254 | const wtPath = path.join(tmpDir, "wt-remove-dir"); |
| 255 | await createGitWorktree({ |
| 256 | cwd: tmpDir, |
| 257 | branch: "wt-remove", |
| 258 | path: wtPath, |
| 259 | }); |
| 260 | expect(existsSync(wtPath)).toBe(true); |
| 261 | |
| 262 | await removeGitWorktree({ cwd: tmpDir, path: wtPath }); |
| 263 | expect(existsSync(wtPath)).toBe(false); |
| 264 | }); |
| 265 | }); |
| 266 | |
| 267 | // ── Full flow: local branch checkout ── |
| 268 | |
| 269 | describe("full flow: local branch checkout", () => { |
| 270 | 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" }); |
| 274 | |
| 275 | const result = await listGitBranches({ cwd: tmpDir }); |
| 276 | const current = result.branches.find((b) => b.current); |
| 277 | expect(current!.name).toBe("feature-login"); |
| 278 | }); |
| 279 | }); |
| 280 | |
| 281 | // ── Full flow: worktree creation from selected branch ── |
| 282 | |
| 283 | describe("full flow: worktree creation", () => { |
| 284 | it("creates worktree from a non-current branch", async () => { |
| 285 | await initRepoWithCommit(tmpDir); |
| 286 | await createGitBranch({ cwd: tmpDir, branch: "feature-wt" }); |
| 287 | |
| 288 | const wtPath = path.join(tmpDir, "my-worktree"); |
| 289 | const result = await createGitWorktree({ |
| 290 | cwd: tmpDir, |
| 291 | branch: "feature-wt", |
| 292 | path: wtPath, |
| 293 | }); |
| 294 | |
| 295 | // Worktree exists |
| 296 | expect(existsSync(result.worktree.path)).toBe(true); |
| 297 | |
| 298 | // Main repo still on original branch |
| 299 | const mainBranches = await listGitBranches({ cwd: tmpDir }); |
| 300 | const mainCurrent = mainBranches.branches.find((b) => b.current); |
| 301 | expect(mainCurrent!.name).not.toBe("feature-wt"); |
| 302 | |
| 303 | // Worktree is on feature-wt |
| 304 | const wtBranch = await git(wtPath, "branch --show-current"); |
| 305 | expect(wtBranch).toBe("feature-wt"); |
| 306 | |
| 307 | await removeGitWorktree({ cwd: tmpDir, path: wtPath }); |
| 308 | }); |
| 309 | }); |
| 310 | |
| 311 | // ── Full flow: thread switching simulation ── |
| 312 | |
| 313 | describe("full flow: thread switching (checkout toggling)", () => { |
| 314 | 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" }); |
| 318 | |
| 319 | // Simulate switching to thread A's branch |
| 320 | await checkoutGitBranch({ cwd: tmpDir, branch: "branch-a" }); |
| 321 | let branches = await listGitBranches({ cwd: tmpDir }); |
| 322 | expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a"); |
| 323 | |
| 324 | // Simulate switching to thread B's branch |
| 325 | await checkoutGitBranch({ cwd: tmpDir, branch: "branch-b" }); |
| 326 | branches = await listGitBranches({ cwd: tmpDir }); |
| 327 | expect(branches.branches.find((b) => b.current)!.name).toBe("branch-b"); |
| 328 | |
| 329 | // Switch back to thread A |
| 330 | await checkoutGitBranch({ cwd: tmpDir, branch: "branch-a" }); |
| 331 | branches = await listGitBranches({ cwd: tmpDir }); |
| 332 | expect(branches.branches.find((b) => b.current)!.name).toBe("branch-a"); |
| 333 | }); |
| 334 | }); |
| 335 | |
| 336 | // ── Full flow: checkout conflict ── |
| 337 | |
| 338 | describe("full flow: checkout conflict", () => { |
| 339 | it("uncommitted changes prevent checkout to a diverged branch", async () => { |
| 340 | await initRepoWithCommit(tmpDir); |
| 341 | await createGitBranch({ cwd: tmpDir, branch: "diverged" }); |
| 342 | |
| 343 | // 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'"); |
| 348 | |
| 349 | // Go back to default branch |
| 350 | const defaultBranch = ( |
| 351 | await git(tmpDir, "rev-parse --abbrev-ref HEAD") |
| 352 | ).includes("diverged") |
| 353 | ? // we're on diverged, need to find the other one |
| 354 | (await listGitBranches({ cwd: tmpDir })).branches.find( |
| 355 | (b) => !b.current && b.name !== "diverged", |
| 356 | )!.name |
| 357 | : (await git(tmpDir, "rev-parse --abbrev-ref HEAD")); |
| 358 | |
| 359 | // 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 }); |
| 365 | |
| 366 | // Make local uncommitted changes to the same file |
| 367 | await writeFile(path.join(tmpDir, "README.md"), "local uncommitted\n"); |
| 368 | |
| 369 | // Attempt checkout should fail |
| 370 | await expect( |
| 371 | checkoutGitBranch({ cwd: tmpDir, branch: "diverged" }), |
| 372 | ).rejects.toThrow(); |
| 373 | |
| 374 | // Current branch should still be the initial one |
| 375 | const result = await listGitBranches({ cwd: tmpDir }); |
| 376 | expect(result.branches.find((b) => b.current)!.name).toBe(initialBranch); |
| 377 | }); |
| 378 | }); |
| 379 | }); |