import { existsSync } from "node:fs";import { mkdtemp, rm, writeFile } from "node:fs/promises";import { tmpdir } from "node:os";import path from "node:path";import { describe, expect, it, vi } from "vitest"; import { GitCoreService, checkoutGitBranch, createGitBranch, createGitWorktree, initGitRepo, listGitBranches, pullGitBranch, removeGitWorktree, runTerminalCommand,} from "./git"; // ── Helpers ── /** Run a raw git command for test setup (not under test). */async function git(cwd: string, command: string): Promise<string> { const result = await runTerminalCommand({ command: `git ${command}`, cwd, timeoutMs: 10_000, }); if (result.code !== 0) { throw new Error(`git ${command} failed: ${result.stderr}`); } return result.stdout.trim();} /** Create a disposable temp directory that cleans up automatically. */async function makeTmpDir() { const dir = await mkdtemp(path.join(tmpdir(), "git-test-")); return { path: dir, [Symbol.asyncDispose]: async () => { await rm(dir, { recursive: true, force: true }); }, };} /** Create a repo with an initial commit so branches work. */async function initRepoWithCommit(cwd: string): Promise<void> { await initGitRepo({ cwd }); await git(cwd, "config user.email 'test@test.com'"); await git(cwd, "config user.name 'Test'"); await writeFile(path.join(cwd, "README.md"), "# test\n"); await git(cwd, "add ."); await git(cwd, "commit -m 'initial commit'");} async function commitWithDate( cwd: string, fileName: string, fileContents: string, dateIsoString: string, message: string,): Promise<void> { await writeFile(path.join(cwd, fileName), fileContents); await git(cwd, `add ${fileName}`); const commitResult = await runTerminalCommand({ command: `GIT_AUTHOR_DATE="${dateIsoString}" GIT_COMMITTER_DATE="${dateIsoString}" git commit -m "${message}"`, cwd, timeoutMs: 10_000, }); if (commitResult.code !== 0) { throw new Error(`git dated commit failed: ${commitResult.stderr}`); }} // ── Tests ── describe("git integration", () => { describe("runTerminalCommand", () => { it("caps captured output when maxOutputBytes is exceeded", async () => { const result = await runTerminalCommand({ command: `node -e "process.stdout.write('x'.repeat(2000))"`, cwd: process.cwd(), timeoutMs: 10_000, maxOutputBytes: 128, }); expect(result.code).toBe(0); expect(result.stdout.length).toBeLessThanOrEqual(128); expect(result.stderr).toContain("output truncated"); }); }); // ── initGitRepo ── describe("initGitRepo", () => { it("creates a valid git repo", async () => { await using tmp = await makeTmpDir(); await initGitRepo({ cwd: tmp.path }); expect(existsSync(path.join(tmp.path, ".git"))).toBe(true); }); it("listGitBranches reports isRepo: true after init + commit", async () => { await using tmp = await makeTmpDir(); await initRepoWithCommit(tmp.path); const result = await listGitBranches({ cwd: tmp.path }); expect(result.isRepo).toBe(true); expect(result.branches.length).toBeGreaterThanOrEqual(1); }); }); // ── listGitBranches ── describe("listGitBranches", () => { it("returns isRepo: false for non-git directory", async () => { await using tmp = await makeTmpDir(); const result = await listGitBranches({ cwd: tmp.path }); expect(result.isRepo).toBe(false); expect(result.branches).toEqual([]); }); it("returns the current branch with current: true", async () => { await using tmp = await makeTmpDir(); await initRepoWithCommit(tmp.path); const result = await listGitBranches({ cwd: tmp.path }); const current = result.branches.find((b) => b.current); expect(current).toBeDefined(); expect(current!.current).toBe(true); }); it("keeps current branch first and sorts the remaining branches by recency", async () => { await using tmp = await makeTmpDir(); await initRepoWithCommit(tmp.path); const initialBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( (branch) => branch.current, )!.name; await createGitBranch({ cwd: tmp.path, branch: "older-branch" }); await checkoutGitBranch({ cwd: tmp.path, branch: "older-branch" }); await commitWithDate( tmp.path, "older.txt", "older branch change\n", "Thu, 1 Jan 2037 00:00:00 +0000", "older branch change", ); await checkoutGitBranch({ cwd: tmp.path, branch: initialBranch }); await createGitBranch({ cwd: tmp.path, branch: "newer-branch" }); await checkoutGitBranch({ cwd: tmp.path, branch: "newer-branch" }); await commitWithDate( tmp.path, "newer.txt", "newer branch change\n", "Fri, 1 Jan 2038 00:00:00 +0000", "newer branch change", ); // Switch away to show current branch is pinned, then remaining branches are recency-sorted. await checkoutGitBranch({ cwd: tmp.path, branch: "older-branch" }); const result = await listGitBranches({ cwd: tmp.path }); expect(result.branches[0]!.name).toBe("older-branch"); expect(result.branches[1]!.name).toBe("newer-branch"); }); it("keeps default branch right after current branch", async () => { await using tmp = await makeTmpDir(); await using remote = await makeTmpDir(); await initRepoWithCommit(tmp.path); const defaultBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( (branch) => branch.current, )!.name; await git(remote.path, "init --bare"); await git(tmp.path, `remote add origin ${JSON.stringify(remote.path)}`); await git(tmp.path, `push -u origin ${defaultBranch}`); await git(tmp.path, `remote set-head origin ${defaultBranch}`); await createGitBranch({ cwd: tmp.path, branch: "current-branch" }); await checkoutGitBranch({ cwd: tmp.path, branch: "current-branch" }); await commitWithDate( tmp.path, "current.txt", "current change\n", "Thu, 1 Jan 2037 00:00:00 +0000", "current change", ); await checkoutGitBranch({ cwd: tmp.path, branch: defaultBranch }); await createGitBranch({ cwd: tmp.path, branch: "newer-branch" }); await checkoutGitBranch({ cwd: tmp.path, branch: "newer-branch" }); await commitWithDate( tmp.path, "newer.txt", "newer change\n", "Fri, 1 Jan 2038 00:00:00 +0000", "newer change", ); await checkoutGitBranch({ cwd: tmp.path, branch: "current-branch" }); const result = await listGitBranches({ cwd: tmp.path }); expect(result.branches[0]!.name).toBe("current-branch"); expect(result.branches[1]!.name).toBe(defaultBranch); expect(result.branches[2]!.name).toBe("newer-branch"); }); it("lists multiple branches after creating them", async () => { await using tmp = await makeTmpDir(); await initRepoWithCommit(tmp.path); await createGitBranch({ cwd: tmp.path, branch: "feature-a" }); await createGitBranch({ cwd: tmp.path, branch: "feature-b" }); const result = await listGitBranches({ cwd: tmp.path }); const names = result.branches.map((b) => b.name); expect(names).toContain("feature-a"); expect(names).toContain("feature-b"); }); it("isDefault is false when no remote exists", async () => { await using tmp = await makeTmpDir(); await initRepoWithCommit(tmp.path); const result = await listGitBranches({ cwd: tmp.path }); expect(result.branches.every((b) => b.isDefault === false)).toBe(true); }); }); // ── checkoutGitBranch ── describe("checkoutGitBranch", () => { it("checks out an existing branch", async () => { await using tmp = await makeTmpDir(); await initRepoWithCommit(tmp.path); await createGitBranch({ cwd: tmp.path, branch: "feature" }); await checkoutGitBranch({ cwd: tmp.path, branch: "feature" }); const result = await listGitBranches({ cwd: tmp.path }); const current = result.branches.find((b) => b.current); expect(current!.name).toBe("feature"); }); it("refreshes upstream behind count after checkout when remote branch advanced", async () => { await using remote = await makeTmpDir(); await using source = await makeTmpDir(); await using clone = await makeTmpDir(); await git(remote.path, "init --bare"); await initRepoWithCommit(source.path); const defaultBranch = (await listGitBranches({ cwd: source.path })).branches.find( (branch) => branch.current, )!.name; await git(source.path, `remote add origin ${JSON.stringify(remote.path)}`); await git(source.path, `push -u origin ${defaultBranch}`); const featureBranch = "feature-behind"; await createGitBranch({ cwd: source.path, branch: featureBranch }); await checkoutGitBranch({ cwd: source.path, branch: featureBranch }); await writeFile(path.join(source.path, "feature.txt"), "feature base\n"); await git(source.path, "add feature.txt"); await git(source.path, "commit -m 'feature base'"); await git(source.path, `push -u origin ${featureBranch}`); await checkoutGitBranch({ cwd: source.path, branch: defaultBranch }); await git(clone.path, `clone ${JSON.stringify(remote.path)} .`); await git(clone.path, "config user.email 'test@test.com'"); await git(clone.path, "config user.name 'Test'"); await git(clone.path, `checkout -b ${featureBranch} --track origin/${featureBranch}`); await writeFile(path.join(clone.path, "feature.txt"), "feature from remote\n"); await git(clone.path, "add feature.txt"); await git(clone.path, "commit -m 'remote feature update'"); await git(clone.path, `push origin ${featureBranch}`); await checkoutGitBranch({ cwd: source.path, branch: featureBranch }); const core = new GitCoreService(); const details = await core.statusDetails(source.path); expect(details.branch).toBe(featureBranch); expect(details.aheadCount).toBe(0); expect(details.behindCount).toBe(1); }); it("keeps checkout successful when upstream refresh fails", async () => { await using remote = await makeTmpDir(); await using source = await makeTmpDir(); await git(remote.path, "init --bare"); await initRepoWithCommit(source.path); const defaultBranch = (await listGitBranches({ cwd: source.path })).branches.find( (branch) => branch.current, )!.name; await git(source.path, `remote add origin ${JSON.stringify(remote.path)}`); await git(source.path, `push -u origin ${defaultBranch}`); const featureBranch = "feature-refresh-failure"; await git(source.path, `branch ${featureBranch}`); await git(source.path, `checkout ${featureBranch}`); await writeFile(path.join(source.path, "feature.txt"), "feature base\n"); await git(source.path, "add feature.txt"); await git(source.path, "commit -m 'feature base'"); await git(source.path, `push -u origin ${featureBranch}`); await git(source.path, `checkout ${defaultBranch}`); class RefreshFailingGitCoreService extends GitCoreService { refreshFetchAttempts = 0; override async git( cwd: string, args: readonly string[], allowNonZeroExit = false, ): Promise<void> { if (args[0] === "fetch") { this.refreshFetchAttempts += 1; throw new Error("simulated fetch timeout"); } await super.git(cwd, args, allowNonZeroExit); } } const core = new RefreshFailingGitCoreService(); await expect(core.checkoutBranch({ cwd: source.path, branch: featureBranch })).resolves.toBe( undefined, ); expect(core.refreshFetchAttempts).toBe(1); expect(await git(source.path, "branch --show-current")).toBe(featureBranch); }); it("refresh fetch is scoped to the checked out branch upstream refspec", async () => { await using remote = await makeTmpDir(); await using source = await makeTmpDir(); await git(remote.path, "init --bare"); await initRepoWithCommit(source.path); const defaultBranch = (await listGitBranches({ cwd: source.path })).branches.find( (branch) => branch.current, )!.name; await git(source.path, `remote add origin ${JSON.stringify(remote.path)}`); await git(source.path, `push -u origin ${defaultBranch}`); const featureBranch = "feature/scoped-fetch"; await git(source.path, `checkout -b ${featureBranch}`); await writeFile(path.join(source.path, "feature.txt"), "feature base\n"); await git(source.path, "add feature.txt"); await git(source.path, "commit -m 'feature base'"); await git(source.path, `push -u origin ${featureBranch}`); await git(source.path, `checkout ${defaultBranch}`); class FetchRecordingGitCoreService extends GitCoreService { fetchArgs: readonly string[] | null = null; override async git( cwd: string, args: readonly string[], allowNonZeroExit = false, ): Promise<void> { if (args[0] === "fetch") { this.fetchArgs = args; return; } await super.git(cwd, args, allowNonZeroExit); } } const core = new FetchRecordingGitCoreService(); await core.checkoutBranch({ cwd: source.path, branch: featureBranch }); expect(await git(source.path, "branch --show-current")).toBe(featureBranch); expect(core.fetchArgs).toEqual([ "fetch", "--quiet", "--no-tags", "origin", `+refs/heads/${featureBranch}:refs/remotes/origin/${featureBranch}`, ]); }); it("throws when branch does not exist", async () => { await using tmp = await makeTmpDir(); await initRepoWithCommit(tmp.path); await expect(checkoutGitBranch({ cwd: tmp.path, branch: "nonexistent" })).rejects.toThrow(); }); it("throws when checkout would overwrite uncommitted changes", async () => { await using tmp = await makeTmpDir(); await initRepoWithCommit(tmp.path); await createGitBranch({ cwd: tmp.path, branch: "other" }); // Create a conflicting change: modify README on current branch await writeFile(path.join(tmp.path, "README.md"), "modified\n"); await git(tmp.path, "add README.md"); // First, checkout other branch cleanly await git(tmp.path, "stash"); await checkoutGitBranch({ cwd: tmp.path, branch: "other" }); await writeFile(path.join(tmp.path, "README.md"), "other content\n"); await git(tmp.path, "add ."); await git(tmp.path, "commit -m 'other change'"); // Go back to default branch const defaultBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( (b) => !b.current, )!.name; await checkoutGitBranch({ cwd: tmp.path, branch: defaultBranch }); // Make uncommitted changes to the same file await writeFile(path.join(tmp.path, "README.md"), "conflicting local\n"); // Checkout should fail due to uncommitted changes await expect(checkoutGitBranch({ cwd: tmp.path, branch: "other" })).rejects.toThrow(); }); }); // ── createGitBranch ── describe("createGitBranch", () => { it("creates a new branch visible in listGitBranches", async () => { await using tmp = await makeTmpDir(); await initRepoWithCommit(tmp.path); await createGitBranch({ cwd: tmp.path, branch: "new-feature" }); const result = await listGitBranches({ cwd: tmp.path }); expect(result.branches.some((b) => b.name === "new-feature")).toBe(true); }); it("throws when branch already exists", async () => { await using tmp = await makeTmpDir(); await initRepoWithCommit(tmp.path); await createGitBranch({ cwd: tmp.path, branch: "dupe" }); await expect(createGitBranch({ cwd: tmp.path, branch: "dupe" })).rejects.toThrow(); }); }); // ── createGitWorktree + removeGitWorktree ── describe("createGitWorktree", () => { it("creates a worktree with a new branch from the base branch", async () => { await using tmp = await makeTmpDir(); await initRepoWithCommit(tmp.path); const wtPath = path.join(tmp.path, "worktree-out"); const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( (b) => b.current, )!.name; const result = await createGitWorktree({ cwd: tmp.path, branch: currentBranch, newBranch: "wt-branch", path: wtPath, }); expect(result.worktree.path).toBe(wtPath); expect(result.worktree.branch).toBe("wt-branch"); expect(existsSync(wtPath)).toBe(true); expect(existsSync(path.join(wtPath, "README.md"))).toBe(true); // Clean up worktree before tmp dir disposal await removeGitWorktree({ cwd: tmp.path, path: wtPath }); }); it("worktree has the new branch checked out", async () => { await using tmp = await makeTmpDir(); await initRepoWithCommit(tmp.path); const wtPath = path.join(tmp.path, "wt-check-dir"); const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( (b) => b.current, )!.name; await createGitWorktree({ cwd: tmp.path, branch: currentBranch, newBranch: "wt-check", path: wtPath, }); // Verify the worktree is on the new branch const branchOutput = await git(wtPath, "branch --show-current"); expect(branchOutput).toBe("wt-check"); await removeGitWorktree({ cwd: tmp.path, path: wtPath }); }); it("throws when new branch name already exists", async () => { await using tmp = await makeTmpDir(); await initRepoWithCommit(tmp.path); await createGitBranch({ cwd: tmp.path, branch: "existing" }); const wtPath = path.join(tmp.path, "wt-conflict"); const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( (b) => b.current, )!.name; await expect( createGitWorktree({ cwd: tmp.path, branch: currentBranch, newBranch: "existing", path: wtPath, }), ).rejects.toThrow();Lines 1–500 of 790. Load more
import { existsSync } from "node:fs"; import { mkdtemp, rm, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; import { GitCoreService, checkoutGitBranch, createGitBranch, createGitWorktree, initGitRepo, listGitBranches, pullGitBranch, removeGitWorktree, runTerminalCommand, } from "./git"; // ── Helpers ── /** Run a raw git command for test setup (not under test). */ async function git(cwd: string, command: string): Promise<string> { const result = await runTerminalCommand({ command: `git ${command}`, cwd, timeoutMs: 10_000, }); if (result.code !== 0) { throw new Error(`git ${command} failed: ${result.stderr}`); } return result.stdout.trim(); } /** Create a disposable temp directory that cleans up automatically. */ async function makeTmpDir() { const dir = await mkdtemp(path.join(tmpdir(), "git-test-")); return { path: dir, [Symbol.asyncDispose]: async () => { await rm(dir, { recursive: true, force: true }); }, }; } /** Create a repo with an initial commit so branches work. */ async function initRepoWithCommit(cwd: string): Promise<void> { await initGitRepo({ cwd }); await git(cwd, "config user.email 'test@test.com'"); await git(cwd, "config user.name 'Test'"); await writeFile(path.join(cwd, "README.md"), "# test\n"); await git(cwd, "add ."); await git(cwd, "commit -m 'initial commit'"); } async function commitWithDate( cwd: string, fileName: string, fileContents: string, dateIsoString: string, message: string, ): Promise<void> { await writeFile(path.join(cwd, fileName), fileContents); await git(cwd, `add ${fileName}`); const commitResult = await runTerminalCommand({ command: `GIT_AUTHOR_DATE="${dateIsoString}" GIT_COMMITTER_DATE="${dateIsoString}" git commit -m "${message}"`, cwd, timeoutMs: 10_000, }); if (commitResult.code !== 0) { throw new Error(`git dated commit failed: ${commitResult.stderr}`); } } // ── Tests ── describe("git integration", () => { describe("runTerminalCommand", () => { it("caps captured output when maxOutputBytes is exceeded", async () => { const result = await runTerminalCommand({ command: `node -e "process.stdout.write('x'.repeat(2000))"`, cwd: process.cwd(), timeoutMs: 10_000, maxOutputBytes: 128, }); expect(result.code).toBe(0); expect(result.stdout.length).toBeLessThanOrEqual(128); expect(result.stderr).toContain("output truncated"); }); }); // ── initGitRepo ── describe("initGitRepo", () => { it("creates a valid git repo", async () => { await using tmp = await makeTmpDir(); await initGitRepo({ cwd: tmp.path }); expect(existsSync(path.join(tmp.path, ".git"))).toBe(true); }); it("listGitBranches reports isRepo: true after init + commit", async () => { await using tmp = await makeTmpDir(); await initRepoWithCommit(tmp.path); const result = await listGitBranches({ cwd: tmp.path }); expect(result.isRepo).toBe(true); expect(result.branches.length).toBeGreaterThanOrEqual(1); }); }); // ── listGitBranches ── describe("listGitBranches", () => { it("returns isRepo: false for non-git directory", async () => { await using tmp = await makeTmpDir(); const result = await listGitBranches({ cwd: tmp.path }); expect(result.isRepo).toBe(false); expect(result.branches).toEqual([]); }); it("returns the current branch with current: true", async () => { await using tmp = await makeTmpDir(); await initRepoWithCommit(tmp.path); const result = await listGitBranches({ cwd: tmp.path }); const current = result.branches.find((b) => b.current); expect(current).toBeDefined(); expect(current!.current).toBe(true); }); it("keeps current branch first and sorts the remaining branches by recency", async () => { await using tmp = await makeTmpDir(); await initRepoWithCommit(tmp.path); const initialBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( (branch) => branch.current, )!.name; await createGitBranch({ cwd: tmp.path, branch: "older-branch" }); await checkoutGitBranch({ cwd: tmp.path, branch: "older-branch" }); await commitWithDate( tmp.path, "older.txt", "older branch change\n", "Thu, 1 Jan 2037 00:00:00 +0000", "older branch change", ); await checkoutGitBranch({ cwd: tmp.path, branch: initialBranch }); await createGitBranch({ cwd: tmp.path, branch: "newer-branch" }); await checkoutGitBranch({ cwd: tmp.path, branch: "newer-branch" }); await commitWithDate( tmp.path, "newer.txt", "newer branch change\n", "Fri, 1 Jan 2038 00:00:00 +0000", "newer branch change", ); // Switch away to show current branch is pinned, then remaining branches are recency-sorted. await checkoutGitBranch({ cwd: tmp.path, branch: "older-branch" }); const result = await listGitBranches({ cwd: tmp.path }); expect(result.branches[0]!.name).toBe("older-branch"); expect(result.branches[1]!.name).toBe("newer-branch"); }); it("keeps default branch right after current branch", async () => { await using tmp = await makeTmpDir(); await using remote = await makeTmpDir(); await initRepoWithCommit(tmp.path); const defaultBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( (branch) => branch.current, )!.name; await git(remote.path, "init --bare"); await git(tmp.path, `remote add origin ${JSON.stringify(remote.path)}`); await git(tmp.path, `push -u origin ${defaultBranch}`); await git(tmp.path, `remote set-head origin ${defaultBranch}`); await createGitBranch({ cwd: tmp.path, branch: "current-branch" }); await checkoutGitBranch({ cwd: tmp.path, branch: "current-branch" }); await commitWithDate( tmp.path, "current.txt", "current change\n", "Thu, 1 Jan 2037 00:00:00 +0000", "current change", ); await checkoutGitBranch({ cwd: tmp.path, branch: defaultBranch }); await createGitBranch({ cwd: tmp.path, branch: "newer-branch" }); await checkoutGitBranch({ cwd: tmp.path, branch: "newer-branch" }); await commitWithDate( tmp.path, "newer.txt", "newer change\n", "Fri, 1 Jan 2038 00:00:00 +0000", "newer change", ); await checkoutGitBranch({ cwd: tmp.path, branch: "current-branch" }); const result = await listGitBranches({ cwd: tmp.path }); expect(result.branches[0]!.name).toBe("current-branch"); expect(result.branches[1]!.name).toBe(defaultBranch); expect(result.branches[2]!.name).toBe("newer-branch"); }); it("lists multiple branches after creating them", async () => { await using tmp = await makeTmpDir(); await initRepoWithCommit(tmp.path); await createGitBranch({ cwd: tmp.path, branch: "feature-a" }); await createGitBranch({ cwd: tmp.path, branch: "feature-b" }); const result = await listGitBranches({ cwd: tmp.path }); const names = result.branches.map((b) => b.name); expect(names).toContain("feature-a"); expect(names).toContain("feature-b"); }); it("isDefault is false when no remote exists", async () => { await using tmp = await makeTmpDir(); await initRepoWithCommit(tmp.path); const result = await listGitBranches({ cwd: tmp.path }); expect(result.branches.every((b) => b.isDefault === false)).toBe(true); }); }); // ── checkoutGitBranch ── describe("checkoutGitBranch", () => { it("checks out an existing branch", async () => { await using tmp = await makeTmpDir(); await initRepoWithCommit(tmp.path); await createGitBranch({ cwd: tmp.path, branch: "feature" }); await checkoutGitBranch({ cwd: tmp.path, branch: "feature" }); const result = await listGitBranches({ cwd: tmp.path }); const current = result.branches.find((b) => b.current); expect(current!.name).toBe("feature"); }); it("refreshes upstream behind count after checkout when remote branch advanced", async () => { await using remote = await makeTmpDir(); await using source = await makeTmpDir(); await using clone = await makeTmpDir(); await git(remote.path, "init --bare"); await initRepoWithCommit(source.path); const defaultBranch = (await listGitBranches({ cwd: source.path })).branches.find( (branch) => branch.current, )!.name; await git(source.path, `remote add origin ${JSON.stringify(remote.path)}`); await git(source.path, `push -u origin ${defaultBranch}`); const featureBranch = "feature-behind"; await createGitBranch({ cwd: source.path, branch: featureBranch }); await checkoutGitBranch({ cwd: source.path, branch: featureBranch }); await writeFile(path.join(source.path, "feature.txt"), "feature base\n"); await git(source.path, "add feature.txt"); await git(source.path, "commit -m 'feature base'"); await git(source.path, `push -u origin ${featureBranch}`); await checkoutGitBranch({ cwd: source.path, branch: defaultBranch }); await git(clone.path, `clone ${JSON.stringify(remote.path)} .`); await git(clone.path, "config user.email 'test@test.com'"); await git(clone.path, "config user.name 'Test'"); await git(clone.path, `checkout -b ${featureBranch} --track origin/${featureBranch}`); await writeFile(path.join(clone.path, "feature.txt"), "feature from remote\n"); await git(clone.path, "add feature.txt"); await git(clone.path, "commit -m 'remote feature update'"); await git(clone.path, `push origin ${featureBranch}`); await checkoutGitBranch({ cwd: source.path, branch: featureBranch }); const core = new GitCoreService(); const details = await core.statusDetails(source.path); expect(details.branch).toBe(featureBranch); expect(details.aheadCount).toBe(0); expect(details.behindCount).toBe(1); }); it("keeps checkout successful when upstream refresh fails", async () => { await using remote = await makeTmpDir(); await using source = await makeTmpDir(); await git(remote.path, "init --bare"); await initRepoWithCommit(source.path); const defaultBranch = (await listGitBranches({ cwd: source.path })).branches.find( (branch) => branch.current, )!.name; await git(source.path, `remote add origin ${JSON.stringify(remote.path)}`); await git(source.path, `push -u origin ${defaultBranch}`); const featureBranch = "feature-refresh-failure"; await git(source.path, `branch ${featureBranch}`); await git(source.path, `checkout ${featureBranch}`); await writeFile(path.join(source.path, "feature.txt"), "feature base\n"); await git(source.path, "add feature.txt"); await git(source.path, "commit -m 'feature base'"); await git(source.path, `push -u origin ${featureBranch}`); await git(source.path, `checkout ${defaultBranch}`); class RefreshFailingGitCoreService extends GitCoreService { refreshFetchAttempts = 0; override async git( cwd: string, args: readonly string[], allowNonZeroExit = false, ): Promise<void> { if (args[0] === "fetch") { this.refreshFetchAttempts += 1; throw new Error("simulated fetch timeout"); } await super.git(cwd, args, allowNonZeroExit); } } const core = new RefreshFailingGitCoreService(); await expect(core.checkoutBranch({ cwd: source.path, branch: featureBranch })).resolves.toBe( undefined, ); expect(core.refreshFetchAttempts).toBe(1); expect(await git(source.path, "branch --show-current")).toBe(featureBranch); }); it("refresh fetch is scoped to the checked out branch upstream refspec", async () => { await using remote = await makeTmpDir(); await using source = await makeTmpDir(); await git(remote.path, "init --bare"); await initRepoWithCommit(source.path); const defaultBranch = (await listGitBranches({ cwd: source.path })).branches.find( (branch) => branch.current, )!.name; await git(source.path, `remote add origin ${JSON.stringify(remote.path)}`); await git(source.path, `push -u origin ${defaultBranch}`); const featureBranch = "feature/scoped-fetch"; await git(source.path, `checkout -b ${featureBranch}`); await writeFile(path.join(source.path, "feature.txt"), "feature base\n"); await git(source.path, "add feature.txt"); await git(source.path, "commit -m 'feature base'"); await git(source.path, `push -u origin ${featureBranch}`); await git(source.path, `checkout ${defaultBranch}`); class FetchRecordingGitCoreService extends GitCoreService { fetchArgs: readonly string[] | null = null; override async git( cwd: string, args: readonly string[], allowNonZeroExit = false, ): Promise<void> { if (args[0] === "fetch") { this.fetchArgs = args; return; } await super.git(cwd, args, allowNonZeroExit); } } const core = new FetchRecordingGitCoreService(); await core.checkoutBranch({ cwd: source.path, branch: featureBranch }); expect(await git(source.path, "branch --show-current")).toBe(featureBranch); expect(core.fetchArgs).toEqual([ "fetch", "--quiet", "--no-tags", "origin", `+refs/heads/${featureBranch}:refs/remotes/origin/${featureBranch}`, ]); }); it("throws when branch does not exist", async () => { await using tmp = await makeTmpDir(); await initRepoWithCommit(tmp.path); await expect(checkoutGitBranch({ cwd: tmp.path, branch: "nonexistent" })).rejects.toThrow(); }); it("throws when checkout would overwrite uncommitted changes", async () => { await using tmp = await makeTmpDir(); await initRepoWithCommit(tmp.path); await createGitBranch({ cwd: tmp.path, branch: "other" }); // Create a conflicting change: modify README on current branch await writeFile(path.join(tmp.path, "README.md"), "modified\n"); await git(tmp.path, "add README.md"); // First, checkout other branch cleanly await git(tmp.path, "stash"); await checkoutGitBranch({ cwd: tmp.path, branch: "other" }); await writeFile(path.join(tmp.path, "README.md"), "other content\n"); await git(tmp.path, "add ."); await git(tmp.path, "commit -m 'other change'"); // Go back to default branch const defaultBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( (b) => !b.current, )!.name; await checkoutGitBranch({ cwd: tmp.path, branch: defaultBranch }); // Make uncommitted changes to the same file await writeFile(path.join(tmp.path, "README.md"), "conflicting local\n"); // Checkout should fail due to uncommitted changes await expect(checkoutGitBranch({ cwd: tmp.path, branch: "other" })).rejects.toThrow(); }); }); // ── createGitBranch ── describe("createGitBranch", () => { it("creates a new branch visible in listGitBranches", async () => { await using tmp = await makeTmpDir(); await initRepoWithCommit(tmp.path); await createGitBranch({ cwd: tmp.path, branch: "new-feature" }); const result = await listGitBranches({ cwd: tmp.path }); expect(result.branches.some((b) => b.name === "new-feature")).toBe(true); }); it("throws when branch already exists", async () => { await using tmp = await makeTmpDir(); await initRepoWithCommit(tmp.path); await createGitBranch({ cwd: tmp.path, branch: "dupe" }); await expect(createGitBranch({ cwd: tmp.path, branch: "dupe" })).rejects.toThrow(); }); }); // ── createGitWorktree + removeGitWorktree ── describe("createGitWorktree", () => { it("creates a worktree with a new branch from the base branch", async () => { await using tmp = await makeTmpDir(); await initRepoWithCommit(tmp.path); const wtPath = path.join(tmp.path, "worktree-out"); const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( (b) => b.current, )!.name; const result = await createGitWorktree({ cwd: tmp.path, branch: currentBranch, newBranch: "wt-branch", path: wtPath, }); expect(result.worktree.path).toBe(wtPath); expect(result.worktree.branch).toBe("wt-branch"); expect(existsSync(wtPath)).toBe(true); expect(existsSync(path.join(wtPath, "README.md"))).toBe(true); // Clean up worktree before tmp dir disposal await removeGitWorktree({ cwd: tmp.path, path: wtPath }); }); it("worktree has the new branch checked out", async () => { await using tmp = await makeTmpDir(); await initRepoWithCommit(tmp.path); const wtPath = path.join(tmp.path, "wt-check-dir"); const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( (b) => b.current, )!.name; await createGitWorktree({ cwd: tmp.path, branch: currentBranch, newBranch: "wt-check", path: wtPath, }); // Verify the worktree is on the new branch const branchOutput = await git(wtPath, "branch --show-current"); expect(branchOutput).toBe("wt-check"); await removeGitWorktree({ cwd: tmp.path, path: wtPath }); }); it("throws when new branch name already exists", async () => { await using tmp = await makeTmpDir(); await initRepoWithCommit(tmp.path); await createGitBranch({ cwd: tmp.path, branch: "existing" }); const wtPath = path.join(tmp.path, "wt-conflict"); const currentBranch = (await listGitBranches({ cwd: tmp.path })).branches.find( (b) => b.current, )!.name; await expect( createGitWorktree({ cwd: tmp.path, branch: currentBranch, newBranch: "existing", path: wtPath, }), ).rejects.toThrow();