From 89e7f495bf2ccd3fa1391ce0b0075dfed272ae6b Mon Sep 17 00:00:00 2001 From: Mubbasher Ahmed Qureshi Date: Mon, 28 Jul 2025 14:29:37 +0500 Subject: [PATCH 1/2] feat(commitlint): added the package codes --- .github/CONTRIBUTING.md | 76 +++++++++ .github/SECURITY.md | 13 ++ .github/hooks/commit-msg | 85 ++++++++++ .github/hooks/pre-push | 57 +++++++ .github/workflows/ci.yml | 224 +++++++++++++++++++++++++++ .github/workflows/release-please.yml | 37 +++++ .gitignore | 9 ++ .release-please-manifest.json | 3 + CHANGELOG.md | 6 + README.md | 122 +++++++++++++++ composer.json | 36 +++++ phpunit.xml | 19 +++ release-please-config.json | 20 +++ src/CommitLintServiceProvider.php | 44 ++++++ src/Console/InstallHookCommand.php | 67 ++++++++ src/Hooks/commit-msg | 85 ++++++++++ tests/InstallHookCommandTest.php | 172 ++++++++++++++++++++ 17 files changed, 1075 insertions(+) create mode 100644 .github/CONTRIBUTING.md create mode 100644 .github/SECURITY.md create mode 100644 .github/hooks/commit-msg create mode 100644 .github/hooks/pre-push create mode 100644 .github/workflows/ci.yml create mode 100644 .github/workflows/release-please.yml create mode 100644 .gitignore create mode 100644 .release-please-manifest.json create mode 100644 CHANGELOG.md create mode 100644 README.md create mode 100644 composer.json create mode 100644 phpunit.xml create mode 100644 release-please-config.json create mode 100644 src/CommitLintServiceProvider.php create mode 100644 src/Console/InstallHookCommand.php create mode 100644 src/Hooks/commit-msg create mode 100644 tests/InstallHookCommandTest.php diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..1db5e93 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,76 @@ +# Contributing Guide + +Thank you for considering contributing to Laravel Commit Lint! + +## Prerequisites + +- PHP >= 8.0 +- Composer +- Git +- PHPUnit +- Bash (for hook scripts) + +Ensure these are installed and available in your environment before contributing. + +## How to Contribute + +### Conventional Commits Example + +``` +feat(auth): add login functionality + +fix(hook): correct regex for commit message validation +``` + +See [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) for more details. + +## Code of Conduct +Please be respectful and follow the [Contributor Covenant](https://www.contributor-covenant.org/). + +## Reporting Issues +Open an issue with details and steps to reproduce. + +For questions, reach out via GitHub Discussions or open an issue. + + +## Development + + - Requires Bash, PHP, and PHPUnit to be available in your environment. + +## Testing + +Run tests and generate coverage reports and clover XML file: +```bash + vendor/bin/phpunit --colors=always --testdox +``` + +Coverage enforcement: The pre-push hook will block pushes if coverage is below 80%. Review the HTML report in the `coverage/` directory for details. + +## Development Setup + +To contribute to this project, please set up the git hooks to ensure commit message linting and test coverage enforcement: + +1. **Copy hooks to your local git hooks directory:** + ```sh + cp .github/hooks/* .git/hooks/ + chmod +x .git/hooks/commit-msg .git/hooks/pre-push + ``` + +2. **Verify hooks are executable:** + ```sh + ls -l .git/hooks/commit-msg .git/hooks/pre-push + ``` + +3. **Contribute as usual:** + - Make your changes. + - Commit with a message following the Conventional Commits format. + - Push your branch. The pre-push hook will run tests and enforce minimum 80% coverage. + +If you encounter any issues with the hooks, ensure you have the required dependencies installed and that your environment allows execution of shell scripts. + +## Resources + +- [Laravel Package Development Documentation](https://laravel.com/docs/12.x/packages) +- [Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) +- [PHPUnit Documentation](https://phpunit.de/documentation.html) + diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..420cc97 --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,13 @@ +# Security Policy + +## Reporting a Vulnerability +If you discover a security vulnerability, please email hello@mubbi.me or open a private issue on GitHub. + +- Do not disclose vulnerabilities publicly until they are resolved. +- Provide details and steps to reproduce the issue. + +## Supported Versions +Only the latest major version is supported for security updates. + +## Responsible Disclosure +We appreciate responsible disclosure and will respond promptly to valid reports. diff --git a/.github/hooks/commit-msg b/.github/hooks/commit-msg new file mode 100644 index 0000000..4e011b4 --- /dev/null +++ b/.github/hooks/commit-msg @@ -0,0 +1,85 @@ +#!/bin/sh +# commitlint.sh - Enforce Conventional Commits in commit messages +set -e + +# Set up cleanup trap for any temporary files +trap 'rm -f /tmp/commitlint-* 2>/dev/null || true' EXIT + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo "${YELLOW}πŸ” Validating commit message format...${NC}" + +# Check if this is a merge commit +if [ -f .git/MERGE_HEAD ]; then + echo "${GREEN}βœ… Merge commit detected, skipping validation${NC}" + exit 0 +fi + +# Read the commit message +COMMIT_MSG_FILE="$1" +if [ -z "$COMMIT_MSG_FILE" ]; then + echo "Usage: $0 " + exit 1 +fi + +if [ ! -f "$COMMIT_MSG_FILE" ]; then + echo "${RED}❌ Commit message file not found: $COMMIT_MSG_FILE${NC}" + exit 1 +fi + +COMMIT_MSG=$(head -n1 "$COMMIT_MSG_FILE") + +# Skip empty commits +if [ -z "$COMMIT_MSG" ] || [ "$COMMIT_MSG" = "" ]; then + echo "${RED}❌ Empty commit message${NC}" + exit 1 +fi + +# Skip special commits that don't need conventional format validation +# Also skip versioned commits like 10.x or [10.x] (to match workflow logic) +if printf "%s" "$COMMIT_MSG" | grep -Eq "^(Merge|WIP|Revert)"; then + echo "${GREEN}βœ… Special commit type detected, skipping conventional format validation${NC}" + exit 0 +fi + +# Conventional Commits regex (type[optional scope]: subject) +CONVENTIONAL_REGEX='^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-zA-Z0-9_-]+\))?: .{1,}' + +# Log the commit message for debugging (safely quoted) +printf "${YELLOW}Checking message: %s${NC}\n" "$COMMIT_MSG" + +if printf "%s" "$COMMIT_MSG" | grep -Eq "$CONVENTIONAL_REGEX"; then + echo "${GREEN}βœ… Commit message format is valid${NC}" + exit 0 +else + echo "" + echo "${RED}❌ Commit message does not follow Conventional Commits format!${NC}" + echo "" + echo "${YELLOW}Expected format:${NC}" + echo " ${GREEN}type(scope): description${NC}" + echo "" + echo "${YELLOW}Valid types:${NC}" + echo " feat: A new feature" + echo " fix: A bug fix" + echo " docs: Documentation only changes" + echo " style: Changes that do not affect the meaning of the code" + echo " refactor: A code change that neither fixes a bug nor adds a feature" + echo " test: Adding missing tests or correcting existing tests" + echo " chore: Changes to the build process or auxiliary tools" + echo " perf: A code change that improves performance" + echo " ci: Changes to CI configuration files and scripts" + echo " build: Changes that affect the build system" + echo " revert: Reverts a previous commit" + echo "" + echo "${YELLOW}Examples:${NC}" + echo " ${GREEN}feat(auth): add user authentication${NC}" + echo " ${GREEN}fix(api): resolve validation error in user endpoint${NC}" + echo " ${GREEN}docs: update API documentation${NC}" + echo "" + echo "See: https://www.conventionalcommits.org/en/v1.0.0/" + exit 1 +fi \ No newline at end of file diff --git a/.github/hooks/pre-push b/.github/hooks/pre-push new file mode 100644 index 0000000..bed1bb2 --- /dev/null +++ b/.github/hooks/pre-push @@ -0,0 +1,57 @@ +#!/bin/sh +# Git pre-push hook to run PHPUnit tests and enforce minimum coverage threshold + +set -e + +COVERAGE_FILE="coverage/coverage.xml" +MIN_COVERAGE=80 + +# Run PHPUnit with Clover coverage +vendor/bin/phpunit + +if [ ! -f "$COVERAGE_FILE" ]; then + echo "[pre-push] ❌ Coverage report not found at $COVERAGE_FILE. Push aborted." + exit 1 +fi + +# Extract total statements and covered statements using POSIX-compliant sed +STATEMENTS=$(sed -n 's/.*statements="\([0-9][0-9]*\)".*/\1/p' "$COVERAGE_FILE" | head -n 1) +COVERED=$(sed -n 's/.*coveredstatements="\([0-9][0-9]*\)".*/\1/p' "$COVERAGE_FILE" | head -n 1) + +# Debug info (optional) +echo "[pre-push] Detected Statements: ${STATEMENTS:-}, Covered: ${COVERED:-}" + +# Validate parsed values +if ! echo "$STATEMENTS" | grep -Eq '^[0-9]+$'; then + echo "[pre-push] ❌ Invalid or missing statements count. Push aborted." + exit 1 +fi +if ! echo "$COVERED" | grep -Eq '^[0-9]+$'; then + echo "[pre-push] ❌ Invalid or missing covered statements count. Push aborted." + exit 1 +fi + +if [ "$STATEMENTS" -eq 0 ]; then + echo "[pre-push] ❌ Zero statements found. Coverage meaningless. Push aborted." + exit 1 +fi + +# Calculate percentage with POSIX awk +COVERAGE_PERCENT=$(awk "BEGIN {printf \"%.2f\", ($COVERED / $STATEMENTS) * 100}") + +# Validate percentage output +if [ -z "$COVERAGE_PERCENT" ]; then + echo "[pre-push] ❌ Could not calculate coverage percent. Push aborted." + exit 1 +fi + +# Float comparison using awk (portable) +IS_BELOW=$(awk "BEGIN {print ($COVERAGE_PERCENT < $MIN_COVERAGE) ? 1 : 0}") + +if [ "$IS_BELOW" -eq 1 ]; then + echo "[pre-push] ❌ Test coverage ${COVERAGE_PERCENT}% is below required ${MIN_COVERAGE}%. Push aborted." + exit 1 +else + echo "[pre-push] βœ… Test coverage ${COVERAGE_PERCENT}% meets minimum ${MIN_COVERAGE}%. Push allowed." + exit 0 +fi diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..441740c --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,224 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +permissions: + contents: read + pull-requests: write + +jobs: + commitlint: + runs-on: ubuntu-latest + name: Validate Commit Messages (using Shell Script) + + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Make commitlint script executable + run: chmod +x src/Hooks/commit-msg + + - name: Validate commit messages (Pull Request) + if: github.event_name == 'pull_request' + run: | + set -e + echo "πŸ” Validating commit messages in pull request using shell script..." + BASE_SHA="${{ github.event.pull_request.base.sha }}" + HEAD_SHA="${{ github.event.pull_request.head.sha }}" + + # Create a temporary directory for commit message files + mkdir -p /tmp/commit-msgs + + # Get commit count and check if any commits exist (using rev-list for performance) + COMMIT_COUNT=$(git rev-list --count ${BASE_SHA}..${HEAD_SHA}) + if [ "$COMMIT_COUNT" -eq 0 ]; then + echo "::warning::No commits found in pull request" + exit 0 + fi + + echo "Found $COMMIT_COUNT commits to validate" + + # Use a temporary file to track validation status across subshell + VALIDATION_STATUS_FILE="/tmp/validation-status" + echo "success" > "$VALIDATION_STATUS_FILE" + + # Set up cleanup trap + trap 'rm -f "$VALIDATION_STATUS_FILE" /tmp/commit-msgs/msg-* 2>/dev/null || true' EXIT + + # Get all commits in the PR and validate each one + git log --format="%H|%s" ${BASE_SHA}..${HEAD_SHA} --reverse | while IFS='|' read -r commit_sha commit_msg; do + # Handle malformed commit entries gracefully + if [ -z "$commit_sha" ] || [ -z "$commit_msg" ]; then + echo "::warning::Skipping malformed commit entry" + continue + fi + + # Skip special commits that don't need conventional format validation + if printf "%s" "$commit_msg" | grep -Eq "^(Merge|WIP|Revert)"; then + echo "Skipping special commit: $commit_sha - $commit_msg" + continue + fi + + echo "Validating commit: $commit_sha" + printf "Message: %q\n" "$commit_msg" + + # Create a temporary file with the commit message (properly quoted) + TEMP_MSG_FILE="/tmp/commit-msgs/msg-${commit_sha}" + printf "%s" "$commit_msg" > "$TEMP_MSG_FILE" + + # Run the commitlint script + if ! ./src/Hooks/commit-msg "$TEMP_MSG_FILE"; then + printf "::error::Commit %s failed validation: %q\n" "$commit_sha" "$commit_msg" + echo "failed" > "$VALIDATION_STATUS_FILE" + fi + + rm -f "$TEMP_MSG_FILE" + done + + # Check if any validation failed + VALIDATION_STATUS=$(cat "$VALIDATION_STATUS_FILE") + if [ "$VALIDATION_STATUS" = "failed" ]; then + echo "::error::One or more commit messages failed validation" + exit 1 + fi + + - name: Validate commit message (Push) + if: github.event_name == 'push' + run: | + set -e + echo "πŸ” Validating latest commit message using shell script..." + + # Set up cleanup trap + TEMP_MSG_FILE="/tmp/latest-commit-msg" + trap 'rm -f "$TEMP_MSG_FILE" 2>/dev/null || true' EXIT + + # Get the latest commit message + COMMIT_MSG=$(git log -1 --pretty=format:"%s") + printf "Validating: %q\n" "$COMMIT_MSG" + + # Check if commit message is empty + if [ -z "$COMMIT_MSG" ]; then + echo "::error::Empty commit message found" + exit 1 + fi + + # Skip special commits that don't need conventional format validation + if printf "%s" "$COMMIT_MSG" | grep -Eq "^(Merge|WIP|Revert)"; then + echo "Skipping special commit: $COMMIT_MSG" + exit 0 + fi + + # Create a temporary file with the commit message (properly quoted) + printf "%s" "$COMMIT_MSG" > "$TEMP_MSG_FILE" + + # Run the commitlint script + if ! ./src/Hooks/commit-msg "$TEMP_MSG_FILE"; then + printf "::error::Latest commit message failed validation: %q\n" "$COMMIT_MSG" + exit 1 + fi + + - name: Comment on PR (Failure) + if: failure() && github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + + const comment = `## ❌ Commit Message Validation Failed + + One or more commit messages in this pull request do not follow the [Conventional Commits](https://www.conventionalcommits.org/) format. + + ### Expected format: + \`\`\` + type(scope): description + \`\`\` + + ### Valid types: + - **feat**: A new feature + - **fix**: A bug fix + - **docs**: Documentation only changes + - **style**: Changes that do not affect the meaning of the code + - **refactor**: A code change that neither fixes a bug nor adds a feature + - **test**: Adding missing tests or correcting existing tests + - **chore**: Changes to the build process or auxiliary tools + - **perf**: A code change that improves performance + - **ci**: Changes to CI configuration files and scripts + - **build**: Changes that affect the build system + - **revert**: Reverts a previous commit + + ### Special commits (automatically allowed): + - Commits starting with **Merge**, **WIP**, or **Revert** are automatically skipped + + ### Examples: + - \`feat(auth): add user authentication\` + - \`fix(api): resolve validation error in user endpoint\` + - \`docs: update API documentation\` + + Please update your commit messages to follow this format. You can use \`git commit --amend\` for the latest commit or \`git rebase -i\` for multiple commits. + + For more information, see: [Conventional Commits](https://www.conventionalcommits.org/) + + --- + *This validation uses the same shell script as your local git hook (\`src/Hooks/commit-msg\`) to ensure consistency.*`; + + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: comment + }); + + - name: Comment on PR (Success) + if: success() && github.event_name == 'pull_request' + uses: actions/github-script@v7 + with: + script: | + const { owner, repo } = context.repo; + const issue_number = context.issue.number; + + const comment = `## βœ… Commit Message Validation Passed + + All commit messages in this pull request follow the [Conventional Commits](https://www.conventionalcommits.org/) format. Great work! πŸŽ‰ + + *Validated using the shell script (\`src/Hooks/commit-msg\`) for consistency with local git hooks.*`; + + // Check if we already commented on this PR to avoid spam + const comments = await github.rest.issues.listComments({ + owner, + repo, + issue_number + }); + + const existingComment = comments.data.find(comment => + comment.user.type === 'Bot' && + comment.body.includes('Commit Message Validation') + ); + + if (!existingComment) { + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: comment + }); + } + + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Set up PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.2' + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-suggest + - name: Run tests + run: vendor/bin/phpunit || echo "No tests found" diff --git a/.github/workflows/release-please.yml b/.github/workflows/release-please.yml new file mode 100644 index 0000000..ea31ecb --- /dev/null +++ b/.github/workflows/release-please.yml @@ -0,0 +1,37 @@ +name: Release Please + +on: + push: + branches: + - main + +permissions: + contents: write + pull-requests: write + +jobs: + release-please: + runs-on: ubuntu-latest + outputs: + release_created: ${{ steps.release.outputs.release_created }} + tag_name: ${{ steps.release.outputs.tag_name }} + steps: + - name: Release Please + id: release + uses: googleapis/release-please-action@v4 + with: + token: ${{ secrets.RELEASE_PLEASE_TOKEN }} + release-type: simple + config-file: release-please-config.json + manifest-file: .release-please-manifest.json + + publish-artifacts: + runs-on: ubuntu-latest + needs: release-please + if: ${{ needs.release-please.outputs.release_created }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c26814b --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# Laravel Commit Lint +/vendor/ +/.idea/ +/.vscode/ +/.DS_Store +composer.lock +/coverage +/reports +.phpunit.result.cache \ No newline at end of file diff --git a/.release-please-manifest.json b/.release-please-manifest.json new file mode 100644 index 0000000..46b1b67 --- /dev/null +++ b/.release-please-manifest.json @@ -0,0 +1,3 @@ +{ + ".": "0.0.0" +} \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d225b31 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,6 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +## [Unreleased] +- Initial release diff --git a/README.md b/README.md new file mode 100644 index 0000000..7e77d8d --- /dev/null +++ b/README.md @@ -0,0 +1,122 @@ +# Laravel Commit Lint + +[![Latest Version on Packagist](https://img.shields.io/packagist/v/mubbi/laravel-commit-lint.svg?style=flat-square)](https://packagist.org/packages/mubbi/laravel-commit-lint) +[![Build Status](https://img.shields.io/github/workflow/status/mubbi/laravel-commit-lint/CI?style=flat-square)](https://github.com/mubbi/laravel-commit-lint/actions) +## Requirements + +- Laravel >= 9.0 +- PHP >= 8.0 + +## Overview + +Laravel Commit Lint helps teams enforce [Conventional Commits](https://www.conventionalcommits.org/) in Laravel projects by automatically validating commit messages using a Git hook. This ensures consistent commit history and enables better automation and tooling. + +## Features + +- Automatic commit message validation using a `commit-msg` hook +- Customizable hook installation path via Artisan command +- Clear error messages and guidance for invalid commit messages +- Skips validation for merge, WIP, and revert commits +- Extensible via Laravel’s service provider and command structure + +## Installation + +```bash +composer require mubbi/laravel-commit-lint --dev +php artisan commitlint:install +``` + + +## Usage + +After installation, every commit will be checked for Conventional Commit compliance. + + +If your message does not match the required format, the commit will be rejected with guidance. + +### Example of valid commit messages +- feat: add user authentication +- fix: resolve issue with email validation +- docs: update API documentation +- refactor: improve query performance +- chore: update dependencies +- style: format code according to PSR-12 +- test: add unit tests for login + +### Example of invalid commit messages +- updated stuff +- bug fix +- changes +- fixed it +- wip: working on something (WIP is skipped, but not recommended for final commits) + +## How it works +After installation, the package places a `commit-msg` hook in your `.git/hooks` directory (or a custom path if specified). This hook runs on every commit and checks your commit message against the Conventional Commits specification using a regex. If the message is invalid, the commit is rejected and guidance is shown. + +The validation script automatically skips validation for merge, WIP, and revert commits. + +## Configuration +You can install the hook to a custom path: + +```bash +php artisan commitlint:install /custom/path/to/commit-msg +``` + +You may also specify a custom stub file for the hook script: + +```bash +php artisan commitlint:install --stub=/path/to/custom-stub +``` + +## Troubleshooting +### Common Issues +- **Hook not working:** Ensure your repository has a `.git/hooks` directory and that the `commit-msg` file is executable (`chmod +x .git/hooks/commit-msg`). +- **Artisan command not found:** Make sure the package is installed as a dev dependency and your Laravel app's autoload files are up to date (`composer dump-autoload`). +- **Commit rejected unexpectedly:** Check your commit message format and ensure it matches the Conventional Commits spec. See valid examples above. +- **Custom hook path not working:** Verify the path exists and is writable. + +If you encounter other issues, please [open an issue on GitHub](https://github.com/mubbi/laravel-commit-lint/issues) with details. + +## Uninstallation + +To remove the commit lint hook, simply delete the `commit-msg` file from your `.git/hooks` directory: + +```bash +rm .git/hooks/commit-msg +``` + +To remove the package: + +```bash +composer remove mubbi/laravel-commit-lint +``` + +## FAQ + +**Q: Does this work with all git clients?** +A: Yes, as long as your client supports git hooks. + +**Q: Can I customize the commit message rules?** +A: You can modify the stub or extend the validation logic in the package. + +**Q: Will this block merge commits?** +A: No, merge, WIP, and revert commits are skipped. + + +## Contributing + +Contributions are welcome! Please open issues or submit pull requests via GitHub. Follow Conventional Commits for your messages. + +For questions and discussions, visit [GitHub Discussions](https://github.com/mubbi/laravel-commit-lint/discussions). + +For more details, see the [Contributing Guide](.github/CONTRIBUTING.md). + + +## License + +This project is licensed under the [MIT License](LICENSE). + + +## Security + +Please refer to our [Security Policy](.github/SECURITY.md) for reporting vulnerabilities and security concerns. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..b14a313 --- /dev/null +++ b/composer.json @@ -0,0 +1,36 @@ +{ + "name": "mubbi/laravel-commit-lint", + "description": "It provides a Git hook to enforce Conventional Commits in Laravel projects.", + "type": "library", + "license": "MIT", + "authors": [ + { + "name": "Mubbasher Ahmed Qureshi", + "email": "hello@mubbi.me" + } + ], + "support": { + "issues": "https://github.com/mubbi/laravel-commit-lint/issues", + "source": "https://github.com/mubbi/laravel-commit-lint" + }, + "autoload": { + "psr-4": { + "Mubbi\\CommitLint\\": "src/" + } + }, + "require": { + "php": ">=8.0", + "illuminate/support": "^9.0|^10.0|^11.0|^12.0" + }, + "extra": { + "laravel": { + "providers": [ + "Mubbi\\CommitLint\\CommitLintServiceProvider" + ] + } + }, + "require-dev": { + "phpunit/phpunit": "^12.2", + "orchestra/testbench": "^10.4" + } +} diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..5baa7fc --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,19 @@ + + + + + ./tests + + + + + src + + + + + + + + + diff --git a/release-please-config.json b/release-please-config.json new file mode 100644 index 0000000..1bc3f0e --- /dev/null +++ b/release-please-config.json @@ -0,0 +1,20 @@ +{ + "release-type": "simple", + "package-name": "laravel-commit-lint", + "changelog-sections": [ + {"type": "feat", "section": "Features"}, + {"type": "fix", "section": "Bug Fixes"}, + {"type": "perf", "section": "Performance Improvements"}, + {"type": "revert", "section": "Reverts"}, + {"type": "docs", "section": "Documentation"}, + {"type": "style", "section": "Styles"}, + {"type": "chore", "section": "Miscellaneous"}, + {"type": "refactor", "section": "Code Refactoring"}, + {"type": "test", "section": "Tests"}, + {"type": "build", "section": "Build System"}, + {"type": "ci", "section": "Continuous Integration"} + ], + "extra-files": [ + "composer.json" + ] +} \ No newline at end of file diff --git a/src/CommitLintServiceProvider.php b/src/CommitLintServiceProvider.php new file mode 100644 index 0000000..5104eb9 --- /dev/null +++ b/src/CommitLintServiceProvider.php @@ -0,0 +1,44 @@ +app->runningInConsole()) { + $this->publishes([ + __DIR__ . '/Hooks/commit-msg' => \base_path('.git/hooks/commit-msg'), + ], 'commitlint-hook'); + + $this->commands([ + InstallHookCommand::class, + ]); + } + } +} diff --git a/src/Console/InstallHookCommand.php b/src/Console/InstallHookCommand.php new file mode 100644 index 0000000..cfb2c57 --- /dev/null +++ b/src/Console/InstallHookCommand.php @@ -0,0 +1,67 @@ +files = $files ?? new Filesystem; + } + + public function handle(): int + { + $hookPath = $this->argument('hookPath') ?? base_path('.git/hooks/commit-msg'); + $stubPath = $this->argument('stubPath') ?? (__DIR__ . '/../src/Hooks/commit-msg'); + + $hooksDir = \dirname($hookPath); + if (!$this->files->exists($hooksDir)) { + $this->error("No .git/hooks directory found at {$hooksDir}. Is this a Git repository?"); + return 1; + } + + if (!$this->files->exists($stubPath)) { + $this->error("Stub file not found at {$stubPath}"); + return 1; + } + + if ($this->files->exists($hookPath)) { + if (!$this->confirm('commit-msg hook already exists. Overwrite?', false)) { + $this->info('Aborted. Existing hook not overwritten.'); + return 0; + } + } + + if (!$this->files->copy($stubPath, $hookPath)) { + $this->error('Failed to copy hook file. Check permissions.'); + return 1; + } + + if (!$this->setExecutable($hookPath)) { + $this->error('Failed to set hook file as executable.'); + return 1; + } + + $this->info('βœ… commit-msg hook installed successfully. Make sure to follow semantic commits from now on for new commits.'); + return 0; + } + + protected function setExecutable(string $path): bool + { + return $this->files->chmod($path, 0755); + } +} diff --git a/src/Hooks/commit-msg b/src/Hooks/commit-msg new file mode 100644 index 0000000..4e011b4 --- /dev/null +++ b/src/Hooks/commit-msg @@ -0,0 +1,85 @@ +#!/bin/sh +# commitlint.sh - Enforce Conventional Commits in commit messages +set -e + +# Set up cleanup trap for any temporary files +trap 'rm -f /tmp/commitlint-* 2>/dev/null || true' EXIT + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo "${YELLOW}πŸ” Validating commit message format...${NC}" + +# Check if this is a merge commit +if [ -f .git/MERGE_HEAD ]; then + echo "${GREEN}βœ… Merge commit detected, skipping validation${NC}" + exit 0 +fi + +# Read the commit message +COMMIT_MSG_FILE="$1" +if [ -z "$COMMIT_MSG_FILE" ]; then + echo "Usage: $0 " + exit 1 +fi + +if [ ! -f "$COMMIT_MSG_FILE" ]; then + echo "${RED}❌ Commit message file not found: $COMMIT_MSG_FILE${NC}" + exit 1 +fi + +COMMIT_MSG=$(head -n1 "$COMMIT_MSG_FILE") + +# Skip empty commits +if [ -z "$COMMIT_MSG" ] || [ "$COMMIT_MSG" = "" ]; then + echo "${RED}❌ Empty commit message${NC}" + exit 1 +fi + +# Skip special commits that don't need conventional format validation +# Also skip versioned commits like 10.x or [10.x] (to match workflow logic) +if printf "%s" "$COMMIT_MSG" | grep -Eq "^(Merge|WIP|Revert)"; then + echo "${GREEN}βœ… Special commit type detected, skipping conventional format validation${NC}" + exit 0 +fi + +# Conventional Commits regex (type[optional scope]: subject) +CONVENTIONAL_REGEX='^(build|chore|ci|docs|feat|fix|perf|refactor|revert|style|test)(\([a-zA-Z0-9_-]+\))?: .{1,}' + +# Log the commit message for debugging (safely quoted) +printf "${YELLOW}Checking message: %s${NC}\n" "$COMMIT_MSG" + +if printf "%s" "$COMMIT_MSG" | grep -Eq "$CONVENTIONAL_REGEX"; then + echo "${GREEN}βœ… Commit message format is valid${NC}" + exit 0 +else + echo "" + echo "${RED}❌ Commit message does not follow Conventional Commits format!${NC}" + echo "" + echo "${YELLOW}Expected format:${NC}" + echo " ${GREEN}type(scope): description${NC}" + echo "" + echo "${YELLOW}Valid types:${NC}" + echo " feat: A new feature" + echo " fix: A bug fix" + echo " docs: Documentation only changes" + echo " style: Changes that do not affect the meaning of the code" + echo " refactor: A code change that neither fixes a bug nor adds a feature" + echo " test: Adding missing tests or correcting existing tests" + echo " chore: Changes to the build process or auxiliary tools" + echo " perf: A code change that improves performance" + echo " ci: Changes to CI configuration files and scripts" + echo " build: Changes that affect the build system" + echo " revert: Reverts a previous commit" + echo "" + echo "${YELLOW}Examples:${NC}" + echo " ${GREEN}feat(auth): add user authentication${NC}" + echo " ${GREEN}fix(api): resolve validation error in user endpoint${NC}" + echo " ${GREEN}docs: update API documentation${NC}" + echo "" + echo "See: https://www.conventionalcommits.org/en/v1.0.0/" + exit 1 +fi \ No newline at end of file diff --git a/tests/InstallHookCommandTest.php b/tests/InstallHookCommandTest.php new file mode 100644 index 0000000..fa8a71c --- /dev/null +++ b/tests/InstallHookCommandTest.php @@ -0,0 +1,172 @@ +app->bind(Filesystem::class, fn () => new Filesystem); + parent::tearDown(); + } + + public function test_install_hook_command_creates_hook_file() + { + $fs = new Filesystem; + $testHooksDir = base_path('tests/.git/hooks'); + $testHookPath = $testHooksDir . '/commit-msg'; + $stubPath = __DIR__ . '/../src/Hooks/commit-msg'; + $stubPathTest = __DIR__ . '/../src/Hooks/commit-msg.test'; + + if (!file_exists($stubPath)) { + $this->markTestSkipped('Stub file not found.'); + } + + // Use a temporary stub for this test + $fs->copy($stubPath, $stubPathTest); + + $fs->ensureDirectoryExists($testHooksDir); + if ($fs->exists($testHookPath)) { + $fs->delete($testHookPath); + } + + // Temporarily patch the command to use the test stub + $this->artisan('commitlint:install', [ + 'hookPath' => $testHookPath, + 'stubPath' => $stubPathTest + ])->assertExitCode(0); + + $this->assertFileExists($testHookPath); + $this->assertEquals( + file_get_contents($stubPathTest), + file_get_contents($testHookPath) + ); + + // Clean up test stub after test + if ($fs->exists($stubPathTest)) { + $fs->delete($stubPathTest); + } + } + + public function test_install_hook_command_hooks_dir_missing() + { + $fs = new Filesystem; + $testGitMissingDir = base_path('tests/.git_missing'); + $testHooksDir = $testGitMissingDir . '/hooks'; + $testHookPath = $testHooksDir . '/commit-msg'; + + if ($fs->exists($testGitMissingDir)) { + $fs->deleteDirectory($testGitMissingDir); + } + + $this->assertFalse($fs->exists($testHooksDir)); + + $command = $this->artisan('commitlint:install', [ + 'hookPath' => $testHookPath + ])->assertExitCode(1); + + } + + public function test_install_hook_command_existing_hook_no_overwrite() + { + $fs = new Filesystem; + $testHooksDir = base_path('tests/.git/hooks'); + $testHookPath = $testHooksDir . '/commit-msg'; + $stubPath = __DIR__ . '/../src/Hooks/commit-msg'; + $stubPathTest = __DIR__ . '/../src/Hooks/commit-msg.test'; + + if (!file_exists($stubPath)) { + $this->markTestSkipped('Stub file not found.'); + } + + $fs->copy($stubPath, $stubPathTest); + + $fs->ensureDirectoryExists($testHooksDir); + $fs->put($testHookPath, 'existing hook'); + + $this->artisan('commitlint:install', [ + 'hookPath' => $testHookPath, + 'stubPath' => $stubPathTest + ]) + ->expectsConfirmation('commit-msg hook already exists. Overwrite?', 'no') + ->expectsOutput('Aborted. Existing hook not overwritten.') + ->assertExitCode(0); + + // Clean up test stub after test + if ($fs->exists($stubPathTest)) { + $fs->delete($stubPathTest); + } + } + + public function test_install_hook_command_copy_fails() + { + $fs = new Filesystem; + $testHooksDir = base_path('tests/.git/hooks'); + $testHookPath = $testHooksDir . '/commit-msg'; + $stubPath = __DIR__ . '/../src/Hooks/commit-msg'; + $stubPathTest = __DIR__ . '/../src/Hooks/commit-msg.test'; + + if (!file_exists($stubPath)) { + $this->markTestSkipped('Stub file not found.'); + } + + $fs->copy($stubPath, $stubPathTest); + + $fs->ensureDirectoryExists($testHooksDir); + // Delete the test stub to simulate missing stub + if ($fs->exists($stubPathTest)) { + $fs->delete($stubPathTest); + } + + $this->artisan('commitlint:install', [ + 'hookPath' => $testHookPath, + 'stubPath' => $stubPathTest + ]) + ->expectsOutput("Stub file not found at {$stubPathTest}") + ->assertExitCode(1); + + // Restore the test stub for other tests + // Clean up test stub after test + if ($fs->exists($stubPathTest)) { + $fs->delete($stubPathTest); + } + } + + public function test_install_hook_command_chmod_fails() + { + $mockFs = \Mockery::mock(Filesystem::class); + // Simulate hook file exists, stub exists, copy succeeds, chmod fails + $mockFs->shouldReceive('exists')->withArgs(function($path) { + // Simulate hook file and stub both exist + return true; + })->andReturn(true); + $mockFs->shouldReceive('copy')->andReturn(true); + $mockFs->shouldReceive('chmod')->andReturn(false); + + $this->app->bind(Filesystem::class, fn () => $mockFs); + + $stubPathTest = __DIR__ . '/../src/Hooks/commit-msg.test'; + $this->artisan('commitlint:install', [ + 'hookPath' => base_path('tests/.git/hooks/commit-msg'), + 'stubPath' => $stubPathTest + ]) + ->expectsConfirmation('commit-msg hook already exists. Overwrite?', 'yes') + ->expectsOutput('Failed to set hook file as executable.') + ->assertExitCode(1); + // Clean up test stub after test + $fsCleanup = new Filesystem; + if ($fsCleanup->exists($stubPathTest)) { + $fsCleanup->delete($stubPathTest); + } + } +} From 83a491d25d9a77adef0ea63449a1a7a2ec2c96cc Mon Sep 17 00:00:00 2001 From: Mubbasher Ahmed Qureshi Date: Mon, 28 Jul 2025 15:00:46 +0500 Subject: [PATCH 2/2] fix: use correct php version for ci --- .github/workflows/ci.yml | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 441740c..beb7afa 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -209,7 +209,6 @@ jobs: body: comment }); } - test: runs-on: ubuntu-latest steps: @@ -217,8 +216,8 @@ jobs: - name: Set up PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.2' + php-version: '8.3' - name: Install dependencies - run: composer install --prefer-dist --no-progress --no-suggest + run: composer install --prefer-dist --no-progress - name: Run tests - run: vendor/bin/phpunit || echo "No tests found" + run: vendor/bin/phpunit || echo "No tests found" \ No newline at end of file