Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/nodejs.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
name: Node.js CI

on:
push:
branches: [ main ]
pull_request:
branches: [ main ]

jobs:
build:
runs-on: ubuntu-latest

strategy:
matrix:
node-version: [16, 18, 20]

steps:
- name: Checkout repository
uses: actions/checkout@v3

- name: Setup Node.js ${{ matrix.node-version }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'

- name: Install dependencies
run: npm install

- name: Run tests
run: npm test
392 changes: 248 additions & 144 deletions README.md

Large diffs are not rendered by default.

154 changes: 154 additions & 0 deletions __tests__/amend.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { Command } from 'commander';
import { registerAmendCommand } from '../src/commands/amend';
import { loadConfig, ensureGitRepo, lintCommitMessage } from '../src/utils';
import inquirer from 'inquirer';
import { execSync } from 'child_process';

jest.mock('inquirer', () => ({
prompt: jest.fn(),
}));
jest.mock('child_process', () => ({
execSync: jest.fn(),
}));
jest.mock('../src/utils', () => ({
ensureGitRepo: jest.fn(),
loadConfig: jest.fn(),
lintCommitMessage: jest.fn(),
}));

describe('registerAmendCommand', () => {
let program: Command;
let mockExit: jest.SpyInstance;

beforeEach(() => {
program = new Command();
registerAmendCommand(program);

(inquirer.prompt as unknown as jest.Mock).mockReset();
(execSync as jest.Mock).mockReset();
(ensureGitRepo as jest.Mock).mockReset();
(loadConfig as jest.Mock).mockReset();
(lintCommitMessage as jest.Mock).mockReset();

mockExit = jest.spyOn(process, 'exit').mockImplementation(code => {
throw new Error(`process.exit: ${code}`);
});
});

afterEach(() => {
mockExit.mockRestore();
});

it('should throw if ensureGitRepo throws an error', async () => {
(ensureGitRepo as jest.Mock).mockImplementation(() => {
throw new Error('Not a Git repo');
});

await expect(program.parseAsync(['node', 'test', 'amend']))
.rejects
.toThrow('Not a Git repo');

expect(execSync).not.toHaveBeenCalled();
});

it('should exit if user does not confirm amend', async () => {
(ensureGitRepo as jest.Mock).mockImplementation(() => { });
(loadConfig as jest.Mock).mockReturnValue({});

(execSync as jest.Mock).mockReturnValueOnce('Old commit message\n');

(inquirer.prompt as unknown as jest.Mock)
.mockResolvedValueOnce({ amendConfirm: false });

await expect(program.parseAsync(['node', 'test', 'amend']))
.resolves
.not.toThrow();

expect(execSync).toHaveBeenCalledTimes(1);
});

it('should amend normally if user confirms and lint is disabled', async () => {
(ensureGitRepo as jest.Mock).mockImplementation(() => { });
(loadConfig as jest.Mock).mockReturnValue({
enableLint: false,
});
(execSync as jest.Mock).mockReturnValueOnce('Old commit message\n');

(inquirer.prompt as unknown as jest.Mock)
.mockResolvedValueOnce({ amendConfirm: true })
.mockResolvedValueOnce({ newMessage: 'New commit message' });

await program.parseAsync(['node', 'test', 'amend']);

const calls = (execSync as jest.Mock).mock.calls;
const amendCall = calls.find(call => call[0].includes('git commit --amend'));
expect(amendCall).toBeTruthy();
expect(amendCall[0]).toMatch(/New commit message/);

expect(lintCommitMessage).not.toHaveBeenCalled();
});

it('should abort amend if lint errors persist and user chooses not to re-edit', async () => {
(ensureGitRepo as jest.Mock).mockImplementation(() => { });
(loadConfig as jest.Mock).mockReturnValue({
enableLint: true,
lintRules: { /* ... */ },
});
(execSync as jest.Mock).mockReturnValueOnce('Old commit message\n');

(inquirer.prompt as unknown as jest.Mock)
.mockResolvedValueOnce({ amendConfirm: true })
.mockResolvedValueOnce({ newMessage: 'bad commit message' })
.mockResolvedValueOnce({ retry: false });

(lintCommitMessage as jest.Mock).mockReturnValue(['Error: summary too long']);

await expect(program.parseAsync(['node', 'test', 'amend']))
.rejects
.toThrow('process.exit: 1');

expect(execSync).toHaveBeenCalledTimes(1);
const calls = (execSync as jest.Mock).mock.calls;
const amendCall = calls.find(call => call[0].includes('git commit --amend'));
expect(amendCall).toBeUndefined();
});

it('should allow re-edit after lint errors and amend successfully', async () => {
(ensureGitRepo as jest.Mock).mockImplementation(() => { });
(loadConfig as jest.Mock).mockReturnValue({
enableLint: true,
lintRules: { /* ... */ },
});
(execSync as jest.Mock).mockReturnValueOnce('Old commit message\n');

(inquirer.prompt as unknown as jest.Mock)
.mockResolvedValueOnce({ amendConfirm: true })
.mockResolvedValueOnce({ newMessage: 'bad commit message' })
.mockResolvedValueOnce({ retry: true })
.mockResolvedValueOnce({ newMessage: 'good commit message' });

(lintCommitMessage as jest.Mock)
.mockReturnValueOnce(['Error: summary too long'])
.mockReturnValueOnce([]);

await program.parseAsync(['node', 'test', 'amend']);

const calls = (execSync as jest.Mock).mock.calls;
const amendCall = calls.find(call => call[0].includes('git commit --amend'));
expect(amendCall).toBeTruthy();
expect(amendCall[0]).toMatch(/good commit message/);
});

it('should catch execSync errors and exit with code 1', async () => {
(ensureGitRepo as jest.Mock).mockImplementation(() => { });
(loadConfig as jest.Mock).mockReturnValue({ enableLint: false });

(execSync as jest.Mock).mockImplementationOnce(() => {
throw new Error('git error');
});

await expect(program.parseAsync(['node', 'test', 'amend']))
.rejects
.toThrow('process.exit: 1');
});
});
184 changes: 184 additions & 0 deletions __tests__/branch.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
import { execSync } from 'child_process';
import { Command } from 'commander';
import { loadConfig } from '../src/utils';
import inquirer from 'inquirer';
import { registerBranchCommand, sanitizeForBranch } from '../src/commands/branch';

jest.mock('child_process', () => ({
execSync: jest.fn(),
}));
jest.mock('inquirer', () => ({
prompt: jest.fn(),
}));
jest.mock('../src/utils', () => ({
loadConfig: jest.fn(),
ensureGitRepo: jest.fn(),
}));

describe('sanitizeForBranch', () => {
it('should replace spaces with default separator and lowercase the input', () => {
const input = 'My Custom Branch';
const result = sanitizeForBranch(input);
expect(result).toBe('my-custom-branch');
});

it('should not convert to lowercase if lowercase is set to false', () => {
const input = 'My Custom Branch';
const result = sanitizeForBranch(input, { lowercase: false });
expect(result).toBe('My-Custom-Branch');
});

it('should replace spaces with a custom separator', () => {
const input = 'My Custom Branch';
const result = sanitizeForBranch(input, { separator: '_' });
expect(result).toBe('my_custom_branch');
});

it('should collapse multiple separators if collapseSeparator is true', () => {
const input = 'My Custom Branch';
const result = sanitizeForBranch(input, { separator: '-', collapseSeparator: true });
expect(result).toBe('my-custom-branch');
});

it('should not collapse separators if collapseSeparator is false', () => {
const input = 'My Custom Branch';
const result = sanitizeForBranch(input, { separator: '-', collapseSeparator: false });
expect(result).toBe('my---custom----branch');
});

it('should truncate the result to the specified maxLength', () => {
const input = 'this is a very long branch name that should be truncated';
const result = sanitizeForBranch(input, { maxLength: 20 });
expect(result.length).toBeLessThanOrEqual(20);
});

it('should remove invalid characters', () => {
const input = 'Branch@Name!#%';
const result = sanitizeForBranch(input);
expect(result).toBe('branchname');
});
});

describe('registerBranchCommand', () => {
let program: Command;

beforeEach(() => {
program = new Command();
(execSync as jest.Mock).mockReset();
(inquirer.prompt as unknown as jest.Mock).mockReset();
(loadConfig as jest.Mock).mockReturnValue({
branch: {
template: "{type}/{ticketId}-{shortDesc}",
types: [
{ value: 'feat', description: 'Feature' },
{ value: 'fix', description: 'Bug fix' }
],
placeholders: {}
}
});
});

it('should create a branch with valid branch name using provided inputs', async () => {
(inquirer.prompt as unknown as jest.Mock)
.mockResolvedValueOnce({ baseBranchChoice: 'main' })
.mockResolvedValueOnce({ type: 'feat' })
.mockResolvedValueOnce({ ticketId: '123' })
.mockResolvedValueOnce({ shortDesc: 'add login' })
.mockResolvedValueOnce({ stayOnBranch: true });

registerBranchCommand(program);
await program.parseAsync(['node', 'test', 'branch']);

const calls = (execSync as jest.Mock).mock.calls;
const branchCommandCall = calls.find(call => call[0].includes('git checkout -b'));
expect(branchCommandCall[0]).toMatch(/feat\/123-add-login/);
});

it('should handle "Manual input..." for base branch', async () => {
(inquirer.prompt as unknown as jest.Mock)
.mockResolvedValueOnce({ baseBranchChoice: 'Manual input...' })
.mockResolvedValueOnce({ manualBranch: 'develop' })
.mockResolvedValueOnce({ type: 'fix' })
.mockResolvedValueOnce({ ticketId: '456' })
.mockResolvedValueOnce({ shortDesc: 'bug fix' })
.mockResolvedValueOnce({ stayOnBranch: true });

registerBranchCommand(program);
await program.parseAsync(['node', 'test', 'branch']);

const calls = (execSync as jest.Mock).mock.calls;
const branchCommandCall = calls.find(call => call[0].includes('git checkout -b'));
expect(branchCommandCall[0]).toMatch(/fix\/456-bug-fix/);
});

it('should prompt for custom branch type when "CUSTOM_INPUT" is selected', async () => {
(inquirer.prompt as unknown as jest.Mock)
.mockResolvedValueOnce({ baseBranchChoice: 'main' })
.mockResolvedValueOnce({ type: 'CUSTOM_INPUT' })
.mockResolvedValueOnce({ customType: 'custom' })
.mockResolvedValueOnce({ ticketId: '789' })
.mockResolvedValueOnce({ shortDesc: 'custom branch' })
.mockResolvedValueOnce({ stayOnBranch: true });

registerBranchCommand(program);
await program.parseAsync(['node', 'test', 'branch']);

const calls = (execSync as jest.Mock).mock.calls;
const branchCommandCall = calls.find(call => call[0].includes('git checkout -b'));
expect(branchCommandCall[0]).toMatch(/custom\/789-custom-branch/);
});

it('should generate fallback branch name if final branch name is empty', async () => {
(inquirer.prompt as unknown as jest.Mock)
.mockResolvedValueOnce({ baseBranchChoice: 'main' })
.mockResolvedValueOnce({ type: '' })
.mockResolvedValueOnce({ ticketId: '' })
.mockResolvedValueOnce({ shortDesc: '' })
.mockResolvedValueOnce({ stayOnBranch: true });

registerBranchCommand(program);
await program.parseAsync(['node', 'test', 'branch']);

const calls = (execSync as jest.Mock).mock.calls;
const branchCommandCall = calls.find(call => call[0].includes('git checkout -b'));
// new-branch-XXXX
expect(branchCommandCall[0]).toMatch(/new-branch-\d+/);
});

it('should switch back to the base branch when user opts not to stay on the new branch', async () => {
(inquirer.prompt as unknown as jest.Mock)
.mockResolvedValueOnce({ baseBranchChoice: 'main' })
.mockResolvedValueOnce({ type: 'feat' })
.mockResolvedValueOnce({ ticketId: '123' })
.mockResolvedValueOnce({ shortDesc: 'add feature' })
.mockResolvedValueOnce({ stayOnBranch: false });

registerBranchCommand(program);
await program.parseAsync(['node', 'test', 'branch']);

const calls = (execSync as jest.Mock).mock.calls;
const switchBackCall = calls.find(call => call[0].includes('git checkout "') && !call[0].includes('-b'));
expect(switchBackCall).toBeTruthy();
expect(switchBackCall[0]).toMatch(/git checkout "main"/);
});

it('should handle empty manual input for base branch and not attempt to switch back if base branch is empty', async () => {
(inquirer.prompt as unknown as jest.Mock)
.mockResolvedValueOnce({ baseBranchChoice: 'Manual input...' })
.mockResolvedValueOnce({ manualBranch: ' ' })
.mockResolvedValueOnce({ type: 'fix' })
.mockResolvedValueOnce({ ticketId: '456' })
.mockResolvedValueOnce({ shortDesc: 'fix bug' })
.mockResolvedValueOnce({ stayOnBranch: false });

registerBranchCommand(program);
await program.parseAsync(['node', 'test', 'branch']);

const calls = (execSync as jest.Mock).mock.calls;
const branchCommandCall = calls.find(call => call[0].includes('git checkout -b'));
expect(branchCommandCall[0]).toMatch(/^git checkout -b "[^"]+"$/);

const switchBackCall = calls.find(call => call[0].startsWith('git checkout "') && !call[0].includes('-b'));
expect(switchBackCall).toBeUndefined();
});
});
Loading