diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml new file mode 100644 index 0000000..7e54323 --- /dev/null +++ b/.github/workflows/nodejs.yml @@ -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 \ No newline at end of file diff --git a/README.md b/README.md index b82aec9..59cc6c8 100644 --- a/README.md +++ b/README.md @@ -1,201 +1,305 @@ # Smart Commit -Smart Commit is a highly customizable CLI utility for creating Git commits interactively. It offers a range of features to help you produce consistent, well-formatted commit messages while integrating with your workflow. Below is a detailed overview of its features and commands: +Smart Commit is a highly customizable CLI utility for creating Git commits interactively. It helps you produce consistent, well-formatted commit messages and branch names that integrate seamlessly with your development workflow. ## Features -- **Interactive Prompts:** +- **Interactive Prompts** - Customize which prompts appear during commit creation (commit type, scope, summary, body, footer, ticket, and CI tests). - - Automatically suggest a commit type based on staged changes. + - Automatically suggest commit types based on staged changes. -- **Template-Based Commit Message:** +- **Template-Based Commit Messages** - Define your commit message format using placeholders: - - {type}: Commit type (e.g., feat, fix, docs, etc.) - - {scope}: Optional scope (if enabled) - - {ticket}: Ticket ID (if provided or auto-extracted) - - {ticketSeparator}: Separator inserted if a ticket is provided - - {summary}: Commit summary (short description) - - {body}: Detailed commit message body (if enabled) - - {footer}: Additional footer information (if enabled) - -- **CI Integration:** - - Optionally run a CI command before executing a commit. - -- **Auto Ticket Extraction:** - - Extract a ticket ID from your branch name using a custom regular expression (if configured). - -- **Push Support:** - - Automatically push commits to the remote repository using the --push flag. - -- **Signed Commits:** - - Create GPG-signed commits using the --sign flag. - -- **Commit Statistics:** - - View commit statistics (e.g., Git shortlog by author or commit activity with ASCII graphs) using the `sc stats` command. - -- **Commit History Search:** - - Search your commit history by: - - Keyword in commit messages - - Author name or email - - Date range - - Use the `sc history` command for flexible commit searching. - -- **Additional Commands:** - - **Amend:** Interactively amend the last commit, with optional linting support. - - **Rollback:** Rollback the last commit with an option for a soft reset (keeping changes staged) or a hard reset (discarding changes). - - **Rebase Helper:** Launch an interactive rebase helper that provides in-editor instructions for modifying recent commits. - -- **Local and Global Configuration:** - - Global configuration is stored in your home directory as ~/.smart-commit-config.json. - - Override global settings for a specific project by creating a .smartcommitrc.json file in the project root. - - Configure settings such as auto-add, emoji usage, CI command, commit message template, prompt toggles (scope, body, footer, ticket, CI), linting rules, and ticket extraction regex via the `sc config` command or the interactive setup (`sc setup`). - -- **Commit Message Linting:** - - Optionally enable linting to enforce rules such as maximum summary length, lowercase starting character in the summary, and ticket inclusion when required. + - {type}: The commit type (e.g., feat, fix, docs, etc.) + - {ticketSeparator}: A separator inserted if a ticket ID is provided. + - {ticket}: The ticket ID (entered by the user or auto-extracted). + - {summary}: A short summary of the commit. + - {body}: A detailed description of the commit. + - {footer}: Additional footer text. + +- **CI Integration** + - Optionally run a specified CI command (e.g., tests) before creating the commit. + +- **Auto Ticket Extraction** + - Automatically extract a ticket ID from the current branch name using a custom regular expression. + +- **Push and Signed Commits** + - Automatically push commits after creation using the --push flag. + - Create GPG-signed commits with the --sign flag. + +- **Commit Statistics and History Search** + - View commit statistics as ASCII graphs (shortlog by author, activity graphs) with the `sc stats` command. + - Search commit history by keyword, author, or date range using the `sc history` command. + +- **Additional Commands** + - **Amend:** Interactively edit the last commit message (with optional linting). + - **Rollback:** Rollback the last commit, with options for soft (keeping changes staged) or hard (discarding changes) resets. + - **Rebase Helper:** Launch an interactive rebase session with guidance on modifying recent commits. + +- **Advanced Branch Creation** + - **sc branch** creates a new branch from a base branch (or current HEAD) using a naming template and autocomplete. + - **Universal Placeholders:** Use placeholders (e.g., {type}, {ticketId}, {shortDesc}, or any custom placeholder) in your branch template. + - **Branch Type Selection:** Define a list of branch types in your configuration; if defined, you can select one or provide a custom input. + - **Custom Sanitization Options:** For each placeholder, you can set custom sanitization rules: + - **lowercase:** (default true) Converts the value to lowercase unless set to false. + - **separator:** (default "-") Character to replace spaces. + - **collapseSeparator:** (default true) Collapses multiple consecutive separators into one. + - **maxLength:** Limits the maximum length of the sanitized value. + - The branch name is built from the template by replacing placeholders with sanitized inputs. Extraneous separators are removed, and if the final branch name is empty, a random fallback name is generated. + - After branch creation, you are prompted whether to remain on the new branch or switch back to the base branch. ## Commands -- **sc commit (or sc c):** - - Start the interactive commit process. - - Prompts for commit type, scope, summary, body, footer, ticket, CI tests, and staging changes. - - Supports auto-add, signed commits, and CI integration. +- **sc commit (or sc c)** + - Initiates the interactive commit process. + - Prompts for commit type, scope, summary, body, footer, ticket, and CI test execution. + - Supports manual file staging or auto-add, GPG signing (--sign), and pushing (--push). + - Applies commit message linting if enabled. + - **Linting Behavior and Overrides:** + + By default, commit message linting is disabled (i.e. `enableLint` is set to `false` in your configuration). This means that if you don’t specify any command‑line flag, your commit will be created without linting the message. + + If you want to enable linting for a specific commit—even if your configuration has it disabled—you can pass the `--lint` flag. Conversely, if linting is enabled in your configuration but you want to skip it for one commit, you can pass the standard Commander flag `--no-lint` (which sets the option to false). + + For example: + + ```bash + sc commit --lint + sc commit --no-lint + ``` + + The command‑line flags override the configuration settings, giving you flexibility on a per‑commit basis. + +- **sc amend** + - Opens the last commit message in your default editor for amendment. + - Validates the amended message using linting rules (if enabled) before updating the commit. + +- **sc rollback** + - Rolls back the last commit. + - Offers a choice between a soft reset (keep changes staged) or a hard reset (discard changes). + +- **sc rebase-helper (or sc rebase)** + - Launches an interactive rebase session. + - Guides you through modifying recent commits with options like pick, reword, edit, squash, fixup, exec, and drop. + +- **sc stats** + - Displays commit statistics as ASCII graphs. + - Choose between a shortlog by author or an activity graph over a specified period (Day, Week, Month). + +- **sc history** + - Searches commit history. + - Offers search options by keyword, author, or date range. + - Provides different view modes via interactive prompt: + - **All commits:** Shows complete commit history + - **Current branch only:** Shows commits unique to the current branch + +- **sc config (or sc cfg)** + - View and update Smart Commit settings. + - Reset Configuration + - Configure options such as: + - Auto-add (automatically stage changes) + - Emoji usage in commit type prompts + - CI command + - Commit message template + - Prompt toggles for scope, body, footer, ticket, and CI + - Ticket extraction regex + - Commit linting rules + - Branch configuration (template, types, and custom sanitization options) + - **Examples:** + - Enable auto-add: `sc config --auto-add true` + - Set CI command: `sc config --ci-command "npm test"` + - View current configuration: `sc config` + - Reset configuration to default: `sc config --reset` + +- **sc setup** + - Launches an interactive setup wizard to configure your Smart Commit preferences step by step. + - Walks you through each configuration option. + +- **sc branch (or sc b)** + - Creates a new branch from a base branch (or current HEAD) using a naming template and autocomplete. + - **Key Features:** + - **Universal Placeholders:** Customize branch names with placeholders such as {type}, {ticketId}, {shortDesc}, or any custom placeholder. + - **Branch Type Selection:** If branch types are defined in the configuration, you can select from a list or enter a custom type. + - **Custom Sanitization Options:** For each placeholder, set options to control: + - Conversion to lowercase (default true) + - Replacement of spaces with a specific separator (default "-") + - Collapsing of consecutive separators (default true) + - Maximum length of the sanitized value + - **Final Name Assembly:** Constructs the branch name from the template by replacing placeholders with sanitized values and cleaning extraneous separators. + - **Fallback Mechanism:** Generates a random branch name if the final name is empty. + - **Stay on Branch Prompt:** After creation, decide whether to remain on the new branch or switch back to the base branch. -- **sc amend:** - - Amend the last commit interactively. - - Opens the current commit message in your default editor for modifications. - - Validates the amended commit message with linting rules if enabled. - -- **sc rollback:** - - Rollback the last commit. - - Offers a choice between a soft reset (keeping changes staged) and a hard reset (discarding changes). - -- **sc rebase-helper:** - - Launch an interactive rebase helper. - - Provides instructions and options (pick, reword, edit, squash, fixup, drop) for modifying recent commits. +## Configuration File -- **sc stats:** - - Display commit statistics. - - Options include viewing a shortlog by author or commit activity graphs over a selected period (day, week, month). +Global configuration is stored in your home directory as `~/.smart-commit-config.json`. To override these settings for a specific project, create a `.smartcommitrc.json` file in the project root. Use the `sc setup` or `sc config` commands to modify your settings. + +### Detailed Configuration Options + +| Option | Type | Default | Description | Example | +|--------------------------------|---------|-----------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------| +| **commitTypes** | Array | List of commit types (feat, fix, docs, style, refactor, perf, test, chore) | Each type includes an emoji, a value, and a description used in commit prompts. | [{"emoji": "✨", "value": "feat", "description": "A new feature"}, ...] | +| **autoAdd** | Boolean | false | If true, automatically stage all changed files before committing. | true | +| **useEmoji** | Boolean | true | If true, display emojis in commit type prompts. | false | +| **ciCommand** | String | "" | Command to run CI tests before committing. | "npm test" | +| **templates.defaultTemplate** | String | "[{type}]{ticketSeparator}{ticket}: {summary}\n\nBody:\n{body}\n\nFooter:\n{footer}" | Template for commit messages; placeholders are replaced with user input or auto-generated content. | "[{type}]: {summary}" | +| **steps.scope** | Boolean | false | Whether to prompt for a commit scope. | true | +| **steps.body** | Boolean | false | Whether to prompt for a detailed commit body. | true | +| **steps.footer** | Boolean | false | Whether to prompt for additional footer information. | true | +| **steps.ticket** | Boolean | false | Whether to prompt for a ticket ID. If enabled and left empty, the ticket may be auto-extracted using the regex. | true | +| **steps.runCI** | Boolean | false | Whether to prompt for running CI tests before committing. | true | +| **ticketRegex** | String | "" | Regular expression for extracting a ticket ID from the branch name. | "^(DEV-\\d+)" | +| **enableLint** | Boolean | false | If true, enable commit message linting. | true | +| **lintRules.summaryMaxLength** | Number | 72 | Maximum allowed length for the commit summary. | 72 | +| **lintRules.typeCase** | String | "lowercase" | Required case for the first character of the commit summary. | "lowercase" | +| **lintRules.requiredTicket** | Boolean | false | If true, a ticket ID is required in the commit message. | true | +| **branch.template** | String | "{type}/{ticketId}-{shortDesc}" | Template for branch names; supports placeholders replaced by user input. | "{type}/{ticketId}-{shortDesc}" | +| **branch.types** | Array | List of branch types (feature, fix, chore, hotfix, release, dev) | Provides options for branch types during branch creation. | [{"value": "feature", "description": "New feature"}, ...] | +| **branch.placeholders** | Object | { ticketId: { lowercase: false } } | Custom sanitization options for branch placeholders. Options include: lowercase (default true), separator (default "-"), collapseSeparator (default true), maxLength. | {"ticketId": {"lowercase": false}} | + +### Example Local Configuration File (.smartcommitrc.json) -- **sc history:** - - Search commit history with flexible options. - - Choose to search by a keyword in commit messages, by author, or by a date range. +```json +{ + "autoAdd": true, + "useEmoji": true, + "ciCommand": "npm test", + "templates": { + "defaultTemplate": "[{type}]: {summary}" + }, + "steps": { + "scope": true, + "body": true, + "footer": true, + "ticket": true, + "runCI": true + }, + "ticketRegex": "^(DEV-\\d+)", + "enableLint": true, + "lintRules": { + "summaryMaxLength": 72, + "typeCase": "lowercase", + "requiredTicket": true + }, + "branch": { + "template": "{type}/{ticketId}-{shortDesc}", + "types": [ + { "value": "feature", "description": "New feature" }, + { "value": "fix", "description": "Bug fix" }, + { "value": "chore", "description": "Chore branch" }, + { "value": "hotfix", "description": "Hotfix branch" }, + { "value": "release", "description": "Release branch" }, + { "value": "dev", "description": "Development branch" } + ], + "placeholders": { + "ticketId": { + "lowercase": false, + "separator": "-", + "collapseSeparator": true, + "maxLength": 10 + } + } + } +} +``` -- **sc config (or sc cfg):** - - View and update global Smart Commit settings. - - Configure options such as auto-add, emoji usage, CI command, commit message template, prompt settings, linting, and ticket extraction regex. +## Custom Sanitization Options -- **sc setup:** - - Run the interactive setup wizard to configure your Smart Commit preferences. +When creating branch names, each placeholder can be sanitized using custom options defined in the configuration. The available options are: +- **lowercase:** Converts input to lowercase (default true; set to false to preserve original case). +- **separator:** Character to replace spaces (default is "-"). +- **collapseSeparator:** If true, collapses multiple consecutive separator characters into one (default true). +- **maxLength:** Limits the maximum length of the sanitized string. Note that the fallback branch name (generated randomly) is appended and should be considered when setting this value. ## Installation -Install Smart Commit globally via npm: +Install Smart Commit globally using npm: -```bash -npm install -g @el1fe/smart-commit -``` +npm install -g @el1fe/smart-commit -After installation, the commands smart-commit and the alias sc will be available in your terminal. +After installation, the commands `smart-commit` and `sc` will be available in your terminal. ## Usage Examples -- Creating a commit: +- **Creating a Commit:** ```bash -sc commit [--push] [--sign] + sc commit [--push] [--sign] ``` -- Amending the last commit: +- **Amending the Last Commit:** ```bash -sc amend + sc amend ``` -- Rolling back the last commit: +- **Rolling Back the Last Commit:** ```bash -sc rollback + sc rollback ``` -- Launching the interactive rebase helper: +- **Launching the Interactive Rebase Helper:** ```bash -sc rebase-helper + sc rebase-helper ``` -- Viewing commit statistics: +- **Viewing Commit Statistics:** ```bash -sc stats + sc stats ``` -- Searching commit history: +- **Searching Commit History:** ```bash -sc history + sc history ``` -- Configuring settings: - +- **Configuring Settings:** + ```bash -sc config -sc setup + sc config + sc setup ``` -## Configuration File +- **Creating a Branch:** -Global settings are stored in ~/.smart-commit-config.json. You can override these settings locally by creating a .smartcommitrc.json file in your project directory. To configure Smart Commit, run `sc setup` or you can use the `sc config` command to manually edit the configuration file. - -### Configuration Options - -Below is a table explaining each configuration option available in Smart Commit, along with their types, default values, descriptions, and examples. - -| Option | Type | Default | Description | Example | -|--------------------------------|----------|----------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------|----------------------------------| -| **commitTypes** | Array | See default list below:
• feat: "A new feature"
• fix: "A bug fix"
• docs: "Documentation changes"
• style: "Code style improvements"
• refactor: "Code refactoring"
• perf: "Performance improvements"
• test: "Adding tests"
• chore: "Maintenance and chores" | List of available commit types, each with an emoji, a value, and a description. | `[{"emoji": "✨", "value": "feat", "description": "A new feature"}, ...]` | -| **autoAdd** | Boolean | false | If set to true, all changes will be staged automatically before creating a commit. | true | -| **useEmoji** | Boolean | true | Determines whether emojis are displayed in the commit type selection prompt. | false | -| **ciCommand** | String | "" | Command to run CI tests before committing. If provided, CI tests will run automatically when prompted. | "npm test" | -| **templates.defaultTemplate** | String | `[{type}]{ticketSeparator}{ticket}: {summary}\n\nBody:\n{body}\n\nFooter:\n{footer}` | Template used to format the commit message. Placeholders will be replaced with user-provided or auto-generated content. | `"[{type}]: {summary}"` | -| **steps.scope** | Boolean | false | Whether to prompt for a commit scope (an optional field). | true | -| **steps.body** | Boolean | false | Whether to prompt for a detailed commit body. | true | -| **steps.footer** | Boolean | false | Whether to prompt for additional commit footer information. | true | -| **steps.ticket** | Boolean | false | Whether to prompt for a ticket ID. If enabled and left empty, ticket ID might be auto-extracted using the regex. | true | -| **steps.runCI** | Boolean | false | Whether to prompt for running CI tests before committing. | true | -| **ticketRegex** | String | "" | A regular expression used to extract a ticket ID from the current branch name. | `"^(DEV-\\d+)"` | -| **enableLint** | Boolean | false | Enables commit message linting based on specified linting rules. | true | -| **lintRules.summaryMaxLength** | Number | 72 | Maximum allowed length for the commit summary. | 72 | -| **lintRules.typeCase** | String | "lowercase" | Specifies the required case for the first character of the commit summary. | "lowercase" | -| **lintRules.requiredTicket** | Boolean | false | If true, a ticket ID is required in the commit message. | true | +```bash + sc branch +``` + - Select the base branch via autocomplete or enter manually. + - When prompted, choose a branch type from the list (if defined) or provide a custom value. + - Enter values for placeholders (e.g., ticket ID, short description, or any custom placeholder). + - The branch name is constructed from your branch template with custom sanitization applied. + - After branch creation, choose whether to remain on the new branch or switch back to the base branch. -### Example of a Local Configuration File (`.smartcommitrc.json`) +## Configuration File -```json -{ - "autoAdd": true, - "useEmoji": true, - "ciCommand": "npm test", - "templates": { - "defaultTemplate": "[{type}]: {summary}" - }, - "steps": { - "scope": true, - "body": true, - "footer": true, - "ticket": true, - "runCI": true - }, - "ticketRegex": "^(DEV-\\d+)", - "enableLint": true, - "lintRules": { - "summaryMaxLength": 72, - "typeCase": "lowercase", - "requiredTicket": true - } -} -``` +Global configuration is stored in `~/.smart-commit-config.json`. To override global settings for a project, create a `.smartcommitrc.json` file in the project directory. Use the `sc setup` or `sc config` commands to update your settings. + +### Detailed Configuration Options + +- **commitTypes:** Array of commit types (each with emoji, value, and description). +- **autoAdd:** Boolean indicating whether changes are staged automatically. +- **useEmoji:** Boolean to enable emoji display in commit type prompts. +- **ciCommand:** Command to run CI tests before committing. +- **templates.defaultTemplate:** Template for commit messages. +- **steps:** Object with booleans for each prompt: scope, body, footer, ticket, and runCI. +- **ticketRegex:** Regular expression to extract a ticket ID from the branch name. +- **enableLint:** Boolean to enable commit message linting. +- **lintRules:** Object defining linting rules (summaryMaxLength, typeCase, requiredTicket). +- **branch:** Branch configuration including: + - **template:** Template for branch names (e.g., "{type}/{ticketId}-{shortDesc}"). + - **types:** Array of branch types (each with value and description). + - **placeholders:** Custom sanitization options for branch placeholders. For each placeholder, you can set: + - **lowercase:** Whether to convert the value to lowercase (default true). + - **separator:** Character to replace spaces (default "-"). + - **collapseSeparator:** Whether to collapse multiple separators (default true). + - **maxLength:** Maximum length for the sanitized value. ## License -MIT \ No newline at end of file +MIT + +For more information and to contribute, please visit the GitHub repository. \ No newline at end of file diff --git a/__tests__/amend.test.ts b/__tests__/amend.test.ts new file mode 100644 index 0000000..9468011 --- /dev/null +++ b/__tests__/amend.test.ts @@ -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'); + }); +}); \ No newline at end of file diff --git a/__tests__/branch.test.ts b/__tests__/branch.test.ts new file mode 100644 index 0000000..0865c00 --- /dev/null +++ b/__tests__/branch.test.ts @@ -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(); + }); +}); \ No newline at end of file diff --git a/__tests__/commit.test.ts b/__tests__/commit.test.ts new file mode 100644 index 0000000..43fe93a --- /dev/null +++ b/__tests__/commit.test.ts @@ -0,0 +1,414 @@ +import { Command } from 'commander'; +import { registerCommitCommand } from '../src/commands/commit'; +import { + loadConfig, + getUnstagedFiles, + loadGitignorePatterns, + stageSelectedFiles, + computeAutoSummary, + suggestCommitType, + previewCommitMessage, + ensureGitRepo, + showDiffPreview, +} from '../src/utils'; +import inquirer from 'inquirer'; +import { execSync } from 'child_process'; +import micromatch from 'micromatch'; + +jest.mock('inquirer', () => ({ + prompt: jest.fn(), +})); +jest.mock('child_process', () => ({ + execSync: jest.fn(), +})); +jest.mock('../src/utils', () => ({ + loadConfig: jest.fn(), + getUnstagedFiles: jest.fn(), + loadGitignorePatterns: jest.fn(), + stageSelectedFiles: jest.fn(), + computeAutoSummary: jest.fn(), + suggestCommitType: jest.fn(), + previewCommitMessage: jest.fn(), + ensureGitRepo: jest.fn(), + showDiffPreview: jest.fn(), +})); +jest.mock('micromatch', () => ({ + isMatch: jest.fn(), +})); + +describe('registerCommitCommand', () => { + let program: Command; + let mockExit: jest.SpyInstance; + + beforeEach(() => { + program = new Command(); + registerCommitCommand(program); + + (inquirer.prompt as unknown as jest.Mock).mockReset(); + (execSync as jest.Mock).mockReset(); + (loadConfig as jest.Mock).mockReset(); + (getUnstagedFiles as jest.Mock).mockReset(); + (loadGitignorePatterns as jest.Mock).mockReset(); + (stageSelectedFiles as jest.Mock).mockReset(); + (computeAutoSummary as jest.Mock).mockReset(); + (suggestCommitType as jest.Mock).mockReset(); + (previewCommitMessage as jest.Mock).mockReset(); + (ensureGitRepo as jest.Mock).mockReset(); + (showDiffPreview as jest.Mock).mockReset(); + (micromatch.isMatch as jest.Mock).mockReset(); + + mockExit = jest.spyOn(process, 'exit').mockImplementation((code?: string | number | null | undefined) => { + throw new Error(`process.exit: ${code}`); + }); + }); + + afterEach(() => { + mockExit.mockRestore(); + }); + + it('should fail if not a git repo', async () => { + (ensureGitRepo as jest.Mock).mockImplementation(() => { throw new Error('Not a Git repo'); }); + await expect(program.parseAsync(['node', 'test', 'commit'])).rejects.toThrow('Not a Git repo'); + }); + + it('should abort if no unstaged files (manual staging)', async () => { + (ensureGitRepo as jest.Mock).mockImplementation(() => { }); + (loadConfig as jest.Mock).mockReturnValue({ + autoAdd: false, + commitTypes: [], + useEmoji: true, + steps: {}, + templates: { defaultTemplate: '[{type}]: {summary}' }, + }); + (getUnstagedFiles as jest.Mock).mockReturnValue([]); + (loadGitignorePatterns as jest.Mock).mockReturnValue([]); + (execSync as jest.Mock).mockReturnValueOnce('').mockReturnValue(''); + await expect(program.parseAsync(['node', 'test', 'commit'])).resolves.not.toThrow(); + expect(stageSelectedFiles).not.toHaveBeenCalled(); + }); + + it('should let user pick files then abort if no changes staged', async () => { + (ensureGitRepo as jest.Mock).mockImplementation(() => { }); + (loadConfig as jest.Mock).mockReturnValue({ + autoAdd: false, + commitTypes: [{ emoji: '✨', value: 'feat', description: 'feature' }], + useEmoji: true, + steps: {}, + templates: { defaultTemplate: '[{type}]: {summary}' }, + }); + (getUnstagedFiles as jest.Mock).mockReturnValue(['src/a.ts', 'test/b.ts']); + (loadGitignorePatterns as jest.Mock).mockReturnValue([]); + (micromatch.isMatch as jest.Mock).mockReturnValue(false); + (inquirer.prompt as unknown as jest.Mock).mockResolvedValueOnce({ files: ['src/a.ts', 'test/b.ts'] }); + (execSync as jest.Mock).mockReturnValueOnce('').mockReturnValue(''); + await expect(program.parseAsync(['node', 'test', 'commit'])).resolves.not.toThrow(); + expect(stageSelectedFiles).toHaveBeenCalledWith(['src/a.ts', 'test/b.ts']); + }); + + it('should auto-add files if autoAdd=true then abort if no changes staged', async () => { + (ensureGitRepo as jest.Mock).mockImplementation(() => { }); + (loadConfig as jest.Mock).mockReturnValue({ + autoAdd: true, + commitTypes: [], + useEmoji: true, + steps: {}, + templates: {}, + }); + (getUnstagedFiles as jest.Mock).mockReturnValue(['src/x.ts']); + (loadGitignorePatterns as jest.Mock).mockReturnValue([]); + (micromatch.isMatch as jest.Mock).mockReturnValue(false); + (execSync as jest.Mock).mockReturnValue(''); + await expect(program.parseAsync(['node', 'test', 'commit'])).resolves.not.toThrow(); + expect(stageSelectedFiles).toHaveBeenCalledWith(['src/x.ts']); + }); + + it('should ask main questions and commit if all is good (no diff preview, no lint, no CI)', async () => { + (ensureGitRepo as jest.Mock).mockImplementation(() => { }); + (loadConfig as jest.Mock).mockReturnValue({ + autoAdd: false, + commitTypes: [ + { emoji: '✨', value: 'feat', description: 'feature' }, + { emoji: '🐛', value: 'fix', description: 'bug fix' } + ], + useEmoji: true, + steps: { scope: true, body: false, footer: false, ticket: false, runCI: false }, + templates: { defaultTemplate: '[{type}]{scope}: {summary}' }, + enableLint: false + }); + (getUnstagedFiles as jest.Mock).mockReturnValue(['a.js']); + (loadGitignorePatterns as jest.Mock).mockReturnValue([]); + (micromatch.isMatch as jest.Mock).mockReturnValue(false); + (inquirer.prompt as unknown as jest.Mock) + .mockResolvedValueOnce({ files: ['a.js'] }) + .mockResolvedValueOnce({ type: 'feat', scope: 'myScope', summary: 'My summary', pushCommit: false }) + .mockResolvedValueOnce({ diffPreview: false }) + .mockResolvedValueOnce({ previewChoice: false }) + .mockResolvedValueOnce({ finalConfirm: true }); + (execSync as jest.Mock) + .mockReturnValueOnce('a.js\n') + .mockReturnValueOnce('a.js\n') + .mockReturnValueOnce(''); + (computeAutoSummary as jest.Mock).mockReturnValue('AutoSummary'); + (suggestCommitType as jest.Mock).mockReturnValue('feat'); + await program.parseAsync(['node', 'test', 'commit']); + const calls = (execSync as jest.Mock).mock.calls; + const commitCall = calls.find(c => c[0].includes('git commit')); + expect(commitCall).toBeTruthy(); + expect(commitCall[0]).toMatch(/\[feat\]\(myScope\): My summary/); + }); + + it('should run CI and fail, causing exit(1) and no commit', async () => { + (ensureGitRepo as jest.Mock).mockImplementation(() => { }); + (loadConfig as jest.Mock).mockReturnValue({ + autoAdd: false, + commitTypes: [], + useEmoji: true, + steps: { runCI: true }, + ciCommand: 'npm test', + templates: { defaultTemplate: '[{type}]: {summary}' }, + enableLint: false + }); + (getUnstagedFiles as jest.Mock).mockReturnValue(['file.js']); + (loadGitignorePatterns as jest.Mock).mockReturnValue([]); + (micromatch.isMatch as jest.Mock).mockReturnValue(false); + (inquirer.prompt as unknown as jest.Mock) + .mockResolvedValueOnce({ files: ['file.js'] }) + .mockResolvedValueOnce({ type: 'fix', summary: 'some fix', runCI: true, pushCommit: false }) + .mockResolvedValueOnce({ diffPreview: false }) + .mockResolvedValueOnce({ previewChoice: false }) + .mockResolvedValueOnce({ finalConfirm: true }); + (execSync as jest.Mock) + .mockReturnValueOnce('file.js\n') + .mockImplementationOnce(() => { throw new Error('Tests failed'); }); + await expect(program.parseAsync(['node', 'test', 'commit'])).rejects.toThrow('process.exit: 1'); + expect((execSync as jest.Mock).mock.calls.some(call => call[0].includes('git commit'))).toBe(false); + }); + + it('should show diff preview and abort if user declines diff confirm', async () => { + (ensureGitRepo as jest.Mock).mockImplementation(() => { }); + (loadConfig as jest.Mock).mockReturnValue({ + steps: { runCI: false }, + commitTypes: [{ value: 'feat' }], + templates: { defaultTemplate: '' }, + enableLint: false + }); + (getUnstagedFiles as jest.Mock).mockReturnValue(['f1']); + (loadGitignorePatterns as jest.Mock).mockReturnValue([]); + (micromatch.isMatch as jest.Mock).mockReturnValue(false); + (inquirer.prompt as unknown as jest.Mock) + .mockResolvedValueOnce({ files: ['f1'] }) + .mockResolvedValueOnce({ type: 'feat', summary: 'summary', pushCommit: false }) + .mockResolvedValueOnce({ diffPreview: true }) + .mockResolvedValueOnce({ diffConfirm: false }); + (execSync as jest.Mock) + .mockReturnValueOnce('f1\n') + .mockReturnValueOnce('f1\n'); + (showDiffPreview as jest.Mock).mockImplementation(() => { }); + await expect(program.parseAsync(['node', 'test', 'commit'])).resolves.not.toThrow(); + expect((execSync as jest.Mock).mock.calls.some(call => call[0].includes('git commit'))).toBe(false); + }); + + it('should call previewCommitMessage if lint=true and fail, causing exit(1)', async () => { + (ensureGitRepo as jest.Mock).mockImplementation(() => { }); + (loadConfig as jest.Mock).mockReturnValue({ + autoAdd: false, + commitTypes: [{ value: 'feat' }], + templates: { defaultTemplate: '[{type}]: {summary}' }, + steps: {}, + enableLint: true + }); + (getUnstagedFiles as jest.Mock).mockReturnValue(['x.ts']); + (loadGitignorePatterns as jest.Mock).mockReturnValue([]); + (micromatch.isMatch as jest.Mock).mockReturnValue(false); + (inquirer.prompt as unknown as jest.Mock) + .mockResolvedValueOnce({ files: ['x.ts'] }) + .mockResolvedValueOnce({ type: 'feat', scope: '', summary: 'some', pushCommit: false }) + .mockResolvedValueOnce({ diffPreview: false }); + (execSync as jest.Mock) + .mockReturnValueOnce('x.ts\n') + .mockReturnValueOnce('x.ts\n'); + (previewCommitMessage as jest.Mock).mockImplementation(() => { throw new Error('lint fail'); }); + await expect(program.parseAsync(['node', 'test', 'commit', '--lint'])).rejects.toThrow('lint fail'); + expect((execSync as jest.Mock).mock.calls.some(c => c[0].includes('git commit'))).toBe(false); + }); + + it('should skip lint, show preview, and abort if finalConfirm is false', async () => { + (ensureGitRepo as jest.Mock).mockImplementation(() => { }); + (loadConfig as jest.Mock).mockReturnValue({ + autoAdd: false, + commitTypes: [{ value: 'feat' }], + templates: { defaultTemplate: '[{type}]: {summary}' }, + steps: {} + }); + (getUnstagedFiles as jest.Mock).mockReturnValue(['abc']); + (loadGitignorePatterns as jest.Mock).mockReturnValue([]); + (micromatch.isMatch as jest.Mock).mockReturnValue(false); + (inquirer.prompt as unknown as jest.Mock) + .mockResolvedValueOnce({ files: ['abc'] }) + .mockResolvedValueOnce({ type: 'feat', scope: '', summary: 'summary', pushCommit: false }) + .mockResolvedValueOnce({ diffPreview: false }) + .mockResolvedValueOnce({ previewChoice: true }) + .mockResolvedValueOnce({ finalConfirm: false }); + (execSync as jest.Mock) + .mockReturnValueOnce('abc\n') + .mockReturnValueOnce('abc\n'); + await expect(program.parseAsync(['node', 'test', 'commit'])).resolves.not.toThrow(); + expect((execSync as jest.Mock).mock.calls.some(call => call[0].includes('git commit'))).toBe(false); + }); + + it('should commit and push if pushCommit=true', async () => { + (ensureGitRepo as jest.Mock).mockImplementation(() => { }); + (loadConfig as jest.Mock).mockReturnValue({ + autoAdd: false, + commitTypes: [{ value: 'fix' }], + templates: { defaultTemplate: '[{type}]: {summary}' }, + steps: {} + }); + (getUnstagedFiles as jest.Mock).mockReturnValue(['file']); + (loadGitignorePatterns as jest.Mock).mockReturnValue([]); + (micromatch.isMatch as jest.Mock).mockReturnValue(false); + (inquirer.prompt as unknown as jest.Mock) + .mockResolvedValueOnce({ files: ['file'] }) + .mockResolvedValueOnce({ type: 'fix', scope: '', summary: 'fix something', pushCommit: true }) + .mockResolvedValueOnce({ diffPreview: false }) + .mockResolvedValueOnce({ previewChoice: false }) + .mockResolvedValueOnce({ finalConfirm: true }); + (execSync as jest.Mock) + .mockReturnValueOnce('file\n') + .mockReturnValueOnce('file\n') + .mockReturnValueOnce('') // commit + .mockReturnValueOnce(''); // push + await program.parseAsync(['node', 'test', 'commit']); + const calls = (execSync as jest.Mock).mock.calls; + const commitCall = calls.find(c => c[0].includes('git commit -m')); + expect(commitCall).toBeTruthy(); + expect(commitCall[0]).toMatch(/\[fix\]\: fix something/); + const pushCall = calls.find(c => c[0] === 'git push'); + expect(pushCall).toBeTruthy(); + }); + + it('should do git commit -S if --sign is true', async () => { + (ensureGitRepo as jest.Mock).mockImplementation(() => { }); + (loadConfig as jest.Mock).mockReturnValue({ + autoAdd: false, + commitTypes: [{ value: 'chore' }], + templates: { defaultTemplate: '[{type}]: {summary}' }, + steps: {} + }); + (getUnstagedFiles as jest.Mock).mockReturnValue(['xxx']); + (loadGitignorePatterns as jest.Mock).mockReturnValue([]); + (micromatch.isMatch as jest.Mock).mockReturnValue(false); + (execSync as jest.Mock) + .mockReturnValueOnce('xxx\n') + .mockReturnValueOnce('xxx\n') + .mockReturnValueOnce(''); + (inquirer.prompt as unknown as jest.Mock) + .mockResolvedValueOnce({ files: ['xxx'] }) + .mockResolvedValueOnce({ type: 'chore', scope: '', summary: 'some chore', pushCommit: false }) + .mockResolvedValueOnce({ diffPreview: false }) + .mockResolvedValueOnce({ previewChoice: false }) + .mockResolvedValueOnce({ finalConfirm: true }); + await program.parseAsync(['node', 'test', 'commit', '--sign']); + const calls = (execSync as jest.Mock).mock.calls; + const commitCall = calls.find(c => c[0].includes('git commit -S -m')); + expect(commitCall).toBeTruthy(); + expect(commitCall[0]).toMatch(/some chore/); + }); + + it('should extract ticket from branch name if config.ticket is enabled and ticket is empty', async () => { + (ensureGitRepo as jest.Mock).mockImplementation(() => {}); + (loadConfig as jest.Mock).mockReturnValue({ + autoAdd: false, + commitTypes: [{ emoji: '✨', value: 'feat', description: 'feature' }], + useEmoji: true, + steps: { ticket: true }, + templates: { defaultTemplate: '[{ticket}]{ticketSeparator}[{type}]: {summary}' }, + ticketRegex: 'ABC-\\d+', + enableLint: false + }); + (getUnstagedFiles as jest.Mock).mockReturnValue(['file1.js']); + (loadGitignorePatterns as jest.Mock).mockReturnValue([]); + (micromatch.isMatch as jest.Mock).mockReturnValue(false); + + const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + + (inquirer.prompt as unknown as jest.Mock) + .mockResolvedValueOnce({ files: ['file1.js'] }) + .mockResolvedValueOnce({ type: 'feat', summary: 'added feature', pushCommit: false }) + .mockResolvedValueOnce({ ticket: '' }) + .mockResolvedValueOnce({ diffPreview: false }) + .mockResolvedValueOnce({ previewChoice: false }) + .mockResolvedValueOnce({ finalConfirm: true }); + + (execSync as jest.Mock).mockImplementation((cmd: string, options: any) => { + if (cmd.startsWith('git diff --cached')) { + return 'file1.js\n'; + } + if (cmd.startsWith('git rev-parse --abbrev-ref HEAD')) { + return 'ABC-123-feature'; + } + return 'file1.js\n'; + }); + + await program.parseAsync(['commit'], { from: 'user' }); + + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Extracted ticket from branch: ABC-123')); + consoleLogSpy.mockRestore(); + }); + + it('should commit if diff preview is shown and confirmed', async () => { + (ensureGitRepo as jest.Mock).mockImplementation(() => {}); + (loadConfig as jest.Mock).mockReturnValue({ + autoAdd: false, + commitTypes: [{ value: 'fix' }], + useEmoji: false, + steps: {}, + templates: { defaultTemplate: '[{type}]: {summary}' }, + enableLint: false + }); + (getUnstagedFiles as jest.Mock).mockReturnValue(['update.js']); + (loadGitignorePatterns as jest.Mock).mockReturnValue([]); + (micromatch.isMatch as jest.Mock).mockReturnValue(false); + (inquirer.prompt as unknown as jest.Mock) + .mockResolvedValueOnce({ files: ['update.js'] }) + .mockResolvedValueOnce({ type: 'fix', scope: '', summary: 'bug fix', pushCommit: false }) + .mockResolvedValueOnce({ diffPreview: true }) + .mockResolvedValueOnce({ diffConfirm: true }) + .mockResolvedValueOnce({ previewChoice: false }) + .mockResolvedValueOnce({ finalConfirm: true }); + (execSync as jest.Mock) + .mockReturnValueOnce('update.js\n') + .mockReturnValueOnce('update.js\n'); + (showDiffPreview as jest.Mock).mockImplementation(() => {}); + await program.parseAsync(['commit'], { from: 'user' }); + const commitCall = (execSync as jest.Mock).mock.calls.find(call => call[0].includes('git commit')); + expect(commitCall).toBeTruthy(); + }); + + it('should abort commit if no staged changes remain after diff preview', async () => { + (ensureGitRepo as jest.Mock).mockImplementation(() => {}); + (loadConfig as jest.Mock).mockReturnValue({ + autoAdd: false, + commitTypes: [{ value: 'chore' }], + useEmoji: false, + steps: {}, + templates: { defaultTemplate: '[{type}]: {summary}' }, + enableLint: false + }); + (getUnstagedFiles as jest.Mock).mockReturnValue(['script.js']); + (loadGitignorePatterns as jest.Mock).mockReturnValue([]); + (micromatch.isMatch as jest.Mock).mockReturnValue(false); + (inquirer.prompt as unknown as jest.Mock) + .mockResolvedValueOnce({ files: ['script.js'] }) + .mockResolvedValueOnce({ type: 'chore', scope: '', summary: 'cleanup', pushCommit: false }) + .mockResolvedValueOnce({ diffPreview: true }) + .mockResolvedValueOnce({ diffConfirm: false }); + (execSync as jest.Mock) + .mockReturnValueOnce('script.js\n') + .mockReturnValueOnce(''); + (showDiffPreview as jest.Mock).mockImplementation(() => {}); + await expect(program.parseAsync(['commit'], { from: 'user' })).resolves.not.toThrow(); + const commitCall = (execSync as jest.Mock).mock.calls.find(call => call[0].includes('git commit')); + expect(commitCall).toBeUndefined(); + }); +}); \ No newline at end of file diff --git a/__tests__/config.test.ts b/__tests__/config.test.ts new file mode 100644 index 0000000..475b5a9 --- /dev/null +++ b/__tests__/config.test.ts @@ -0,0 +1,216 @@ +import { Command } from 'commander'; +import { registerConfigCommand } from '../src/commands/config'; +import { defaultConfig, loadConfig, saveConfig } from '../src/utils'; + +jest.mock('../src/utils', () => ({ + loadConfig: jest.fn(), + saveConfig: jest.fn(), + defaultConfig: { + autoAdd: false, + useEmoji: true, + ciCommand: "", + templates: { defaultTemplate: "[{type}]{ticketSeparator}{ticket}: {summary}" }, + steps: { scope: false, body: false, footer: false, ticket: false, runCI: false }, + ticketRegex: "", + enableLint: false, + lintRules: { summaryMaxLength: 72, typeCase: "lowercase", requiredTicket: false }, + commitTypes: [ + { emoji: "✨", value: "feat", description: "A new feature" }, + { emoji: "🐛", value: "fix", description: "A bug fix" } + ], + branch: { + template: "{type}/{ticketId}-{shortDesc}", + types: [ + { value: "feature", description: "New feature" }, + { value: "fix", description: "Bug fix" } + ], + placeholders: { ticketId: { lowercase: false } } + } + }, +})); + +jest.mock('chalk', () => ({ + ...jest.requireActual('chalk'), + blue: jest.fn((str) => str), + red: jest.fn((str) => str), + green: jest.fn((str) => str), +})); + +describe('registerConfigCommand with --reset', () => { + let program: Command; + let saveConfigMock: jest.Mock; + let consoleLogSpy: jest.SpyInstance; + + beforeEach(() => { + program = new Command(); + registerConfigCommand(program); + saveConfigMock = saveConfig as jest.Mock; + saveConfigMock.mockReset(); + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => { }); + }); + + afterEach(() => { + consoleLogSpy.mockRestore(); + jest.resetAllMocks(); + }); + + it('should reset configuration to default when --reset is passed', async () => { + await program.parseAsync(['node', 'test', 'config', '--reset']); + expect(saveConfigMock).toHaveBeenCalledWith(defaultConfig); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining("Configuration has been reset to default settings.")); + }); +}); + +describe('registerConfigCommand', () => { + let program: Command; + let mockLoadConfig: jest.Mock; + let mockSaveConfig: jest.Mock; + let consoleLogSpy: jest.SpyInstance; + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + program = new Command(); + registerConfigCommand(program); + + mockLoadConfig = loadConfig as jest.Mock; + mockSaveConfig = saveConfig as jest.Mock; + + mockLoadConfig.mockReturnValue({ + autoAdd: false, + useEmoji: true, + ciCommand: "", + templates: { + defaultTemplate: "[{type}]{ticketSeparator}{ticket}: {summary}" + }, + steps: { + scope: false, + body: false, + footer: false, + ticket: false, + runCI: false + }, + ticketRegex: "", + enableLint: false, + lintRules: { + summaryMaxLength: 72, + typeCase: "lowercase", + requiredTicket: false + }, + commitTypes: [ + { emoji: "✨", value: "feat", description: "A new feature" }, + { emoji: "🐛", value: "fix", description: "A bug fix" } + ], + branch: { + template: "{type}/{ticketId}-{shortDesc}", + types: [ + { value: "feature", description: "New feature" }, + { value: "fix", description: "Bug fix" } + ], + placeholders: { + ticketId: { lowercase: false } + } + } + }); + + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => { }); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => { }); + }); + + afterEach(() => { + consoleLogSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + jest.resetAllMocks(); + }); + + it('prints current config if no flags are passed', async () => { + const tableSpy = jest.spyOn(console, 'table').mockImplementation(() => { }); + await program.parseAsync(['node', 'test', 'config']); + + expect(saveConfig).not.toHaveBeenCalled(); + expect(tableSpy).toHaveBeenCalledWith(expect.arrayContaining([ + expect.objectContaining({ Key: 'autoAdd', Value: false }) + ])); + tableSpy.mockRestore(); + }); + + it('updates autoAdd when passing --auto-add true', async () => { + await program.parseAsync(['node', 'test', 'config', '--auto-add', 'true']); + + expect(mockLoadConfig).toHaveBeenCalled(); + expect(saveConfig).toHaveBeenCalledTimes(1); + const updatedConfig = (saveConfig as jest.Mock).mock.calls[0][0]; + expect(updatedConfig.autoAdd).toBe(true); + }); + + it('updates multiple fields in one go', async () => { + await program.parseAsync([ + 'node', + 'test', + 'config', + '--auto-add', + 'true', + '--enable-body', + 'true', + '--enable-run-ci', + 'true', + '--ci-command', + 'npm run test' + ]); + expect(saveConfig).toHaveBeenCalledTimes(1); + const updatedConfig = (saveConfig as jest.Mock).mock.calls[0][0]; + expect(updatedConfig.autoAdd).toBe(true); + expect(updatedConfig.steps.body).toBe(true); + expect(updatedConfig.steps.runCI).toBe(true); + expect(updatedConfig.ciCommand).toBe('npm run test'); + }); + + it('parses valid JSON in --branch-type and updates config', async () => { + const validJson = '[{"value":"hotfix","description":"Hotfix branch"}]'; + await program.parseAsync(['node', 'test', 'config', '--branch-type', validJson]); + expect(saveConfig).toHaveBeenCalledTimes(1); + const updatedConfig = (saveConfig as jest.Mock).mock.calls[0][0]; + expect(updatedConfig.branch.types).toEqual([ + { value: "hotfix", description: "Hotfix branch" } + ]); + }); + + it('logs error if invalid JSON in --branch-type', async () => { + const invalidJson = '{"not":"an array"}'; + await program.parseAsync(['node', 'test', 'config', '--branch-type', invalidJson]); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("branch-type JSON must be an array of objects!") + ); + expect(saveConfig).not.toHaveBeenCalled(); + }); + + it('parses valid JSON in --branch-placeholder and updates config', async () => { + const validJson = '{"ticketId": {"maxLength":10,"separator":"_"}}'; + await program.parseAsync(['node', 'test', 'config', '--branch-placeholder', validJson]); + expect(saveConfig).toHaveBeenCalledTimes(1); + const updatedConfig = (saveConfig as jest.Mock).mock.calls[0][0]; + expect(updatedConfig.branch.placeholders).toEqual({ + ticketId: { maxLength: 10, separator: "_" } + }); + }); + + it('logs error if invalid JSON in --branch-placeholder', async () => { + const invalidJson = 'not valid json...'; + await program.parseAsync(['node', 'test', 'config', '--branch-placeholder', invalidJson]); + + expect(consoleErrorSpy).toHaveBeenCalledTimes(1); + const [firstArg, secondArg] = consoleErrorSpy.mock.calls[0]; + + expect(firstArg).toContain("Invalid JSON for --branch-placeholder:"); + expect(secondArg).toBeInstanceOf(SyntaxError); + + expect(saveConfig).not.toHaveBeenCalled(); + }); + + it('updates enableLint if passed --enable-lint true', async () => { + await program.parseAsync(['node', 'test', 'config', '--enable-lint', 'true']); + expect(saveConfig).toHaveBeenCalledTimes(1); + const updatedConfig = (saveConfig as jest.Mock).mock.calls[0][0]; + expect(updatedConfig.enableLint).toBe(true); + }); +}); \ No newline at end of file diff --git a/__tests__/history.test.ts b/__tests__/history.test.ts new file mode 100644 index 0000000..a98aa0d --- /dev/null +++ b/__tests__/history.test.ts @@ -0,0 +1,141 @@ +import { Command } from 'commander'; +import { registerHistoryCommand } from '../src/commands/history'; +import inquirer from 'inquirer'; +import { execSync } from 'child_process'; +import { ensureGitRepo } from '../src/utils'; +import chalk from 'chalk'; + +jest.mock('inquirer', () => ({ + prompt: jest.fn(), +})); +jest.mock('child_process', () => ({ + execSync: jest.fn(), +})); +jest.mock('../src/utils', () => ({ + ensureGitRepo: jest.fn(), +})); + +describe('registerHistoryCommand', () => { + let program: Command; + let consoleLogSpy: jest.SpyInstance; + let consoleErrorSpy: jest.SpyInstance; + let mockExit: jest.SpyInstance; + + beforeEach(() => { + program = new Command(); + registerHistoryCommand(program); + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => { }); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => { }); + mockExit = jest.spyOn(process, 'exit').mockImplementation((code?: number | string | null | undefined) => { + throw new Error(`process.exit: ${code}`); + }); + (inquirer.prompt as unknown as jest.Mock).mockReset(); + (execSync as jest.Mock).mockReset(); + (ensureGitRepo as jest.Mock).mockReset(); + }); + + afterEach(() => { + consoleLogSpy.mockRestore(); + consoleErrorSpy.mockRestore(); + mockExit.mockRestore(); + }); + + it('should build and paginate the command correctly for filterType "keyword"', async () => { + (inquirer.prompt as unknown as jest.Mock) + .mockResolvedValueOnce({ filterType: 'keyword', viewMode: 'merged' }) + .mockResolvedValueOnce({ keyword: 'fix' }) + .mockResolvedValueOnce({ limit: '20' }) + .mockResolvedValueOnce({ showMore: false }); + + (execSync as jest.Mock).mockReturnValueOnce('commit1\ncommit2\n'); + + await program.parseAsync(['node', 'test', 'history']); + + expect(ensureGitRepo).toHaveBeenCalled(); + expect(inquirer.prompt).toHaveBeenCalledTimes(4); + expect(execSync).toHaveBeenCalledWith( + 'git log --pretty=oneline --grep="fix" --max-count=20 --skip=0', + { encoding: 'utf8' } + ); + expect(consoleLogSpy).toHaveBeenCalledWith(chalk.blue("\nCommit History:\n")); + expect(consoleLogSpy).toHaveBeenCalledWith(chalk.green('commit1\ncommit2\n')); + }); + + it('should build and paginate the command correctly for filterType "author"', async () => { + (inquirer.prompt as unknown as jest.Mock) + .mockResolvedValueOnce({ filterType: 'author', viewMode: 'merged' }) + .mockResolvedValueOnce({ author: 'john@example.com' }) + .mockResolvedValueOnce({ limit: '15' }) + .mockResolvedValueOnce({ showMore: false }); + (execSync as jest.Mock).mockReturnValueOnce('commitA\ncommitB\n'); + + await program.parseAsync(['node', 'test', 'history']); + + expect(inquirer.prompt).toHaveBeenCalledTimes(4); + expect(execSync).toHaveBeenCalledWith( + 'git log --pretty=oneline --author="john@example.com" --max-count=15 --skip=0', + { encoding: 'utf8' } + ); + expect(consoleLogSpy).toHaveBeenCalledWith(chalk.green('commitA\ncommitB\n')); + }); + + it('should build and paginate the command correctly for filterType "date"', async () => { + (inquirer.prompt as unknown as jest.Mock) + .mockResolvedValueOnce({ filterType: 'date', viewMode: 'merged' }) + .mockResolvedValueOnce({ since: '2023-01-01', until: '2023-01-31' }) + .mockResolvedValueOnce({ limit: '10' }) + .mockResolvedValueOnce({ showMore: false }); + (execSync as jest.Mock).mockReturnValueOnce('commitX\ncommitY\n'); + + await program.parseAsync(['node', 'test', 'history']); + + expect(inquirer.prompt).toHaveBeenCalledTimes(4); + expect(execSync).toHaveBeenCalledWith( + 'git log --pretty=oneline --since="2023-01-01" --until="2023-01-31" --max-count=10 --skip=0', + { encoding: 'utf8' } + ); + expect(consoleLogSpy).toHaveBeenCalledWith(chalk.green('commitX\ncommitY\n')); + }); + + it('should paginate over multiple pages if user selects to show more', async () => { + (inquirer.prompt as unknown as jest.Mock) + .mockResolvedValueOnce({ filterType: 'keyword', viewMode: 'merged' }) + .mockResolvedValueOnce({ keyword: 'update' }) + .mockResolvedValueOnce({ limit: '5' }) + .mockResolvedValueOnce({ showMore: true }) + .mockResolvedValueOnce({ showMore: false }); + + (execSync as jest.Mock) + .mockReturnValueOnce('commit1\ncommit2\ncommit3\ncommit4\ncommit5\n') + .mockReturnValueOnce(''); + + await program.parseAsync(['node', 'test', 'history']); + + expect(execSync).toHaveBeenNthCalledWith( + 1, + 'git log --pretty=oneline --grep="update" --max-count=5 --skip=0', + { encoding: 'utf8' } + ); + expect(execSync).toHaveBeenNthCalledWith( + 2, + 'git log --pretty=oneline --grep="update" --max-count=5 --skip=5', + { encoding: 'utf8' } + ); + }); + + it('should log error and exit if execSync fails during pagination', async () => { + (inquirer.prompt as unknown as jest.Mock) + .mockResolvedValueOnce({ filterType: 'keyword', viewMode: 'merged' }) + .mockResolvedValueOnce({ keyword: 'error' }) + .mockResolvedValueOnce({ limit: '20' }) + .mockResolvedValueOnce({ showMore: false }); + (execSync as jest.Mock).mockImplementation(() => { + throw new Error('Git error occurred'); + }); + + await expect(program.parseAsync(['node', 'test', 'history'])) + .rejects.toThrow('process.exit: 1'); + + expect(consoleErrorSpy).toHaveBeenCalledWith(chalk.red("Error retrieving history:"), 'Git error occurred'); + }); +}); \ No newline at end of file diff --git a/__tests__/rebaseHelper.test.ts b/__tests__/rebaseHelper.test.ts new file mode 100644 index 0000000..053827f --- /dev/null +++ b/__tests__/rebaseHelper.test.ts @@ -0,0 +1,73 @@ +import { Command } from 'commander'; +import inquirer from 'inquirer'; +import { registerRebaseHelperCommand } from '../src/commands/rebaseHelper'; +import { ensureGitRepo } from '../src/utils'; + +jest.mock('inquirer'); +jest.mock('../src/utils', () => ({ + ensureGitRepo: jest.fn() +})); + +describe('registerRebaseHelperCommand', () => { + let program: Command; + let exitSpy: jest.SpyInstance; + let execSyncSpy: jest.SpyInstance; + let consoleLogSpy: jest.SpyInstance; + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + program = new Command(); + registerRebaseHelperCommand(program); + exitSpy = jest.spyOn(process, 'exit').mockImplementation((code?: string | number | null | undefined) => { + throw new Error(`process.exit: ${code}`); + }); + execSyncSpy = jest.spyOn(require('child_process'), 'execSync').mockImplementation(() => {}); + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('executes rebase when valid inputs and confirmed', async () => { + (inquirer.prompt as unknown as jest.Mock) + .mockResolvedValueOnce({ commitCount: '3' }) + .mockResolvedValueOnce({ confirm: true }); + + await program.parseAsync(['rebase-helper'], { from: 'user' }); + + expect(ensureGitRepo).toHaveBeenCalled(); + expect(execSyncSpy).toHaveBeenCalledWith('git rebase -i HEAD~3', { stdio: 'inherit' }); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Interactive rebase completed.')); + }); + + test('aborts rebase when user does not confirm', async () => { + (inquirer.prompt as unknown as jest.Mock) + .mockResolvedValueOnce({ commitCount: '5' }) + .mockResolvedValueOnce({ confirm: false }); + + await expect(program.parseAsync(['rebase-helper'], { from: 'user' })) + .rejects.toThrow('process.exit: 0'); + + expect(execSyncSpy).not.toHaveBeenCalled(); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining('Rebase aborted by user.')); + }); + + test('exits with error if execSync throws an error', async () => { + (inquirer.prompt as unknown as jest.Mock) + .mockResolvedValueOnce({ commitCount: '2' }) + .mockResolvedValueOnce({ confirm: true }); + + const errorMessage = 'Simulated exec error'; + execSyncSpy.mockImplementation(() => { throw new Error(errorMessage); }); + + await expect(program.parseAsync(['rebase-helper'], { from: 'user' })) + .rejects.toThrow('process.exit: 1'); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining('Error during interactive rebase:'), + expect.stringContaining(errorMessage) + ); + }); +}); \ No newline at end of file diff --git a/__tests__/rollback.test.ts b/__tests__/rollback.test.ts new file mode 100644 index 0000000..b889835 --- /dev/null +++ b/__tests__/rollback.test.ts @@ -0,0 +1,132 @@ +import { Command } from 'commander'; +import inquirer from 'inquirer'; +import { registerRollbackCommand } from '../src/commands/rollback'; +import { ensureGitRepo } from '../src/utils'; + +jest.mock('inquirer'); +jest.mock('../src/utils', () => ({ + ensureGitRepo: jest.fn() +})); + +describe('registerRollbackCommand', () => { + let program: Command; + let exitSpy: jest.SpyInstance; + let execSyncSpy: jest.SpyInstance; + let consoleLogSpy: jest.SpyInstance; + let consoleErrorSpy: jest.SpyInstance; + + beforeEach(() => { + program = new Command(); + registerRollbackCommand(program); + exitSpy = jest.spyOn(process, 'exit').mockImplementation((code?: string | number | null | undefined) => { + throw new Error(`process.exit: ${code}`); + }); + execSyncSpy = jest.spyOn(require('child_process'), 'execSync').mockImplementation(() => { }); + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => { }); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => { }); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + test('performs soft reset with default target (HEAD~1) when user does not choose specific commit and confirms', async () => { + (inquirer.prompt as unknown as jest.Mock) + .mockResolvedValueOnce({ resetType: 'soft' }) + .mockResolvedValueOnce({ chooseSpecific: false }) + .mockResolvedValueOnce({ confirmRollback: true }); + + await program.parseAsync(['rollback'], { from: 'user' }); + + expect(ensureGitRepo).toHaveBeenCalled(); + expect(execSyncSpy).toHaveBeenCalledWith('git reset --soft HEAD~1', { stdio: 'inherit' }); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining("Rollback successful!")); + }); + + test('performs soft reset with chosen commit when user opts to choose specific commit', async () => { + const fakeLog = 'abc123 Commit message one\ndef456 Commit message two\n'; + (inquirer.prompt as unknown as jest.Mock) + .mockResolvedValueOnce({ resetType: 'soft' }) + .mockResolvedValueOnce({ chooseSpecific: true }) + .mockResolvedValueOnce({ selectedCommit: 'def456' }) + .mockResolvedValueOnce({ confirmRollback: true }); + + execSyncSpy.mockImplementationOnce(() => fakeLog); + + await program.parseAsync(['rollback'], { from: 'user' }); + + expect(ensureGitRepo).toHaveBeenCalled(); + expect(execSyncSpy).toHaveBeenCalledWith('git log --oneline -n 10', { encoding: 'utf8' }); + expect(execSyncSpy).toHaveBeenCalledWith('git reset --soft def456', { stdio: 'inherit' }); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining("Rollback successful!")); + }); + + test('performs hard reset successfully when confirmed', async () => { + (inquirer.prompt as unknown as jest.Mock) + .mockResolvedValueOnce({ resetType: 'hard' }) + .mockResolvedValueOnce({ confirmRollback: true }); + + await program.parseAsync(['rollback'], { from: 'user' }); + + expect(ensureGitRepo).toHaveBeenCalled(); + expect(execSyncSpy).toHaveBeenCalledWith('git reset --hard HEAD~1', { stdio: 'inherit' }); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining("Rollback successful!")); + }); + + test('cancels rollback when confirmation is false', async () => { + (inquirer.prompt as unknown as jest.Mock) + .mockResolvedValueOnce({ resetType: 'soft' }) + .mockResolvedValueOnce({ chooseSpecific: false }) + .mockResolvedValueOnce({ confirmRollback: false }); + + await expect(program.parseAsync(['rollback'], { from: 'user' })) + .rejects.toThrow('process.exit: 0'); + + expect(execSyncSpy).not.toHaveBeenCalled(); + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining("Rollback cancelled.")); + }); + + test('exits with error if execSync throws an error during soft reset', async () => { + (inquirer.prompt as unknown as jest.Mock) + .mockResolvedValueOnce({ resetType: 'soft' }) + .mockResolvedValueOnce({ chooseSpecific: false }) + .mockResolvedValueOnce({ confirmRollback: true }); + + const errorMessage = 'Simulated soft reset error'; + execSyncSpy.mockImplementation(() => { throw new Error(errorMessage); }); + + await expect(program.parseAsync(['rollback'], { from: 'user' })) + .rejects.toThrow('process.exit: 1'); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("Error during rollback:"), + expect.stringContaining(errorMessage) + ); + }); + + test('exits with error if execSync throws an error during hard reset', async () => { + (inquirer.prompt as unknown as jest.Mock) + .mockResolvedValueOnce({ resetType: 'hard' }) + .mockResolvedValueOnce({ confirmRollback: true }); + + const errorMessage = 'Simulated hard reset error'; + execSyncSpy.mockImplementation(() => { throw new Error(errorMessage); }); + + await expect(program.parseAsync(['rollback'], { from: 'user' })) + .rejects.toThrow('process.exit: 1'); + + expect(consoleErrorSpy).toHaveBeenCalledWith( + expect.stringContaining("Error during rollback:"), + expect.stringContaining(errorMessage) + ); + }); + + test('validates that ensureGitRepo is always called before any rollback logic', async () => { + (inquirer.prompt as unknown as jest.Mock) + .mockResolvedValueOnce({ resetType: 'hard' }) + .mockResolvedValueOnce({ confirmRollback: true }); + + await program.parseAsync(['rollback'], { from: 'user' }); + expect(ensureGitRepo).toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/__tests__/setup.test.ts b/__tests__/setup.test.ts new file mode 100644 index 0000000..0162e05 --- /dev/null +++ b/__tests__/setup.test.ts @@ -0,0 +1,231 @@ +import { Command } from 'commander'; +import inquirer from 'inquirer'; +import { registerSetupCommand } from '../src/commands/setup'; +import { defaultConfig } from '../src/utils'; +import { Config } from '../src/types'; + +jest.mock('inquirer', () => ({ + prompt: jest.fn(), +})); +jest.mock('../src/utils', () => ({ + saveConfig: jest.fn(), + defaultConfig: { + autoAdd: false, + ciCommand: "npm test", + templates: { defaultTemplate: "[{type}]: {summary}" }, + steps: { scope: false, body: false, footer: false, ticket: false, runCI: false }, + ticketRegex: "", + enableLint: false, + branch: { + template: "{type}/{ticketId}-{shortDesc}", + types: [], + placeholders: {}, + }, + }, +})); + +describe('registerSetupCommand', () => { + let program: Command; + let promptMock: jest.Mock; + let saveConfigMock: jest.Mock; + + beforeEach(() => { + program = new Command(); + registerSetupCommand(program); + promptMock = (inquirer.prompt as unknown) as jest.Mock; + saveConfigMock = require('../src/utils').saveConfig; + promptMock.mockReset(); + saveConfigMock.mockReset(); + }); + + it('should run through setup and save config without branch config', async () => { + promptMock.mockResolvedValueOnce({ + enableScope: true, + enableBody: false, + enableFooter: false, + enableTicket: true, + enableRunCi: false, + ticketRegex: "ABC-\\d+", + template: "[{type}]: {summary}", + autoAdd: true, + ciCommand: "yarn test", + enableLint: true, + enableBranchConfig: false, + }); + await program.parseAsync(['setup'], { from: 'user' }); + const expectedConfig: Config = { + ...defaultConfig, + autoAdd: true, + ciCommand: "yarn test", + templates: { defaultTemplate: "[{type}]: {summary}" }, + steps: { + scope: true, + body: false, + footer: false, + ticket: true, + runCI: false, + }, + ticketRegex: "ABC-\\d+", + enableLint: true, + }; + expect(saveConfigMock).toHaveBeenCalledWith(expectedConfig); + }); + + it('should run through setup with branch config and parse branch types and placeholders', async () => { + const branchTypesJson = `[ + {"value": "feature", "description": "New feature"}, + {"value": "fix", "description": "Bug fix"} + ]`; + const branchPlaceholdersJson = `{ + "ticketId": {"lowercase": true} + }`; + promptMock.mockResolvedValueOnce({ + enableScope: false, + enableBody: true, + enableFooter: true, + enableTicket: true, + enableRunCi: true, + ticketRegex: "XYZ-\\d+", + template: "[{ticket}]{ticketSeparator}[{type}]: {summary}", + autoAdd: false, + ciCommand: "npm run ci", + enableLint: false, + enableBranchConfig: true, + branchTemplate: "{type}/{ticketId}-{shortDesc}", + addBranchTypes: true, + branchTypes: branchTypesJson, + addPlaceholders: true, + branchPlaceholders: branchPlaceholdersJson, + }); + await program.parseAsync(['setup'], { from: 'user' }); + const expectedConfig: Config = { + ...defaultConfig, + autoAdd: false, + ciCommand: "npm run ci", + templates: { defaultTemplate: "[{ticket}]{ticketSeparator}[{type}]: {summary}" }, + steps: { + scope: false, + body: true, + footer: true, + ticket: true, + runCI: true, + }, + ticketRegex: "XYZ-\\d+", + enableLint: false, + branch: { + template: "{type}/{ticketId}-{shortDesc}", + types: JSON.parse(branchTypesJson), + placeholders: JSON.parse(branchPlaceholdersJson), + }, + }; + expect(saveConfigMock).toHaveBeenCalledWith(expectedConfig); + }); + + it('should ignore branch types JSON if parsing fails', async () => { + promptMock.mockResolvedValueOnce({ + enableScope: false, + enableBody: false, + enableFooter: false, + enableTicket: false, + enableRunCi: false, + ticketRegex: "", + template: "[{type}]: {summary}", + autoAdd: false, + ciCommand: "", + enableLint: false, + enableBranchConfig: true, + branchTemplate: "{type}/{ticketId}-{shortDesc}", + addBranchTypes: true, + branchTypes: "invalid json", + addPlaceholders: false, + }); + await program.parseAsync(['setup'], { from: 'user' }); + expect(saveConfigMock).toHaveBeenCalledWith(expect.objectContaining({ + branch: { + template: "{type}/{ticketId}-{shortDesc}", + types: defaultConfig.branch?.types, + placeholders: defaultConfig.branch?.placeholders, + } + })); + }); + + it('should ignore branch placeholders JSON if parsing fails', async () => { + promptMock.mockResolvedValueOnce({ + enableScope: false, + enableBody: false, + enableFooter: false, + enableTicket: false, + enableRunCi: false, + ticketRegex: "", + template: "[{type}]: {summary}", + autoAdd: false, + ciCommand: "", + enableLint: false, + enableBranchConfig: true, + branchTemplate: "{type}/{ticketId}-{shortDesc}", + addBranchTypes: false, + addPlaceholders: true, + branchPlaceholders: "not a json", + }); + await program.parseAsync(['setup'], { from: 'user' }); + expect(saveConfigMock).toHaveBeenCalledWith(expect.objectContaining({ + branch: { + template: "{type}/{ticketId}-{shortDesc}", + types: defaultConfig.branch?.types, + placeholders: defaultConfig.branch?.placeholders, + } + })); + }); + + it('should save config using default values for unanswered questions', async () => { + promptMock.mockResolvedValueOnce({ + enableScope: false, + enableBody: false, + enableFooter: false, + enableTicket: false, + enableRunCi: false, + ticketRegex: "", + template: "", + autoAdd: false, + ciCommand: "", + enableLint: false, + enableBranchConfig: false, + }); + await program.parseAsync(['setup'], { from: 'user' }); + expect(saveConfigMock).toHaveBeenCalledWith({ + ...defaultConfig, + autoAdd: false, + ciCommand: defaultConfig.ciCommand, + templates: { defaultTemplate: defaultConfig.templates.defaultTemplate }, + steps: { + scope: false, + body: false, + footer: false, + ticket: false, + runCI: false, + }, + ticketRegex: "", + enableLint: false, + }); + }); + + it('should log "Setup complete!" after saving config', async () => { + const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + promptMock.mockResolvedValueOnce({ + enableScope: false, + enableBody: false, + enableFooter: false, + enableTicket: false, + enableRunCi: false, + ticketRegex: "", + template: "[{type}]: {summary}", + autoAdd: false, + ciCommand: "", + enableLint: false, + enableBranchConfig: false, + }); + await program.parseAsync(['setup'], { from: 'user' }); + expect(consoleLogSpy).toHaveBeenCalledWith("Setup complete!"); + consoleLogSpy.mockRestore(); + }); +}); \ No newline at end of file diff --git a/__tests__/stats.test.ts b/__tests__/stats.test.ts new file mode 100644 index 0000000..164ed8e --- /dev/null +++ b/__tests__/stats.test.ts @@ -0,0 +1,116 @@ +import { Command } from 'commander'; +import { registerStatsCommand } from '../src/commands/stats'; +import inquirer from 'inquirer'; +import { execSync } from 'child_process'; +import { ensureGitRepo } from '../src/utils'; +import chalk from 'chalk'; + +jest.mock('inquirer', () => ({ + prompt: jest.fn(), +})); +jest.mock('child_process', () => ({ + execSync: jest.fn(), +})); +jest.mock('../src/utils', () => ({ + ensureGitRepo: jest.fn(), +})); + +describe('registerStatsCommand', () => { + let program: Command; + let promptMock: jest.Mock; + let execSyncMock: jest.Mock; + let consoleLogSpy: jest.SpyInstance; + let consoleErrorSpy: jest.SpyInstance; + let exitSpy: jest.SpyInstance; + + beforeEach(() => { + program = new Command(); + registerStatsCommand(program); + promptMock = inquirer.prompt as unknown as jest.Mock; + execSyncMock = execSync as jest.Mock; + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => { }); + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => { }); + exitSpy = jest.spyOn(process, 'exit').mockImplementation((code?: number | string | null | undefined) => { + throw new Error(`process.exit: ${code}`); + }); + (ensureGitRepo as jest.Mock).mockReset(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('should show shortlog statistics for selected period', async () => { + promptMock + .mockResolvedValueOnce({ period: '1 week ago' }) + .mockResolvedValueOnce({ statsType: 'shortlog' }); + + execSyncMock.mockImplementation((cmd: string, options: any) => { + if (cmd.startsWith('git --no-pager shortlog')) { + expect(cmd).toContain('--since="1 week ago"'); + return ""; + } + return ""; + }); + + await program.parseAsync(['stats'], { from: 'user' }); + expect(ensureGitRepo).toHaveBeenCalled(); + expect(execSyncMock).toHaveBeenCalledWith('git --no-pager shortlog -s -n --since="1 week ago"', { stdio: 'inherit' }); + }); + + it('should show activity statistics for selected period', async () => { + promptMock + .mockResolvedValueOnce({ period: '1 month ago' }) + .mockResolvedValueOnce({ statsType: 'activity' }); + const datesOutput = `2025-02-01 +2025-02-01 +2025-02-02 +2025-02-03 +2025-02-03 +2025-02-03`; + execSyncMock.mockImplementation((cmd: string, options: any) => { + if (cmd.startsWith('git log')) { + expect(cmd).toContain('--since="1 month ago"'); + return datesOutput; + } + return ""; + }); + + await program.parseAsync(['stats'], { from: 'user' }); + expect(ensureGitRepo).toHaveBeenCalled(); + expect(consoleLogSpy).toHaveBeenCalledWith(chalk.blue("\nCommit Activity:")); + expect(consoleLogSpy).toHaveBeenCalledWith(chalk.green(`2025-02-01: ${"#".repeat(2)} (2)`)); + expect(consoleLogSpy).toHaveBeenCalledWith(chalk.green(`2025-02-02: ${"#".repeat(1)} (1)`)); + expect(consoleLogSpy).toHaveBeenCalledWith(chalk.green(`2025-02-03: ${"#".repeat(3)} (3)`)); + }); + + it('should print "No commits found for the selected period." when no commits are present', async () => { + promptMock + .mockResolvedValueOnce({ period: '1 day ago' }) + .mockResolvedValueOnce({ statsType: 'activity' }); + execSyncMock.mockImplementation((cmd: string, options: any) => { + if (cmd.startsWith('git log')) { + return ""; + } + return ""; + }); + + await program.parseAsync(['stats'], { from: 'user' }); + expect(consoleLogSpy).toHaveBeenCalledWith(chalk.yellow("No commits found for the selected period.")); + }); + + it('should exit with error if execSync fails', async () => { + promptMock + .mockResolvedValueOnce({ period: '1 day ago' }) + .mockResolvedValueOnce({ statsType: 'shortlog' }); + + const errorMessage = 'Simulated error'; + execSyncMock.mockImplementation(() => { + throw new Error(errorMessage); + }); + + await expect(program.parseAsync(['stats'], { from: 'user' })) + .rejects.toThrow('process.exit: 1'); + expect(consoleErrorSpy).toHaveBeenCalledWith(chalk.red("Error retrieving statistics:"), expect.stringContaining(errorMessage)); + }); +}); \ No newline at end of file diff --git a/__tests__/utils.test.ts b/__tests__/utils.test.ts new file mode 100644 index 0000000..0365c6a --- /dev/null +++ b/__tests__/utils.test.ts @@ -0,0 +1,312 @@ +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import { execSync } from 'child_process'; +import parseGitIgnore from 'parse-gitignore'; +import inquirer from 'inquirer'; +import chalk from 'chalk'; + +import { + defaultConfig, + loadConfig, + saveConfig, + loadGitignorePatterns, + getUnstagedFiles, + stageSelectedFiles, + computeAutoSummary, + suggestCommitType, + lintCommitMessage, + previewCommitMessage, + ensureGitRepo, + showDiffPreview, +} from '../src/utils'; + +jest.mock('fs'); +jest.mock('child_process'); +jest.mock('parse-gitignore'); +jest.mock('inquirer'); + +const CONFIG_PATH = path.join(os.homedir(), '.smart-commit-config.json'); + +describe('Utils', () => { + describe('loadConfig', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + it('should return defaultConfig if global config file does not exist', () => { + (fs.existsSync as jest.Mock).mockReturnValue(false); + (fs.existsSync as jest.Mock).mockReturnValueOnce(false); + const config = loadConfig(); + expect(config).toEqual(defaultConfig); + }); + it('should load and parse global config if file exists', () => { + const globalConfig = { ...defaultConfig, autoAdd: true }; + jest.spyOn(process, 'cwd').mockReturnValue('/fake/path'); + (fs.existsSync as jest.Mock).mockImplementation((filePath: string) => { + if (filePath === CONFIG_PATH) return true; + if (filePath === path.join('/fake/path', '.smartcommitrc.json')) return false; + return false; + }); + (fs.readFileSync as jest.Mock).mockReturnValue(JSON.stringify(globalConfig)); + const config = loadConfig(); + expect(config).toEqual(globalConfig); + }); + it('should merge local config over global config', () => { + const globalConfig = { ...defaultConfig, autoAdd: false, ciCommand: "npm test" }; + const localConfig = { autoAdd: true, ciCommand: "yarn test" }; + jest.spyOn(process, 'cwd').mockReturnValue('/fake/path'); + (fs.existsSync as jest.Mock).mockImplementation((filePath: string) => { + if (filePath === CONFIG_PATH) return true; + if (filePath === path.join('/fake/path', '.smartcommitrc.json')) return true; + return false; + }); + (fs.readFileSync as jest.Mock) + .mockImplementation((filePath: string) => { + if (filePath === CONFIG_PATH) return JSON.stringify(globalConfig); + if (filePath === path.join('/fake/path', '.smartcommitrc.json')) return JSON.stringify(localConfig); + return ""; + }); + const config = loadConfig(); + expect(config.autoAdd).toBe(true); + expect(config.ciCommand).toBe("yarn test"); + }); + it('should log error and use default if global config is invalid JSON', () => { + jest.spyOn(process, 'cwd').mockReturnValue('/fake/path'); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + (fs.existsSync as jest.Mock).mockReturnValue(true); + (fs.readFileSync as jest.Mock).mockImplementation(() => { throw new Error('Invalid JSON'); }); + const config = loadConfig(); + expect(console.error).toHaveBeenCalledWith(chalk.red("Error reading global config, using default settings.")); + expect(config).toEqual(defaultConfig); + consoleErrorSpy.mockRestore(); + }); + }); + + describe('saveConfig', () => { + it('should write config to CONFIG_PATH and log message', () => { + const writeFileSyncMock = fs.writeFileSync as jest.Mock; + const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + saveConfig(defaultConfig); + expect(writeFileSyncMock).toHaveBeenCalledWith( + CONFIG_PATH, + JSON.stringify(defaultConfig, null, 2), + 'utf8' + ); + expect(consoleLogSpy).toHaveBeenCalledWith(chalk.green("Global configuration saved at"), CONFIG_PATH); + consoleLogSpy.mockRestore(); + }); + }); + + describe('loadGitignorePatterns', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + it('should return empty array if .gitignore does not exist', () => { + jest.spyOn(process, 'cwd').mockReturnValue('/fake/path'); + (fs.existsSync as jest.Mock).mockReturnValue(false); + const patterns = loadGitignorePatterns(); + expect(patterns).toEqual([]); + }); + it('should parse and return patterns from .gitignore', () => { + jest.spyOn(process, 'cwd').mockReturnValue('/fake/path'); + (fs.existsSync as jest.Mock).mockReturnValue(true); + (fs.readFileSync as jest.Mock).mockReturnValue("node_modules\n.dist\n"); + (parseGitIgnore as unknown as jest.Mock).mockReturnValue(["node_modules", ".dist"]); + const patterns = loadGitignorePatterns(); + expect(patterns).toEqual(["node_modules", ".dist"]); + }); + it('should log error and return empty array if parsing fails', () => { + jest.spyOn(process, 'cwd').mockReturnValue('/fake/path'); + (fs.existsSync as jest.Mock).mockReturnValue(true); + (fs.readFileSync as jest.Mock).mockImplementation(() => { throw new Error("read error"); }); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + const patterns = loadGitignorePatterns(); + expect(consoleErrorSpy).toHaveBeenCalledWith(chalk.red("Failed to parse .gitignore:"), expect.any(Error)); + expect(patterns).toEqual([]); + consoleErrorSpy.mockRestore(); + }); + }); + + describe('getUnstagedFiles', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + it('should return unique list of changed and untracked files', () => { + (execSync as jest.Mock) + .mockReturnValueOnce("file1.js\nfile2.js\n") + .mockReturnValueOnce("file2.js\nfile3.js\n"); + const files = getUnstagedFiles(); + expect(files.sort()).toEqual(["file1.js", "file2.js", "file3.js"].sort()); + }); + it('should log error and return empty array if execSync fails', () => { + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + (execSync as jest.Mock).mockImplementation(() => { throw new Error("git error"); }); + const files = getUnstagedFiles(); + expect(consoleErrorSpy).toHaveBeenCalledWith(chalk.red("Error getting unstaged files:"), "git error"); + expect(files).toEqual([]); + consoleErrorSpy.mockRestore(); + }); + }); + + describe('stageSelectedFiles', () => { + it('should call git add for each file in the list', () => { + (execSync as jest.Mock).mockReset(); + stageSelectedFiles(["file1.js", "file2.js"]); + expect(execSync).toHaveBeenCalledWith('git add "file1.js"', { stdio: 'inherit' }); + expect(execSync).toHaveBeenCalledWith('git add "file2.js"', { stdio: 'inherit' }); + }); + it('should do nothing if file list is empty', () => { + (execSync as jest.Mock).mockReset(); + stageSelectedFiles([]); + expect(execSync).not.toHaveBeenCalled(); + }); + }); + + describe('computeAutoSummary', () => { + it('should return a concatenated summary based on staged files', () => { + (execSync as jest.Mock).mockReturnValue("package.json\nsrc/app.ts\nREADME.md\n"); + const summary = computeAutoSummary(); + expect(summary).toContain("Update dependencies"); + expect(summary).toContain("Update source code"); + expect(summary).toContain("Update documentation"); + }); + it('should return empty string if no staged files', () => { + (execSync as jest.Mock).mockReturnValue(""); + const summary = computeAutoSummary(); + expect(summary).toBe(""); + }); + }); + + describe('suggestCommitType', () => { + it('should suggest "docs" if all staged files are markdown', () => { + (execSync as jest.Mock).mockReturnValue("README.md\nCONTRIBUTING.md\n"); + const type = suggestCommitType(); + expect(type).toBe("docs"); + }); + it('should suggest "chore" if package.json is staged', () => { + (execSync as jest.Mock).mockReturnValue("package.json\nother.txt\n"); + const type = suggestCommitType(); + expect(type).toBe("chore"); + }); + it('should suggest "feat" if any staged file is in src/', () => { + (execSync as jest.Mock).mockReturnValue("src/index.ts\n"); + const type = suggestCommitType(); + expect(type).toBe("feat"); + }); + it('should return null if no staged files', () => { + (execSync as jest.Mock).mockReturnValue(""); + const type = suggestCommitType(); + expect(type).toBeNull(); + }); + }); + + describe('lintCommitMessage', () => { + const rules = { + summaryMaxLength: 10, + typeCase: "lowercase", + requiredTicket: true, + }; + it('should return error if summary too long', () => { + const message = "This summary is definitely too long\nOther lines"; + const errors = lintCommitMessage(message, rules); + expect(errors[0]).toMatch(/Summary is too long/); + }); + it('should return error if summary does not start with lowercase', () => { + const message = "Invalid summary\nBody"; + const errors = lintCommitMessage(message, { ...rules, summaryMaxLength: 100, requiredTicket: false }); + expect(errors).toContain("Summary should start with a lowercase letter."); + }); + it('should return error if ticket is required but not present', () => { + const message = "valid summary\nBody"; + const errors = lintCommitMessage(message, { ...rules, summaryMaxLength: 100, requiredTicket: true }); + expect(errors).toContain("A ticket ID is required in the commit message (e.g., '#DEV-123')."); + }); + it('should return empty array if message passes all lint rules', () => { + const message = "valid\nBody with #123"; + const errors = lintCommitMessage(message, { summaryMaxLength: 100, typeCase: "lowercase", requiredTicket: true }); + expect(errors).toEqual([]); + }); + }); + + describe('previewCommitMessage', () => { + it('should return message if preview is confirmed and no lint errors', async () => { + (inquirer.prompt as unknown as jest.Mock).mockResolvedValueOnce({ confirmPreview: true }); + const message = "valid message with #ticket"; + const result = await previewCommitMessage(message, { summaryMaxLength: 100, typeCase: "lowercase", requiredTicket: false }); + expect(result).toBe(message); + }); + + it('should allow editing message when not confirmed', async () => { + (inquirer.prompt as unknown as jest.Mock) + .mockResolvedValueOnce({ confirmPreview: false }) + .mockResolvedValueOnce({ editedMessage: "edited message" }) + .mockResolvedValueOnce({ confirmPreview: true }); + + const message = "original message"; + const result = await previewCommitMessage(message, { summaryMaxLength: 100, typeCase: "lowercase", requiredTicket: false }); + expect(result).toBe("edited message"); + }); + }); + + describe('ensureGitRepo', () => { + let exitSpy: jest.SpyInstance; + let consoleLogSpy: jest.SpyInstance; + + beforeEach(() => { + exitSpy = jest.spyOn(process, 'exit').mockImplementation((code?: string | number | null | undefined) => { + throw new Error(`process.exit: ${code}`); + }); + consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + }); + + afterEach(() => { + exitSpy.mockRestore(); + consoleLogSpy.mockRestore(); + }); + + it('should do nothing if inside a Git repo', () => { + (execSync as jest.Mock).mockReturnValue("true"); + expect(() => ensureGitRepo()).not.toThrow(); + }); + + it('should log error and call process.exit if not inside a Git repo', () => { + (execSync as jest.Mock).mockImplementation(() => { throw new Error("not a repo"); }); + expect(() => ensureGitRepo()).toThrow('process.exit: 1'); + expect(consoleLogSpy).toHaveBeenCalledWith( + chalk.red("Not a Git repository. Please run 'git init' or navigate to a valid repo.") + ); + }); + }); + + describe('showDiffPreview', () => { + beforeEach(() => { + jest.resetAllMocks(); + }); + it('should show message if no staged changes to show', () => { + (execSync as jest.Mock).mockReturnValue(" "); + const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + showDiffPreview(); + expect(consoleLogSpy).toHaveBeenCalledWith(chalk.yellow("No staged changes to show.")); + consoleLogSpy.mockRestore(); + }); + it('should show diff preview if diff is not empty', () => { + const fakeDiff = "diff --git a/file b/file"; + (execSync as jest.Mock).mockReturnValue(fakeDiff); + const consoleLogSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + showDiffPreview(); + expect(consoleLogSpy).toHaveBeenCalledWith(chalk.green("\nStaged Diff Preview:\n")); + expect(consoleLogSpy).toHaveBeenCalledWith(chalk.green(fakeDiff)); + consoleLogSpy.mockRestore(); + }); + it('should log error if execSync fails', () => { + (execSync as jest.Mock).mockImplementation(() => { throw new Error("diff error"); }); + const consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + showDiffPreview(); + expect(consoleErrorSpy).toHaveBeenCalledWith(chalk.red("Error retrieving diff:"), "diff error"); + consoleErrorSpy.mockRestore(); + }); + }); + afterAll(() => { + jest.restoreAllMocks(); + }); +}); \ No newline at end of file diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..d67ea06 --- /dev/null +++ b/babel.config.js @@ -0,0 +1,5 @@ +module.exports = { + presets: [ + ['@babel/preset-env', { targets: { node: 'current' } }] + ], +}; \ No newline at end of file diff --git a/index.ts b/index.ts deleted file mode 100644 index d978ed7..0000000 --- a/index.ts +++ /dev/null @@ -1,958 +0,0 @@ -#!/usr/bin/env node - -import { program } from 'commander'; -import inquirer, { Question } from 'inquirer'; -import fs from 'fs'; -import path from 'path'; -import os from 'os'; -import { execSync } from 'child_process'; -import chalk from 'chalk'; -import { fileURLToPath } from 'url'; - -// These variables are required to get the current file's path and directory in ES modules. -// In CommonJS, these are available globally, but in ES modules we need to construct them. -const __filename = fileURLToPath(import.meta.url); -const __dirname = path.dirname(__filename); - -/** - * Question type without using generic Answers. - */ -type InputQuestion = Question & { type: 'input' }; -type ListQuestion = Question & { type: 'list' }; -type ConfirmQuestion = Question & { type: 'confirm' }; -type EditorQuestion = Question & { type: 'editor' }; - -/** - * Represents a commit type (with emoji, value, description). - */ -interface CommitType { - emoji: string; - value: string; - description: string; -} - -/** - * Represents commit message templates. - */ -interface Templates { - defaultTemplate: string; -} - -/** - * Represents linting rules for commit messages. - */ -interface LintRules { - summaryMaxLength: number; - typeCase: string; // e.g. 'lowercase' - requiredTicket: boolean; -} - -/** - * Main configuration interface. - */ -interface Config { - commitTypes: CommitType[]; - autoAdd: boolean; - useEmoji: boolean; - ciCommand: string; - templates: Templates; - steps: { - scope: boolean; - body: boolean; - footer: boolean; - ticket: boolean; - runCI: boolean; - }; - ticketRegex: string; - enableLint: boolean; - lintRules: LintRules; - // enableHooks: boolean; // TODO: implement hooks -} - -/** - * Path to the global config file in the user's home directory. - */ -const CONFIG_PATH = path.join(os.homedir(), '.smart-commit-config.json'); - -/** - * Default configuration values. - */ -const defaultConfig: Config = { - commitTypes: [ - { emoji: "✨", value: "feat", description: "A new feature" }, - { emoji: "🐛", value: "fix", description: "A bug fix" }, - { emoji: "📝", value: "docs", description: "Documentation changes" }, - { emoji: "💄", value: "style", description: "Code style improvements" }, - { emoji: "♻️", value: "refactor", description: "Code refactoring" }, - { emoji: "🚀", value: "perf", description: "Performance improvements" }, - { emoji: "✅", value: "test", description: "Adding tests" }, - { emoji: "🔧", value: "chore", description: "Maintenance and chores" } - ], - autoAdd: false, - useEmoji: true, - ciCommand: "", - templates: { - defaultTemplate: "[{type}]{ticketSeparator}{ticket}: {summary}\n\nBody:\n{body}\n\nFooter:\n{footer}" - }, - steps: { - scope: false, - body: false, - footer: false, - ticket: false, - runCI: false, - }, - ticketRegex: "", - enableLint: false, - lintRules: { - summaryMaxLength: 72, - typeCase: "lowercase", - requiredTicket: false, - }, - // enableHooks: false, -}; - -/** - * Loads the global and local config, merging them if both exist. - */ -function loadConfig(): Config { - let config: Config = defaultConfig; - - if (fs.existsSync(CONFIG_PATH)) { - try { - const data = fs.readFileSync(CONFIG_PATH, 'utf8'); - config = JSON.parse(data) as Config; - } catch { - console.error(chalk.red("Error reading global config, using default settings.")); - } - } - const localConfigPath = path.join(process.cwd(), '.smartcommitrc.json'); - if (fs.existsSync(localConfigPath)) { - try { - const localData = fs.readFileSync(localConfigPath, 'utf8'); - const localConfig = JSON.parse(localData) as Partial; - config = { ...config, ...localConfig }; - } catch { - console.error(chalk.red("Error reading local config, ignoring.")); - } - } - - if (!config.lintRules) { - config.lintRules = { ...defaultConfig.lintRules }; - } - - return config; -} - -/** - * Saves the config to disk (global config). - */ -function saveConfig(config: Config): void { - fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), 'utf8'); - console.log(chalk.green("Global configuration saved at"), CONFIG_PATH); -} - -/** - * Answers interface for commit creation. - */ -interface CommitAnswers { - type: string; - scope: string; - summary: string; - body: string; - footer: string; - ticket: string; - runCI: boolean; - autoAdd: boolean; - confirmCommit: boolean; - pushCommit: boolean; - signCommit: boolean; -} - -/** - * Generates a default summary by analyzing staged changes. - */ -function computeAutoSummary(): string { - let summaries: string[] = []; - try { - const diffFiles = execSync('git diff --cached --name-only', { encoding: 'utf8' }) - .split('\n') - .filter(f => f.trim() !== ''); - if (diffFiles.length > 0) { - if (diffFiles.includes('package.json')) summaries.push('Update dependencies'); - if (diffFiles.some(f => f.includes('Dockerfile'))) summaries.push('Update Docker configuration'); - if (diffFiles.some(f => f.endsWith('.md'))) summaries.push('Update documentation'); - if (diffFiles.some(f => f.startsWith('src/') || f.endsWith('.ts') || f.endsWith('.js'))) summaries.push('Update source code'); - return summaries.join(', '); - } - } catch { } - return ''; -} - -/** - * Suggests a commit type based on staged files. - */ -function suggestCommitType(): string | null { - try { - const diffFiles = execSync('git diff --cached --name-only', { encoding: 'utf8' }) - .split('\n') - .filter(f => f.trim() !== ''); - if (diffFiles.length > 0) { - if (diffFiles.every(f => f.endsWith('.md'))) return 'docs'; - if (diffFiles.includes('package.json')) return 'chore'; - if (diffFiles.some(f => f.startsWith('src/'))) return 'feat'; - } - } catch { } - return null; -} - -/** - * Lints the commit message using specified rules. - */ -function lintCommitMessage(message: string, rules: LintRules): string[] { - const errors: string[] = []; - const lines = message.split('\n'); - const summary = lines[0].trim(); - if (summary.length > rules.summaryMaxLength) { - errors.push(`Summary is too long (${summary.length} characters). Max allowed is ${rules.summaryMaxLength}.`); - } - if (rules.typeCase === 'lowercase' && summary && summary[0] !== summary[0].toLowerCase()) { - errors.push("Summary should start with a lowercase letter."); - } - if (rules.requiredTicket && !message.includes('#')) { - errors.push("A ticket ID is required in the commit message (e.g., '#DEV-123')."); - } - return errors; -} - -/** - * Interactive preview of the commit message with optional linting fix. - */ -async function previewCommitMessage(message: string, lintRules: LintRules): Promise { - console.log(chalk.blue("\nPreview commit message:\n")); - console.log(message); - const { confirmPreview } = await inquirer.prompt([ - { - type: 'confirm', - name: 'confirmPreview', - message: 'Does the commit message look OK?', - default: true, - } - ]); - if (confirmPreview) { - const errors = lintCommitMessage(message, lintRules); - if (errors.length > 0) { - console.log(chalk.red("Linting errors:")); - errors.forEach(err => console.log(chalk.red("- " + err))); - const { editedMessage } = await inquirer.prompt([ - { - type: 'editor', - name: 'editedMessage', - message: 'Edit the commit message to fix these issues:', - default: message, - } - ]); - return previewCommitMessage(editedMessage, lintRules); - } else { - return message; - } - } else { - const { editedMessage } = await inquirer.prompt([ - { - type: 'editor', - name: 'editedMessage', - message: 'Edit the commit message as needed:', - default: message, - } - ]); - return previewCommitMessage(editedMessage, lintRules); - } -} - -/** - * Checks if the current directory is inside a Git repository. - * If the directory is not a Git repository, displays an error message and exits the process. - * - * @throws {Error} If the directory is not a Git repository - */ -function ensureGitRepo(): void { - try { - execSync('git rev-parse --is-inside-work-tree', { stdio: 'ignore' }); - } catch { - console.log(chalk.red("Not a Git repository. Please run 'git init' or navigate to a valid repo.")); - process.exit(1); - } -} - -/** - * Shows a preview of the staged diff. - */ -function showDiffPreview(): void { - try { - const diffSoFancyPath = path.join(__dirname, '..', 'node_modules', '.bin', 'diff-so-fancy'); - const diff = execSync(`git diff --staged | "${diffSoFancyPath}"`, { encoding: 'utf8' }); - if (diff.trim() === "") { - console.log(chalk.yellow("No staged changes to show.")); - } else { - console.log(chalk.green("\nStaged Diff Preview:\n")); - console.log(chalk.green(diff)); - } - } catch (err: any) { - console.error(chalk.red("Error retrieving diff:"), err.message); - } -} - -program - .name('sc') - .description('Smart Commit CLI Tool - Create customizable Git commits with ease.') - .version('1.1.5'); - -program.addHelpText('beforeAll', chalk.blue(` -======================================== - Welcome to Smart Commit CLI! -======================================== -`)); - -program.addHelpText('afterAll', chalk.blue(` -Examples: - sc commit # Start interactive commit prompt - sc amend # Amend the last commit interactively - sc rollback # Rollback the last commit (soft or hard reset) - sc rebase-helper # Launch interactive rebase helper - sc ci # Run CI tests as configured - sc stats # Show enhanced commit statistics - sc history # Show commit history with filtering - sc config # Configure or view settings - sc setup # Run interactive setup wizard -`)); - -program - .command('config') - .alias('cfg') - .description('Configure or view Smart Commit settings') - .option('-a, --auto-add ', 'Set auto-add for commits (true/false)', (value: string) => value === 'true') - .option('-e, --use-emoji ', 'Use emojis in commit types (true/false)', (value: string) => value === 'true') - .option('-c, --ci-command ', 'Set CI command (e.g., "npm test")') - .option('-t, --template