- branch-manager: beforeEach→beforeAll (all 12 tests are read-only) - worktree manager: clone template repo per test instead of full init - signal-manager: reduce setTimeout delay from 100ms to 30ms
251 lines
9.0 KiB
TypeScript
251 lines
9.0 KiB
TypeScript
/**
|
|
* 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<void>;
|
|
}> {
|
|
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<void>;
|
|
}> {
|
|
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<void>;
|
|
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<void>;
|
|
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');
|
|
});
|
|
});
|
|
});
|