From e1f7601f292cc84af8c71b6ee881954125b6d281 Mon Sep 17 00:00:00 2001 From: eL1Fe Date: Thu, 20 Feb 2025 12:47:16 +0400 Subject: [PATCH 1/8] V.1.2.0 global refactoring --- README.md | 387 ++- __tests__/ammend.test.ts | 149 + __tests__/branch.test.ts | 139 + __tests__/commit.test.ts | 317 ++ __tests__/config.test.ts | 166 + __tests__/history.test.ts | 98 + babel.config.js | 5 + index.ts | 958 ------ jest.config.js | 9 + package-lock.json | 6040 +++++++++++++++++++++++++++++++--- package.json | 24 +- src/commands/ammend.ts | 51 + src/commands/branch.ts | 256 ++ src/commands/commit.ts | 275 ++ src/commands/config.ts | 186 ++ src/commands/history.ts | 68 + src/commands/rebaseHelper.ts | 76 + src/commands/rollback.ts | 48 + src/commands/setup.ts | 170 + src/commands/stats.ts | 57 + src/index.ts | 53 + src/types.ts | 61 + src/utils.ts | 257 ++ tsconfig.json | 119 +- 24 files changed, 8293 insertions(+), 1676 deletions(-) create mode 100644 __tests__/ammend.test.ts create mode 100644 __tests__/branch.test.ts create mode 100644 __tests__/commit.test.ts create mode 100644 __tests__/config.test.ts create mode 100644 __tests__/history.test.ts create mode 100644 babel.config.js delete mode 100644 index.ts create mode 100644 jest.config.js create mode 100644 src/commands/ammend.ts create mode 100644 src/commands/branch.ts create mode 100644 src/commands/commit.ts create mode 100644 src/commands/config.ts create mode 100644 src/commands/history.ts create mode 100644 src/commands/rebaseHelper.ts create mode 100644 src/commands/rollback.ts create mode 100644 src/commands/setup.ts create mode 100644 src/commands/stats.ts create mode 100644 src/index.ts create mode 100644 src/types.ts create mode 100644 src/utils.ts diff --git a/README.md b/README.md index b82aec9..69e3a04 100644 --- a/README.md +++ b/README.md @@ -1,201 +1,300 @@ # 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. + +- **sc config (or sc cfg)** + - View and update Smart Commit settings. + - 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` + +- **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__/ammend.test.ts b/__tests__/ammend.test.ts new file mode 100644 index 0000000..dba46a2 --- /dev/null +++ b/__tests__/ammend.test.ts @@ -0,0 +1,149 @@ +import { Command } from 'commander'; +import { registerAmendCommand } from '../src/commands/ammend'; +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 without lint', 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 perform lint if enableLint = true and abort amend on errors', 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' }); + + (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; + expect(calls.some(call => call[0].includes('git commit --amend'))).toBe(false); + }); + + it('should perform lint and amend normally if no errors', 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: 'good commit message' }); + + (lintCommitMessage as jest.Mock).mockReturnValue([]); + + await program.parseAsync(['node', 'test', 'amend']); + + expect(execSync).toHaveBeenCalledTimes(2); + const calls = (execSync as jest.Mock).mock.calls; + const amendCall = calls.find(call => call[0].includes('git commit --amend')); + 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..19d0427 --- /dev/null +++ b/__tests__/branch.test.ts @@ -0,0 +1,139 @@ +import { execSync } from 'child_process'; +import { Command } from 'commander'; +import inquirer from 'inquirer'; +import { registerBranchCommand, sanitizeForBranch } from '../src/commands/branch'; + +jest.mock('child_process', () => ({ + execSync: jest.fn(), +})); +jest.mock('inquirer', () => ({ + prompt: 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(); + + (execSync as jest.Mock).mockImplementation((cmd: string) => { + if (cmd.includes('git branch')) { + return "main\ndevelop\n"; + } + return ""; + }); + }); + + 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+/); + }); +}); \ No newline at end of file diff --git a/__tests__/commit.test.ts b/__tests__/commit.test.ts new file mode 100644 index 0000000..302825d --- /dev/null +++ b/__tests__/commit.test.ts @@ -0,0 +1,317 @@ +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/); + }); +}); \ No newline at end of file diff --git a/__tests__/config.test.ts b/__tests__/config.test.ts new file mode 100644 index 0000000..2c7c665 --- /dev/null +++ b/__tests__/config.test.ts @@ -0,0 +1,166 @@ +import { Command } from 'commander'; +import { registerConfigCommand } from '../src/commands/config'; +import { loadConfig, saveConfig } from '../src/utils'; + +jest.mock('../src/utils', () => ({ + loadConfig: jest.fn(), + saveConfig: jest.fn() +})); + +jest.mock('chalk', () => ({ + ...jest.requireActual('chalk'), + blue: jest.fn((str) => str), + red: jest.fn((str) => str), + green: jest.fn((str) => str), +})); + +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 () => { + await program.parseAsync(['node', 'test', 'config']); + + expect(saveConfig).not.toHaveBeenCalled(); + + expect(consoleLogSpy).toHaveBeenCalledWith(expect.stringContaining("Current configuration:")); + }); + + 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..a007263 --- /dev/null +++ b/__tests__/history.test.ts @@ -0,0 +1,98 @@ +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 call ensureGitRepo and prompt for filter type', async () => { + (inquirer.prompt as unknown as jest.Mock) + .mockResolvedValueOnce({ filterType: 'keyword' }) + .mockResolvedValueOnce({ keyword: 'fix' }); + + (execSync as jest.Mock).mockReturnValue('commit1\ncommit2\n'); + + await program.parseAsync(['node', 'test', 'history']); + + expect(ensureGitRepo).toHaveBeenCalled(); + expect(inquirer.prompt).toHaveBeenCalledTimes(2); + expect(execSync).toHaveBeenCalledWith('git log --pretty=oneline --grep="fix"', { encoding: 'utf8' }); + expect(consoleLogSpy).toHaveBeenCalledWith(chalk.blue("\nCommit History:\n")); + expect(consoleLogSpy).toHaveBeenCalledWith(chalk.green('commit1\ncommit2\n')); + }); + + it('should build the command correctly for filterType "author"', async () => { + (inquirer.prompt as unknown as jest.Mock) + .mockResolvedValueOnce({ filterType: 'author' }) + .mockResolvedValueOnce({ author: 'john@example.com' }); + (execSync as jest.Mock).mockReturnValue('commitA\ncommitB\n'); + + await program.parseAsync(['node', 'test', 'history']); + + expect(inquirer.prompt).toHaveBeenCalledTimes(2); + expect(execSync).toHaveBeenCalledWith('git log --pretty=oneline --author="john@example.com"', { encoding: 'utf8' }); + expect(consoleLogSpy).toHaveBeenCalledWith(chalk.green('commitA\ncommitB\n')); + }); + + it('should build the command correctly for filterType "date"', async () => { + (inquirer.prompt as unknown as jest.Mock) + .mockResolvedValueOnce({ filterType: 'date' }) + .mockResolvedValueOnce({ since: '2023-01-01', until: '2023-01-31' }); + (execSync as jest.Mock).mockReturnValue('commitX\ncommitY\n'); + + await program.parseAsync(['node', 'test', 'history']); + + expect(inquirer.prompt).toHaveBeenCalledTimes(2); + expect(execSync).toHaveBeenCalledWith('git log --pretty=oneline --since="2023-01-01" --until="2023-01-31"', { encoding: 'utf8' }); + expect(consoleLogSpy).toHaveBeenCalledWith(chalk.green('commitX\ncommitY\n')); + }); + + it('should log error and exit if execSync fails', async () => { + (inquirer.prompt as unknown as jest.Mock) + .mockResolvedValueOnce({ filterType: 'keyword' }) + .mockResolvedValueOnce({ keyword: 'error' }); + (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/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