/** * SimpleGitBranchManager Tests * * Tests for remoteBranchExists validation used when setting * a project's default branch. */ import { describe, it, expect, beforeEach, afterEach, beforeAll, afterAll } from 'vitest'; import { mkdtemp, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import path from 'node:path'; import { simpleGit } from 'simple-git'; import { SimpleGitBranchManager } from './simple-git-branch-manager.js'; /** * Create a "remote" bare repo and a clone of it for testing. * The bare repo has branches that the clone can see as remote tracking branches. */ async function createTestRepoWithRemote(): Promise<{ clonePath: string; barePath: string; cleanup: () => Promise; }> { const tmpBase = await mkdtemp(path.join(tmpdir(), 'cw-branch-test-')); const barePath = path.join(tmpBase, 'bare.git'); const workPath = path.join(tmpBase, 'work'); const clonePath = path.join(tmpBase, 'clone'); // Create a bare repo const bareGit = simpleGit(); await bareGit.init([barePath, '--bare']); // Clone it to a working directory, add commits and branches, push await simpleGit().clone(barePath, workPath); const workGit = simpleGit(workPath); await workGit.addConfig('user.email', 'test@example.com'); await workGit.addConfig('user.name', 'Test User'); await writeFile(path.join(workPath, 'README.md'), '# Test\n'); await workGit.add('README.md'); await workGit.commit('Initial commit'); await workGit.push('origin', 'main'); // Create additional branches await workGit.checkoutLocalBranch('develop'); await writeFile(path.join(workPath, 'dev.txt'), 'dev\n'); await workGit.add('dev.txt'); await workGit.commit('Dev commit'); await workGit.push('origin', 'develop'); await workGit.checkoutLocalBranch('feature/auth'); await writeFile(path.join(workPath, 'auth.txt'), 'auth\n'); await workGit.add('auth.txt'); await workGit.commit('Auth commit'); await workGit.push('origin', 'feature/auth'); // Clone from bare to simulate what project registration does await simpleGit().clone(barePath, clonePath); return { clonePath, barePath, cleanup: async () => { await rm(tmpBase, { recursive: true, force: true }); }, }; } /** * Create a repo pair for testing diff operations. * Sets up bare + clone with a 'feature' branch that has known changes vs 'main'. */ async function createTestRepoForDiff(): Promise<{ clonePath: string; cleanup: () => Promise; }> { const tmpBase = await mkdtemp(path.join(tmpdir(), 'cw-diff-test-')); const barePath = path.join(tmpBase, 'bare.git'); const workPath = path.join(tmpBase, 'work'); const clonePath = path.join(tmpBase, 'clone'); // Create bare repo await simpleGit().init([barePath, '--bare']); // Set up main branch in work dir await simpleGit().clone(barePath, workPath); const workGit = simpleGit(workPath); await workGit.addConfig('user.email', 'test@example.com'); await workGit.addConfig('user.name', 'Test User'); await writeFile(path.join(workPath, 'README.md'), '# README\n'); await writeFile(path.join(workPath, 'to-delete.txt'), 'delete me\n'); await workGit.add(['README.md', 'to-delete.txt']); await workGit.commit('Initial commit'); await workGit.push('origin', 'main'); // Clone and create feature branch with changes await simpleGit().clone(barePath, clonePath); const cloneGit = simpleGit(clonePath); await cloneGit.addConfig('user.email', 'test@example.com'); await cloneGit.addConfig('user.name', 'Test User'); await cloneGit.checkoutLocalBranch('feature'); // Add new text file await writeFile(path.join(clonePath, 'added.txt'), 'new content\n'); await cloneGit.add('added.txt'); // Modify existing file await writeFile(path.join(clonePath, 'README.md'), '# README\n\nModified content\n'); await cloneGit.add('README.md'); // Delete a file await cloneGit.rm(['to-delete.txt']); // Add binary file await writeFile(path.join(clonePath, 'image.bin'), Buffer.alloc(16)); await cloneGit.add('image.bin'); // Add file with space in name await writeFile(path.join(clonePath, 'has space.txt'), 'content\n'); await cloneGit.add('has space.txt'); await cloneGit.commit('Feature branch changes'); return { clonePath, cleanup: async () => { await rm(tmpBase, { recursive: true, force: true }); }, }; } describe('SimpleGitBranchManager', () => { let clonePath: string; let cleanup: () => Promise; let branchManager: SimpleGitBranchManager; beforeAll(async () => { const setup = await createTestRepoWithRemote(); clonePath = setup.clonePath; cleanup = setup.cleanup; branchManager = new SimpleGitBranchManager(); }); afterAll(async () => { await cleanup(); }); describe('remoteBranchExists', () => { it('should return true for a branch that exists on the remote', async () => { expect(await branchManager.remoteBranchExists(clonePath, 'main')).toBe(true); expect(await branchManager.remoteBranchExists(clonePath, 'develop')).toBe(true); expect(await branchManager.remoteBranchExists(clonePath, 'feature/auth')).toBe(true); }); it('should return false for a branch that does not exist', async () => { expect(await branchManager.remoteBranchExists(clonePath, 'nonexistent')).toBe(false); expect(await branchManager.remoteBranchExists(clonePath, 'feature/nope')).toBe(false); }); it('should return false for an invalid repo path', async () => { expect(await branchManager.remoteBranchExists('/tmp/no-such-repo', 'main')).toBe(false); }); it('should detect remote branches not checked out locally', async () => { // After clone, only 'main' is checked out locally. // 'develop' exists only as origin/develop. const localExists = await branchManager.branchExists(clonePath, 'develop'); const remoteExists = await branchManager.remoteBranchExists(clonePath, 'develop'); expect(localExists).toBe(false); expect(remoteExists).toBe(true); }); }); }); describe('SimpleGitBranchManager - diffBranchesStat and diffFileSingle', () => { let clonePath: string; let cleanup: () => Promise; let branchManager: SimpleGitBranchManager; beforeAll(async () => { const setup = await createTestRepoForDiff(); clonePath = setup.clonePath; cleanup = setup.cleanup; branchManager = new SimpleGitBranchManager(); }); afterAll(async () => { await cleanup(); }); describe('diffBranchesStat', () => { it('returns correct entries for added, modified, and deleted text files', async () => { const entries = await branchManager.diffBranchesStat(clonePath, 'main', 'feature'); const added = entries.find(e => e.path === 'added.txt'); const modified = entries.find(e => e.path === 'README.md'); const deleted = entries.find(e => e.path === 'to-delete.txt'); expect(added?.status).toBe('added'); expect(added?.additions).toBeGreaterThan(0); expect(modified?.status).toBe('modified'); expect(deleted?.status).toBe('deleted'); expect(deleted?.deletions).toBeGreaterThan(0); }); it('marks binary files as status=binary with additions=0, deletions=0', async () => { const entries = await branchManager.diffBranchesStat(clonePath, 'main', 'feature'); const binary = entries.find(e => e.path === 'image.bin'); expect(binary?.status).toBe('binary'); expect(binary?.additions).toBe(0); expect(binary?.deletions).toBe(0); }); it('returns empty array when there are no changes', async () => { const entries = await branchManager.diffBranchesStat(clonePath, 'main', 'main'); expect(entries).toEqual([]); }); it('handles files with spaces in their names', async () => { const entries = await branchManager.diffBranchesStat(clonePath, 'main', 'feature'); const spaced = entries.find(e => e.path === 'has space.txt'); expect(spaced).toBeDefined(); expect(spaced?.status).toBe('added'); }); }); describe('diffFileSingle', () => { it('returns unified diff containing addition hunks for an added file', async () => { const diff = await branchManager.diffFileSingle(clonePath, 'main', 'feature', 'added.txt'); expect(diff).toContain('+'); expect(diff).toContain('added.txt'); }); it('returns unified diff with removal hunks for a deleted file', async () => { const diff = await branchManager.diffFileSingle(clonePath, 'main', 'feature', 'to-delete.txt'); expect(diff).toContain('-'); expect(diff).toContain('to-delete.txt'); }); it('returns string for a binary file', async () => { const diff = await branchManager.diffFileSingle(clonePath, 'main', 'feature', 'image.bin'); // git diff returns empty or a "Binary files differ" line — no hunk content expect(typeof diff).toBe('string'); }); it('handles file paths with spaces', async () => { const diff = await branchManager.diffFileSingle(clonePath, 'main', 'feature', 'has space.txt'); expect(diff).toContain('has space.txt'); }); }); });