Repository: evilmartians/lefthook Branch: master Commit: f8e73b947e2e Files: 372 Total size: 699.5 KB Directory structure: gitextract_h_yok89w/ ├── .github/ │ ├── CODEOWNERS │ ├── ISSUE_TEMPLATE/ │ │ ├── bug_report.md │ │ ├── config.yml │ │ └── feature_request.md │ ├── PULL_REQUEST_TEMPLATE.md │ ├── dependabot.yml │ └── workflows/ │ ├── codeql.yml │ ├── gh-pages.yml │ ├── lint.yml │ ├── release.yml │ └── test.yml ├── .gitignore ├── .golangci.yml ├── .goreleaser.yml ├── .lefthook.yml ├── .tool-versions ├── .typos.toml ├── AGENTS.md ├── CHANGELOG.md ├── CONTRIBUTING.md ├── LICENSE ├── Makefile ├── README.md ├── SECURITY.md ├── assets/ │ └── css/ │ └── lefthook.css ├── book.toml ├── cliff.toml ├── cmd/ │ ├── add-usage.txt │ ├── add.go │ ├── check_install.go │ ├── commands.go │ ├── commands_without_self_update.go │ ├── dump.go │ ├── install.go │ ├── lefthook.go │ ├── run.go │ ├── self_update.go │ ├── uninstall.go │ ├── validate.go │ └── version.go ├── codecov.yml ├── docmd.config.js ├── docs/ │ ├── configuration/ │ │ ├── Commands.md │ │ ├── Hook.md │ │ ├── README.md │ │ ├── Scripts.md │ │ ├── args.md │ │ ├── assert_lefthook_installed.md │ │ ├── colors.md │ │ ├── configs.md │ │ ├── env.md │ │ ├── exclude.md │ │ ├── exclude_tags.md │ │ ├── extends.md │ │ ├── fail_on_changes.md │ │ ├── fail_on_changes_diff.md │ │ ├── fail_text.md │ │ ├── file_types.md │ │ ├── files-global.md │ │ ├── files.md │ │ ├── follow.md │ │ ├── git_url.md │ │ ├── glob.md │ │ ├── glob_matcher.md │ │ ├── group.md │ │ ├── install_non_git_hooks.md │ │ ├── interactive.md │ │ ├── jobs.md │ │ ├── lefthook.md │ │ ├── min_version.md │ │ ├── name.md │ │ ├── no_auto_install.md │ │ ├── no_tty.md │ │ ├── only.md │ │ ├── output.md │ │ ├── parallel.md │ │ ├── piped.md │ │ ├── priority.md │ │ ├── rc.md │ │ ├── ref.md │ │ ├── refetch.md │ │ ├── refetch_frequency.md │ │ ├── remotes.md │ │ ├── root.md │ │ ├── run.md │ │ ├── runner.md │ │ ├── script.md │ │ ├── setup.md │ │ ├── skip.md │ │ ├── skip_lfs.md │ │ ├── source_dir.md │ │ ├── source_dir_local.md │ │ ├── stage_fixed.md │ │ ├── tags.md │ │ ├── templates.md │ │ └── use_stdin.md │ ├── configuration.md │ ├── examples/ │ │ ├── commitlint.md │ │ ├── filters.md │ │ ├── lefthook-local.md │ │ ├── remotes.md │ │ ├── skip.md │ │ ├── stage_fixed.md │ │ └── wrap-commands.md │ ├── index.md │ ├── install.md │ ├── installation/ │ │ ├── alpine.md │ │ ├── arch.md │ │ ├── deb.md │ │ ├── devbox.md │ │ ├── go.md │ │ ├── homebrew.md │ │ ├── manual.md │ │ ├── mise.md │ │ ├── node.md │ │ ├── python.md │ │ ├── rpm.md │ │ ├── ruby.md │ │ ├── scoop.md │ │ ├── snap.md │ │ ├── swift.md │ │ └── winget.md │ ├── misc/ │ │ └── contributors.md │ ├── usage/ │ │ ├── commands/ │ │ │ ├── add.md │ │ │ ├── check-install.md │ │ │ ├── dump.md │ │ │ ├── install.md │ │ │ ├── run.md │ │ │ ├── self-update.md │ │ │ ├── uninstall.md │ │ │ ├── validate.md │ │ │ └── version.md │ │ ├── envs/ │ │ │ ├── CI.md │ │ │ ├── CLICOLOR_FORCE.md │ │ │ ├── LEFTHOOK.md │ │ │ ├── LEFTHOOK_BIN.md │ │ │ ├── LEFTHOOK_CONFIG.md │ │ │ ├── LEFTHOOK_EXCLUDE.md │ │ │ ├── LEFTHOOK_OUTPUT.md │ │ │ ├── LEFTHOOK_VERBOSE.md │ │ │ └── NO_COLOR.md │ │ └── features/ │ │ ├── git-args.md │ │ ├── git-lfs.md │ │ ├── interactive.md │ │ ├── local.md │ │ └── pass-stdin.md │ └── usage.md ├── examples/ │ ├── commitlint/ │ │ ├── README.md │ │ ├── commitlint.config.js │ │ └── lefthook.yml │ ├── complete/ │ │ └── lefthook.yml │ ├── remote/ │ │ └── ping.yml │ ├── verbose/ │ │ └── lefthook.yml │ └── with_scripts/ │ └── lefthook.yml ├── gen/ │ └── jsonschema.go ├── go.mod ├── go.sum ├── integration_test.go ├── internal/ │ ├── command/ │ │ ├── add.go │ │ ├── add_test.go │ │ ├── check_install.go │ │ ├── dump.go │ │ ├── install.go │ │ ├── install_test.go │ │ ├── lefthook.go │ │ ├── run.go │ │ ├── run_test.go │ │ ├── uninstall.go │ │ ├── uninstall_test.go │ │ └── validate.go │ ├── config/ │ │ ├── available_hooks.go │ │ ├── command.go │ │ ├── command_executor.go │ │ ├── command_test.go │ │ ├── config.go │ │ ├── files.go │ │ ├── hook.go │ │ ├── job.go │ │ ├── jsonc_parser.go │ │ ├── jsonschema.go │ │ ├── jsonschema.json │ │ ├── load.go │ │ ├── load_test.go │ │ ├── remote.go │ │ ├── script.go │ │ ├── script_test.go │ │ ├── skip_checker.go │ │ └── skip_checker_test.go │ ├── git/ │ │ ├── command_executor.go │ │ ├── command_executor_test.go │ │ ├── lfs.go │ │ ├── remote.go │ │ ├── repository.go │ │ ├── repository_test.go │ │ └── state.go │ ├── log/ │ │ ├── builder.go │ │ ├── execution.go │ │ ├── log.go │ │ ├── log_test.go │ │ ├── settings.go │ │ ├── settings_test.go │ │ ├── setup.go │ │ └── skip.go │ ├── run/ │ │ ├── controller/ │ │ │ ├── command/ │ │ │ │ ├── build.go │ │ │ │ ├── build_command.go │ │ │ │ ├── build_script.go │ │ │ │ ├── replacer/ │ │ │ │ │ ├── replacer.go │ │ │ │ │ └── replacer_test.go │ │ │ │ └── skip_error.go │ │ │ ├── controller.go │ │ │ ├── controller_test.go │ │ │ ├── exec/ │ │ │ │ ├── exec_unix.go │ │ │ │ ├── exec_windows.go │ │ │ │ └── executor.go │ │ │ ├── filter/ │ │ │ │ ├── detect_text.go │ │ │ │ ├── detect_text_test.go │ │ │ │ ├── filter.go │ │ │ │ └── filter_test.go │ │ │ ├── guard.go │ │ │ ├── guard_test.go │ │ │ ├── job.go │ │ │ ├── lfs.go │ │ │ ├── run.go │ │ │ ├── scope.go │ │ │ ├── scope_test.go │ │ │ ├── setup.go │ │ │ └── utils/ │ │ │ ├── cached_reader.go │ │ │ ├── cached_reader_test.go │ │ │ ├── firstNonBlank.go │ │ │ └── intersect.go │ │ ├── result/ │ │ │ ├── result.go │ │ │ └── result_test.go │ │ └── run.go │ ├── system/ │ │ ├── command.go │ │ ├── limits.go │ │ ├── null_reader.go │ │ ├── null_reader_test.go │ │ ├── sh_unix.go │ │ └── sh_windows.go │ ├── templates/ │ │ ├── config.tmpl │ │ ├── hook.tmpl │ │ └── templates.go │ ├── updater/ │ │ ├── updater.go │ │ └── updater_test.go │ └── version/ │ ├── version.go │ └── version_test.go ├── main.go ├── packaging/ │ ├── .gitignore │ ├── registries/ │ │ ├── aur/ │ │ │ ├── lefthook/ │ │ │ │ └── PKGBUILD │ │ │ └── lefthook-bin/ │ │ │ └── PKGBUILD │ │ ├── npm/ │ │ │ ├── lefthook/ │ │ │ │ ├── bin/ │ │ │ │ │ └── index.js │ │ │ │ ├── get-exe.js │ │ │ │ ├── package.json │ │ │ │ └── postinstall.js │ │ │ ├── lefthook-darwin-arm64/ │ │ │ │ └── package.json │ │ │ ├── lefthook-darwin-x64/ │ │ │ │ └── package.json │ │ │ ├── lefthook-freebsd-arm64/ │ │ │ │ └── package.json │ │ │ ├── lefthook-freebsd-x64/ │ │ │ │ └── package.json │ │ │ ├── lefthook-linux-arm64/ │ │ │ │ └── package.json │ │ │ ├── lefthook-linux-x64/ │ │ │ │ └── package.json │ │ │ ├── lefthook-openbsd-arm64/ │ │ │ │ └── package.json │ │ │ ├── lefthook-openbsd-x64/ │ │ │ │ └── package.json │ │ │ ├── lefthook-windows-arm64/ │ │ │ │ └── package.json │ │ │ └── lefthook-windows-x64/ │ │ │ └── package.json │ │ ├── npm-bundled/ │ │ │ ├── bin/ │ │ │ │ └── index.js │ │ │ ├── get-exe.js │ │ │ ├── package.json │ │ │ └── postinstall.js │ │ ├── npm-installer/ │ │ │ ├── bin/ │ │ │ │ └── index.js │ │ │ ├── install.js │ │ │ └── package.json │ │ ├── pypi/ │ │ │ ├── LICENSE │ │ │ ├── README.md │ │ │ ├── hatch_build.py │ │ │ ├── lefthook/ │ │ │ │ ├── __init__.py │ │ │ │ ├── __main__.py │ │ │ │ ├── bin/ │ │ │ │ │ └── .keep │ │ │ │ └── main.py │ │ │ └── pyproject.toml │ │ └── rubygems/ │ │ ├── Gemfile │ │ ├── README.md │ │ ├── Rakefile │ │ ├── bin/ │ │ │ └── lefthook │ │ ├── lefthook.gemspec │ │ ├── lib/ │ │ │ └── lefthook.rb │ │ └── libexec/ │ │ └── .keep │ └── scripts/ │ ├── META6.json │ ├── clean.raku │ ├── lib/ │ │ ├── Constants.rakumod │ │ ├── Packager.rakumod │ │ ├── Registries/ │ │ │ ├── AUR/ │ │ │ │ └── Publishing.rakumod │ │ │ ├── AUR-Bin.rakumod │ │ │ ├── AUR.rakumod │ │ │ ├── NPM.rakumod │ │ │ ├── PyPI.rakumod │ │ │ └── RubyGems.rakumod │ │ ├── Registry.rakumod │ │ ├── System.rakumod │ │ └── SystemAPI.rakumod │ ├── prepare.raku │ ├── publish.raku │ ├── set-version.raku │ └── t/ │ ├── 01-system.rakutest │ ├── 02-npm.rakutest │ ├── 03-rubygems.rakutest │ ├── 04-pypi.rakutest │ └── lib/ │ ├── FakeSystem.rakumod │ └── TestRegistry.rakumod ├── schema.json ├── tea.yaml └── tests/ ├── helpers/ │ ├── cmdtest/ │ │ ├── cmdtest.go │ │ ├── dumb.go │ │ ├── ordered.go │ │ ├── ordered_test.go │ │ ├── tracking.go │ │ └── tracking_test.go │ ├── configtest/ │ │ ├── config.go │ │ └── config_test.go │ └── gittest/ │ ├── gittest.go │ └── gittest_test.go └── integration/ ├── add.txt ├── check_install.txt ├── cli_run_only.txt ├── dump.txt ├── env_overwrite_issue_1137.txt ├── exclude.txt ├── exclude_arg.txt ├── fail_on_changes.txt ├── fail_on_changes_issue_1125.txt ├── fail_on_changes_recover_previous_change.txt ├── fail_text.txt ├── files_override.txt ├── files_skip_if_empty.txt ├── filter_by_file_type.txt ├── filter_by_mime_type.txt ├── group_envs.txt ├── hide_unstaged.txt ├── install.txt ├── install_specific.txt ├── job_fail_text.txt ├── job_filter_by_file_type.txt ├── job_merging.txt ├── job_stage_fixed.txt ├── lefthook_job_name_issue_1345.txt ├── lefthook_option.txt ├── many_extends_levels.txt ├── min_version.txt ├── pre-commit_issue_919.txt ├── remotes.txt ├── run_deleted_only.txt ├── run_interrupt.txt ├── run_json.txt ├── run_jsonc.txt ├── run_non_existing.txt ├── run_script.txt ├── run_script_with_args.txt ├── run_toml.txt ├── run_yml.txt ├── setup_instructions.txt ├── sh_syntax_in_files.txt ├── skip_group_issue_1083.txt ├── skip_merge_commit.txt ├── skip_run.txt ├── stage_fixed.txt ├── stage_fixed_505.txt ├── templates.txt ├── timeout.txt ├── timeout_success.txt ├── uninstall.txt ├── validate.txt ├── validate_fail.txt └── version.txt ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/CODEOWNERS ================================================ * @mrexox ================================================ FILE: .github/ISSUE_TEMPLATE/bug_report.md ================================================ --- name: 🐞 Report a bug about: Found something broken? Let us know! If it's not yet reproducible, please `Ask a question` instead. labels: 'bug' --- ### Description ### `lefthook.yml` ### Commands to reproduce ```bash export LEFTHOOK_VERBOSE=true ``` ### Lefthook version ### Possible solution ================================================ FILE: .github/ISSUE_TEMPLATE/config.yml ================================================ --- blank_issues_enabled: false contact_links: - name: 💡 Discuss an idea url: https://github.com/evilmartians/lefthook/discussions/new?category=ideas about: Suggest a feature or an improvement. - name: ❔ Ask a question url: https://github.com/evilmartians/lefthook/discussions/new about: Ask questions and discuss with other `lefthook` users or maintainers. - name: 🙏 Request help url: https://github.com/evilmartians/lefthook/discussions/new about: Ask the `lefthook` community for help. ================================================ FILE: .github/ISSUE_TEMPLATE/feature_request.md ================================================ --- name: ⭐ Feature request about: Want something to be implemented in `lefthook`? Create a feature request! If you are not sure, or just have an idea, please `Discuss an idea` instead. labels: 'feature request' --- ### Description ### What problem it is solving? ================================================ FILE: .github/PULL_REQUEST_TEMPLATE.md ================================================ Closes # (issue) ### Context ### Changes ================================================ FILE: .github/dependabot.yml ================================================ --- version: 2 updates: - package-ecosystem: "gomod" directory: "/" target-branch: "dependencies" schedule: interval: "weekly" day: "monday" time: "06:00" # 6:00 UTC commit-message: prefix: "deps" assignees: - "mrexox" ================================================ FILE: .github/workflows/codeql.yml ================================================ name: "CodeQL" on: push: branches: [ "master" ] pull_request: # The branches below must be a subset of the branches above branches: [ "master" ] schedule: # 6:00 UTC on Monday - cron: '0 6 * * 1' jobs: analyze: name: Analyze runs-on: ubuntu-latest permissions: actions: read contents: read security-events: write strategy: fail-fast: false matrix: language: [ 'go' ] # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ] # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support steps: - name: Checkout repository uses: actions/checkout@v4 # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} # If you wish to specify custom queries, you can do so here or in a config file. # By default, queries listed here will override any specified in a config file. # Prefix the list here with "+" to use these queries and those in the config file. # Details on CodeQL's query packs refer to : https://docs.github.com/en/code-security/code-scanning/automatically-scanning-your-code-for-vulnerabilities-and-errors/configuring-code-scanning#using-queries-in-ql-packs # queries: security-extended,security-and-quality # Autobuild attempts to build any compiled languages (C/C++, C#, Go, or Java). # If this step fails, then you should remove it and run the build manually (see below) - name: Autobuild uses: github/codeql-action/autobuild@v3 # ℹ️ Command-line programs to run using the OS shell. # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun # If the Autobuild fails above, remove it and uncomment the following three lines. # modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance. # - run: | # echo "Run, Build Application using script" # ./location_of_script_within_repo/buildscript.sh - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@v3 with: category: "/language:${{matrix.language}}" ================================================ FILE: .github/workflows/gh-pages.yml ================================================ name: Publish Docs on: push: branches: - master pull_request: jobs: gh-pages: runs-on: ubuntu-latest concurrency: group: ${{ github.workflow }}-${{ github.ref }} steps: - uses: actions/checkout@v2 - name: Setup docmd run: npm install -g @docmd/core@0.4.11 - run: docmd build - name: Deploy uses: peaceiris/actions-gh-pages@v3 if: ${{ github.ref == 'refs/heads/master' }} with: github_token: ${{ secrets.GITHUB_TOKEN }} publish_dir: ./site cname: lefthook.dev ================================================ FILE: .github/workflows/lint.yml ================================================ on: push: branches: - master pull_request: name: Lint jobs: golangci: name: golangci-lint runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install Go uses: actions/setup-go@v5 with: go-version-file: go.mod - name: golangci-lint uses: golangci/golangci-lint-action@v8 with: version-file: .tool-versions ================================================ FILE: .github/workflows/release.yml ================================================ name: release on: push: tags: - "*" permissions: attestations: write contents: write id-token: write jobs: build: runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v4 with: fetch-depth: 0 - name: Fetch all tags run: git fetch --force --tags - name: Setup Go uses: actions/setup-go@v5 with: go-version-file: go.mod - name: Install Snapcraft uses: samuelmeuli/action-snapcraft@v2 - name: Prevent from snapcraft fail run: | mkdir -p $HOME/.cache/snapcraft/download mkdir -p $HOME/.cache/snapcraft/stage-packages - name: Run GoReleaser uses: goreleaser/goreleaser-action@v6 with: distribution: goreleaser version: 'v2.10.2' args: release --clean --verbose env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }} - name: Generate artifact attestations uses: actions/attest@v4 with: subject-checksums: dist/lefthook_checksums.txt - name: Preserve artifacts permissions with tar run: tar -cvf dist.tar dist/ - uses: actions/upload-artifact@v4 with: name: dist path: dist.tar publish-npm: needs: build runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - run: git fetch --force --tags - uses: actions/download-artifact@v4 with: name: dist - run: tar -xvf dist.tar - uses: actions/setup-node@v4 with: node-version: '20' registry-url: 'https://registry.npmjs.org' - name: Update npm run: npm install -g npm@latest - uses: Raku/setup-raku@v1 - name: Publish to NPM env: NPM_API_KEY: ${{ secrets.NPM_API_KEY }} run: | raku packaging/scripts/prepare.raku --target=npm raku packaging/scripts/publish.raku --target=npm publish-gem: needs: build runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - run: git fetch --force --tags - uses: actions/download-artifact@v4 with: name: dist - run: tar -xvf dist.tar - uses: Raku/setup-raku@v1 - name: Publish to Rubygems env: RUBYGEMS_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }} run: | mkdir -p ~/.gem/ cat << EOF > ~/.gem/credentials --- :rubygems_api_key: ${RUBYGEMS_API_KEY} EOF chmod 0600 ~/.gem/credentials raku packaging/scripts/prepare.raku --target=rubygems raku packaging/scripts/publish.raku --target=rubygems publish-pypi: needs: build runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - run: git fetch --force --tags - uses: actions/download-artifact@v4 with: name: dist - run: tar -xvf dist.tar - name: Setup uv with python uses: astral-sh/setup-uv@v7 with: enable-cache: false python-version: "3.12" version: "latest" - uses: Raku/setup-raku@v1 - name: Publish to PyPI env: UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_KEY }} run: | raku packaging/scripts/prepare.raku --target=pypi raku packaging/scripts/publish.raku --target=pypi publish-homebrew: needs: build runs-on: ubuntu-latest steps: - name: Update Homebrew formula uses: dawidd6/action-homebrew-bump-formula@v3 with: formula: lefthook token: ${{ secrets.HOMEBREW_TOKEN }} publish-winget: needs: build runs-on: ubuntu-latest steps: - name: Publish to Winget uses: vedantmgoyal2009/winget-releaser@v2 with: identifier: evilmartians.lefthook fork-user: mrexox token: ${{ secrets.WINGET_TOKEN }} publish-distro-packages: needs: build runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: actions/download-artifact@v4 with: name: dist - run: tar -xvf dist.tar - name: Setup Python uses: actions/setup-python@v5 with: python-version: '3.12' - run: python -m pip install --upgrade cloudsmith-cli - name: Push packages to Cloudsmith env: CLOUDSMITH_API_KEY: ${{ secrets.CLOUDSMITH_API_KEY }} run: | cloudsmith push deb evilmartians/lefthook/any-distro/any-version dist/lefthook_*_amd64.deb cloudsmith push deb evilmartians/lefthook/any-distro/any-version dist/lefthook_*_arm64.deb cloudsmith push rpm evilmartians/lefthook/any-distro/any-version dist/lefthook_*_amd64.rpm cloudsmith push rpm evilmartians/lefthook/any-distro/any-version dist/lefthook_*_arm64.rpm cloudsmith push alpine evilmartians/lefthook/alpine/any-version dist/lefthook_*_amd64.apk cloudsmith push alpine evilmartians/lefthook/alpine/any-version dist/lefthook_*_arm64.apk publish-aur_lefthook: needs: build runs-on: ubuntu-latest container: image: archlinux:latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: Raku/setup-raku@v1 - name: Update AUR package run: | pacman -Syu --noconfirm pacman -S --noconfirm openssh git go base-devel curl useradd -m -G wheel runner echo "%wheel ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers chown -R runner:runner . su runner -c ' mkdir -p ~/.ssh echo "${{ secrets.AUR_SSH_KEY }}" > ~/.ssh/aur chmod 600 ~/.ssh/aur echo "Host aur.archlinux.org" >> ~/.ssh/config echo " IdentityFile ~/.ssh/aur" >> ~/.ssh/config echo " User aur" >> ~/.ssh/config ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts raku packaging/scripts/publish.raku --target=aur ' publish-aur_lefthook-bin: needs: build runs-on: ubuntu-latest container: image: archlinux:latest steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - uses: Raku/setup-raku@v1 - name: Update AUR package run: | pacman -Syu --noconfirm pacman -S --noconfirm openssh git base-devel curl useradd -m -G wheel runner echo "%wheel ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers chown -R runner:runner . su runner -c ' mkdir -p ~/.ssh echo "${{ secrets.AUR_SSH_KEY }}" > ~/.ssh/aur chmod 600 ~/.ssh/aur echo "Host aur.archlinux.org" >> ~/.ssh/config echo " IdentityFile ~/.ssh/aur" >> ~/.ssh/config echo " User aur" >> ~/.ssh/config ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts raku packaging/scripts/publish.raku --target=aur-bin ' ================================================ FILE: .github/workflows/test.yml ================================================ on: push: branches: - master pull_request: name: Test jobs: unit: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} steps: - name: Checkout code uses: actions/checkout@v4 - name: Install Go uses: actions/setup-go@v5 with: go-version-file: go.mod - name: Test run: go test $(go list ./... | grep -v '/gen$') -coverprofile coverage.out - name: Report coverage uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} flags: unit name: ${{ join(matrix.*, ' ') }} integration: strategy: matrix: os: [ubuntu-latest, macos-latest, windows-latest] runs-on: ${{ matrix.os }} env: GOCOVERDIR: ${{ github.workspace }}/_icoverdir_ steps: - name: Checkout code uses: actions/checkout@v4 - name: Install Go uses: actions/setup-go@v5 with: go-version-file: go.mod - name: Prepare lefthook run: | mkdir _icoverdir_ go install -cover - name: Run integration tests uses: nick-fields/retry@v3 with: timeout_minutes: 5 max_attempts: 3 command: go test integration_test.go -tags=integration - name: Collect coverage run: | go tool covdata textfmt -i _icoverdir_ -o coverage.out - name: Report coverage uses: codecov/codecov-action@v5 with: token: ${{ secrets.CODECOV_TOKEN }} flags: integration name: integration-${{ join(matrix.*, ' ') }} packaging: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Install Raku uses: Raku/setup-raku@v1 - name: Run packaging tests run: | cd packaging/scripts/ zef install . --deps-only raku -I lib t/*.rakutest raku prepare.raku --target=npm --dry-run raku prepare.raku --target=rubygems --dry-run raku prepare.raku --target=pypi --dry-run raku prepare.raku --target=aur --dry-run raku prepare.raku --target=aur-bin --dry-run raku publish.raku --target=npm --dry-run mkdir -p ../registries/rubygems/pkg touch ../registries/rubygems/pkg/lefthook_99.gem raku publish.raku --target=rubygems --dry-run raku publish.raku --target=pypi --dry-run raku publish.raku --target=aur --dry-run raku publish.raku --target=aur-bin --dry-run build: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v4 - name: Install Go uses: actions/setup-go@v5 with: go-version-file: go.mod - name: Build binaries uses: goreleaser/goreleaser-action@v6 with: distribution: goreleaser version: '~> v2' args: release --snapshot --skip=publish --skip=snapcraft --skip=validate --clean --verbose - name: Tar binaries to preserve executable bit run: 'tar -cvf lefthook-binaries.tar --directory dist/ $(find dist/ -executable -type f -printf "%P\0" | xargs --null)' - name: Upload binaries as artifacts uses: actions/upload-artifact@v4 with: name: Executables path: lefthook-binaries.tar ================================================ FILE: .gitignore ================================================ /lefthook /lefthook-local.yml /bin/ /dist/ /book/ /site/ /vscode/ /.idea/ tmp/ ================================================ FILE: .golangci.yml ================================================ version: "2" linters: default: none enable: - asasalint - asciicheck - bidichk - bodyclose - containedctx - contextcheck - copyloopvar - dogsled - dupl - dupword - durationcheck - errcheck - errchkjson - errname - errorlint - exhaustive - forbidigo - gochecknoinits - goconst - gocritic - gocyclo - godoclint - godot - godox - goheader - goprintffuncname - govet - ineffassign - intrange - makezero - mirror - misspell - mnd - modernize - nestif - noctx - nolintlint - perfsprint - prealloc - predeclared - reassign - revive - staticcheck - tagalign - usetesting - unconvert - unparam - unused - usestdlibvars - whitespace settings: gocritic: disabled-checks: - hugeParam enabled-tags: - performance govet: enable: - shadow goconst: ignore-string-values: - "false" misspell: locale: US perfsprint: strconcat: false revive: rules: - name: unused-parameter disabled: true unused: field-writes-are-uses: false post-statements-are-reads: true exported-fields-are-used: false local-variables-are-used: false generated-is-used: false formatters: enable: - gci - gofumpt - goimports settings: gci: sections: - standard - default - prefix(github.com/evilmartians/lefthook) ================================================ FILE: .goreleaser.yml ================================================ version: 2 project_name: lefthook before: hooks: - go generate ./... builds: # Builds the binaries without `lefthook upgrade` - id: no_self_update tags: - no_self_update env: - CGO_ENABLED=0 goos: - linux - darwin - windows - freebsd - openbsd goarch: - amd64 - arm64 - 386 ignore: - goos: darwin goarch: 386 - goos: linux goarch: 386 - goos: freebsd goarch: 386 - goos: openbsd goarch: 386 flags: - -trimpath ldflags: - -s -w -X github.com/evilmartians/lefthook/internal/version.commit={{.Commit}} # Full lefthook binary - id: lefthook env: - CGO_ENABLED=0 goos: - linux - darwin - windows - freebsd - openbsd goarch: - amd64 - arm64 - 386 ignore: - goos: darwin goarch: 386 - goos: linux goarch: 386 - goos: freebsd goarch: 386 - goos: openbsd goarch: 386 flags: - -trimpath ldflags: - -s -w -X github.com/evilmartians/lefthook/internal/version.commit={{.Commit}} - id: lefthook-linux-aarch64 env: - CGO_ENABLED=0 goos: - linux goarch: - arm64 flags: - -trimpath ldflags: - -s -w -X github.com/evilmartians/lefthook/internal/version.commit={{.Commit}} archives: - id: lefthook formats: [binary] ids: - lefthook files: - none* name_template: >- {{ .ProjectName }}_ {{- .Version }}_ {{- if eq .Os "darwin" }}MacOS {{- else }}{{ title .Os }}{{ end }}_ {{- if eq .Arch "amd64" }}x86_64 {{- else if eq .Arch "386" }}i386 {{- else }}{{ .Arch }}{{ end }} - id: lefthook-gz formats: [gz] ids: - lefthook files: - none* name_template: >- {{ .ProjectName }}_ {{- .Version }}_ {{- if eq .Os "darwin" }}MacOS {{- else }}{{ title .Os }}{{ end }}_ {{- if eq .Arch "amd64" }}x86_64 {{- else if eq .Arch "386" }}i386 {{- else }}{{ .Arch }}{{ end }} - id: lefthook-linux-aarch64 formats: [binary] ids: - lefthook-linux-aarch64 files: - none* name_template: >- {{ .ProjectName }}_ {{- .Version }}_ {{- if eq .Os "darwin" }}MacOS {{- else }}{{ title .Os }}{{ end }}_ {{- if eq .Arch "amd64" }}x86_64 {{- else if eq .Arch "386" }}i386 {{- else if eq .Arch "arm64" }}aarch64 {{- else }}{{ .Arch }}{{ end }} - id: lefthook-linux-aarch64-gz formats: [gz] ids: - lefthook-linux-aarch64 files: - none* name_template: >- {{ .ProjectName }}_ {{- .Version }}_ {{- if eq .Os "darwin" }}MacOS {{- else }}{{ title .Os }}{{ end }}_ {{- if eq .Arch "amd64" }}x86_64 {{- else if eq .Arch "386" }}i386 {{- else if eq .Arch "arm64" }}aarch64 {{- else }}{{ .Arch }}{{ end }} checksum: name_template: "{{ .ProjectName }}_checksums.txt" algorithm: sha256 snapshot: version_template: "{{ .Tag }}" changelog: sort: asc filters: exclude: - '^docs:' - '^test:' - '^spec:' - '^tmp:' - '^context:' - '^\d+\.\d+\.\d+:' snapcrafts: - summary: Fast and powerful Git hooks manager for any type of projects. description: | Lefthook is a single dependency-free binary to manage all your git hooks. It works with any language in any environment, and in all common team workflows. grade: stable confinement: classic publish: true license: MIT ids: - no_self_update nfpms: - file_name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}' homepage: https://github.com/evilmartians/lefthook description: Lefthook a single dependency-free binary to manage all your git hooks that works with any language in any environment, and in all common team workflows maintainer: Evil Martians license: MIT vendor: Evil Martians ids: - no_self_update formats: - apk - deb - rpm dependencies: - git ================================================ FILE: .lefthook.yml ================================================ assert_lefthook_installed: true skip_lfs: true output: - meta - summary - jobs pre-commit: parallel: true setup: - run: command -v typos || brew install typos-cli - run: command -v lychee || brew install lychee jobs: - name: lint & test glob: "*.go" group: jobs: - run: make lint tags: lint stage_fixed: true - run: make test tags: test - name: check links tags: docs run: lychee --max-concurrency 3 -- {staged_files} glob: '*.md' exclude: - CHANGELOG.md - name: fix typos tags: lint run: typos --write-changes {staged_files} exclude: - "*.svg" - "*.png" stage_fixed: true - name: update JSON schema tags: docs run: | go generate gen/jsonschema.go > internal/config/jsonschema.json go generate gen/jsonschema.go > schema.json git add internal/config/jsonschema.json git add schema.json glob: - 'gen/jsonschema.go' - 'internal/config/command.go' - 'internal/config/config.go' - 'internal/config/hook.go' - 'internal/config/job.go' - 'internal/config/remote.go' - 'internal/config/script.go' ================================================ FILE: .tool-versions ================================================ golangci-lint 2.10.1 ================================================ FILE: .typos.toml ================================================ [default.extend-identifiers] "PnP" = "PnP" [default.extend-words] slq = "slq" ================================================ FILE: AGENTS.md ================================================ # AGENTS.md Lefthook is a CLI-first Git hooks manager. Contributions must be predictable, backwards-compatible, and dependency-light. ## Requirements - Go 1.26+ (respect `go.mod` toolchain) - Git, Make ``` make build # compile make test # unit tests make test-integration # integration tests make lint # golangci-lint make jsonschema # regenerate schema.json after config changes ``` ## Codebase map | Path | Purpose | |---|---| | `cmd/` | CLI commands | | `internal/config/` | Config parsing, validation, JSON schema | | `internal/run/` | Hook runner, parallelism | | `internal/command/` | Top-level orchestrator | | `internal/git/` | Git utilities | | `docs/` | documentation source → lefthook.dev | | `tests/` | Integration/fixture tests | ## Rules **Errors** — always wrap with context; never silently ignore; no panic in production paths. **Concurrency** — no goroutine leaks; use `context.Context`; deterministic output when order matters. **CLI** — preserve exit codes, flag names, and output format. Update docs and tests for any behavior change. **Config** — edit structs in `internal/config/`, then run `make jsonschema`. Both `schema.json` and `internal/config/jsonschema.json` must be committed. **Security** — treat user input as untrusted; no unsafe shell concatenation; sanitize paths. ## Testing Prefer table-driven unit tests. Integration tests should validate CLI behavior and real git interaction — not internal implementation details. ## PR checklist - [ ] `make lint` passes - [ ] `make test` passes - [ ] Docs updated if behavior changed or new config option added When in doubt, follow existing patterns. Consistency over cleverness. ================================================ FILE: CHANGELOG.md ================================================ # Change log ## 2.1.4 (2026-03-12) - pkg: fix scripts ([#1348](https://github.com/evilmartians/lefthook/pull/1348)) by [@mrexox](https://github.com/mrexox) - fix: bring back {lefthook_job_name} template ([#1347](https://github.com/evilmartians/lefthook/pull/1347)) by [@mrexox](https://github.com/mrexox) - pkg: refactor packaging (2) ([#1346](https://github.com/evilmartians/lefthook/pull/1346)) by [@mrexox](https://github.com/mrexox) - fix: separate more commands' non-option args with -- ([#1339](https://github.com/evilmartians/lefthook/pull/1339)) by [@scop](https://github.com/scop) - docs: change logo to point to landing page instead of itself ([#1343](https://github.com/evilmartians/lefthook/pull/1343)) by [@igas](https://github.com/igas) - pkg: make it easier to read ([#1340](https://github.com/evilmartians/lefthook/pull/1340)) by [@mrexox](https://github.com/mrexox) - pkg: refactor packaging scripts ([#1308](https://github.com/evilmartians/lefthook/pull/1308)) by [@mrexox](https://github.com/mrexox) ## 2.1.3 (2026-03-07) - chore: switch artifact attestations gen to actions/attest v4 ([#1338](https://github.com/evilmartians/lefthook/pull/1338)) by [@scop](https://github.com/scop) - chore: describe ENV variables usage in CLI help output ([#1337](https://github.com/evilmartians/lefthook/pull/1337)) by [@mrexox](https://github.com/mrexox) - fix: support git debug versions ([#1334](https://github.com/evilmartians/lefthook/pull/1334)) by [@mrexox](https://github.com/mrexox) - deps: March 2026 ([#1330](https://github.com/evilmartians/lefthook/pull/1330)) by [@mrexox](https://github.com/mrexox) - feat: update minimum go version ([#1331](https://github.com/evilmartians/lefthook/pull/1331)) by [@mrexox](https://github.com/mrexox) ## 2.1.2 (2026-03-01) - feat: introduce setup hook option ([#1326](https://github.com/evilmartians/lefthook/pull/1326)) by [@mrexox](https://github.com/mrexox) - refactor: recovering logic for changesets ([#1324](https://github.com/evilmartians/lefthook/pull/1324)) by [@mrexox](https://github.com/mrexox) - fix: rollback auto-staged changes if unwanted changes detected ([#1251](https://github.com/evilmartians/lefthook/pull/1251)) by [@tuchfarber](https://github.com/tuchfarber) - docs: improve docs ui ([#1323](https://github.com/evilmartians/lefthook/pull/1323)) by [@mrexox](https://github.com/mrexox) - docs: additional skip example and note about reinstallation ([#1319](https://github.com/evilmartians/lefthook/pull/1319)) by [@iloveitaly](https://github.com/iloveitaly) - docs: fix incorrect --verbose usage ([#1318](https://github.com/evilmartians/lefthook/pull/1318)) by [@iloveitaly](https://github.com/iloveitaly) - pkg: fix python packages publishing by [@mrexox](https://github.com/mrexox) ## 2.1.1 (2026-02-12) - ci: fix publishing to PyPi by [@mrexox](https://github.com/mrexox) - fix: reset colors on config read ([#1309](https://github.com/evilmartians/lefthook/pull/1309)) by [@mrexox](https://github.com/mrexox) - chore: reduce verbosity of hints in lefthook install ([#1303](https://github.com/evilmartians/lefthook/pull/1303)) by [@joevin-slq-docto](https://github.com/joevin-slq-docto) - docs: add missing /v2 suffix for go get -tool ([#1304](https://github.com/evilmartians/lefthook/pull/1304)) by [@alexandregv](https://github.com/alexandregv) ## 2.1.0 (2026-02-03) - ci: skip Python publishing by [@mrexox][] - chore: fancy wording and indentation for hits by [@mrexox][] - feat: check core.hooksPath when lefthook install ([#1292](https://github.com/evilmartians/lefthook/pull/1292)) by [@joevin-slq-docto][] - feat: allow installing non-git hooks ([#1301](https://github.com/evilmartians/lefthook/pull/1301)) by [@mrexox][] ## 2.0.16 (2026-01-27) - chore: timeout cleanup ([#1297](https://github.com/evilmartians/lefthook/pull/1297)) by [@mrexox](https://github.com/mrexox) - feat: add timeout argument ([#1263](https://github.com/evilmartians/lefthook/pull/1263)) by [@franzramadhan](https://github.com/franzramadhan) - deps: January 2026 ([#1285](https://github.com/evilmartians/lefthook/pull/1285)) by [@mrexox](https://github.com/mrexox) - pkg: pack one binary per platform into python wheels ([#1181](https://github.com/evilmartians/lefthook/pull/1181)) by [@danfimov](https://github.com/danfimov) - fix: accept string in file_types ([#1288](https://github.com/evilmartians/lefthook/pull/1288)) by [@scop](https://github.com/scop) - docs: elaborate on when to refetch and failure mode ([#1287](https://github.com/evilmartians/lefthook/pull/1287)) by [@scop](https://github.com/scop) - fix: try reading direct file instead of all remotes ([#1243](https://github.com/evilmartians/lefthook/pull/1243)) by [@mrexox](https://github.com/mrexox) - perf: [**breaking**] skip ghost hook when hooks are already configured ([#1255](https://github.com/evilmartians/lefthook/pull/1255)) by [@WooWan](https://github.com/WooWan) - chore: upgrade to 2.8.0 ([#1278](https://github.com/evilmartians/lefthook/pull/1278)) by [@scop](https://github.com/scop) ## 2.0.15 (2026-01-13) - docs: clarify remote settings ([#1260](https://github.com/evilmartians/lefthook/pull/1260)) by [@mrexox](https://github.com/mrexox) - feat: skip scripts if args given with empty file template ([#1277](https://github.com/evilmartians/lefthook/pull/1277)) by [@mrexox](https://github.com/mrexox) ## 2.0.14 (2026-01-12) - fix: skip if any files template is empty ([#1275](https://github.com/evilmartians/lefthook/pull/1275)) by [@mrexox](https://github.com/mrexox) - feat: add jsonc support ([#1274](https://github.com/evilmartians/lefthook/pull/1274)) by [@mrexox](https://github.com/mrexox) - deps: switch from gopkg.in/yaml.v3 to go.yaml.in/yaml/v3 ([#1261](https://github.com/evilmartians/lefthook/pull/1261)) by [@scop](https://github.com/scop) - fix: don't install custom hooks to hooks dir ([#1246](https://github.com/evilmartians/lefthook/pull/1246)) by [@scop](https://github.com/scop) - deps: December 2025 ([#1209](https://github.com/evilmartians/lefthook/pull/1209)) by [@mrexox](https://github.com/mrexox) ## 2.0.13 (2025-12-26) - fix: set extends to empty slice after loading remotes ([#1259](https://github.com/evilmartians/lefthook/pull/1259)) by [@mrexox]() - fix: allow custom hooks in JSON schema by updating generator ([#1250](https://github.com/evilmartians/lefthook/pull/1250)) by [@jeonghoon11]() - docs: remove duplicate config: false description ([#1245](https://github.com/evilmartians/lefthook/pull/1245)) by [@scop]() - chore: add more tests ([#1244](https://github.com/evilmartians/lefthook/pull/1244)) by [@mrexox]() ## 2.0.12 (2025-12-15) - chore: small changes on diff printing ([#1242](https://github.com/evilmartians/lefthook/pull/1242)) by [@mrexox](https://github.com/mrexox) - feat: ability to show diff when failing on changes ([#1227](https://github.com/evilmartians/lefthook/pull/1227)) by [@scop](https://github.com/scop) - fix: make short status parser more robust ([#1236](https://github.com/evilmartians/lefthook/pull/1236)) by [@scop](https://github.com/scop) - docs: fix readme ([#1235](https://github.com/evilmartians/lefthook/pull/1235)) by [@matdibu](https://github.com/matdibu) ## 2.0.11 (2025-12-12) - feat: refetch and cleanup on ref change ([#1210](https://github.com/evilmartians/lefthook/pull/1210)) by [@mrexox](https://github.com/mrexox) - ci: npm trusted publishing ([#1234](https://github.com/evilmartians/lefthook/pull/1234)) by [@mrexox](https://github.com/mrexox) - feat: more rudimentary shell completions ([#1230](https://github.com/evilmartians/lefthook/pull/1230)) by [@scop](https://github.com/scop) ## 2.0.10 (2025-12-12) - feat: add no_auto_install to lefthook.yml ([#1231](https://github.com/evilmartians/lefthook/pull/1231)) by [@pavelzw](https://github.com/pavelzw) - fix: skip if empty files template ([#1233](https://github.com/evilmartians/lefthook/pull/1233)) by [@mrexox](https://github.com/mrexox) ## 2.0.9 (2025-12-08) - fix: skip pre commit hook if no staged files ([#1229](https://github.com/evilmartians/lefthook/pull/1229)) by [@mrexox](https://github.com/mrexox) - fix: do not try to hash-object directories ([#1220](https://github.com/evilmartians/lefthook/pull/1220)) by [@scop](https://github.com/scop) - fix: check and report Scanner errors ([#1222](https://github.com/evilmartians/lefthook/pull/1222)) by [@scop](https://github.com/scop) - refactor: command executor tweaks ([#1224](https://github.com/evilmartians/lefthook/pull/1224)) by [@scop](https://github.com/scop) - refactor: remove some redundant code ([#1221](https://github.com/evilmartians/lefthook/pull/1221)) by [@scop](https://github.com/scop) - fix: improve separation of options and filenames for more git commands ([#1225](https://github.com/evilmartians/lefthook/pull/1225)) by [@scop](https://github.com/scop) - chore: upgrade golangci-lint to 2.7.1, add godoclint ([#1223](https://github.com/evilmartians/lefthook/pull/1223)) by [@scop](https://github.com/scop) - chore: remove unnecessary .svg executable permissions ([#1219](https://github.com/evilmartians/lefthook/pull/1219)) by [@scop](https://github.com/scop) ## 2.0.8 (2025-12-05) - fix: do not escape custom templates in command replacement ([#1213](https://github.com/evilmartians/lefthook/pull/1213)) by [@joevin-sql-docto]() ## 2.0.7 (2025-12-04) - fix: prefer using lefthook from the $PATH ([#1211](https://github.com/evilmartians/lefthook/pull/1211)) by [@joevin-sql-docto]() ## 2.0.6 (2025-12-03) - feat: save original executable location in hooks ([#1208](https://github.com/evilmartians/lefthook/pull/1208)) by [@mrexox]() - docs: encourage python install using pipx ([#1207](https://github.com/evilmartians/lefthook/pull/1207)) by [@franzramadhan]() ## 2.0.5 (2025-12-02) - feat: add optional args to scripts ([#1206](https://github.com/evilmartians/lefthook/pull/1206)) by [@mrexox]() - deps: November 2025 ([#1200](https://github.com/evilmartians/lefthook/pull/1200)) by [@mrexox]() - chore: upgrade golangci-lint to 2.6.1, add modernize ([#1190](https://github.com/evilmartians/lefthook/pull/1190)) by [@scop]() - chore: publish artifact attestations ([#1189](https://github.com/evilmartians/lefthook/pull/1189)) by [@scop]() ## 2.0.4 (2025-11-13) - fix: glob_matcher jsonschema values - feat: add optional standard glob matcher (doublestar) ([#1188](https://github.com/evilmartians/lefthook/pull/1188)) by [@jasonwbarnett]() ## 2.0.3 (2025-11-10) - feat: fail_on_changes non-ci option ([#1186](https://github.com/evilmartians/lefthook/pull/1186)) by [@scop](https://github.com/scop) - deps: update mimetypes ([#1185](https://github.com/evilmartians/lefthook/pull/1185)) by [@mrexox](https://github.com/mrexox) ## 2.0.2 (2025-10-29) - fix: add mutex lock before all git commands ([#1178](https://github.com/evilmartians/lefthook/pull/1178)) by [@mrexox]() ## 2.0.0 (2025-10-20) **Breaking changes** - `exclude` option no longer accepts regexp, only globs. - `skip_output` option is dropped, use `output` instead. - Some CLI arguments have changed their names to make it more consistent. See `lefthook run -h` for details. - for `only` and `skip` options with `- run: '...'` values the command executer was changed to Bourne Shell. **Commits** - fix: accept --fail-on-changes=false as override value ([#1168](https://github.com/evilmartians/lefthook/pull/1168)) by [@mrexox]() - feat: [**breaking**] use sh as command executor on Windows ([#1166](https://github.com/evilmartians/lefthook/pull/1166)) by [@mrexox]() - refactor: [**breaking**] drop support for exclude regexp ([#1162](https://github.com/evilmartians/lefthook/pull/1162)) by [@mrexox]() - refactor: [**breaking**] drop deprecated skip_output option ([#1159](https://github.com/evilmartians/lefthook/pull/1159)) by [@mrexox]() - refactor: [**breaking**] use another cli framework ([#1155](https://github.com/evilmartians/lefthook/pull/1155)) by [@mrexox]() ## 1.13.6 (2025-09-30) - fix: embed jsonschema into binary ([#1158](https://github.com/evilmartians/lefthook/pull/1158)) by [@mrexox]() ## 1.13.5 (2025-09-29) - chore: a small cleanup by [@mrexox]() - refactor: use semver to check versions ([#1152](https://github.com/evilmartians/lefthook/pull/1152)) by [@mrexox]() - fix: add comprehensive tests for spinner name formatting ([#1145](https://github.com/evilmartians/lefthook/pull/1145)) [@technicalpickles]() - docs: add LEFTHOOK_BIN environment variable to documentation ([#1151](https://github.com/evilmartians/lefthook/pull/1151)) [@technicalpickles]() - chore: tests improvements ([#1148](https://github.com/evilmartians/lefthook/pull/1148)) by [@mrexox]() - chore: fix naming for integration tests ([#1146](https://github.com/evilmartians/lefthook/pull/1146)) by [@mrexox]() - docs: use codecov coverage badge by [@mrexox]() - ci: codecov ([#1147](https://github.com/evilmartians/lefthook/pull/1147)) by [@mrexox]() - docs: use actual latest version ([#1143](https://github.com/evilmartians/lefthook/pull/1143)) by [@mrexox]() - docs: add exclude to hook-level settings by [@mrexox]() ## 1.13.4 (2025-09-23) - fix: add exclude option to hook level ([#1141](https://github.com/evilmartians/lefthook/pull/1141)) by [@mrexox]() - fix: allow skipping groups ([#1140](https://github.com/evilmartians/lefthook/pull/1140)) by [@mrexox]() ## 1.13.3 (2025-09-23) - deps: September 2025 ([#1139](https://github.com/evilmartians/lefthook/pull/1139)) by [@mrexox]() - fix: concurrent map access issue ([#1138](https://github.com/evilmartians/lefthook/pull/1138)) by [@mrexox]() ## 1.13.2 (2025-09-22) - feat: inherit file_types from parent jobs ([#1135](https://github.com/evilmartians/lefthook/pull/1135)) by [@mrexox]() - fix: move gen at root ([#1133](https://github.com/evilmartians/lefthook/pull/1133)) by [@mrexox]() - refactor: better scope subpackages ([#1132](https://github.com/evilmartians/lefthook/pull/1132)) by [@mrexox]() ## 1.13.1 (2025-09-17) - feat: add no stage fixed argument ([#1130](https://github.com/evilmartians/lefthook/pull/1130)) by [@mrexox]() - refactor: reduce the amount of code in a single file ([#1131](https://github.com/evilmartians/lefthook/pull/1131)) by [@mrexox]() - fix: re-evaluate status for changeset ([#1129](https://github.com/evilmartians/lefthook/pull/1129)) by [@mrexox]() - refactor: reduce the amount of code in a single file ([#1118](https://github.com/evilmartians/lefthook/pull/1118)) by [@mrexox]() - chore: update issue templates by [@mrexox](https://github.com/mrexox) - docs: add fail_on_changes to configuration/README.md ([#1119](https://github.com/evilmartians/lefthook/pull/1119)) by [@7crabs](https://github.com/7crabs) - docs: update go installation note ([#1117](https://github.com/evilmartians/lefthook/pull/1117)) by [@leakedmemory](https://github.com/leakedmemory) ## 1.13.0 (2025-09-11) - fix: use batched cmd for calculating git hashes ([#1116](https://github.com/evilmartians/lefthook/pull/1116)) by [@mrexox]() - fix: add mutex to prevent concurrent git adds ([#1115](https://github.com/evilmartians/lefthook/pull/1115)) by [@mrexox]() - refactor: improve structuring ([#1103](https://github.com/evilmartians/lefthook/pull/1103)) by [@mrexox]() - feat: fail on change ([#1095](https://github.com/evilmartians/lefthook/pull/1095)) by [@olivier-lacroix]() - fix: set --force for git add command ([#1104](https://github.com/evilmartians/lefthook/pull/1104)) by [@michaelm]() - feat: recursively log successful results in summary ([#1108](https://github.com/evilmartians/lefthook/pull/1108)) by [@siler]() - fix: groups with successes and skips are successful ([#1107](https://github.com/evilmartians/lefthook/pull/1107)) by [@siler]() ## 1.12.4 (2025-09-05) - deps: September 2025 ([#1102](https://github.com/evilmartians/lefthook/pull/1102)) by [@mrexox]() - feat: add tags argument ([#1101](https://github.com/evilmartians/lefthook/pull/1101)) by [@mrexox]() - chore: bump github.com/go-viper/mapstructure/v2 ([#1094](https://github.com/evilmartians/lefthook/pull/1094)) ## 1.12.3 (2025-08-12) - feat: add MIME types to file_types filters ([#1092](https://github.com/evilmartians/lefthook/pull/1092)) - fix: respect LEFTHOOK_CONFIG in lefthook install ([#1090](https://github.com/evilmartians/lefthook/pull/1090)) by [@TECHNOFAB11](https://github.com/TECHNOFAB11) - docs: update pnpm installation note ([#1089](https://github.com/evilmartians/lefthook/pull/1089)) by [@skoch13](https://github.com/skoch13) - docs: improve wording of `run`, `files`, and `files-global` config descriptions, document that the `sh` shell is used ([#1086](https://github.com/evilmartians/lefthook/pull/1086)) by [@ItsHarper](https://github.com/ItsHarper) - docs: 404 for local-config ([#1082](https://github.com/evilmartians/lefthook/pull/1082)) by [@rammanoj](https://github.com/rammanoj) - docs: fix typo ([#1079](https://github.com/evilmartians/lefthook/pull/1079)) by [@eai04191](https://github.com/eai04191) ## 1.12.2 (2025-07-11) - feat: add implicit template lefthook_job_name ([#1074](https://github.com/evilmartians/lefthook/pull/1074)) - docs: restructure documentation ([#1075](https://github.com/evilmartians/lefthook/pull/1075)) by [@mrexox](https://github.com/mrexox) - feat: allow overriding config path using LEFTHOOK_CONFIG env ([#1072](https://github.com/evilmartians/lefthook/pull/1072)) by [@TECHNOFAB11](https://github.com/TECHNOFAB11) ## 1.12.1 (2025-07-09) - feat: add check-install command ([#1064](https://github.com/evilmartians/lefthook/pull/1064)) by [@mrexox](https://github.com/mrexox) - chore: only check if local configs exist by [@mrexox](https://github.com/mrexox) - feat: allow using local config only ([#1071](https://github.com/evilmartians/lefthook/pull/1071)) by [@sj26](https://github.com/sj26) ## 1.12.0 (2025-07-08) - feat: allow installing only specific hooks ([#1069](https://github.com/evilmartians/lefthook/pull/1069)) - refactor: [**breaking**] restructure files and folders, remove deprecated options ([#1067](https://github.com/evilmartians/lefthook/pull/1067)) ## 1.11.16 (2025-07-03) - fix: race condition on repo state ([#1066](https://github.com/evilmartians/lefthook/pull/1066)) ## 1.11.15 (2025-07-03) - feat: add exclude arg ([#1063](https://github.com/evilmartians/lefthook/pull/1063)) - feat: inherit group envs ([#1061](https://github.com/evilmartians/lefthook/pull/1061)) - fix: apply implicit staged files filter to all files when all files arg given ([#1062](https://github.com/evilmartians/lefthook/pull/1062)) - deps: bump github.com/kaptinlin/jsonschema to 0.4.5 - deps: bump github.com/knadh/koanf/parsers/yaml to 1.1.0 - deps: bump github.com/knadh/koanf/v2 to 2.2.1 ([#1043](https://github.com/evilmartians/lefthook/pull/1043)) - fix: friendlier updater error message - fix: bump goreleaser ## 1.11.14 (2025-06-16) - feat: show time for jobs ([#1044](https://github.com/evilmartians/lefthook/pull/1044)) by [@adeebshihadeh](https://github.com/adeebshihadeh) - ci: update GoReleaser configurations ([#1040](https://github.com/evilmartians/lefthook/pull/1040)) by [@emmanuel-ferdman](https://github.com/emmanuel-ferdman) - feat: support devbox ([#1031](https://github.com/evilmartians/lefthook/pull/1031)) by [@misogihagi](https://github.com/misogihagi) - chore: regexp use improvements ([#1034](https://github.com/evilmartians/lefthook/pull/1034)) by [@scop](https://github.com/scop) - chore: upgrade golangci-lint to v2, address findings ([#1027](https://github.com/evilmartians/lefthook/pull/1027)) by [@scop](https://github.com/scop) ## 1.11.13 (2025-05-16) - deps: May 2025 ([#1024](https://github.com/evilmartians/lefthook/pull/1024)) by [@mrexox](https://github.com/mrexox) - fix: load scripts from .config too ([#1018](https://github.com/evilmartians/lefthook/pull/1018)) by [@mrexox](https://github.com/mrexox) - chore: change "existed" to "existing" ([#1022](https://github.com/evilmartians/lefthook/pull/1022)) by [@assyrus-favolo](https://github.com/assyrus-favolo) - docs: fix grammatical error in `Local config` section ([#1019](https://github.com/evilmartians/lefthook/pull/1019)) by [@dev-kas](https://github.com/dev-kas) ## 1.11.12 (2025-04-28) - feat: load from .config dir ([#1017](https://github.com/evilmartians/lefthook/pull/1017)) by [@mrexox](https://github.com/mrexox) - feat: complete all job names, recursively ([#1015](https://github.com/evilmartians/lefthook/pull/1015)) by [@scop](https://github.com/scop) - docs: update links to mise by [@mrexox](https://github.com/mrexox) ## 1.11.11 (2025-04-21) - deps: koanf and jsonschema ([#1013](https://github.com/evilmartians/lefthook/pull/1013)) by [@mrexox](https://github.com/mrexox) - feat: add support for mise ([#1007](https://github.com/evilmartians/lefthook/pull/1007)) by [@shahar-py](https://github.com/shahar-py) ## 1.11.10 (2025-04-14) - deps: bump github.com/pelletier/go-toml/v2 from 2.2.3 to 2.2.4 ([#1005](https://github.com/evilmartians/lefthook/pull/1005)) ([#1006](https://github.com/evilmartians/lefthook/pull/1006)) by [@mrexox](https://github.com/mrexox) - feat: add support for uv ([#1004](https://github.com/evilmartians/lefthook/pull/1004)) by [@toshok](https://github.com/toshok) ## 1.11.9 (2025-04-11) - fix: better logging ([#1003](https://github.com/evilmartians/lefthook/pull/1003)) by [@mrexox](https://github.com/mrexox) - feat: allow installing hooks in CI ([#1001](https://github.com/evilmartians/lefthook/pull/1001)) by [@caugner](https://github.com/caugner) - deps: Dependencies upgrade [@mrexox](https://github.com/mrexox) ## 1.11.8 (2025-04-08) - fix: sh lookup on Windows ([#997](https://github.com/evilmartians/lefthook/pull/997)) by [@mrexox](https://github.com/mrexox) - fix: fix command execution error on Windows #989 ([#992](https://github.com/evilmartians/lefthook/pull/992)) by [@atsushifx](https://github.com/atsushifx) ## 1.11.7 (2025-04-07) - fix: avoid error logging when determining pre push files ([#995](https://github.com/evilmartians/lefthook/pull/995)) by [@mrexox](https://github.com/mrexox) - docs: allow duplicate files in SUMMARY ([#988](https://github.com/evilmartians/lefthook/pull/988)) by [@mrexox](https://github.com/mrexox) - fix: unquote paths to valid UTF-8 ([#987](https://github.com/evilmartians/lefthook/pull/987)) by [@mrexox](https://github.com/mrexox) - packaging: aur fixes ([#985](https://github.com/evilmartians/lefthook/pull/985)) by [@mrexox](https://github.com/mrexox) ## 1.11.6 (2025-03-31) - fix: print git errors ([#984](https://github.com/evilmartians/lefthook/pull/984)) by [@mrexox](https://github.com/mrexox) - packaging: maintain lefthook-bin AUR package ([#982](https://github.com/evilmartians/lefthook/pull/982)) by [@mrexox](https://github.com/mrexox) - chore: fancier logging ([#983](https://github.com/evilmartians/lefthook/pull/983)) by [@mrexox](https://github.com/mrexox) - docs: remove a note about the difference for unix-like and windows by [@mrexox](https://github.com/mrexox) ## 1.11.5 (2025-03-25) - fix: windows scripts issues ([#979](https://github.com/evilmartians/lefthook/pull/979)) by [@mrexox](https://github.com/mrexox) ## 1.11.4 (2025-03-24) - feat: support lefthook as go tool ([#976](https://github.com/evilmartians/lefthook/pull/976)) by [@nmoniz](https://github.com/nmoniz) - fix: use dedicated build path for swift plugin ([#978](https://github.com/evilmartians/lefthook/pull/978)) by [@csjones](https://github.com/csjones) - deps: March 2025 ([#977](https://github.com/evilmartians/lefthook/pull/977)) by [@mrexox](https://github.com/mrexox) - docs: update pnpm install command in the installation guide ([#974](https://github.com/evilmartians/lefthook/pull/974)) by [@hoosierhuy](https://github.com/hoosierhuy) ## 1.11.3 (2025-03-07) - fix: remote cloning issues ([#969](https://github.com/evilmartians/lefthook/pull/969)) by [@mrexox](https://github.com/mrexox) ## 1.11.2 (2025-02-26) - fix: do not inherit envs in remote Git commands ([#963](https://github.com/evilmartians/lefthook/pull/963)) by [@mrexox](https://github.com/mrexox) ## 1.11.1 (2025-02-25) - fix: remote issue with worktrees ([#960](https://github.com/evilmartians/lefthook/pull/960)) by [@mrexox](https://github.com/mrexox) ## 1.11.0 (2025-02-23) - perf: speed up git commands ([#956](https://github.com/evilmartians/lefthook/pull/956)) by [@judofyr](https://github.com/judofyr) ## 1.10.11 (2025-02-21) - deps: bump github.com/spf13/cobra from 1.8.1 to 1.9.1 ([#952](https://github.com/evilmartians/lefthook/pull/952)) ([#958](https://github.com/evilmartians/lefthook/pull/958)) by [@mrexox](https://github.com/mrexox) - fix: add $schema property ([#942](https://github.com/evilmartians/lefthook/pull/942)) by [@mst-mkt](https://github.com/mst-mkt) - deps: bump github.com/briandowns/spinner from 1.23.1 to 1.23.2 ([#935](https://github.com/evilmartians/lefthook/pull/935)) ([#940](https://github.com/evilmartians/lefthook/pull/940)) by [@mrexox](https://github.com/mrexox) ## 1.10.10 (2025-01-21) - feat: allow providing a list of globs ([#937](https://github.com/evilmartians/lefthook/pull/937)) by [@mrexox](https://github.com/mrexox) - fix: properly inherit exclude options when not overwritten ([#936](https://github.com/evilmartians/lefthook/pull/936)) by [@mrexox](https://github.com/mrexox) ## 1.10.9 (2025-01-20) - fix: make uninstall --remove-configs description more accurate ([#934](https://github.com/evilmartians/lefthook/pull/934)) by [@scop](https://github.com/scop) ## 1.10.8 (2025-01-17) - feat: add custom plain templates ([#930](https://github.com/evilmartians/lefthook/pull/930)) by [@mrexox](https://github.com/mrexox) - fix: unique names for nested operations ([#931](https://github.com/evilmartians/lefthook/pull/931)) by [@mrexox](https://github.com/mrexox) ## 1.10.7 (2025-01-15) - fix: use lefthook option in ghost hook too ([#929](https://github.com/evilmartians/lefthook/pull/929)) by [@mrexox](https://github.com/mrexox) - feat: add schema.json to npm packages ([#928](https://github.com/evilmartians/lefthook/pull/928)) by [@mrexox](https://github.com/mrexox) - fix: increase timeout for self-update to 2 mins by [@mrexox](https://github.com/mrexox) ## 1.10.5 (2025-01-14) - feat: add lefthook option for custom path or command ([#927](https://github.com/evilmartians/lefthook/pull/927)) by [@mrexox](https://github.com/mrexox) - chore: update config template with new jobs by [@mrexox](https://github.com/mrexox) ## 1.10.4 (2025-01-13) - fix: avoid skipping pre commit when deleted files staged ([#925](https://github.com/evilmartians/lefthook/pull/925)) by [@mrexox](https://github.com/mrexox) - fix: use roots from jobs for possible npm package location ([#924](https://github.com/evilmartians/lefthook/pull/924)) by [@mrexox](https://github.com/mrexox) - deps: January 2025 ([#926](https://github.com/evilmartians/lefthook/pull/926)) by [@mrexox](https://github.com/mrexox) ## 1.10.3 (2025-01-10) - fix: replace cmd in jobs ([#918](https://github.com/evilmartians/lefthook/pull/918)) by [@mrexox](https://github.com/mrexox) ## 1.10.2 (2025-01-10) - feat: add validate command ([#915](https://github.com/evilmartians/lefthook/pull/915)) by [@mrexox](https://github.com/mrexox) - feat: inherit exclude option in groups ([#916](https://github.com/evilmartians/lefthook/pull/916)) by [@mrexox](https://github.com/mrexox) - chore: auto generate json schema ([#914](https://github.com/evilmartians/lefthook/pull/914)) by [@mrexox](https://github.com/mrexox) - feat: run --jobs completion ([#913](https://github.com/evilmartians/lefthook/pull/913)) by [@scop](https://github.com/scop) - ci: add gzipped linux aarch64 binary to release artifacts ([#908](https://github.com/evilmartians/lefthook/pull/908)) by [@mrexox](https://github.com/mrexox) - ## 1.10.1 (2024-12-26) - feat: add ability to specify job names for command run ([#904](https://github.com/evilmartians/lefthook/pull/904)) by [@mrexox](https://github.com/mrexox) - ci: add linux aarch64 binary to release ([#903](https://github.com/evilmartians/lefthook/pull/903)) by [@mrexox](https://github.com/mrexox) - ci: fix aur build ([#905](https://github.com/evilmartians/lefthook/pull/905)) by [@mrexox](https://github.com/mrexox) ## 1.10.0 (2024-12-19) - feat: add jobs option ([#861](https://github.com/evilmartians/lefthook/pull/861)) by [@mrexox](https://github.com/mrexox) - ci: automate aur package update ([#899](https://github.com/evilmartians/lefthook/pull/899)) by [@mrexox](https://github.com/mrexox) ## 1.9.3 (2024-12-18) - fix: correctly parse config options ([#895](https://github.com/evilmartians/lefthook/pull/895)) by [@mrexox](https://github.com/mrexox) - chore: add mdbook ([#894](https://github.com/evilmartians/lefthook/pull/894)) by [@mrexox](https://github.com/mrexox) ## 1.9.2 (2024-12-12) - fix: use correct remote scripts folder ([#891](https://github.com/evilmartians/lefthook/pull/891)) by [@mrexox](https://github.com/mrexox) ## 1.9.1 (2024-12-12) - fix: skip_lfs config option ([#889](https://github.com/evilmartians/lefthook/pull/889)) by [@zachahn](https://github.com/zachahn) ## 1.9.0 (2024-12-06) - chore: add minimum git version support warning ([#886](https://github.com/evilmartians/lefthook/pull/886)) by [@mrexox](https://github.com/mrexox) - fix: reorder available hooks list ([#884](https://github.com/evilmartians/lefthook/pull/884)) by [@scop](https://github.com/scop) - docs: correct typo in 'Scoop for Windows' section ([#883](https://github.com/evilmartians/lefthook/pull/883)) by [@Daniil-Oberlev](https://github.com/Daniil-Oberlev) - refactor: [**breaking**] replace viper with koanf ([#813](https://github.com/evilmartians/lefthook/pull/813)) by [@mrexox](https://github.com/mrexox) - ci: fix packages release ([#881](https://github.com/evilmartians/lefthook/pull/881)) by [@mrexox](https://github.com/mrexox) ## 1.8.5 (2024-12-02) - ci: automate publishing to cloudsmith ([#875](https://github.com/evilmartians/lefthook/pull/875)) by [@mrexox](https://github.com/mrexox) - feat: add option to skip running LFS hooks ([#879](https://github.com/evilmartians/lefthook/pull/879)) by [@zachah](https://github.com/zachah) ## 1.8.4 (2024-11-18) - ci: fix goreleaser update changes ([#874](https://github.com/evilmartians/lefthook/pull/874)) by [@mrexox](https://github.com/mrexox) - deps: November 2024 ([#867](https://github.com/evilmartians/lefthook/pull/867)) by [@mrexox](https://github.com/mrexox) - docs: add docs for fnm configuration ([#869](https://github.com/evilmartians/lefthook/pull/869)) by [@vasylnahuliak](https://github.com/vasylnahuliak) - docs: add `output` to list of config options ([#868](https://github.com/evilmartians/lefthook/pull/868)) by [@cr7pt0gr4ph7](https://github.com/cr7pt0gr4ph7) ## 1.8.3 (2024-11-18) - fix: use absolute paths when cloning remotes ([#873](https://github.com/evilmartians/lefthook/pull/873)) by [@mrexox](https://github.com/mrexox) ## 1.8.2 (2024-10-29) - chore: fix linter and tests by [@mrexox](https://github.com/mrexox) - feat: add refetch_frequency parameter to settings ([#857](https://github.com/evilmartians/lefthook/pull/857)) by [@gabriel-ss](https://github.com/gabriel-ss) - docs: call commitizen properly ([#858](https://github.com/evilmartians/lefthook/pull/858)) by [@politician](https://github.com/politician) ## 1.8.1 (2024-10-23) - chore: bump Go to 1.23 ([#856](https://github.com/evilmartians/lefthook/pull/856)) by Valentin Kiselev - fix: skip git lfs hook when calling manually ([#855](https://github.com/evilmartians/lefthook/pull/855)) by Valentin Kiselev ## 1.8.0 (2024-10-22) - fix: [**breaking**] don't auto-install lefthook with npx if not found ([#602](https://github.com/evilmartians/lefthook/pull/602)) by [@anthony-hayes](https://github.com/anthony-hayes) - fix: [**breaking**] execute files command within configured root ([#607](https://github.com/evilmartians/lefthook/pull/607)) by [@mrexox](https://github.com/mrexox) - fix: calculate hashsum of the full config ([#854](https://github.com/evilmartians/lefthook/pull/854)) by [@mrexox](https://github.com/mrexox) - feat: support globs in extends ([#853](https://github.com/evilmartians/lefthook/pull/853)) by [@mrexox](https://github.com/mrexox) - docs: simplify configuration docs ([#851](https://github.com/evilmartians/lefthook/pull/851)) by [@mrexox](https://github.com/mrexox) ## 1.7.22 (2024-10-18) - feat: add skip option merge-commit ([#850](https://github.com/evilmartians/lefthook/pull/850)) by [@mrexox](https://github.com/mrexox) - ci: parallelize publishing ([#847](https://github.com/evilmartians/lefthook/pull/847)) by [@mrexox](https://github.com/mrexox) - fix: increase self update download timeout ([#849](https://github.com/evilmartians/lefthook/pull/849)) by [@mrexox](https://github.com/mrexox) - docs: update docs with new packages ([#848](https://github.com/evilmartians/lefthook/pull/848)) by [@mrexox](https://github.com/mrexox) ## 1.7.21 (2024-10-17) - feat: maintain Python package too ([#845](https://github.com/evilmartians/lefthook/pull/845)) by [@mrexox](https://github.com/mrexox) - ci: generate apk files ([#843](https://github.com/evilmartians/lefthook/pull/843)) by [@mrexox](https://github.com/mrexox) - docs: mention to uninstall npm package ([#842](https://github.com/evilmartians/lefthook/pull/842)) by [@mrexox](https://github.com/mrexox) - chore: hide remaining wiki links ([#841](https://github.com/evilmartians/lefthook/pull/841)) by [@midskyey](https://github.com/midskyey) - docs: update info about merge order ([#838](https://github.com/evilmartians/lefthook/pull/838)) by [@mrexox](https://github.com/mrexox) - docs: actualize ([#831](https://github.com/evilmartians/lefthook/pull/831)) by [@mrexox](https://github.com/mrexox) ## 1.7.19 and 1.7.20 – failed to build ## 1.7.18 (2024-09-30) - fix: force remote name origin when using remotes ([#830](https://github.com/evilmartians/lefthook/pull/830)) by [@mrexox](https://github.com/mrexox) - deps: September 2024 ([#829](https://github.com/evilmartians/lefthook/pull/829)) by [@mrexox](https://github.com/mrexox) ## 1.7.17 (2024-09-26) - feat: skip LFS hooks when pre-push hook is skipped ([#818](https://github.com/evilmartians/lefthook/pull/818)) by [@zachahn](https://github.com/zachahn) ## 1.7.16 (2024-09-23) - chore: enhance some code parts ([#824](https://github.com/evilmartians/lefthook/pull/824)) by [@mrexox](https://github.com/mrexox) - fix: quote script path ([#823](https://github.com/evilmartians/lefthook/pull/823)) by [@mrexox](https://github.com/mrexox) - docs: fix typo for command names in configuration.md ([#814](https://github.com/evilmartians/lefthook/pull/814)) by [@nack43](https://github.com/nack43) ## 1.7.15 (2024-09-02) - fix: add better colors control ([#812](https://github.com/evilmartians/lefthook/pull/812)) by [@mrexox](https://github.com/mrexox) - deps: August 2024 ([#802](https://github.com/evilmartians/lefthook/pull/802)) by [@mrexox](https://github.com/mrexox) ## 1.7.14 (2024-08-17) Fix lefthook NPM package to include OpenBSD package as optional dependency. ## 1.7.13 (2024-08-16) - feat: support openbsd ([#808](https://github.com/evilmartians/lefthook/pull/808)) by [@mrexox](https://github.com/mrexox) ## 1.7.12 (2024-08-09) - fix: log stderr in debug logs only ([#804](https://github.com/evilmartians/lefthook/pull/804)) by [@mrexox](https://github.com/mrexox) ## 1.7.11 (2024-07-29) - fix: revert packaging change ([#796](https://github.com/evilmartians/lefthook/pull/796)) by [@mrexox](https://github.com/mrexox) ## 1.7.10 (2024-07-29) - deps: July 2024 ([#795](https://github.com/evilmartians/lefthook/pull/795)) by [@mrexox](https://github.com/mrexox) - packaging(npm): try direct reference for lefthook executable ([#794](https://github.com/evilmartians/lefthook/pull/794)) by [@mrexox](https://github.com/mrexox) ## 1.7.9 (2024-07-26) - fix: typo CGO_ENABLED instead of GCO_ENABLED ([#791](https://github.com/evilmartians/lefthook/pull/791)) by [@mrexox](https://github.com/mrexox) ## 1.7.8 (2024-07-26) - fix: npm fix packages ([#789](https://github.com/evilmartians/lefthook/pull/789)) by [@mrexox](https://github.com/mrexox) - fix: explicitly pass static flag to linker ([#788](https://github.com/evilmartians/lefthook/pull/788)) by [@mrexox](https://github.com/mrexox) - ci: update workflow files ([#787](https://github.com/evilmartians/lefthook/pull/787)) by [@mrexox](https://github.com/mrexox) - ci: use latest goreleaser ([#784](https://github.com/evilmartians/lefthook/pull/784)) by [@mrexox](https://github.com/mrexox) ## 1.7.7 (2024-07-24) - fix: multiple excludes ([#782](https://github.com/evilmartians/lefthook/pull/782)) by [@mrexox](https://github.com/mrexox) ## 1.7.6 (2024-07-24) - feat: add self-update command ([#778](https://github.com/evilmartians/lefthook/pull/778)) by [@mrexox](https://github.com/mrexox) ## 1.7.5 (2024-07-22) - feat: use glob in exclude array ([#777](https://github.com/evilmartians/lefthook/pull/777)) by [@mrexox](https://github.com/mrexox) ## 1.7.4 (2024-07-19) - fix: rollback packaging changes ([#776](https://github.com/evilmartians/lefthook/pull/776)) by [@mrexox](https://github.com/mrexox) ## 1.7.3 (2024-07-18) - feat: allow list of files in exclude option ([#772](https://github.com/evilmartians/lefthook/pull/772)) by [@mrexox](https://github.com/mrexox) - docs: add docs for LEFTHOOK_OUTPUT var ([#771](https://github.com/evilmartians/lefthook/pull/771)) by [@manbearwiz](https://github.com/manbearwiz) - fix: use direct lefthook package ([#774](https://github.com/evilmartians/lefthook/pull/774)) by [@mrexox](https://github.com/mrexox) ## 1.7.2 (2024-07-11) - fix: add missing sub directory in hook template ([#768](https://github.com/evilmartians/lefthook/pull/768)) by [@nikeee](https://github.com/nikeee) ## 1.7.1 (2024-07-08) - fix: use correct extension in hook.tmpl ([#767](https://github.com/evilmartians/lefthook/pull/767)) by [@apfohl](https://github.com/apfohl) ## 1.7.0 (2024-07-08) - fix: publishing ([#765](https://github.com/evilmartians/lefthook/pull/765)) by [@mrexox](https://github.com/mrexox) - perf: startup time reduce ([#705](https://github.com/evilmartians/lefthook/pull/705)) by [@dalisoft](https://github.com/dalisoft) - docs: add a note about pnpm package installation ([#761](https://github.com/evilmartians/lefthook/pull/761)) by [@mrexox](https://github.com/mrexox) - ci: retriable integrity tests ([#758](https://github.com/evilmartians/lefthook/pull/758)) by [@mrexox](https://github.com/mrexox) - ci: universal publisher with Ruby script ([#756](https://github.com/evilmartians/lefthook/pull/756)) by [@mrexox](https://github.com/mrexox) ## 1.6.18 (2024-06-21) - fix: allow multiple levels of extends ([#755](https://github.com/evilmartians/lefthook/pull/755)) by [@mrexox](https://github.com/mrexox) ## 1.6.17 (2024-06-20) - fix: apply local extends only if they are present ([#754](https://github.com/evilmartians/lefthook/pull/754)) by [@mrexox](https://github.com/mrexox) - chore: setting proper error message for missing lefthook file ([#748](https://github.com/evilmartians/lefthook/pull/748)) by [@Cadienvan](https://github.com/Cadienvan) ## 1.6.16 (2024-06-13) - fix: skip overwriting hooks when fetching data from remotes ([#745](https://github.com/evilmartians/lefthook/pull/745)) by [@mrexox](https://github.com/mrexox) - fix: fetch remotes only for non ghost hooks ([#744](https://github.com/evilmartians/lefthook/pull/744)) by [@mrexox](https://github.com/mrexox) ## 1.6.15 (2024-06-03) - feat: add refetch option to remotes config ([#739](https://github.com/evilmartians/lefthook/pull/739)) by [@mrexox](https://github.com/mrexox) - deps: June, 3, lipgloss (0.11.0) and viper (1.19.0) ([#742](https://github.com/evilmartians/lefthook/pull/742)) by [@mrexox](https://github.com/mrexox) - chore: enable copyloopvar, intrange, and prealloc ([#740](https://github.com/evilmartians/lefthook/pull/740)) by [@scop](https://github.com/scop) - perf: delay git and uname commands in hook scripts until needed ([#737](https://github.com/evilmartians/lefthook/pull/737)) by [@scop](https://github.com/scop) - chore: refactor commands interfaces ([#735](https://github.com/evilmartians/lefthook/pull/735)) by [@mrexox](https://github.com/mrexox) - chore: upgrade to 1.59.0 ([#738](https://github.com/evilmartians/lefthook/pull/738)) by [@scop](https://github.com/scop) ## 1.6.14 (2024-05-30) - fix: share STDIN across different commands on pre-push hook ([#732](https://github.com/evilmartians/lefthook/pull/732)) by [@tdesveaux](https://github.com/tdesveaux) and [@mrexox](https://github.com/mrexox) ## 1.6.13 (2024-05-27) - feat: expand Swift integration with Mint support ([#724](https://github.com/evilmartians/lefthook/pull/724)) by [@levibostian](https://github.com/levibostian) - deps: May 22 dependencies update ([#706](https://github.com/evilmartians/lefthook/pull/706)) by [@mrexox](https://github.com/mrexox) - chore: remove go patch version in go.mod ([#726](https://github.com/evilmartians/lefthook/pull/726)) by [@mrexox](https://github.com/mrexox) # 1.6.12 (2024-05-17) - fix: more verbose error on versions mismatch ([#721](https://github.com/evilmartians/lefthook/pull/721)) by [@mrexox](https://github.com/mrexox) - fix: enable interactive scripts ([#720](https://github.com/evilmartians/lefthook/pull/720)) by [@mrexox](https://github.com/mrexox) ## 1.6.11 (2024-05-13) - feat: add run --no-auto-install flag ([#716](https://github.com/evilmartians/lefthook/pull/716)) by [@mrexox](https://github.com/mrexox) - fix: add `--porcelain` to `git status --short` ([#711](https://github.com/evilmartians/lefthook/pull/711)) by [@110y](https://github.com/110y) - chore: bump go to 1.22 ([#701](https://github.com/evilmartians/lefthook/pull/701)) by [@mrexox](https://github.com/mrexox) ## 1.6.10 (2024-04-10) - feat: add file type filters ([#698](https://github.com/evilmartians/lefthook/pull/698)) by [@mrexox](https://github.com/mrexox) - ci: update github actions versions ([#699](https://github.com/evilmartians/lefthook/pull/699)) by [@mrexox](https://github.com/mrexox) ## 1.6.9 (2024-04-09) - fix: enable interactive inputs for windows ([#696](https://github.com/evilmartians/lefthook/pull/696)) by [@mrexox](https://github.com/mrexox) - fix: add batching to implicit commands ([#695](https://github.com/evilmartians/lefthook/pull/695)) by [@mrexox](https://github.com/mrexox) - fix: command argument count validations ([#694](https://github.com/evilmartians/lefthook/pull/694)) by [@scop](https://github.com/scop) - fix: re-download remotes when called install with -f ([#692](https://github.com/evilmartians/lefthook/pull/692)) by [@mrexox](https://github.com/mrexox) - chore: remove redundant parallelisation ([#690](https://github.com/evilmartians/lefthook/pull/690)) by [@mrexox](https://github.com/mrexox) - chore: refactor Result handling ([#689](https://github.com/evilmartians/lefthook/pull/689)) by [@mrexox](https://github.com/mrexox) ## 1.6.8 (2024-04-02) - fix: fallback to empty tree sha when no upstream set ([#687](https://github.com/evilmartians/lefthook/pull/687)) by [@mrexox](https://github.com/mrexox) - feat: add priorities to scripts ([#684](https://github.com/evilmartians/lefthook/pull/684)) by [@mrexox](https://github.com/mrexox) - deps: By April, 1 ([#678](https://github.com/evilmartians/lefthook/pull/678)) by [@mrexox](https://github.com/mrexox) ## 1.6.7 (2024-03-15) - fix: don't apply empty patch files on pre-commit hook ([#676](https://github.com/evilmartians/lefthook/pull/676)) by [@mrexox](https://github.com/mrexox) - docs: allow only comma divided tags ([#675](https://github.com/evilmartians/lefthook/pull/675)) by [@mrexox](https://github.com/mrexox) ## 1.6.6 (2024-03-14) - chore: add more tests on skip settings by [@mrexox](https://github.com/mrexox) - chore: add more linters, address findings ([#670](https://github.com/evilmartians/lefthook/pull/670)) by [@scop](https://github.com/scop) - chore: skip printing deprecation warning ([#674](https://github.com/evilmartians/lefthook/pull/674)) by [@mrexox](https://github.com/mrexox) - feat: handle `run` command in skip/only settings ([#634](https://github.com/evilmartians/lefthook/pull/634)) by [@prog-supdex](https://github.com/prog-supdex) - deps: Dependencies March 2024 ([#673](https://github.com/evilmartians/lefthook/pull/673)) by [@mrexox](https://github.com/mrexox) - fix: fix printing when using `output` log setting ([#672](https://github.com/evilmartians/lefthook/pull/672)) by [@mrexox](https://github.com/mrexox) - feat: Add output setting ([#637](https://github.com/evilmartians/lefthook/pull/637)) by [@prog-supdex](https://github.com/prog-supdex) - fix: use swift package before npx ([#668](https://github.com/evilmartians/lefthook/pull/668)) by [@mrexox](https://github.com/mrexox) - feat: use configurable path to lefthook (LEFTHOOK_BIN) ([#653](https://github.com/evilmartians/lefthook/pull/653)) by [@technicalpickles](https://github.com/technicalpickles) ## 1.6.5 (2024-03-04) - fix: decrease max cmd length for windows ([#666](https://github.com/evilmartians/lefthook/pull/666)) by [@mrexox](https://github.com/mrexox) - deps: Dependencies 04.03.2024 ([#664](https://github.com/evilmartians/lefthook/pull/664)) by [@mrexox](https://github.com/mrexox) - chore: fix Makefile by [@mrexox](https://github.com/mrexox) - docs: fix redundant option by [@mrexox](https://github.com/mrexox) ## 1.6.4 (2024-02-28) - deps: update uniseg ([#650](https://github.com/evilmartians/lefthook/pull/650)) by [@technicalpickles](https://github.com/technicalpickles) ## 1.6.3 (2024-02-27) - deps: Dependencies (27.02.2024) ([#648](https://github.com/evilmartians/lefthook/pull/648)) by [@mrexox](https://github.com/mrexox) - chore: remove adaptive colors ([#647](https://github.com/evilmartians/lefthook/pull/647)) by [@mrexox](https://github.com/mrexox) - docs: update request help url ([#641](https://github.com/evilmartians/lefthook/pull/641)) by [@sbsrnt](https://github.com/sbsrnt) ## 1.6.2 (2024-02-26) - fix: respect roots in commands for npm packages ([#616](https://github.com/evilmartians/lefthook/pull/616)) by [@mrexox](https://github.com/mrexox) - fix: don't capture STDIN without interactive or use_stdin options ([#638](https://github.com/evilmartians/lefthook/pull/638)) by [@technicalpickles](https://github.com/technicalpickles) - fix: handle LEFTHOOK_QUIET when there is no skip_output in config by [@prog-supdex](https://github.com/prog-supdex) - docs: add stage_fixed to the examples by [@mrexxo](https://github.com/mrexxo) - docs: clarify the difference between piped and parallel options by [@mrexox](https://github.com/mrexox) ## 1.6.1 (2024-01-24) - fix: files from stdin only null separated ([#615](https://github.com/evilmartians/lefthook/pull/615)) by [@mrexox](https://github.com/mrexox) - docs: add a new article link by [@mrexox](https://github.com/mrexox) ## 1.6.0 (2024-01-22) - feat: add remotes and configs options ([#609](https://github.com/evilmartians/lefthook/pull/609)) by [@NikitaCOEUR](https://github.com/NikitaCOEUR) - feat: add replaces to all template and parse files from stdin ([#596](https://github.com/evilmartians/lefthook/pull/596)) by [@sanmai-NL](https://github.com/sanmai-NL) ## 1.5.7 (2024-01-17) - fix: pre push hook handling ([#613](https://github.com/evilmartians/lefthook/pull/613)) by [@mrexox](https://github.com/mrexox) - chore: hide wiki links ([#608](https://github.com/evilmartians/lefthook/pull/608)) by [@mrexox](https://github.com/mrexox) ## 1.5.6 (2024-01-12) - feat: shell completion improvements ([#577](https://github.com/evilmartians/lefthook/pull/577)) by [@scop](https://github.com/scop) - fix: safe execute git commands without sh wrapper ([#606](https://github.com/evilmartians/lefthook/pull/606)) by [@mrexox](https://github.com/mrexox) - fix: use lefthook package with npx ([#604](https://github.com/evilmartians/lefthook/pull/604)) by [@mrexox](https://github.com/mrexox) - feat: allow setting a bool value for skip_output ([#601](https://github.com/evilmartians/lefthook/pull/601)) by [@nsklyarov](https://github.com/nsklyarov) - docs: update exception case about interactive option by [@mrexox](https://github.com/mrexox) ## 1.5.5 (2023-11-30) - fix: use empty stdin by default ([#590](https://github.com/evilmartians/lefthook/pull/590)) by [@mrexox](https://github.com/mrexox) - feat: add priorities to commands ([#589](https://github.com/evilmartians/lefthook/pull/589)) by [@mrexox](https://github.com/mrexox) ## 1.5.4 (2023-11-27) - chore: add typos fixer by [@mrexox](https://github.com/mrexox) - fix: drop new argument for git diff compatibility ([#586](https://github.com/evilmartians/lefthook/pull/586)) by [@mrexox](https://github.com/mrexox) ## 1.5.3 (2023-11-22) - fix: don't check checksum file when explicitly calling lefthook install ([#572](https://github.com/evilmartians/lefthook/pull/572)) by [@mrexox](https://github.com/mrexox) - chore: skip summary separator if nothing is printed ([#575](https://github.com/evilmartians/lefthook/pull/575)) by [@mrexox](https://github.com/mrexox) - docs: update info about root option by [@mrexox](https://github.com/mrexox) ## 1.5.2 (2023-10-9) - fix: correctly sort alphanumeric commands ([#562](https://github.com/evilmartians/lefthook/pull/562)) by [@mrexox](https://github.com/mrexox) ## 1.5.1 (2023-10-6) - feat: add force flag to run command ([#561](https://github.com/evilmartians/lefthook/pull/561)) by [@mrexox](https://github.com/mrexox) - fix: do not enable export when sourcing rc file ([#553](https://github.com/evilmartians/lefthook/pull/553)) by [@hyperupcall](https://github.com/hyperupcall) - chore: wrap shell args in quotes for consistency by [@mrexox](https://github.com/mrexox) - docs: add a note that files template supports directories by [@mrexox](https://github.com/mrexox) - feat: initial support for Swift Plugins ([#556](https://github.com/evilmartians/lefthook/pull/556)) by [@csjones](https://github.com/csjones) ## 1.5.0 (2023-09-21) - chore: output enhancements ([#549](https://github.com/evilmartians/lefthook/pull/549)) by [@mrexox](https://github.com/mrexox) - feat: add interrupt (Ctrl-C) handling ([#550](https://github.com/evilmartians/lefthook/pull/550)) by [@mrcljx](https://github.com/mrcljx) ## 1.4.11 (2023-09-13) - docs: update docs and readme with tl;dr instructions ([#548](https://github.com/evilmartians/lefthook/pull/548)) by [@mrexox](https://github.com/mrexox) - fix: add use_stdin option for just reading from stdin ([#547](https://github.com/evilmartians/lefthook/pull/547)) by [@mrexox](https://github.com/mrexox) - chore: refactor commands passing ([#546](https://github.com/evilmartians/lefthook/pull/546)) by [@mrexox](https://github.com/mrexox) - fix: fail on non existing hook name ([#545](https://github.com/evilmartians/lefthook/pull/545)) by [@mrexox](https://github.com/mrexox) ## 1.4.10 (2023-09-04) - fix: split command with file templates into chunks ([#541](https://github.com/evilmartians/lefthook/pull/541)) by [@mrexox](https://github.com/mrexox) - chore: add git-cliff config for easier changelog generation by [@mrexox](https://github.com/mrexox) - fix: allow empty staged files diffs ([#543](https://github.com/evilmartians/lefthook/pull/543)) by [@mrexox](https://github.com/mrexox) ## 1.4.9 (2023-08-15) - chore: fix linter issues ([#537](https://github.com/evilmartians/lefthook/pull/537)) by [@mrexox](https://github.com/mrexox) - feat: add files, all-files, and commands flags ([#534](https://github.com/evilmartians/lefthook/pull/534)) by [@nihalgonsalves](https://github.com/nihalgonsalves) - chore: bump go to 1.21 ([#536](https://github.com/evilmartians/lefthook/pull/536)) by [@nihalgonsalves](https://github.com/nihalgonsalves) ## 1.4.8 (2023-07-31) - feat: add assert_lefthook_installed option ([#533](https://github.com/evilmartians/lefthook/pull/533)) by [@mrexox](https://github.com/mrexox) - chore: add *Add docs* to PR template ([#532](https://github.com/evilmartians/lefthook/pull/532)) by [@technicalpickles](https://github.com/technicalpickles) - feat: add support for skipping empty summaries ([#531](https://github.com/evilmartians/lefthook/pull/531)) by [@technicalpickles](https://github.com/technicalpickles) ## 1.4.7 (2023-07-24) - docs: add scoop installation method ([#527](https://github.com/evilmartians/lefthook/pull/527)) by [@sitiom](https://github.com/sitiom) - fix: correct merging of extends from remote config ([#529](https://github.com/evilmartians/lefthook/pull/529)) by [@mrexox](https://github.com/mrexox) - ci: add Winget Releaser action ([#526](https://github.com/evilmartians/lefthook/pull/526)) by [@sitiom](https://github.com/sitiom) - chore: improve correctness of load_test.go ([#525](https://github.com/evilmartians/lefthook/pull/525)) by [@hyperupcall](https://github.com/hyperupcall) ## 1.4.6 (2023-07-18) - fix: do not print extraneous newlines when executionInfo output is hidden ([#519](https://github.com/evilmartians/lefthook/pull/519)) by [@hyperupcall](https://github.com/hyperupcall) - fix: uninstall all possible formats ([#523](https://github.com/evilmartians/lefthook/pull/523)) by [@mrexox](https://github.com/mrexox) - fix: LEFTHOOK_VERBOSE properly overrides --verbose flag ([#521](https://github.com/evilmartians/lefthook/pull/521)) by [@hyperupcall](https://github.com/hyperupcall) - feat: support .lefthook.yml and .lefthook-local.yml ([#520](https://github.com/evilmartians/lefthook/pull/520)) by [@hyperupcall](https://github.com/hyperupcall) ## 1.4.5 (2023-07-12) - docs: improve documentation and examples ([#517](https://github.com/evilmartians/lefthook/pull/517)) by [@hyperupcall](https://github.com/hyperupcall) - fix: improve hook template ([#516](https://github.com/evilmartians/lefthook/pull/516)) by [@hyperupcall](https://github.com/hyperupcall) ## 1.4.4 (2023-07-10) - fix: don't render bold ANSI sequence when colors are disabled ([#515](https://github.com/evilmartians/lefthook/pull/515)) by [@adam12](https://github.com/adam12) - deps: July 2023 ([#514](https://github.com/evilmartians/lefthook/pull/514)) by [@mrexox](https://github.com/mrexox) ## 1.4.3 (2023-06-19) - fix: auto stage non-standard files ([#506](https://github.com/evilmartians/lefthook/pull/506)) by [@mrexox](https://github.com/mrexox) ## 1.4.2 (2023-06-13) - deps: June 2023 ([#499](https://github.com/evilmartians/lefthook/pull/499)) - feat: support toml dumpint ([#490](https://github.com/evilmartians/lefthook/pull/490)) by [@mrexox](https://github.com/mrexox) - feat: support json configs ([#489](https://github.com/evilmartians/lefthook/pull/489)) by [@mrexox](https://github.com/mrexox) ## 1.4.1 (2023-05-22) - fix: add win32 binary to artifacts (by [@mrexox](https://github.com/mrexox)) - feat: allow dumping with JSON ([#485](https://github.com/evilmartians/lefthook/pull/485) by [@mrexox](https://github.com/mrexox) - feat: add skip execution_info option ([#484](https://github.com/evilmartians/lefthook/pull/484)) by [@mrexox](https://github.com/mrexox) - deps: from 05.2023 ([#487](https://github.com/evilmartians/lefthook/pull/487)) by [@mrexox](https://github.com/mrexox) ## 1.4.0 (2023-05-18) - feat: add adaptive colors ([#482](https://github.com/evilmartians/lefthook/pull/482)) by [@mrexox](https://github.com/mrexox) - fix: skip output for interactive commands if configured ([#483](https://github.com/evilmartians/lefthook/pull/483)) by [@mrexox](https://github.com/mrexox) - feat: add dump command ([#481](https://github.com/evilmartians/lefthook/pull/481)) by [@mrexox](https://github.com/mrexox) ## 1.3.13 (2023-05-11) - feat: add only option ([#478](https://github.com/evilmartians/lefthook/pull/478)) by [@mrexox](https://github.com/mrexox) ## 1.3.12 (2023-04-28) - fix: allow skipping execution_out with interactive mode ([#476](https://github.com/evilmartians/lefthook/pull/476)) by [@mrexox](https://github.com/mrexox) ## 1.3.11 (2023-04-27) - feat: add execution_out to skip output settings ([#475](https://github.com/evilmartians/lefthook/pull/475)) by [@mrexox](https://github.com/mrexox) - chore: add debug logs when hook is skipped ([#474](https://github.com/evilmartians/lefthook/pull/474)) by [@mrexox](https://github.com/mrexox) ## 1.3.10 - feat: don't show when commands are skipped because of no matched files ([#468](https://github.com/evilmartians/lefthook/pull/468)) by [@technicalpickles](https://github.com/technicalpickles) ## 1.3.9 (2023-04-04) - feat: allow extra hooks in local config ([#462](https://github.com/evilmartians/lefthook/pull/462)) by [@fabn](https://github.com/fabn) - feat: pass numeric placeholders to files command ([#461](https://github.com/evilmartians/lefthook/pull/461)) by [@fabn](https://github.com/fabn) ## 1.3.8 (2023-03-23) - fix: make hook template compatible with shells without source command ([#458](https://github.com/evilmartians/lefthook/pull/458)) by [@mdesantis](https://github.com/mdesantis) ## 1.3.7 (2023-03-20) - fix: allow globs in skip option ([#457](https://github.com/evilmartians/lefthook/pull/457)) by [@mrexox](https://github.com/mrexox) - deps: dependencies update (March 2023) ([#455](https://github.com/evilmartians/lefthook/pull/455)) by [@mrexox](https://github.com/mrexox) - fix: don't fail on missing config file ([#450](https://github.com/evilmartians/lefthook/pull/450)) by [@mrexox](https://github.com/mrexox) ## 1.3.6 (2023-03-16) - fix: stage fixed when root specified ([#449](https://github.com/evilmartians/lefthook/pull/449)) by [@mrexox](https://github.com/mrexox) - feat: implitic skip on missing files for pre-commit and pre-push hooks ([#448](https://github.com/evilmartians/lefthook/pull/448)) by [@mrexox](https://github.com/mrexox) ## 1.3.5 (2023-03-15) - feat: add stage_fixed option ([#445](https://github.com/evilmartians/lefthook/pull/445)) by [@mrexox](https://github.com/mrexox) ## 1.3.4 (2023-03-13) - fix: don't extra extend config if lefthook-local.yml is missing ([#444](https://github.com/evilmartians/lefthook/pull/444)) by [@mrexox](https://github.com/mrexox) ## 1.3.3 (2023-03-01) - fix: restore release assets name ([#437](https://github.com/evilmartians/lefthook/pull/437)) by [@watarukura](https://github.com/watarukura) ## 1.3.2 (2023-02-27) - fix: Allow sh syntax in files ([#435](https://github.com/evilmartians/lefthook/pull/435)) by [@mrexox](https://github.com/mrexox) ## 1.3.1 (2023-02-27) - fix: Force creation of git hooks folder ([#434](https://github.com/evilmartians/lefthook/pull/434)) by [@mrexox](https://github.com/mrexox) ## 1.3.0 (2023-02-22) - fix: Use correct branch for {push_files} template ([#429](https://github.com/evilmartians/lefthook/pull/429)) by [@mrexox](https://github.com/mrexox) - feature: Skip unstaged changes for pre-commit hook ([#402](https://github.com/evilmartians/lefthook/pull/402)) by [@mrexox](https://github.com/mrexox) ## 1.2.9 (2023-02-13) - fix: memory leak dependency ([#426](https://github.com/evilmartians/lefthook/pull/426)) by [@strpc](https://github.com/strpc) ## 1.2.8 (2023-01-23) - fix: Don't join info path with root ([#418](https://github.com/evilmartians/lefthook/pull/418)) by [@mrexox](https://github.com/mrexox) ## 1.2.7 (2023-01-10) - fix: Make info dir when it is absent ([#414](https://github.com/evilmartians/lefthook/pull/414)) by [@sato11](https://github.com/sato11) - deps: bump github.com/mattn/go-isatty from 0.0.16 to 0.0.17 ([#409](https://github.com/evilmartians/lefthook/pull/409)) by [@dependabot](https://github.com/dependabot) - deps: bump github.com/briandowns/spinner from 1.19.0 to 1.20.0 ([#406](https://github.com/evilmartians/lefthook/pull/406)) by [@dependabot](https://github.com/dependabot) - fix: Double quote eval statements with $dir ([#404](https://github.com/evilmartians/lefthook/pull/404)) by [@jrfoell](https://github.com/jrfoell) - chore: Add PR template ([#401](https://github.com/evilmartians/lefthook/pull/401)) by [@mrexox](https://github.com/mrexox) - chore: Fix yml syntax missing colon ([#399](https://github.com/evilmartians/lefthook/pull/399)) by [@aaronkelton](https://github.com/aaronkelton) ## 1.2.6 (2022-12-14) - feature: Allow following output ([#397](https://github.com/evilmartians/lefthook/pull/397)) by [@mrexox](https://github.com/mrexox) - fix: Remove quotes for rc in template ([#398](https://github.com/evilmartians/lefthook/pull/398)) by [@mrexox](https://github.com/mrexox) ## 1.2.5 (2022-12-13) - feature: Add an option to disable spinner ([#396](https://github.com/evilmartians/lefthook/pull/396)) by [@mrexox](https://github.com/mrexox) - feature: Use pnpm before npx ([#393](https://github.com/evilmartians/lefthook/pull/393)) by [@mrexox](https://github.com/mrexox) - chore: Use lipgloss for output ([#395](https://github.com/evilmartians/lefthook/pull/395)) by [@mrexox](https://github.com/mrexox) ## 1.2.4 (2022-12-05) - feature: Allow providing rc file ([PR #392](https://github.com/evilmartians/lefthook/pull/392) by [@mrexox](https://github.com/mrexox)) ## 1.2.3 (2022-11-30) - feature: Expand env variables ([PR #391](https://github.com/evilmartians/lefthook/pull/391) by [@mrexox](https://github.com/mrexox)) - deps: Update important dependencies ([PR #389](https://github.com/evilmartians/lefthook/pull/389) by [@mrexox](https://github.com/mrexox)) ## 1.2.2 (2022-11-23) - chore: Add FreeBSD OS to packages ([PR #377](https://github.com/evilmartians/lefthook/pull/377) by [@mrexox](https://github.com/mrexox)) - feature: Skip based on branch name and allow global skip rules ([PR #376](https://github.com/evilmartians/lefthook/pull/376) by [@mrexox](https://github.com/mrexox)) - fix: Omit LFS output unless it is required ([PR #373](https://github.com/evilmartians/lefthook/pull/373) by [@mrexox](https://github.com/mrexox)) ## 1.2.1 (2022-11-17) - fix: Remove quoting for scripts ([PR #371](https://github.com/evilmartians/lefthook/pull/371) by [@stonesbg](https://github.com/stonesbg) + [@mrexox](https://github.com/mrexox)) - fix: remove lefthook.checksum on uninstall ([PR #370](https://github.com/evilmartians/lefthook/pull/370) by [@JuliusHenke](https://github.com/JuliusHenke)) - fix: Print prepare-commit-msg hook if it exists in config ([PR #368](https://github.com/evilmartians/lefthook/pull/368) by [@mrexox](https://github.com/mrexox)) - fix: Allow changing refs for remote ([PR #363](https://github.com/evilmartians/lefthook/pull/363) by [@mrexox](https://github.com/mrexox)) ## 1.2.0 (2022-11-7) - fix: Full support for interactive commands and scripts ([PR #352](https://github.com/evilmartians/lefthook/pull/352) by [@mrexox](https://github.com/mrexox)) - chore: Remove deprecated config options ([PR #351](https://github.com/evilmartians/lefthook/pull/351) by [@mrexox](https://github.com/mrexox)) - feature: Add remote config support ([PR #343](https://github.com/evilmartians/lefthook/pull/343) by [@oatovar](https://github.com/oatovar) and [@mrexox](https://github.com/mrexox)) ## 1.1.4 (2022-11-1) - feature: Add `LEFTHOOK_VERBOSE` env ([PR #346](https://github.com/evilmartians/lefthook/pull/346) by [@mrexox](https://github.com/mrexox)) ## 1.1.3 (2022-10-15) - ci: Fix snapcraft trying to create dirs in parallel by [@mrexox](https://github.com/mrexox) - feature: Allow setting env vars ([PR #337](https://github.com/evilmartians/lefthook/pull/337) by [@mrexox](https://github.com/mrexox)) - feature: Show current running command and script name(s) ([PR #338](https://github.com/evilmartians/lefthook/pull/338) by [@mrexox](https://github.com/mrexox)) - feature: Exclude by command names too ([PR #335](https://github.com/evilmartians/lefthook/pull/335) by [@mrexox](https://github.com/mrexox)) - fix: Don't uninstall lefthook.yml and lefthook-local.yml by default ([PR #334](https://github.com/evilmartians/lefthook/pull/334) by [@mrexox](https://github.com/mrexox)) - fix: Fixing typo in gemspec ([PR #333](https://github.com/evilmartians/lefthook/pull/333) by [@kerrizor](https://github.com/kerrizor)) ## 1.1.2 (2022-10-10) - Fix regression from #314 (chmod missed fix) ([PR #330](https://github.com/evilmartians/lefthook/pull/330) by [@ariccio](https://github.com/ariccio)) - Pass stdin by default ([PR #324](https://github.com/evilmartians/lefthook/pull/324) by [@mrexox](https://github.com/mrexox)) ## 1.1.1 (2022-08-22) - Quote path to script ([PR #321](https://github.com/evilmartians/lefthook/pull/321) by [@mrexox](https://github.com/mrexox)) ## 1.1.0 (2022-08-13) - Add goreleaser action ([PR #307](https://github.com/evilmartians/lefthook/pull/307) by [@mrexox](https://github.com/mrexox)) - Windows escaping issues ([PR #314](https://github.com/evilmartians/lefthook/pull/314) by [@mrexox](https://github.com/mrexox)) - Check for lefthook.bat in hook template ([PR #316](https://github.com/evilmartians/lefthook/pull/316) by [@mrexox](https://github.com/mrexox)) - Update node.md docs ([PR #312](https://github.com/evilmartians/lefthook/pull/312) by [@fantua](https://github.com/fantua)) - Move postinstall script to main lefthook NPM package ([PR #311](https://github.com/evilmartians/lefthook/pull/311) by [@mrexox](https://github.com/mrexox)) - Allow suppressing execution output ([PR #309](https://github.com/evilmartians/lefthook/pull/309) by [@mrexox](https://github.com/mrexox)) - Update dependencies ([PR #308](https://github.com/evilmartians/lefthook/pull/308) by [@mrexox](https://github.com/mrexox)) - Add support for Git LFS ([PR #306](https://github.com/evilmartians/lefthook/pull/306) by [@mrexox](https://github.com/mrexox)) - Bump Go version to 1.19 ([PR #305](https://github.com/evilmartians/lefthook/pull/305) by [@mrexox](https://github.com/mrexox)) - Add tests on runner ([PR #304](https://github.com/evilmartians/lefthook/pull/304) by [@mrexox](https://github.com/mrexox)) - Add fail text option ([PR #301](https://github.com/evilmartians/lefthook/pull/301) by [@mrexox](https://github.com/mrexox)) - Store lefthook checksum in non-hook file ([PR #280](https://github.com/evilmartians/lefthook/pull/280) by [@mrexox](https://github.com/mrexox)) ## 1.0.5 (2022-07-19) - Submodules issue ([PR #300](https://github.com/evilmartians/lefthook/pull/300) by [@mrexox](https://github.com/mrexox)) - Remove rspec tests ([PR #299](https://github.com/evilmartians/lefthook/pull/299) by [@mrexox](https://github.com/mrexox)) - Add `when "mingw" then "windows"` case ([PR #297](https://github.com/evilmartians/lefthook/pull/297) by [@ariccio](https://github.com/ariccio)) - Define security policy and contact method ([PR #293](https://github.com/evilmartians/lefthook/pull/293) by [@Envek](https://github.com/Envek)) # 1.0.4 (2022-06-27) - Support skipping on rebase ([PR #289](https://github.com/evilmartians/lefthook/pull/289) by [@mrexox](https://github.com/mrexox)) # 1.0.3 (2022-06-25) - Fix NPM package - Update email information # 1.0.2 (2022-06-24) - Bring auto install back ([PR #286](https://github.com/evilmartians/lefthook/pull/286) by [@mrexox](https://github.com/mrexox)) - Move main.go to root ([PR #285](https://github.com/evilmartians/lefthook/pull/285) by [@mrexox](https://github.com/mrexox)) - Panic on commands structure misuse ([PR #284](https://github.com/evilmartians/lefthook/pull/284) by [@mrexox](https://github.com/mrexox)) - Split npm package by os and cpu ([PR #281](https://github.com/evilmartians/lefthook/pull/281) by [@mrexox](https://github.com/mrexox)) # 1.0.1 (2022-06-20) Ruby gem and NPM package fix - Fix folders structure for `[@evilmartians](https://github.com/evilmartians)/lefthook` and `[@evilmartians](https://github.com/evilmartians)/lefthook-installer` packages - Fix folders structure for `lefthook` gem # 1.0.0 (2022-06-19) - Refactoring ([PR #275](https://github.com/evilmartians/lefthook/pull/275) by [@mrexox](https://github.com/mrexox), [@skryukov](https://github.com/skryukov), [@markovichecha](https://github.com/markovichecha)) - Replace deprecated `File.exists?` with `exist?` for Ruby script ([PR #263](https://github.com/evilmartians/lefthook/pull/263) by [@pocke](https://github.com/pocke)) # 0.8.0 (2022-06-07) - Allow skipping hooks in certain git states: merge and/or rebase ([PR #173](https://github.com/evilmartians/lefthook/pull/173) by [@DmitryTsepelev](https://github.com/DmitryTsepelev)) - NPM: installer package that downloads the required binaries during installation ([PR #188](https://github.com/evilmartians/lefthook/pull/188) by [@aminya](https://github.com/aminya), [PR #273](https://github.com/evilmartians/lefthook/pull/273) by [@Envek](https://github.com/Envek)) - Add ability to skip summary output. Also support a `LEFTHOOK_QUIET` env variable ([PR #187](https://github.com/evilmartians/lefthook/pull/187) by [@washtubs](https://github.com/washtubs)) - Make filename globs case-insensitive ([PR #196](https://github.com/evilmartians/lefthook/pull/196) by [@skryukov](https://github.com/skryukov)) - Fix lefthook binary extension on Windows ([PR #188](https://github.com/evilmartians/lefthook/pull/188) by [@aminya](https://github.com/aminya)) - Stop building 32-bit binaries for releases due to low usage ([@Envek](https://github.com/Envek)) - Allow lefthook to work when node_modules is not in root folder for npx ([PR #224](https://github.com/evilmartians/lefthook/pull/224) by [@spearmootz](https://github.com/spearmootz)) - Fix unreachable conditional in hook template ([PR #242](https://github.com/evilmartians/lefthook/pull/242) by [@dannobytes](https://github.com/dannobytes)) - Add cpu arch and os arch to lefthook's filepath in hook template ([PR #260](https://github.com/evilmartians/lefthook/pull/260) by [@rmachado-studocu](https://github.com/rmachado-studocu)) # 0.7.7 (2021-10-02) - Fix incorrect npx command in git hook script template ([PR #236](https://github.com/evilmartians/lefthook/pull/236)) [@PikachuEXE](https://github.com/PikachuEXE) - Update project URLs in NPM package.json ([PR #235](https://github.com/evilmartians/lefthook/pull/235)) [@PikachuEXE](https://github.com/PikachuEXE) - Pass all arguments to downstream hooks ([PR #231](https://github.com/evilmartians/lefthook/pull/231)) [@pablobirukov](https://github.com/pablobirukov) - Allows lefthook to work when node_modules is not in root folder for npx ([PR #224](https://github.com/evilmartians/lefthook/pull/224)) [@spearmootz](https://github.com/spearmootz) - Do not initialize git config on `help` and `version` commands ([PR #209](https://github.com/evilmartians/lefthook/pull/209)) [@pwinckles](https://github.com/pwinckles) - node: fix postinstall: process.cwd is a function and should be called [@Envek](https://github.com/Envek) # 0.7.6 (2021-06-02) - Fix lefthook binary extension on Windows. [@aminya](https://github.com/aminya) - [PR #193](https://github.com/evilmartians/lefthook/pull/193) Fix path for searching npm-installed binary when in worktree. [@Envek](https://github.com/Envek) - NPM, RPM, and DEB packaging fixes. [@Envek](https://github.com/Envek) # 0.7.5 (2021-05-14) - [PR #179](https://github.com/evilmartians/lefthook/pull/179) Fix running on Windows under MSYS and MINGW64 when run from Ruby gem or JS npm package. [@akiver](https://github.com/akiver), [@Envek](https://github.com/Envek) - [PR #177](https://github.com/evilmartians/lefthook/pull/177) Support non-default git hooks path. [@charlie-wasp](https://github.com/charlie-wasp) - [PR #182](https://github.com/evilmartians/lefthook/pull/182) Support git workspaces and submodules. [@skryukov](https://github.com/skryukov) - [PR #184](https://github.com/evilmartians/lefthook/pull/184) Rewrite npm's scripts in JavaScript to support running on Windows without `sh`. [@aminya](https://github.com/aminya) # 0.7.4 (2021-04-30) - [PR](https://github.com/evilmartians/lefthook/pull/171) Improve check for installed git [@DmitryTsepelev](https://github.com/DmitryTsepelev) - [PR](https://github.com/evilmartians/lefthook/pull/169) Create .git/hooks directory when it does not exist [@DmitryTsepelev](https://github.com/DmitryTsepelev) # 0.7.3 (2021-04-23) - [PR](https://github.com/evilmartians/lefthook/pull/168) Package versions for all architectures (x86_64, ARM64, x86) into Ruby gem and NPM package [@Envek](https://github.com/Envek) - [PR](https://github.com/evilmartians/lefthook/pull/167) Fix golang 15+ build [@skryukov](https://github.com/skryukov) # 0.7.2 (2020-02-02) - [PR](https://github.com/evilmartians/lefthook/pull/126) Feature multiple extends. Thanks [@Evilweed](https://github.com/Evilweed) - [PR](https://github.com/evilmartians/lefthook/pull/124) Fix `npx` when only `yarn` exists. Thanks [@dotterian](https://github.com/dotterian) - [PR](https://github.com/evilmartians/lefthook/pull/116) Fix use '-h' for robust lefthook. Thanks [@fahrinh](https://github.com/fahrinh) # 0.7.1 (2020-02-02) - [PR](https://github.com/evilmartians/lefthook/pull/108) Fix `sh` dependency on windows when executing `git`. Thanks [@lionskape](https://github.com/lionskape) - [PR](https://github.com/evilmartians/lefthook/pull/103) Add possibility for using `yaml` and `yml` extension for config. Thanks [@rbUUbr](https://github.com/rbUUbr) # 0.7.0 (2019-12-14) - [PR](https://github.com/evilmartians/lefthook/pull/98) Support relative roots for monorepos. Thanks [@jsmestad](https://github.com/jsmestad) # 0.6.7 (2019-12-14) - [Commit](https://github.com/evilmartians/lefthook/commit/e898b5c8ba56c4d6f29a4d1f433baa1779a0845b) Skip before executing command - [PR](https://github.com/evilmartians/lefthook/pull/94) Add option --keep-config. Thanks [@justinasposiunas](https://github.com/justinasposiunas) - [Commit](https://github.com/evilmartians/lefthook/commit/d79a3a46e7d1ee709b97e97f823bfd27e9466eff) Check if shell is non interactive # 0.6.6 (2019-12-03) - [PR](https://github.com/evilmartians/lefthook/pull/94) Use eval instead of exec; Enable tty for the shell. Thanks [@ssnickolay](https://github.com/ssnickolay) # 0.6.5 (2019-11-15) - [PR](https://github.com/evilmartians/lefthook/pull/89) Add support for git-worktree. Thanks [@f440](https://github.com/f440) - [Commit](https://github.com/evilmartians/lefthook/commit/48702a0806d2b2eab13636ba56b0e0b99f346f1c) Commands and Scripts now can catch Stdin - [Commit](https://github.com/evilmartians/lefthook/commit/9a226842292ff1dda0f2273b66a0799988aa5289) Add partial support for monorepos and command execution not from project root # 0.6.4 (2019-11-08) - [PR](https://github.com/evilmartians/lefthook/pull/84) Fix return value from shell exit. Thanks [@HaiD84](https://github.com/HaiD84) - [PR](https://github.com/evilmartians/lefthook/pull/86) Support postinstall script for npm installation for monorepos. Thanks [@sHooKDT](https://github.com/sHooKDT) - [PR](https://github.com/evilmartians/lefthook/pull/82) Now relative path to scripts supported. Thanks [@AlexeyMatskevich](https://github.com/AlexeyMatskevich) - [Commit](https://github.com/evilmartians/lefthook/pull/80/commits/1a4b0ee155eb66ae6f3c365164012bee9332605a) Option `extends` for top level config added. Now you can merge some settings from different places: ```yml extends: $HOME/work/lefthook-extend.yml ``` - [Commit](https://github.com/evilmartians/lefthook/commit/83cf818106dbf222ea33ba86aafce8f30d7cb5a9) Add examples to generated lefthook.yml ## 0.6.3 (2019-07-15) - [Commit](https://github.com/evilmartians/lefthook/commit/0426936f48f248221126f15619932b0dc8c54d7a) Add `-a` means `aggressive` strategy for `install` command ```bash lefthook install -a # clear .git/hooks dir and reinstall lefthook hooks ``` - [Commit](https://github.com/evilmartians/lefthook/commit/5efb0677a4a9ec1728d3cf1a083075e23315a796) Add Lefthook version indicator for commands and script execution - [Commit](https://github.com/evilmartians/lefthook/commit/8b55d91eed46643a1674bd4ad96fa211a177e159) Remove `npx` as dependency from node wrapper Now we will call directly binary from `./node_modules` - [Commit](https://github.com/evilmartians/lefthook/commit/76ffed4c698bc074984e91f5610c0b98784bd10b) Add `-f` means `force` strategy for `install` command ```bash lefthook install -f # reinstall lefthook hooks without sync info check ``` - PR [#27](https://github.com/evilmartians/lefthook/pull/27) Move LEFTHOOK env check in hooks files Now if LEFTHOOK=0 we will not call the binary file - PR [#26](https://github.com/evilmartians/lefthook/pull/26) + [commit](https://github.com/evilmartians/lefthook/commit/afd67f94631a10975209ed4c5fabc763f44280eb) Add `{push_files}` shortcut Add shortcut `{push_files}` ``` pre-commit: commands: rubocop: run: rubocop {push_files} ``` It same as: ``` pre-commit: commands: rubocop: files: git diff --name-only HEAD @{push} || git diff --name-only HEAD master run: rubocop {push_files} ``` - [Commit](https://github.com/evilmartians/lefthook/commit/af087b032a14952aa1dd235a3d0b5a51bc760a10) Add `min_version` option You can mark your config for minimum Lefthook version: ``` min_version: 0.6.1 ``` ## 0.6.0 (2019-07-10) - PR [#24](https://github.com/palkan/logidze/pull/110) Wrap `run` command in shell context. Now in `run` option available `sh` syntax. ``` pre-commit: commands: bashed: run: rubocop -a && git add ``` Will be executed in this way: ``` sh -c "rubocop -a && git add" ``` - PR [#23](https://github.com/evilmartians/lefthook/pull/24) Search Lefthook in Gemfile. Now it's possible to use Lefthook from Gemfile. ```ruby # Gemfile gem 'lefthook' ``` [@mrexox]: https://github.com/mrexox [@olivier-lacroix]: https://github.com/olivier-lacroix [@michael-pplx]: https://github.com/michael-pplx [@siler]: https://github.com/siler [@technicalpickles]: https://github.com/technicalpickles [@jasonwbarnett]: https://github.com/jasonwbarnett [@scop]: https://github.com/scop [@franzramadhan]: https://github.com/franzramadhan [@joevin-sql-docto]: https://github.com/joevin-slq-docto [@jeonghoon11]: https://github.com/jeonghoon11 ================================================ FILE: CONTRIBUTING.md ================================================ # Contributing First off, thanks for taking the time to contribute! Feel free to make Pull Request with your changes. # Requirements Go >= 1.26.0 # Process 1. Fork repo 2. git clone 3. Make changes 4. Push your changes in ================================================ FILE: LICENSE ================================================ The MIT License (MIT) Copyright (c) 2019 Arkweid Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: Makefile ================================================ COMMIT_HASH = $(shell git rev-parse HEAD) .PHONY: build build: go build -ldflags "-s -w -X github.com/evilmartians/lefthook/v2/internal/version.commit=$(COMMIT_HASH)" -o lefthook .PHONY: build-with-coverage build-with-coverage: go build -cover -ldflags "-s -w -X github.com/evilmartians/lefthook/v2/internal/version.commit=$(COMMIT_HASH)" -o lefthook .PHONY: jsonschema jsonschema: go generate gen/jsonschema.go > schema.json go generate gen/jsonschema.go > internal/config/jsonschema.json install: build ifeq ($(shell go env GOOS),windows) copy lefthook $(shell go env GOPATH)\bin\lefthook.exe else cp lefthook $$(go env GOPATH)/bin endif .PHONY: test test: go test -cpu 24 -race -count=1 -timeout=30s ./... .PHONY: test-integration test-integration: install go test -cpu 24 -race -count=1 -timeout=30s -tags=integration integration_test.go .PHONY: bench bench: go test -cpu 24 -race -run=Bench -bench=. ./... .PHONY: lint lint: bin/golangci-lint bin/golangci-lint run --fix bin/golangci-lint: curl -sSfL https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh | sh -s -- -b bin/ v$$(cat .tool-versions | grep golangci-lint | cut -d' ' -f2) .ONESHELL: version: @read -p "New version: " version sed -i "s/const version = .*/const version = \"$$version\"/" internal/version/version.go sed -i "s/VERSION = .*/VERSION = \"$$version\";/" packaging/scripts/lib/Constants.rakumod sed -i "s/lefthook-plugin.git\", exact: \".*\"/lefthook-plugin.git\", exact: \"$$version\"/" docs/installation/swift.md sed -i "s/go install github.com\/evilmartians\/lefthook\/v2.*/go install github.com\/evilmartians\/lefthook\/v2@v$$version/" docs/installation/go.md sed -i "s/go install github.com\/evilmartians\/lefthook\/v2.*/go install github.com\/evilmartians\/lefthook\/v2@v$$version/" README.md sed -i "s/go get -tool github.com\/evilmartians\/lefthook\/v2.*/go get -tool github.com\/evilmartians\/lefthook\/v2@v$$version/" README.md raku packaging/scripts/set-version.raku git add internal/version/version.go packaging/* docs/ README.md ================================================ FILE: README.md ================================================ ![Build Status](https://github.com/evilmartians/lefthook/actions/workflows/test.yml/badge.svg?branch=master) [![codecov](https://codecov.io/gh/evilmartians/lefthook/graph/badge.svg?token=d93ya8MfmB)](https://codecov.io/gh/evilmartians/lefthook) # Lefthook A Git hooks manager for Node.js, Ruby, Python and many other types of projects. * **Fast.** It is written in Go. Can run commands in parallel. * **Powerful.** It allows to control execution and files you pass to your commands. * **Simple.** It is single dependency-free binary which can work in any environment. 📖 [Introduction post](https://evilmartians.com/chronicles/lefthook-knock-your-teams-code-back-into-shape?utm_source=lefthook) Sponsored by Evil Martians ## Install With **Go** (>= 1.26): ```bash go install github.com/evilmartians/lefthook/v2@v2.1.4 ``` * or as a go tool ```bash go get -tool github.com/evilmartians/lefthook/v2@v2.1.4 ``` With **NPM**: ```bash npm install lefthook --save-dev ``` For **Ruby**: ```bash gem install lefthook ``` For **Python**: ```bash pipx install lefthook ``` **[Installation guide][installation]** with more ways to install lefthook: [apt][install-apt], [brew][install-brew], [winget][install-winget], and others. ## Usage Configure your hooks, install them once and forget about it: rely on the magic underneath. #### TL;DR ```bash # Configure your hooks vim lefthook.yml # Install them to the git project lefthook install # Enjoy your work with git git add -A && git commit -m '...' ``` #### More details - [**Configuration**][configuration] for `lefthook.yml` config options. - [**Usage**][usage] for **lefthook** CLI options, and features. - [**Discussions**][discussion] for questions, ideas, suggestions. ## Why Lefthook * ### **Parallel execution** Gives you more speed. [docs][config-parallel] ```yml pre-push: parallel: true ``` * ### **Flexible list of files** If you want your own list. [Custom][config-files] and [prebuilt][config-run] examples. ```yml pre-commit: jobs: - name: lint frontend run: yarn eslint {staged_files} - name: lint backend run: bundle exec rubocop --force-exclusion -- {all_files} - name: stylelint frontend files: git diff --name-only HEAD @{push} run: yarn stylelint {files} ``` * ### **Glob and regexp filters** If you want to filter list of files. You could find more glob pattern examples [here](https://github.com/gobwas/glob#example). ```yml pre-commit: jobs: - name: lint backend glob: "*.rb" # glob filter exclude: - "*/application.rb" - "*/routes.rb" run: bundle exec rubocop --force-exclusion -- {all_files} ``` * ### **Execute in sub-directory** If you want to execute the commands in a relative path ```yml pre-commit: jobs: - name: lint backend root: "api/" # Careful to have only trailing slash glob: "*.rb" # glob filter run: bundle exec rubocop -- {all_files} ``` * ### **Run scripts** If oneline commands are not enough, you can execute files. [docs][config-scripts] ```yml commit-msg: jobs: - script: "template_checker" runner: bash ``` * ### **Tags** If you want to control a group of commands. [docs][config-tags] ```yml pre-push: jobs: - name: audit packages tags: - frontend - linters run: yarn lint - name: audit gems tags: - backend - security run: bundle audit ``` * ### **Support Docker** If you are in the Docker environment. [docs][config-run] ```yml pre-commit: jobs: - script: "good_job.js" runner: docker run -it --rm {cmd} ``` * ### **Local config** If you are a frontend/backend developer and want to skip unnecessary commands or override something in Docker. [docs][usage-local-config] ```yml # lefthook-local.yml pre-push: exclude_tags: - frontend jobs: - name: audit packages skip: true ``` * ### **Direct control** If you want to run hooks group directly. ```bash $ lefthook run pre-commit ``` * ### **Your own tasks** If you want to run specific group of commands directly. ```yml fixer: jobs: - run: bundle exec rubocop --force-exclusion --safe-auto-correct -- {staged_files} - run: yarn eslint --fix {staged_files} ``` ```bash $ lefthook run fixer ``` * ### **Control output** You can control what lefthook prints with [output][config-output] option. ```yml output: - execution - failure ``` ---- ### Guides * [Install with Node.js][install-node] * [Install with Ruby][install-ruby] * [Install with Homebrew][install-brew] * [Install with Winget][install-winget] * [Install for Debian-based Linux][install-apt] * [Install for RPM-based Linux][install-rpm] * [Install for Arch Linux][install-arch] * [Install for Alpine Linux][install-alpine] * [Usage][usage] * [Configuration][configuration] ### Examples Check [examples][examples] ### Articles * [5 cool (and surprising) ways to configure Lefthook for automation joy](https://evilmartians.com/chronicles/5-cool-and-surprising-ways-to-configure-lefthook-for-automation-joy?utm_source=lefthook) * [Lefthook: Knock your team’s code back into shape](https://evilmartians.com/chronicles/lefthook-knock-your-teams-code-back-into-shape?utm_source=lefthook) * [Lefthook + Crystalball](https://evilmartians.com/chronicles/lefthook-crystalball-and-git-magic?utm_source=lefthook) * [Keeping OSS documentation in check with docsify, Lefthook, and friends](https://evilmartians.com/chronicles/keeping-oss-documentation-in-check-with-docsify-lefthook-and-friends?utm_source=lefthook) * [Automatically linting docker containers](https://dev.to/nitzano/linting-docker-containers-2lo6?utm_source=lefthook) * [Smooth PostgreSQL upgrades in DockerDev environments with Lefthook](https://dev.to/palkan_tula/smooth-postgresql-upgrades-in-dockerdev-environments-with-lefthook-203k?utm_source=lefthook) * [Lefthook for React/React Native apps](https://blog.logrocket.com/deep-dive-into-lefthook-react-native?utm_source=lefthook) [documentation]: https://lefthook.dev/ [configuration]: https://lefthook.dev/configuration/index [examples]: https://lefthook.dev/examples/lefthook-local [installation]: https://lefthook.dev/install/ [usage]: https://lefthook.dev/usage/ [discussion]: https://github.com/evilmartians/lefthook/discussions [install-apt]: https://lefthook.dev/installation/deb [install-ruby]: https://lefthook.dev/installation/ruby [install-node]: https://lefthook.dev/installation/node [install-brew]: https://lefthook.dev/installation/homebrew [install-winget]: https://lefthook.dev/installation/winget [install-rpm]: https://lefthook.dev/installation/rpm [install-arch]: https://lefthook.dev/installation/arch [install-alpine]: https://lefthook.dev/installation/alpine [config-parallel]: https://lefthook.dev/configuration/parallel [config-files]: https://lefthook.dev/configuration/files [config-glob]: https://lefthook.dev/configuration/glob [config-run]: https://lefthook.dev/configuration/run [config-scripts]: https://lefthook.dev/configuration/Scripts [config-tags]: https://lefthook.dev/configuration/tags [config-output]: https://lefthook.dev/configuration/output [usage-local-config]: https://lefthook.dev/examples/lefthook-local ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Supported Versions Latest major version of Lefthook is being supported with security updates. | Version | Supported | | ------- | ------------------ | | 1.x | :white_check_mark: | | 0.x | :x: | ## Reporting a Vulnerability If you have found a security issue in Lefthook, please **do not** create a new issue in the GitHub repository. Instead, please send an email to [lefthook@evilmartians.com](mailto:lefthook@evilmartians.com?subject=Lefthook%3A%20security%20issue) describing what the problem is and how to reproduce it. We will get in touch with you! Please note that Lefthook, as a CLI tool, executes arbitrary commands and scripts from its configuration file by design. This is intended behavior. Feel free to join the discussion on [issue #229](https://github.com/evilmartians/lefthook/issues/229). ================================================ FILE: assets/css/lefthook.css ================================================ :root { --link-color: #ff1e1e; --font-family-mono: "Martian Mono", SFMono-Regular, Consolas, "Liberation Mono", Menlo, Courier, monospace; } .main-content-wrapper { min-height: 100vh; } body[data-theme="dark"] { --link-color: #ff1e1e; --font-family-mono: "Martian Mono", SFMono-Regular, Consolas, "Liberation Mono", Menlo, Courier, monospace; } ================================================ FILE: book.toml ================================================ [book] authors = ["Evil Martians"] language = "en" multilingual = false src = "docs/mdbook" title = "Lefthook Documentation" [output.html] no-section-label = true git-repository-url = "https://github.com/evilmartians/lefthook" [output.html.fold] enable = true ================================================ FILE: cliff.toml ================================================ # https://git-cliff.org/docs/configuration [changelog] header = "# Change log\n\n" body = """ {% if version %}\ ## {{ version | trim_start_matches(pat="v") }} ({{ timestamp | date(format="%Y-%m-%d") }}) {% else %}\ ## (unreleased) {% endif %} {% for commit in commits %}\ - {{ commit.group }}: {% if commit.breaking %}[**breaking**] {% endif %}{{ commit.message }} by {% if commit.remote.username %}[@{{commit.remote.username}}](https://github.com/{{commit.remote.username}}) {% else %}{{ commit.author.name }}{% endif %} {% endfor %}\n """ trim = true [git] # parse the commits based on https://www.conventionalcommits.org conventional_commits = true # filter out the commits that are not conventional filter_unconventional = true # process each line of a commit as an individual commit split_commits = false # regex for preprocessing the commit messages commit_preprocessors = [ { pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](https://github.com/evilmartians/lefthook/pull/${2}))"}, # replace issue numbers ] # regex for parsing and grouping commits commit_parsers = [ { message = "^feat", group = "feat" }, { message = "^fix", group = "fix" }, { message = "^docs", group = "docs" }, { message = "^perf", group = "perf" }, { message = "^refactor", group = "refactor" }, { message = "^ci", group = "ci" }, { message = "^test", group = "test" }, { message = "^chore\\(release\\): prepare for", skip = true }, { message = "^chore", group = "chore" }, { body = ".*security", group = "security" }, ] # protect breaking changes from being skipped due to matching a skipping commit_parser protect_breaking_commits = false # filter out the commits that are not matched by commit parsers filter_commits = false # glob pattern for matching git tags tag_pattern = "v[0-9]*" # regex for ignoring tags ignore_tags = "" # sort the tags topologically topo_order = false # sort the commits inside sections by oldest/newest order sort_commits = "newest" # limit the number of commits included in the changelog. # limit_commits = 42 ================================================ FILE: cmd/add-usage.txt ================================================ lefthook add pre-commit This command will try to build the following structure in repository: ├───.git │ └───hooks │ └───pre-commit // this executable will be added. Existing file with │ // same name will be renamed to pre-commit.old (lefthook adds these dirs if you run the command with the -d option) │ ├───.lefthook // directory for project level hooks │ └───pre-commit // directory with hook executables └───.lefthook-local // directory for personal hooks; add it in .gitignore └───pre-commit ================================================ FILE: cmd/add.go ================================================ package cmd import ( "context" _ "embed" "github.com/urfave/cli/v3" "github.com/evilmartians/lefthook/v2/internal/command" ) //go:embed add-usage.txt var addUsageText string func add() *cli.Command { var args command.AddArgs var verbose bool return &cli.Command{ Name: "add", Usage: "add scripts directory and install the hook", UsageText: addUsageText, Flags: []cli.Flag{ &cli.BoolFlag{ Name: "force", Aliases: []string{"f"}, Destination: &args.Force, }, &cli.BoolFlag{ Name: "create-dirs", Aliases: []string{"dirs"}, Usage: "create directories for scripts", Destination: &args.CreateDirs, }, &cli.BoolFlag{ Name: "verbose", Aliases: []string{"v"}, Destination: &verbose, }, }, Action: func(ctx context.Context, cmd *cli.Command) error { l, err := command.NewLefthook(verbose, "auto") if err != nil { return err } args.Hook = cmd.Args().Get(0) return l.Add(ctx, args) }, ShellComplete: func(ctx context.Context, cmd *cli.Command) { command.ShellCompleteFlags(cmd) command.ShellCompleteHookNames() }, } } ================================================ FILE: cmd/check_install.go ================================================ package cmd import ( "context" "github.com/urfave/cli/v3" "github.com/evilmartians/lefthook/v2/internal/command" ) func checkInstall() *cli.Command { var verbose bool return &cli.Command{ Name: "check-install", Usage: "check if hooks are installed", UsageText: `lefthook check-install – Check if lefthook is installed. Exit codes: 0 – hooks are installed 1 – hooks are not installed or stale`, Flags: []cli.Flag{ &cli.BoolFlag{ Name: "verbose", Aliases: []string{"v"}, Destination: &verbose, }, }, Action: func(ctx context.Context, cmd *cli.Command) error { l, err := command.NewLefthook(verbose, "auto") if err != nil { return err } return l.CheckInstall(ctx) }, ShellComplete: func(ctx context.Context, cmd *cli.Command) { command.ShellCompleteFlags(cmd) }, } } ================================================ FILE: cmd/commands.go ================================================ //go:build !no_self_update && !jsonschema package cmd import "github.com/urfave/cli/v3" var commands = []*cli.Command{ run(), install(), uninstall(), checkInstall(), dump(), add(), validate(), version(), selfUpdate(), } ================================================ FILE: cmd/commands_without_self_update.go ================================================ //go:build no_self_update && !jsonschema package cmd import "github.com/urfave/cli/v3" var commands = []*cli.Command{ run(), install(), uninstall(), checkInstall(), dump(), add(), validate(), version(), // selfUpdate(), } ================================================ FILE: cmd/dump.go ================================================ package cmd import ( "context" "errors" "github.com/urfave/cli/v3" "github.com/evilmartians/lefthook/v2/internal/command" ) var errInvalidFormat = errors.New("invalid 'format' value, supported: 'toml', 'yaml', 'json'") func dump() *cli.Command { args := command.DumpArgs{ Format: "yaml", } return &cli.Command{ Name: "dump", Usage: "print config merged from all extensions", Flags: []cli.Flag{ &cli.StringFlag{ Name: "format", Usage: "'yaml', 'toml', or 'json' (default: 'yaml')", Aliases: []string{"f"}, Destination: &args.Format, Validator: func(format string) error { switch format { case "": case "yaml": case "toml": case "json": default: return errInvalidFormat } return nil }, }, }, Action: func(ctx context.Context, cmd *cli.Command) error { l, err := command.NewLefthook(false, "no") if err != nil { return err } return l.Dump(ctx, args) }, ShellComplete: func(ctx context.Context, cmd *cli.Command) { command.ShellCompleteFlags(cmd) }, } } ================================================ FILE: cmd/install.go ================================================ package cmd import ( "context" "github.com/urfave/cli/v3" "github.com/evilmartians/lefthook/v2/internal/command" ) func install() *cli.Command { var args command.InstallArgs var verbose bool return &cli.Command{ Name: "install", Usage: "install Git hook from the config or create a blank lefthook.yml", UsageText: "lefthook install [hook-names...] [options]", Flags: []cli.Flag{ &cli.BoolFlag{ Name: "force", Usage: "overwrite .old files and proceed even if core.hooksPath is set", Aliases: []string{"f"}, Destination: &args.Force, }, &cli.BoolFlag{ Name: "reset-hooks-path", Usage: "automatically unset core.hooksPath configuration", Aliases: []string{"r"}, Destination: &args.ResetHooksPath, }, &cli.BoolFlag{ Name: "verbose", Aliases: []string{"v"}, Destination: &verbose, }, }, Action: func(ctx context.Context, cmd *cli.Command) error { l, err := command.NewLefthook(verbose, "auto") if err != nil { return err } return l.Install(ctx, args, cmd.Args().Slice()) }, ShellComplete: func(ctx context.Context, cmd *cli.Command) { command.ShellCompleteFlags(cmd) command.ShellCompleteHookNames() }, } } ================================================ FILE: cmd/lefthook.go ================================================ package cmd import ( "github.com/urfave/cli/v3" ver "github.com/evilmartians/lefthook/v2/internal/version" ) func Lefthook() *cli.Command { return &cli.Command{ Name: "lefthook", Usage: "Git hooks manager", Version: ver.Version(true), Commands: commands, Description: `... of supported ENV variables: LEFTHOOK set to '0' or 'false' to disable lefthook execution LEFTHOOK_CONFIG override main config path LEFTHOOK_OUTPUT control printed sections (see config option 'output') LEFTHOOK_VERBOSE enable debug logs`, EnableShellCompletion: true, Suggest: true, } } ================================================ FILE: cmd/run.go ================================================ package cmd import ( "context" "errors" "github.com/urfave/cli/v3" "github.com/evilmartians/lefthook/v2/internal/command" ) func run() *cli.Command { var args command.RunArgs var colors string failOnChanges := &cli.BoolWithInverseFlag{ Name: "fail-on-changes", Usage: "exit with 1 if some of the files were changed", } failOnChangesDiff := &cli.BoolWithInverseFlag{ Name: "fail-on-changes-diff", Usage: "output a diff when failing on changes", } return &cli.Command{ Name: "run", Usage: "execute a group of hooks", UsageText: "lefthook run [args...] [options]", Flags: []cli.Flag{ &cli.BoolFlag{ Name: "verbose", Aliases: []string{"v"}, Usage: "enable debug logs", Destination: &args.Verbose, }, &cli.StringFlag{ Name: "colors", Usage: "on, off, or auto (default: auto)", Destination: &colors, Value: "auto", }, &cli.StringSliceFlag{ Name: "job", Usage: "run only jobs with names", Destination: &args.RunOnlyJobs, }, &cli.StringSliceFlag{ Name: "tag", Usage: "run only jobs with tag names", Destination: &args.RunOnlyTags, }, &cli.StringSliceFlag{ Name: "command", Usage: "run only commands", Destination: &args.RunOnlyCommands, }, &cli.StringSliceFlag{ Name: "exclude", Usage: "exclude files from all templates", Destination: &args.Exclude, }, &cli.StringSliceFlag{ Name: "file", Usage: "overwrite file templates with files", Destination: &args.Files, }, &cli.BoolFlag{ Name: "force", Aliases: []string{"f"}, Usage: "do not skip if no files changed", Destination: &args.Force, }, &cli.BoolFlag{ Name: "all-files", Usage: "replace files templates with {all_files}", Destination: &args.AllFiles, }, &cli.BoolFlag{ Name: "no-auto-install", Usage: "do not implicitly install hooks", Destination: &args.NoAutoInstall, }, &cli.BoolFlag{ Name: "no-stage-fixed", Usage: "ignore 'stage_fixed: true' setting", Destination: &args.NoStageFixed, }, &cli.BoolFlag{ Name: "no-tty", Usage: "act as if no TTY is connected", Destination: &args.NoTTY, }, &cli.BoolFlag{ Name: "skip-lfs", Usage: "do not run LFS hooks", Destination: &args.SkipLFS, }, failOnChanges, &cli.BoolFlag{ Name: "files-from-stdin", Usage: "parse filelist from STDIN", Destination: &args.FilesFromStdin, }, }, Action: func(ctx context.Context, cmd *cli.Command) error { l, err := command.NewLefthook(args.Verbose, colors) if err != nil { return err } if failOnChanges.IsSet() { value := cmd.Bool("fail-on-changes") args.FailOnChanges = &value } if failOnChangesDiff.IsSet() { value := cmd.Bool("fail-on-changes-diff") args.FailOnChangesDiff = &value } if cmd.Args().Len() < 1 { return errors.New("hook name missing") } args.Hook = cmd.Args().Get(0) args.GitArgs = cmd.Args().Slice()[1:] return l.Run(ctx, args) }, ShellComplete: func(ctx context.Context, cmd *cli.Command) { command.ShellCompleteFlags(cmd) command.ShellCompleteHookNames() }, } } ================================================ FILE: cmd/self_update.go ================================================ package cmd import ( "context" "fmt" "os" "os/signal" "github.com/urfave/cli/v3" "github.com/evilmartians/lefthook/v2/internal/command" "github.com/evilmartians/lefthook/v2/internal/log" "github.com/evilmartians/lefthook/v2/internal/updater" ) func selfUpdate() *cli.Command { var yes, force, verbose bool return &cli.Command{ Name: "self-update", Usage: "update lefthook executable", Flags: []cli.Flag{ &cli.BoolFlag{ Name: "yes", Aliases: []string{"y"}, Usage: "do not prompt y/n", Destination: &yes, }, &cli.BoolFlag{ Name: "force", Aliases: []string{"f"}, Usage: "force reinstall", Destination: &force, }, &cli.BoolFlag{ Name: "verbose", Aliases: []string{"v"}, Destination: &verbose, }, }, Action: func(ctx context.Context, cmd *cli.Command) error { if os.Getenv(command.EnvVerbose) == "1" || os.Getenv(command.EnvVerbose) == "true" { verbose = true } if verbose { log.SetLevel(log.DebugLevel) log.Debug("Verbose mode enabled") } exePath, err := os.Executable() if err != nil { return fmt.Errorf("failed to determine the binary path: %w", err) } ctxCancel, stop := signal.NotifyContext(ctx, os.Interrupt) defer stop() return updater.New().SelfUpdate(ctxCancel, updater.Options{ Yes: yes, Force: force, ExePath: exePath, }) }, ShellComplete: func(ctx context.Context, cmd *cli.Command) { command.ShellCompleteFlags(cmd) }, } } ================================================ FILE: cmd/uninstall.go ================================================ package cmd import ( "context" "github.com/urfave/cli/v3" "github.com/evilmartians/lefthook/v2/internal/command" ) func uninstall() *cli.Command { var args command.UninstallArgs var verbose bool return &cli.Command{ Name: "uninstall", Usage: "delete installed hooks", Flags: []cli.Flag{ &cli.BoolFlag{ Name: "verbose", Aliases: []string{"v"}, Destination: &verbose, }, &cli.BoolFlag{ Name: "force", Aliases: []string{"f"}, Usage: "remove all Git hooks", Destination: &args.Force, }, &cli.BoolFlag{ Name: "remove-configs", Usage: "remove lefthook configs", Destination: &args.RemoveConfig, }, }, Action: func(ctx context.Context, cmd *cli.Command) error { l, err := command.NewLefthook(verbose, "auto") if err != nil { return err } return l.Uninstall(ctx, args) }, ShellComplete: func(ctx context.Context, cmd *cli.Command) { command.ShellCompleteFlags(cmd) }, } } ================================================ FILE: cmd/validate.go ================================================ package cmd import ( "context" "github.com/urfave/cli/v3" "github.com/evilmartians/lefthook/v2/internal/command" ) func validate() *cli.Command { var args command.ValidateArgs var verbose bool return &cli.Command{ Name: "validate", Usage: "validate lefthook config", Flags: []cli.Flag{ &cli.BoolFlag{ Name: "verbose", Aliases: []string{"v"}, Destination: &verbose, }, }, Action: func(ctx context.Context, cmd *cli.Command) error { l, err := command.NewLefthook(verbose, "auto") if err != nil { return nil } return l.Validate(ctx, args) }, ShellComplete: func(ctx context.Context, cmd *cli.Command) { command.ShellCompleteFlags(cmd) }, } } ================================================ FILE: cmd/version.go ================================================ package cmd import ( "context" "github.com/urfave/cli/v3" "github.com/evilmartians/lefthook/v2/internal/command" "github.com/evilmartians/lefthook/v2/internal/log" ver "github.com/evilmartians/lefthook/v2/internal/version" ) func version() *cli.Command { var verbose bool return &cli.Command{ Name: "version", Usage: "print version", Flags: []cli.Flag{ &cli.BoolFlag{ Name: "verbose", Aliases: []string{"v"}, Destination: &verbose, }, &cli.BoolFlag{ Name: "full", Aliases: []string{"f"}, Destination: &verbose, }, }, Action: func(_ctx context.Context, cmd *cli.Command) error { log.Println(ver.Version(verbose)) return nil }, ShellComplete: func(ctx context.Context, cmd *cli.Command) { command.ShellCompleteFlags(cmd) }, } } ================================================ FILE: codecov.yml ================================================ comment: false ================================================ FILE: docmd.config.js ================================================ module.exports = { siteTitle: "Lefthook", siteUrl: "https://lefthook.dev", logo: { light: "/assets/lefthook.png", dark: "/assets/lefthook.png", alt: "Logo", href: "/" }, favicon: "/assets/favicon.svg", srcDir: "docs", outputDir: "site", layout: { spa: true, header: { enabled: true }, sidebar: { collapsible: true, defaultCollapsed: false }, optionsMenu: { position: "header", components: { search: true, themeSwitch: true, sponsor: null } }, footer: { style: "minimal", content: "© 2026 Lefthook. Evil Martians." } }, theme: { name: "default", defaultMode: "system", codeHighlight: true, customCss: [ "assets/css/lefthook.css" ] }, minify: true, autoTitleFromH1: true, copyCode: true, pageNavigation: false, editLink: { enabled: false, baseUrl: "https://github.com/evilmartians/lefthook/edit/main/docs", text: "Edit this page" }, plugins: { seo: { defaultDescription: "Lefthook documentation.", openGraph: { defaultImage: "assets/lefthook.png" } }, analytics: {}, sitemap: { defaultChangefreq: "weekly", defaultPriority: 0.8 }, search: {}, mermaid: {}, llms: {} }, navigation: [ { title: "Installation", icon: "rocket", path: "/install", collapsible: true, children: [ { title: "Ruby gem", path: "/installation/ruby" }, { title: "NPM", path: "/installation/node" }, { title: "Go", path: "/installation/go" }, { title: "Python", path: "/installation/python" }, { title: "Swift", path: "/installation/swift" }, { title: "Homebrew", path: "/installation/homebrew" }, { title: "Winget", path: "/installation/winget" }, { title: "Scoop", path: "/installation/scoop" }, { title: "Debian-based distro", path: "/installation/deb" }, { title: "RPM-based distro", path: "/installation/rpm" }, { title: "Alpine", path: "/installation/alpine" }, { title: "Arch Linux", path: "/installation/arch" }, { title: "Snap", path: "/installation/snap" }, { title: "Devbox", path: "/installation/devbox" }, { title: "Mise", path: "/installation/mise" }, { title: "Manual", path: "/installation/manual" } ] }, { title: "Configuration", path: "/configuration", collapsible: true, icon: "settings", children: [ { title: "assert_lefthook_installed", path: "/configuration/assert_lefthook_installed" }, { title: "colors", path: "/configuration/colors" }, { title: "extends", path: "/configuration/extends" }, { title: "install_non_git_hooks", path: "/configuration/install_non_git_hooks" }, { title: "lefthook", path: "/configuration/lefthook" }, { title: "min_version", path: "/configuration/min_version" }, { title: "no_auto_install", path: "/configuration/no_auto_install" }, { title: "no_tty", path: "/configuration/no_tty" }, { title: "output", path: "/configuration/output" }, { title: "rc", path: "/configuration/rc" }, { title: "remotes", path: "/configuration/remotes", children: [ { title: "git_url", path: "/configuration/git_url" }, { title: "ref", path: "/configuration/ref" }, { title: "refetch", path: "/configuration/refetch" }, { title: "refetch_frequency", path: "/configuration/refetch_frequency" }, { title: "configs", path: "/configuration/configs" } ] }, { title: "source_dir", path: "/configuration/source_dir" }, { title: "source_dir_local", path: "/configuration/source_dir_local" }, { title: "skip_lfs", path: "/configuration/skip_lfs" }, { title: "glob_matcher", path: "/configuration/glob_matcher" }, { title: "templates", path: "/configuration/templates" }, { title: "Hook", path: "/configuration/Hook", children: [ { title: "parallel", path: "/configuration/parallel" }, { title: "piped", path: "/configuration/piped" }, { title: "follow", path: "/configuration/follow" }, { title: "files", path: "/configuration/files-global" }, { title: "fail_on_changes", path: "/configuration/fail_on_changes" }, { title: "fail_on_changes_diff", path: "/configuration/fail_on_changes_diff" }, { title: "exclude_tags", path: "/configuration/exclude_tags" }, { title: "exclude", path: "/configuration/exclude" }, { title: "only", path: "/configuration/only" }, { title: "skip", path: "/configuration/skip" }, { title: "setup", path: "/configuration/setup" }, { title: "jobs", path: "/configuration/jobs", children: [ { title: "name", path: "/configuration/name" }, { title: "run", path: "/configuration/run" }, { title: "script", path: "/configuration/script" }, { title: "runner", path: "/configuration/runner" }, { title: "args", path: "/configuration/args" }, { title: "group", collapsible: true, path: "/configuration/group", children: [ { title: "parallel", path: "/configuration/parallel" }, { title: "piped", path: "/configuration/piped" }, { title: "jobs", path: "/configuration/jobs" } ] }, { title: "skip", path: "/configuration/skip" }, { title: "only", path: "/configuration/only" }, { title: "tags", path: "/configuration/tags" }, { title: "glob", path: "/configuration/glob" }, { title: "files", path: "/configuration/files" }, { title: "file_types", path: "/configuration/file_types" }, { title: "env", path: "/configuration/env" }, { title: "root", path: "/configuration/root" }, { title: "exclude", path: "/configuration/exclude" }, { title: "fail_text", path: "/configuration/fail_text" }, { title: "stage_fixed", path: "/configuration/stage_fixed" }, { title: "interactive", path: "/configuration/interactive" }, { title: "use_stdin", path: "/configuration/use_stdin" } ] }, { title: "commands", path: "/configuration/Commands", children: [ { title: "run", path: "/configuration/run" }, { title: "skip", path: "/configuration/skip" }, { title: "only", path: "/configuration/only" }, { title: "tags", path: "/configuration/tags" }, { title: "glob", path: "/configuration/glob" }, { title: "files", path: "/configuration/files" }, { title: "file_types", path: "/configuration/file_types" }, { title: "env", path: "/configuration/env" }, { title: "root", path: "/configuration/root" }, { title: "exclude", path: "/configuration/exclude" }, { title: "fail_text", path: "/configuration/fail_text" }, { title: "stage_fixed", path: "/configuration/stage_fixed" }, { title: "interactive", path: "/configuration/interactive" }, { title: "use_stdin", path: "/configuration/use_stdin" }, { title: "priority", path: "/configuration/priority" } ] }, { title: "scripts", path: "/configuration/Scripts", children: [ { title: "runner", path: "/configuration/runner" }, { title: "args", path: "/configuration/args" }, { title: "skip", path: "/configuration/skip" }, { title: "only", path: "/configuration/only" }, { title: "tags", path: "/configuration/tags" }, { title: "env", path: "/configuration/env" }, { title: "fail_text", path: "/configuration/fail_text" }, { title: "stage_fixed", path: "/configuration/stage_fixed" }, { title: "interactive", path: "/configuration/interactive" }, { title: "use_stdin", path: "/configuration/use_stdin" }, { title: "priority", path: "/configuration/priority" } ] } ] } ] }, { title: "CLI", collapsible: true, icon: "terminal", children: [ { title: "lefthook install", icon: "chevron-right", path: "/usage/commands/install" }, { title: "lefthook uninstall", icon: "chevron-right", path: "/usage/commands/uninstall" }, { title: "lefthook run", icon: "chevron-right", path: "/usage/commands/run" }, { title: "lefthook add", icon: "chevron-right", path: "/usage/commands/add" }, { title: "lefthook validate", icon: "chevron-right", path: "/usage/commands/validate" }, { title: "lefthook dump", icon: "chevron-right", path: "/usage/commands/dump" }, { title: "lefthook check-install", icon: "chevron-right", path: "/usage/commands/check-install" }, { title: "lefthook self-update", icon: "chevron-right", path: "/usage/commands/self-update" }, { title: "ENV variables", collapsible: true, icon: "dollar-sign", children: [ { title: "LEFTHOOK", path: "/usage/envs/LEFTHOOK" }, { title: "LEFTHOOK_VERBOSE", path: "/usage/envs/LEFTHOOK_VERBOSE" }, { title: "LEFTHOOK_OUTPUT", path: "/usage/envs/LEFTHOOK_OUTPUT" }, { title: "LEFTHOOK_CONFIG", path: "/usage/envs/LEFTHOOK_CONFIG" }, { title: "LEFTHOOK_EXCLUDE", path: "/usage/envs/LEFTHOOK_EXCLUDE" }, { title: "CLICOLOR_FORCE", path: "/usage/envs/CLICOLOR_FORCE" }, { title: "NO_COLOR", path: "/usage/envs/NO_COLOR" }, { title: "CI", path: "/usage/envs/CI" } ] } ] }, { title: "Examples", collapsible: true, icon: "file-code", children: [ { title: "Using local only config", path: "/examples/lefthook-local" }, { title: "Wrap commands locally", path: "/examples/wrap-commands" }, { title: "Auto add linter fixes to commit", path: "/examples/stage_fixed" }, { title: "Filter files", path: "/examples/filters" }, { title: "Skip or run on condition", path: "/examples/skip" }, { title: "Remote configs", path: "/examples/remotes" }, { title: "With commitlint", path: "/examples/commitlint" } ] }, { title: "Contributors", path: "/misc/contributors", icon: "users-round" }, { title: "GitHub", path: "https://github.com/evilmartians/lefthook", icon: "github", external: true } ] }; ================================================ FILE: docs/configuration/Commands.md ================================================ --- title: "commands" --- # `commands` Commands to be executed for the hook. Each command has a name and associated run [options](#command). #### Example ```yml # lefthook.yml pre-commit: commands: lint: ... # command options ``` ### Command options - [`run`](./run.md) - [`skip`](./skip.md) - [`only`](./only.md) - [`tags`](./tags.md) - [`glob`](./glob.md) - [`files`](./files.md) - [`file_types`](./file_types.md) - [`env`](./env.md) - [`root`](./root.md) - [`exclude`](./exclude.md) - [`fail_text`](./fail_text.md) - [`stage_fixed`](./stage_fixed.md) - [`interactive`](./interactive.md) - [`use_stdin`](./use_stdin.md) - [`priority`](./priority.md) ================================================ FILE: docs/configuration/Hook.md ================================================ --- title: "Hook" --- # Git hook Contains settings for the git hook (commands, scripts, skip rules, etc.). You can specify any Git hook or your own custom, e.g. `test` ```yml # lefthook.yml # Git hook pre-commit: jobs: - run: yarn lint {staged_files} --fix stage_fixed: true # Custom hook check-docs: jobs: - run: yarn check-docs - run: typos ``` ================================================ FILE: docs/configuration/README.md ================================================ ## Config file name Lefthook supports the following file names for the main config: | Format | File name | |-------|-----------| | YAML | `lefthook.yml` | | YAML | `.lefthook.yml` | | YAML | `.config/lefthook.yml` | | | | | YAML | `lefthook.yaml` | | YAML | `.lefthook.yaml` | | YAML | `.config/lefthook.yaml` | | | | | TOML | `lefthook.toml` | | TOML | `.lefthook.toml` | | TOML | `.config/lefthook.toml` | | | | | JSON | `lefthook.json` | | JSON | `.lefthook.json` | | JSON | `.config/lefthook.json` | | | | | JSONC | `lefthook.jsonc` | | JSONC | `.lefthook.jsonc` | | JSONC | `.config/lefthook.jsonc` | If there are more than 1 file in the project, only one will be used, and you'll never know which one. So, please, use one format in a project. Filenames without the leading dot will also be looked up from the [`.config` subdirectory](https://github.com/pi0/config-dir). Lefthook also merges an extra config with the name `lefthook-local`. All supported formats can be applied to this `-local` config. If you name your main config with the leading dot, like `.lefthook.json`, the `-local` config also must be named with the leading dot: `.lefthook-local.json`. The `-local` config can be used without a main config file. This is useful when you want to use lefthook locally without imposing it on your teammates – just create a `lefthook-local.yml` file and add it to your global `.gitignore`. ## Options - [`assert_lefthook_installed`](./assert_lefthook_installed.md) - [`colors`](./colors.md) - [`extends`](./extends.md) - [`lefthook`](./lefthook.md) - [`min_version`](./min_version.md) - [`no_tty`](./no_tty.md) - [`output`](./output.md) - [`rc`](./rc.md) - [`remotes`](./remotes.md) - [`git_url`](./git_url.md) - [`ref`](./ref.md) - [`refetch`](./refetch.md) - [`refetch_frequency`](./refetch_frequency.md) - [`configs`](./configs.md) - [`source_dir`](./source_dir.md) - [`source_dir_local`](./source_dir_local.md) - [`skip_lfs`](./skip_lfs.md) - [`templates`](./templates.md) - [{Git hook name}](./Hook.md) (e.g. `pre-commit`) - [`files` (global)](./files-global.md) - [`parallel`](./parallel.md) - [`piped`](./piped.md) - [`follow`](./follow.md) - [`fail_on_changes`](./fail_on_changes.md) - [`fail_on_changes_diff`](./fail_on_changes_diff.md) - [`exclude_tags`](./exclude_tags.md) - [`exclude`](./exclude.md) - [`skip`](./skip.md) - [`only`](./only.md) - [`jobs`](./jobs.md) - [`name`](./name.md) - [`run`](./run.md) - [`script`](./script.md) - [`runner`](./runner.md) - [`args`](./args.md) - [`group`](./group.md) - [`parallel`](./parallel.md) - [`piped`](./piped.md) - [`jobs`](./jobs.md) - [`skip`](./skip.md) - [`only`](./only.md) - [`tags`](./tags.md) - [`glob`](./glob.md) - [`files`](./files.md) - [`file_types`](./file_types.md) - [`env`](./env.md) - [`root`](./root.md) - [`exclude`](./exclude.md) - [`fail_text`](./fail_text.md) - [`stage_fixed`](./stage_fixed.md) - [`interactive`](./interactive.md) - [`use_stdin`](./use_stdin.md) - [`commands`](./Commands.md) - [`run`](./run.md) - [`skip`](./skip.md) - [`only`](./only.md) - [`tags`](./tags.md) - [`glob`](./glob.md) - [`files`](./files.md) - [`file_types`](./file_types.md) - [`env`](./env.md) - [`root`](./root.md) - [`exclude`](./exclude.md) - [`fail_text`](./fail_text.md) - [`stage_fixed`](./stage_fixed.md) - [`interactive`](./interactive.md) - [`use_stdin`](./use_stdin.md) - [`priority`](./priority.md) - [`scripts`](./Scripts.md) - [`runner`](./runner.md) - [`args`](./args.md) - [`skip`](./skip.md) - [`only`](./only.md) - [`tags`](./tags.md) - [`env`](./env.md) - [`fail_text`](./fail_text.md) - [`stage_fixed`](./stage_fixed.md) - [`interactive`](./interactive.md) - [`use_stdin`](./use_stdin.md) - [`priority`](./priority.md) ================================================ FILE: docs/configuration/Scripts.md ================================================ --- title: "Scripts" --- # Scripts Scripts are stored under `//` folder. These scripts are your own executables which are being run in the project root. To add a script for a `pre-commit` hook: 1. Run `lefthook add -d pre-commit` 1. Edit `.lefthook/pre-commit/my-script.sh` 1. Add an entry to `lefthook.yml` ```yml # lefthook.yml pre-commit: scripts: "my-script.sh": runner: bash ``` ### Example Let's create a bash script to check commit templates `.lefthook/commit-msg/template_checker`: ```bash INPUT_FILE=$1 START_LINE=`head -n1 $INPUT_FILE` PATTERN="^(TICKET)-[[:digit:]]+: " if ! [[ "$START_LINE" =~ $PATTERN ]]; then echo "Bad commit message, see example: TICKET-123: some text" exit 1 fi ``` Now we can ask lefthook to run our bash script by adding this code to `lefthook.yml` file: ```yml # lefthook.yml commit-msg: scripts: "template_checker": runner: bash ``` When you try to commit `git commit -m "bad commit text"` script `template_checker` will be executed. Since commit text doesn't match the described pattern the commit process will be interrupted. ================================================ FILE: docs/configuration/args.md ================================================ --- title: "args" --- # `args` ::: callout tip New feature Added in lefthook `2.0.5` ::: Sometimes you want to pass arguments to the scripts or be able to overwrite arguments to the commands in `lefthook-local.yml`. For this you can use `args` option which will simply be appended to the command. You can use the same templates as in [`run`](./run.md). Arguments passed by Git will be omitted if you specify `args` in the config. Providing no `args` or providing `args: "{0}"` works the same way. See [`run`](./run.md) for supported templates. #### Example ```yml # lefthook.yml pre-commit: jobs: - script: check-python-files.sh runner: bash args: "{staged_files}" glob: "*.py" - run: yarn lint args: "{staged_files}" glob: - "*.ts" - "*.js" ``` ================================================ FILE: docs/configuration/assert_lefthook_installed.md ================================================ --- title: "assert_lefthook_installed" --- # `assert_lefthook_installed` **Default: `false`** When set to `true`, fail (with exit status 1) if `lefthook` executable can't be found in $PATH, under node_modules/, as a Ruby gem, or other supported method. This makes sure git hook won't omit `lefthook` rules if `lefthook` ever was installed. ================================================ FILE: docs/configuration/colors.md ================================================ --- title: "colors" --- # `colors` **Default: `auto`** Whether enable or disable colorful output of Lefthook. This option can be overwritten with `--colors` option. You can also provide your own color codes. #### Example Disable colors. ```yml # lefthook.yml colors: false ``` Custom color codes. Can be hex or ANSI codes. ```yml # lefthook.yml colors: cyan: 14 gray: 244 green: '#32CD32' red: '#FF1493' yellow: '#F0E68C' ``` Control via ENV variable. - Set `NO_COLOR=true` to disable colored output in lefthook and all subcommands that lefthook calls. - Set `CLICOLOR_FORCE=true` to force colored output in lefthook and all subcommands. ================================================ FILE: docs/configuration/configs.md ================================================ --- title: "configs" --- # `configs` **Default:** `[lefthook.yml]` An optional array of config paths from remote's root. #### Example ```yml # lefthook.yml remotes: - git_url: git@github.com:evilmartians/lefthook ref: v1.0.0 configs: - examples/ruby-linter.yml - examples/test.yml ``` Example with multiple remotes merging multiple configurations. ```yml # lefthook.yml remotes: - git_url: git@github.com:org/lefthook-configs ref: v1.0.0 configs: - examples/ruby-linter.yml - examples/test.yml - git_url: https://github.com/org2/lefthook-configs configs: - lefthooks/pre_commit.yml - lefthooks/post_merge.yml - git_url: https://github.com/org3/lefthook-configs ref: feature/new configs: - configs/pre-push.yml ``` ================================================ FILE: docs/configuration/env.md ================================================ --- title: "env" --- # `env` You can specify some ENV variables for the command or script. #### Example ```yml # lefthook.yml pre-commit: commands: test: env: RAILS_ENV: test run: bundle exec rspec ``` #### Extending PATH If your hook is run by GUI program, and you use some PATH tweaks in your ~/.rc, you might see an error saying *executable not found*. In that case You can extend the **$PATH** variable with `lefthook-local.yml` configuration the following way. ```yml # lefthook.yml pre-commit: commands: test: run: yarn test ``` ```yml # lefthook-local.yml pre-commit: commands: test: env: PATH: $PATH:/home/me/path/to/yarn ``` **Notes** This option is useful when using lefthook on different OSes or shells where ENV variables are set in different ways. ================================================ FILE: docs/configuration/exclude.md ================================================ --- title: "exclude" --- # `exclude` This option allows to setup a list of globs for files to be excluded in files template. ::: callout info Note The glob patterns used in `exclude` are affected by the [`glob_matcher`](./glob_matcher.md) setting. See the glob_matcher documentation for details on how `**` patterns behave. ::: #### Example Run Rubocop on staged files with `.rb` extension except for `application.rb`, `routes.rb`, `rails_helper.rb`, and all Ruby files in `config/initializers/`. ```yml # lefthook.yml pre-commit: jobs: - name: lint glob: "*.rb" exclude: - config/routes.rb - config/application.rb - config/initializers/*.rb - spec/rails_helper.rb run: bundle exec rubocop --force-exclusion -- {staged_files} ``` If you've specified `exclude` but don't have a files template in [`run`](./run.md) option, lefthook will check `{staged_files}` for `pre-commit` hook and `{push_files}` for `pre-push` hook and apply filtering. If no files left, the command will be skipped. ```yml # lefthook.yml pre-commit: exclude: - "*/application.rb" jobs: - name: lint run: bundle exec rubocop # will skip if only application.rb was staged ``` ================================================ FILE: docs/configuration/exclude_tags.md ================================================ --- title: "exclude_tags" --- # `exclude_tags` [Tags](./tags.md) or command names that you want to exclude. This option can be overwritten with `LEFTHOOK_EXCLUDE` env variable. #### Example ```yml # lefthook.yml pre-commit: exclude_tags: frontend commands: lint: tags: frontend ... test: tags: frontend ... check-syntax: tags: documentation ``` ```bash lefthook run pre-commit # will only run check-syntax command ``` **Notes** This option is good to specify in `lefthook-local.yml` when you want to skip some execution locally. ```yml # lefthook.yml pre-push: commands: packages-audit: tags: - frontend - security run: yarn audit gems-audit: tags: - backend - security run: bundle audit ``` You can skip commands by tags: ```yml # lefthook-local.yml pre-push: exclude_tags: - frontend ``` ================================================ FILE: docs/configuration/extends.md ================================================ --- title: "extends" --- # `extends` You can extend your config with another one YAML file. Its content will be merged. Extends for `lefthook.yml`, `lefthook-local.yml`, and [`remotes`](./remotes.md) configs are handled separately, so you can have different extends in these files. You can use asterisk to make a glob. #### Example ```yml # lefthook.yml extends: - /home/user/work/lefthook-extend.yml - /home/user/work/lefthook-extend-2.yml - lefthook-extends/file.yml - ../extend.yml - projects/*/specific-lefthook-config.yml ``` > The extends will be merged to the main configuration in your file. Here is the order of settings applied: > > - `lefthook.yml` – main config file > - `extends` – configs specified in [extends](./extends.md) option > - `remotes` – configs specified in [remotes](./remotes.md) option > - `lefthook-local.yml` – local config file > > So, `extends` override settings from `lefthook.yml`, `remotes` override `extends`, and `lefthook-local.yml` can override everything. ================================================ FILE: docs/configuration/fail_on_changes.md ================================================ --- title: "fail_on_changes" --- # `fail_on_changes` The behaviour of lefthook when files (tracked by git) are modified can set by modifying the `fail_on_changes` configuration parameter. The possible values are: - `never`: never exit with a non-zero status if files were modified (default). - `always`: always exit with a non-zero status if files were modified. - `ci`: exit with a non-zero status only when the `CI` environment variable is set. This can be useful when combined with `stage_fixed` to ensure a frictionless devX locally, and a robust CI. - `non-ci`: exit with a non-zero status only when the `CI` environment variable is _not_ set. This can be useful in setups where the CI pipeline commits changes automatically, such as [autofix.ci](https://autofix.ci). See also [`fail_on_changes_diff`](./fail_on_changes_diff.md). ```yml # lefthook.yml pre-commit: parallel: true fail_on_changes: "always" commands: lint: run: yarn lint test: run: yarn test ``` ================================================ FILE: docs/configuration/fail_on_changes_diff.md ================================================ --- title: "fail_on_changes_diff" --- # `fail_on_changes_diff` When Lefthook exits with a non-zero status as a result of [`fail_on_changes`](./fail_on_changes.md) triggering, it can optionally output a diff of the detected changes. The default behavior is to output the diff when run in a CI pipeline. The `fail_on_changes_diff` boolean configuration parameter can be used to override this. ```yml # lefthook.yml pre-commit: parallel: true fail_on_changes: "always" fail_on_changes_diff: true commands: lint: run: yarn lint test: run: yarn test ``` ================================================ FILE: docs/configuration/fail_text.md ================================================ --- title: "fail_text" --- # `fail_text` You can specify a text to show when the command or script fails. #### Example ```yml # lefthook.yml pre-commit: commands: lint: run: yarn lint fail_text: Add node executable to $PATH ``` ```bash $ git commit -m 'fix: Some bug' Lefthook v1.1.3 RUNNING HOOK: pre-commit EXECUTE > lint SUMMARY: (done in 0.01 seconds) 🥊 lint: Add node executable to $PATH env ``` ================================================ FILE: docs/configuration/file_types.md ================================================ --- title: "file_types" --- # `file_types` Filter files in a [`run`](./run.md) templates by their type. Special file types and MIME types are supported[^1]: |File type| Explanation| |---------|-----------| |`text` | Any file that contains text. Symlinks are not followed. | |`binary` | Any file that contains non-text bytes. Symlinks are not followed. | |`executable` | Any file that has executable bits set. Symlinks are not followed. | |`not executable` | Any file without executable bits in file mode. Symlinks included. | |`symlink` | A symlink file. | |`not symlink` | Any non-symlink file. | |`text/html` | An HTML file. | |`text/xml` | An XML file. | |`text/javascript` | A Javascript file. | |`text/x-php` | A PHP file. | |`text/x-lua` | A Lua file. | |`text/x-perl` | A Perl file. | |`text/x-python` | A Python file. | |`text/x-shellscript` | Shell script file. | |`text/x-sh` | Also shell script file. | |`application/json` | JSON file. | > **Important** > The following types are applied using AND logic: > - text > - binary > - executable > - not executable > - symlink > - not symlink > > The mime types are applied using OR logic. So, you can have both `text/x-lua` and `text/x-sh`, but you can't specify both `symlink` and `not symlink`. **Examples** Apply some different linters on text and binary files. ```yml # lefthook.yml pre-commit: commands: lint-code: run: yarn lint {staged_files} file_types: text check-hex-codes: run: yarn check-hex {staged_files} file_types: binary ``` Skip symlinks. ```yml # lefthook.yml pre-commit: commands: lint: run: yarn lint --fix {staged_files} file_types: - not symlink ``` Lint executable scripts. ```yml # lefthook.yml pre-commit: commands: lint: run: yarn lint --fix {staged_files} file_types: - executable - text ``` Check typos in scripts. ```yml # lefthook.yml pre-commit: jobs: - run: typos -w -- {staged_files} file_types: - text/x-perl - text/x-python - text/x-php - text/x-lua - text/x-sh ``` [^1]: All supported MIME types can be found here: [supported_mimes.md](https://github.com/gabriel-vasile/mimetype/blob/v1.4.11/supported_mimes.md) ================================================ FILE: docs/configuration/files-global.md ================================================ --- title: "files (hook-level)" --- # `files` A custom command executed by the `sh` shell that returns the files or directories to be referenced in `{files}` template. See [`run`](#run) and [`files`](#files). If the result of this command is empty, the execution of commands will be skipped. #### Example ```yml # lefthook.yml pre-commit: files: git diff --name-only master # custom list of files commands: ... ``` ================================================ FILE: docs/configuration/files.md ================================================ --- title: "files (job-level)" --- # `files` A custom command executed by the `sh` shell that returns the files or directories to be referenced in `{files}` template for [`run`](./run.md) setting. If the result of this command is empty, the execution of commands will be skipped. This option overwrites the [hook-level `files`](./files-global.md) option. #### Example Provide a git command to list files. ```yml # lefthook.yml pre-push: commands: stylelint: tags: - frontend - style files: git diff --name-only master glob: "*.js" run: yarn stylelint {files} ``` Call a custom script for listing files. ```yml # lefthook.yml pre-push: commands: rubocop: tags: backend glob: "**/*.rb" files: node ./lefthook-scripts/ls-files.js # you can call your own scripts run: bundle exec rubocop --force-exclusion --parallel -- {files} ``` ================================================ FILE: docs/configuration/follow.md ================================================ --- title: "follow" --- # `follow` **Default: `false`** Follow the STDOUT of the running commands and scripts. #### Example ```yml # lefthook.yml pre-push: follow: true commands: backend-tests: run: bundle exec rspec frontend-tests: run: yarn test ``` ::: callout info Note If used with [`parallel`](#parallel) the output can be a mess, so please avoid setting both options to `true` ::: ================================================ FILE: docs/configuration/git_url.md ================================================ --- title: "git_url" --- # `git_url` A URL to Git repository. It will be accessed with privileges of the machine lefthook runs on. #### Example ```yml # lefthook.yml remotes: - git_url: git@github.com:evilmartians/lefthook ``` Or ```yml # lefthook.yml remotes: - git_url: https://github.com/evilmartians/lefthook ``` ================================================ FILE: docs/configuration/glob.md ================================================ --- title: "glob" --- # `glob` You can set a glob to filter files for your command. This is only used if you use a file template in [`run`](./run.md) option or provide your custom [`files`](./files.md) command. #### Example ```yml # lefthook.yml pre-commit: jobs: - name: lint run: yarn eslint {staged_files} glob: "*.{js,ts,jsx,tsx}" ``` ::: callout info Note From lefthook version `1.10.10` you can also provide a list of globs: ```yml # lefthook.yml pre-commit: jobs: - run: yarn lint {staged_files} glob: - "*.ts" - "*.js" ``` ::: For patterns that you can use see [this](https://tldp.org/LDP/GNU-Linux-Tools-Summary/html/x11655.htm) reference. We use [glob](https://github.com/gobwas/glob) library. **When using `root:`** Globs are still calculated from the actual root of the git repo, `root` is ignored. **Behaviour of `**`** Note that the behaviour of `**` is different from typical glob implementations, like `ls` or tools like `lint-staged` in that a double-asterisk matches 1+ directories deep, not zero or more directories. If you want to match *both* files at the top level and nested, then rather than: ```yaml glob: "src/**/*.js" ``` You'll need: ```yaml glob: "src/*.js" ``` Alternatively, you can opt-in to standard glob behavior by setting [`glob_matcher: doublestar`](./glob_matcher.md) at the top level of your configuration. With this setting, `**` will match 0 or more directories, making it consistent with most other glob implementations. **Using `glob` without a files template in`run`** If you've specified `glob` but don't have a files template in [`run`](./run.md) option, lefthook will check `{staged_files}` for `pre-commit` hook and `{push_files}` for `pre-push` hook and apply filtering. If no files left, the command will be skipped. ```yml # lefthook.yml pre-commit: jobs: - name: lint run: npm run lint # skipped if no .js files staged glob: "*.js" ``` ================================================ FILE: docs/configuration/glob_matcher.md ================================================ --- title: "glob_matcher" --- # `glob_matcher` You can configure which glob matching engine lefthook uses to filter files. By default, lefthook uses `gobwas/glob`, but you can opt-in to use `doublestar` for standard glob behavior. **Values:** - `gobwas` (default): The current glob implementation - `doublestar`: Standard glob behavior where `**` matches 0 or more directories **Example:** ```yml # lefthook.yml glob_matcher: doublestar pre-commit: jobs: - name: lint run: yarn eslint {staged_files} glob: "**/*.{js,ts}" ``` ### Key Differences The main difference between the two matchers is how they handle `**`: #### Default behavior (`gobwas`) The `**` pattern matches **1 or more** directories: - `**/*.js` matches `folder/file.js`, `a/b/c/file.js` - `**/*.js` does **NOT** match `file.js` at the root level #### Standard behavior (`doublestar`) The `**` pattern matches **0 or more** directories: - `**/*.js` matches `file.js`, `folder/file.js`, `a/b/c/file.js` - This is consistent with most glob implementations ### When to Use **Use `glob_matcher: doublestar` when:** - You want standard glob behavior consistent with other tools - You need `**` to match files at any level including the root - You're migrating from other tools that use standard glob patterns **Keep the default (`gobwas`) when:** - You want to maintain existing behavior - You specifically need `**` to require at least one directory level - You have existing patterns that depend on the current behavior ### Example Comparison ```yml # With default (gobwas) glob_matcher: gobwas # or omit this line pre-commit: jobs: - run: eslint -- {staged_files} glob: "**/*.js" # Matches: src/app.js, lib/util.js # Does NOT match: app.js - run: eslint -- {staged_files} glob: "*.js" # Matches: app.js # Does NOT match: src/app.js ``` ```yml # With doublestar glob_matcher: doublestar pre-commit: jobs: - run: eslint -- {staged_files} glob: "**/*.js" # Matches: app.js, src/app.js, lib/util.js ``` ### Notes - The `glob_matcher` setting is global and applies to all `glob` and `exclude` patterns in your configuration - This setting does not affect `root` or other path-related options - The setting is fully backward compatible - existing configurations continue to work without modification ================================================ FILE: docs/configuration/group.md ================================================ --- title: "group" --- # `group` You can define a group of jobs and configure how they should execute using the following options: - [`parallel`](./parallel.md): Executes all jobs in the group simultaneously. - [`piped`](./piped.md): Executes jobs sequentially, passing output between them. - [`jobs`](./jobs.md): Specifies the jobs within the group. ### Example ```yml # lefthook.yml pre-commit: jobs: - group: parallel: true jobs: - run: echo 1 - run: echo 2 - run: echo 3 ``` If you specify `env`, `root`, `glob`, or `exclude` on a group, they will be inherited to the underlying jobs. ```yml # lefthook.yml pre-commit: jobs: - env: E1: hello glob: - "*.md" exclude: - "README.md" root: "subdir/" group: parallel: true jobs: - run: echo $E1 - run: echo $E1 env: E1: bonjour ``` ::: callout info Note To make a group mergeable with settings defined in local config or extends you have to specify the name of the job group belongs to: ```yml pre-commit: jobs: - name: a name of a group group: jobs: - name: lint run: yarn lint - name: test run: yarn test ``` ::: ================================================ FILE: docs/configuration/install_non_git_hooks.md ================================================ --- title: "install_non_git_hooks" --- # `install_non_git_hooks` > Since lefthook 2.0.17 Install non-Git hooks into `.git/hooks`. May be useful for using with tools like https://git-flow.sh/. ================================================ FILE: docs/configuration/interactive.md ================================================ --- title: "interactive" --- # `interactive` **Default: `false`** ::: callout info Note If you want to pass stdin to your command or script but don't need to get the input from CLI, use [`use_stdin`](./use_stdin.md) option instead. ::: Whether to use interactive mode. This applies the certain behavior: - All `interactive` commands/scripts are executed after non-interactive. Exception: [`piped`](./piped.md) option is set to `true`. - When executing, lefthook tries to open /dev/tty (Linux/Unix only) and use it as stdin. - When [`no_tty`](./no_tty.md) option is set, `interactive` is ignored. ================================================ FILE: docs/configuration/jobs.md ================================================ --- title: "jobs" --- # `jobs` ::: callout tip New feature Added in lefthook `1.10.0` ::: Jobs provide a flexible way to define tasks, supporting both commands and scripts. Jobs can be grouped for advanced flow control. ### Basic example Define jobs in your `lefthook.yml` file under a specific hook like `pre-commit`: ```yml # lefthook.yml pre-commit: jobs: - run: yarn lint - run: yarn test ``` ### Differences from Commands and Scripts **Optional Job Names** - Named jobs are merged across [`extends`](./extends.md) and local config. - Unnamed jobs are appended in the order of their definition. **Job Groups** - Groups can include other jobs. - Flow within groups can be parallel or piped. Options `glob`, `root`, and `exclude` apply to all jobs in the group, including nested ones. ### Example ::: callout info Note Currently, only `root`, `glob`, and `exclude` options are applied to group jobs. Other options must be set for each job individually. Submit a [feature request](https://github.com/evilmartians/lefthook/issues/new?assignees=&labels=feature+request&projects=&template=feature_request.md) if this limits your workflow. ::: A configuration demonstrating a piped group running in parallel with other jobs: ```yml # lefthook.yml pre-commit: parallel: true jobs: - name: migrate root: backend/ glob: "db/migrations/*" group: piped: true jobs: - run: bundle install - run: rails db:migrate - run: yarn lint --fix {staged_files} root: frontend/ stage_fixed: true - run: bundle exec rubocop root: backend/ - run: golangci-lint root: proxy/ - script: verify.sh runner: bash ``` This configuration runs migrate jobs in a piped flow while other jobs execute in parallel. ================================================ FILE: docs/configuration/lefthook.md ================================================ --- title: "lefthook" --- # `lefthook` **Default:** `null` ::: callout tip New feature Added in lefthook `1.10.5` ::: Provide a full path to lefthook executable or a command to run lefthook. Bourne shell (`sh`) syntax is supported. > **Important:** This option does not merge from `remotes` or `extends` for security reasons. But it gets merged from lefthook local config if specified. There are three reasons you may want to specify `lefthook`: 1. You want to force using specific lefthook version from your dependencies (e.g. npm package) 1. You use PnP loader for your JS/TS project, and your `package.json` with lefthook dependency locates in a subfolder 1. You want to make sure you use concrete lefthook executable path and want to defined it in `lefthook-local.yml` ### Examples #### Specify lefthook executable ```yml # lefthook.yml lefthook: /usr/bin/lefthook pre-commit: jobs: - run: yarn lint ``` #### Specify a command to run lefthook ```yml # lefthook.yml lefthook: | cd project-with-lefthook pnpm lefthook pre-commit: jobs: - run: yarn lint root: project-with-lefthook ``` #### Force using a version from Rubygems ```yml # lefthook.yml lefthook: bundle exec lefthook pre-commit: jobs: - run: bundle exec rubocop -- {staged_files} ``` #### Enable debug logs ```yml # lefthook-local.yml lefthook: LEFTHOOK_VERBOSE=1 lefthook ``` ================================================ FILE: docs/configuration/min_version.md ================================================ --- title: "min_version" --- # `min_version` If you want to specify a minimum version for lefthook binary (e.g. if you need some features older versions don't have) you can set this option. #### Example ```yml # lefthook.yml min_version: 1.1.3 ``` ================================================ FILE: docs/configuration/name.md ================================================ --- title: "name" --- # `name` Name of a job. Will be printed in summary. If specified, the jobs can be merged with a jobs of the same name in a [local config](../examples/lefthook-local.md) or [extends](./extends.md). ### Example ```yml # lefthook.yml pre-commit: jobs: - name: lint and fix run: yarn run eslint --fix {staged_files} ``` ================================================ FILE: docs/configuration/no_auto_install.md ================================================ --- title: "no_auto_install" --- # `no_auto_install` **Default: `false`** Disable automatic installation and synchronization of git hooks when running lefthook. By default, lefthook automatically installs and updates hooks when you run `lefthook run` if the configuration has changed. Setting this to `true` disables that behavior. This can also be controlled with the `--no-auto-install` option for the `lefthook run` command. #### Example ```yml # lefthook.yml no_auto_install: true pre-commit: commands: lint: run: npm run lint ``` ================================================ FILE: docs/configuration/no_tty.md ================================================ --- title: "no_tty" --- # `no_tty` **Default: `false`** Whether hide spinner and other interactive things. This can be also controlled with `--no-tty` option for `lefthook run` command. #### Example ```yml # lefthook.yml no_tty: true ``` ================================================ FILE: docs/configuration/only.md ================================================ --- title: "only" --- # `only` You can force a command, script, or the whole hook to execute only in certain conditions. This option acts like the opposite of [`skip`](./skip.md). It accepts the same values but skips execution only if the condition is not satisfied. ::: callout info Note `skip` option takes precedence over `only` option, so if you have conflicting conditions the execution will be skipped. ::: #### Example Execute a hook only for `dev/*` branches. ```yml # lefthook.yml pre-commit: only: - ref: dev/* commands: lint: run: yarn lint test: run: yarn test ``` When rebasing execute quick linter but skip usual linter and tests. ```yml # lefthook.yml pre-commit: commands: lint: skip: rebase run: yarn lint test: skip: rebase run: yarn test lint-on-rebase: only: rebase run: yarn lint-quickly ``` ================================================ FILE: docs/configuration/output.md ================================================ --- title: "output" --- # `output` You can manage verbosity using the `output` config. You can specify what to print in your output by setting these values, which you need to have Possible values are `meta,summary,success,failure,execution,execution_out,execution_info,skips`. By default, all output values are enabled You can also disable all output with setting `output: false`. In this case only errors will be printed. #### Example ```yml # lefthook.yml output: - meta # Print lefthook version - summary # Print summary block (successful and failed steps) - empty_summary # Print summary heading when there are no steps to run - success # Print successful steps - failure # Print failed steps printing - execution # Print any execution logs - execution_out # Print execution output - execution_info # Print `EXECUTE > ...` logging - skips # Print "skip" (i.e. no files matched) ``` You can also *extend* this list with an environment variable `LEFTHOOK_OUTPUT`: ```bash LEFTHOOK_OUTPUT="meta,success,summary" lefthook run pre-commit ``` ================================================ FILE: docs/configuration/parallel.md ================================================ --- title: "parallel" --- # `parallel` **Default: `false`** ::: callout info Note Lefthook runs commands and scripts **sequentially** by default ::: Run commands and scripts concurrently. ================================================ FILE: docs/configuration/piped.md ================================================ --- title: "piped" --- # `piped` **Default: `false`** ::: callout info Note Lefthook will return an error if both `piped: true` and `parallel: true` are set ::: Stop running commands and scripts if one of them fail. #### Example ```yml # lefthook.yml database: piped: true # Stop if one of the steps fail commands: 1_create: run: rake db:create 2_migrate: run: rake db:migrate 3_seed: run: rake db:seed ``` ================================================ FILE: docs/configuration/priority.md ================================================ --- title: "priority" --- # `priority` **Default: `0`** ::: callout info Note This option makes sense only when `parallel: false` or `piped: true` is set. Value `0` is considered an `+Infinity`, so commands or scripts with `priority: 0` or without this setting will be run at the very end. ::: Set priority from 1 to +Infinity. This option can be used to configure the order of the sequential steps. #### Example ```yml # lefthook.yml post-checkout: piped: true commands: db-create: priority: 1 run: rails db:create db-migrate: priority: 2 run: rails db:migrate db-seed: priority: 3 run: rails db:seed scripts: "check-spelling.sh": runner: bash priority: 1 "check-grammar.rb": runner: ruby priority: 2 ``` ================================================ FILE: docs/configuration/rc.md ================================================ --- title: "rc" --- # `rc` Provide an [**rc**](https://www.baeldung.com/linux/rc-files) file, which is actually a simple `sh` script. Currently it can be used to set ENV variables that are not accessible from non-shell programs. #### Example Use cases: - You have a GUI program that runs git hooks (e.g., VSCode) - You reference executables that are accessible only from a tweaked $PATH environment variable (e.g., when using rbenv or nvm, fnm) - Or even if your GUI program cannot locate the `lefthook` executable :scream: - Or if you want to use ENV variables that control the executables behavior in `lefthook.yml` ```bash # An npm executable which is managed by nvm $ which npm /home/user/.nvm/versions/node/v15.14.0/bin/npm ``` ```yml # lefthook.yml pre-commit: commands: lint: run: npm run eslint {staged_files} ``` Provide a tweak to access `npm` executable the same way you do it in your ~/rc. ```yml # lefthook-local.yml # You can choose whatever name you want. # You can share it between projects where you use lefthook. # Make sure the path is absolute. rc: ~/.lefthookrc ``` Or ```yml # lefthook-local.yml # If the path contains spaces, you need to quote it. rc: '"${XDG_CONFIG_HOME:-$HOME/.config}/lefthookrc"' ``` In the rc file, export any new environment variables or modify existing ones. ```bash # ~/.lefthookrc # An nvm way export NVM_DIR="$HOME/.nvm" [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # An fnm way export FNM_DIR="$HOME/.fnm" [ -s "$FNM_DIR/fnm.sh" ] && \. "$FNM_DIR/fnm.sh" # Or maybe just PATH=$PATH:$HOME/.nvm/versions/node/v15.14.0/bin ``` ```bash # Make sure you updated git hooks. This is important. $ lefthook install -f ``` Now any program that runs your hooks will have a tweaked PATH environment variable and will be able to get `nvm` :wink: ================================================ FILE: docs/configuration/ref.md ================================================ --- title: "ref" --- # `ref` An optional *branch* or *tag* name. ::: callout info Note If you initially had `ref` option, ran `lefthook install`, and then removed it, lefthook won't decide which branch/tag to use as a ref. So, if you added it once, please, use it always to avoid issues in local setups. ::: See also [`refetch_frequency`](./refetch_frequency.md). #### Example ```yml # lefthook.yml remotes: - git_url: git@github.com:evilmartians/lefthook ref: v1.0.0 ``` ================================================ FILE: docs/configuration/refetch.md ================================================ --- title: "refetch" --- # `refetch` **Default:** `false` Force remote config refetching on every run. Lefthook will be refetching the specified remote every time it is called. See [`refetch_frequency`](./refetch_frequency.md) for more flexible refetching options and additional considerations. #### Example ```yml # lefthook.yml remotes: - git_url: https://github.com/evilmartians/lefthook refetch: true ``` ================================================ FILE: docs/configuration/refetch_frequency.md ================================================ --- title: "refetch_frequency" --- # `refetch_frequency` **Default:** Not set Specifies how frequently Lefthook should refetch the remote configuration. This can be set to `always`, `never` or a time duration like `24h`, `30m`, etc. - When set to `always`, Lefthook will always refetch the remote configuration on each run. - When set to a duration (e.g., `24h`), Lefthook will check the last fetch time and refetch the configuration only if the specified amount of time has passed. - When set to `never` or not set, Lefthook will not fetch from remote. It is recommended to configure remotes that point to mutable references (including ones without a `ref`) to be refetched with some frequency appropriate for the project. Failure to fetch does not cause an error, but just a warning message. If a successfully fetched previous configuration exists, it will be used. Otherwise, the remote will be ignored. #### Example ```yml # lefthook.yml remotes: - git_url: https://github.com/evilmartians/lefthook refetch_frequency: 24h # Refetches once every 24 hours ``` > WARNING > If `refetch` is set to `true`, it overrides any setting in `refetch_frequency`. ================================================ FILE: docs/configuration/remotes.md ================================================ --- title: "remotes" --- # `remotes` You can provide multiple remote configs if you want to share yours lefthook configurations across many projects. Lefthook will automatically download and merge configurations into your local `lefthook.yml`. You can use [`extends`](./extends.md) but the paths must be relative to the remote repository root. If you provide [`scripts`](./scripts.md) in a remote config file, the [scripts](./source_dir.md) folder must also be in the **root of the repository**. **Note** The configuration from `remotes` will be merged to the local config using the following priority: 1. Local main config (`lefthook.yml`) 1. Remote configs (`remotes`) 1. Local overrides (`lefthook-local.yml`) This priority may be changed in the future. For simplicity, try to keep jobs in remote settings independent from any other steps. ================================================ FILE: docs/configuration/root.md ================================================ --- title: "root" --- # `root` You can change the CWD for the command you execute using `root` option. This is useful when you execute some `npm` or `yarn` command but the `package.json` is in another directory. For `pre-push` and `pre-commit` hooks and for the custom `files` command `root` option is used to filter file paths. If all files are filtered the command will be skipped. #### Example Format and stage files from a `client/` folder. ```bash # Folders structure $ tree . . ├── client/ │ ├── package.json │ ├── node_modules/ | ├── ... ├── server/ | ... ``` ```yml # lefthook.yml pre-commit: commands: lint: root: "client/" glob: "*.{js,ts}" run: yarn eslint --fix {staged_files} && git add {staged_files} ``` **When using `root:`** Globs are still calculated from the actual root of the git repo, `root` is ignored. ================================================ FILE: docs/configuration/run.md ================================================ --- title: "run" --- # `run` This is a mandatory option for a command, which specifies the actual command to be run using the `sh` shell. You can use files templates that will be substituted with the appropriate files on execution: - `{files}` - custom [`files`](./files.md) command result. - `{staged_files}` - staged files which you try to commit. - `{push_files}` - files that are committed but not pushed. - `{all_files}` - all files tracked by git. - `{cmd}` - shorthand for the command from `lefthook.yml`. - `{0}` - shorthand for the single space-joint string of git hook arguments. - `{1}` - shorthand for the 1-st git hook argument (and so on for `{2}`, `{3}`, etc.) - `{lefthook_job_name}` - current job/command/script name ::: callout info Note Command line length has a limit on every system. If your list of files is quite long, lefthook splits your files list to fit in the limit and runs few commands sequentially. ::: #### Example Run `yarn lint` on `pre-commit` hook. ```yml # lefthook.yml pre-commit: commands: lint: run: yarn lint ``` #### `{files}` template Run `go vet` only on files listed with `git ls-files -m` command with `.go` extension. ```yml # lefthook.yml pre-commit: commands: govet: files: git ls-files -m glob: "*.go" run: go vet -- {files} ``` #### `{staged_files}` Run `yarn eslint` only on staged files with `.js`, `.ts`, `.jsx`, and `.tsx` extensions. ```yml # lefthook.yml pre-commit: commands: eslint: glob: "*.{js,ts,jsx,tsx}" run: yarn eslint {staged_files} ``` #### `{push_files}` If you want to lint files only before pushing them. ```yml # lefthook.yml pre-push: commands: eslint: glob: "*.{js,ts,jsx,tsx}" run: yarn eslint {push_files} ``` #### `{all_files}` Simply run `bundle exec rubocop` on all files with `.rb` extension excluding `application.rb` and `routes.rb` files. ::: callout info Note `--force-exclusion` will apply `Exclude` configuration setting of Rubocop ::: ```yml # lefthook.yml pre-commit: commands: rubocop: tags: - backend - style glob: "*.rb" exclude: - config/application.rb - config/routes.rb run: bundle exec rubocop --force-exclusion -- {all_files} ``` #### `{cmd}` ```yml # lefthook.yml pre-commit: commands: lint: run: yarn lint scripts: "good_job.js": runner: node ``` You can wrap it in docker runner locally: ```yml # lefthook-local.yml pre-commit: commands: lint: run: docker run -it --rm {cmd} scripts: "good_job.js": runner: docker run -it --rm {cmd} ``` #### Git arguments Make sure commits are signed. ```yml # lefthook.yml # Note: commit-msg hook takes a single parameter, # the name of the file that holds the proposed commit log message. # Source: https://git-scm.com/docs/githooks#_commit_msg commit-msg: commands: multiple-sign-off: run: 'test $(grep -c "^Signed-off-by: " {1}) -lt 2' ``` #### Rubocop If using `{all_files}` with RuboCop, it will ignore RuboCop's `Exclude` configuration setting. To avoid this, pass `--force-exclusion`. #### Quotes If you want to have all your files quoted with double quotes `"` or single quotes `'`, quote the appropriate shorthand: ```yml # lefthook.yml pre-commit: commands: lint: glob: "*.js" # Quoting with double quotes `"` might be helpful for Windows users run: yarn eslint "{staged_files}" # will run `yarn eslint "file1.js" "file2.js" "[strange name].js"` test: glob: "*.{spec.js}" run: yarn test '{staged_files}' # will run `yarn eslint 'file1.spec.js' 'file2.spec.js' '[strange name].spec.js'` format: glob: "*.js" # Will quote where needed with single quotes run: yarn test {staged_files} # will run `yarn eslint file1.js file2.js '[strange name].spec.js'` ``` #### Scripts ```yml # lefthook.yml pre-commit: jobs: - name: a whole script in a run run: | for file in $(ls .); do yarn lint $file done ``` ================================================ FILE: docs/configuration/runner.md ================================================ --- title: "runner" --- # `runner` You should specify a runner for the script. This is a command that should execute a script file. It will be called the following way: ` ` (e.g. `ruby .lefthook/pre-commit/lint.rb`). #### Example ```yml # lefthook.yml pre-commit: scripts: "lint.js": runner: node "check.go": runner: go run ``` ================================================ FILE: docs/configuration/script.md ================================================ --- title: "script" --- # `script` Name of a script to execute. The rules are the same as for [`scripts`](./Scripts.md) ### Example ```yml # lefthook.yml pre-commit: jobs: - script: linter.sh runner: bash ``` ```bash # .lefthook/pre-commit/linter.sh echo "Everything is OK" ``` ================================================ FILE: docs/configuration/setup.md ================================================ --- title: 'setup' --- # `setup` ::: callout tip New feature Added in lefthook `2.1.2` ::: A list of instructions to run before any job. Supports templates and Git args like in [`run`](./run.md). ::: callout info Note When merging configs (with `lefthook-local.yml` or files from [`extends`](./extends.md)) `setup` instructions get **prepended**. When there are multiple `extends`, they get **appended** in the same order as extend files are specified. ::: #### Example ```yml # lefthook.yml pre-commit: setup: - run: | if ! command -v golangci-lint >/dev/null 2>&1; then go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.10.1 fi jobs: - run: golangci-lint -- {staged_files} glob: "*.go" ``` ================================================ FILE: docs/configuration/skip.md ================================================ --- title: "skip" --- # `skip` You can skip all or specific commands and scripts using `skip` option. You can also skip when merging, rebasing, or being on a specific branch. Globs are available for branches. Possible skip values: - `rebase` - when in rebase git state - `merge` - when in merge git state - `merge-commit` - when current HEAD commit is the merge commit - `ref: main` - when on a `main` branch - `run: test ${SKIP_ME} -eq 1` - when `test ${SKIP_ME} -eq 1` is successful (return code is 0) #### Example Always skipping a command: ```yml # lefthook.yml pre-commit: commands: lint: skip: true run: yarn lint ``` Skipping on merging and rebasing: ```yml # lefthook.yml pre-commit: commands: lint: skip: - merge - rebase run: yarn lint ``` Or ```yml # lefthook.yml pre-commit: commands: lint: skip: merge run: yarn lint ``` Skipping when your are on a merge commit: ```yml # lefthook.yml pre-push: commands: lint: skip: merge-commit run: yarn lint ``` Skipping the whole hook on `main` branch: ```yml # lefthook.yml pre-commit: skip: - ref: main commands: lint: run: yarn lint test: run: yarn test ``` Skipping hook for all `dev/*` branches: ```yml # lefthook.yml pre-commit: skip: - ref: dev/* commands: lint: run: yarn lint test: run: yarn test ``` Skipping hook by running a command: ```yml # lefthook.yml pre-commit: skip: - run: test "${NO_HOOK}" -eq 1 commands: lint: run: yarn lint test: run: yarn test ``` Skipping a command conditionally based on the existence of a CLI tool: ```yml prepare-commit-msg: skip: - merge - rebase commands: aiautocommit: interactive: true run: aiautocommit commit --output-file "{1}" env: LOG_LEVEL: info skip: # only run this if the tool exists - run: "! which aiautocommit" ``` > TIP > > Always skipping is useful when you have a `lefthook-local.yml` config and you don't want to run some commands locally. So you just overwrite the `skip` option for them to be `true`. > > ```yml > # lefthook.yml > > pre-commit: > commands: > lint: > run: yarn lint > ``` > > ```yml > # lefthook-local.yml > > pre-commit: > commands: > lint: > skip: true > ``` ================================================ FILE: docs/configuration/skip_lfs.md ================================================ --- title: "skip_lfs" --- # `skip_lfs` **Default:** `false` Skip running LFS hooks even if it exists on your system. ### Example ```yml # lefthook.yml skip_lfs: true pre-push: commands: test: run: yarn test ``` ================================================ FILE: docs/configuration/source_dir.md ================================================ --- title: "source_dir" --- # `source_dir` **Default: `.lefthook/`** Change a directory for script files. Directory for script files contains folders with git hook names which contain script files. Example of directory tree: ``` .lefthook/ ├── pre-commit/ │ ├── lint.sh │ └── test.py └── pre-push/ └── check-files.rb ``` ================================================ FILE: docs/configuration/source_dir_local.md ================================================ --- title: "source_dir_local" --- # `source_dir_local` **Default: `.lefthook-local/`** Change a directory for *local* script files (not stored in VCS). This option is useful if you have a `lefthook-local.yml` config file and want to reference different scripts there. ================================================ FILE: docs/configuration/stage_fixed.md ================================================ --- title: "stage_fixed" --- # `stage_fixed` **Default: `false`** > Works **only for `pre-commit`** hook When set to `true` lefthook will automatically call `git add` on files after running the command or script. For a command if [`files`](./files.md) option was specified, the specified command will be used to retrieve files for `git add`. For scripts and commands without [`files`](./files.md) option `{staged_files}` template will be used. All filters ([`glob`](./glob.md), [`exclude`](./exclude.md)) will be applied if specified. #### Example ```yml # lefthook.yml pre-commit: commands: lint: run: npm run lint --fix {staged_files} stage_fixed: true ``` ================================================ FILE: docs/configuration/tags.md ================================================ --- title: "tags" --- # `tags` You can specify tags for commands and scripts. This is useful for [excluding](./exclude_tags.md). You can specify more than one tag using comma. #### Example ```yml # lefthook.yml pre-commit: commands: lint: tags: - frontend - js run: yarn lint test: tags: - backend - ruby run: bundle exec rspec ``` ================================================ FILE: docs/configuration/templates.md ================================================ --- title: "templates" --- # `templates` ::: callout tip New feature Added in lefthook `1.10.8` ::: Provide custom replacement for templates in `run` values. With `templates` you can specify what can be overridden via `lefthook-local.yml` without a need to overwrite every jobs in your configuration. ## Example ### Override with lefthook-local.yml ```yml # lefthook.yml templates: dip: # empty pre-commit: jobs: # Will run: `bundle exec rubocop -- file1 file2 file3 ...` - run: {dip} bundle exec rubocop -- {staged_files} ``` ```yml # lefthook-local.yml templates: dip: dip # Will run: `dip bundle exec rubocop -- file1 file2 file3 ...` ``` ### Reduce redundancy ```yml # lefthook.yml templates: wrapper: docker-compose run --rm -v $(pwd):/app service pre-commit: jobs: - run: {wrapper} yarn format - run: {wrapper} yarn lint - run: {wrapper} yarn test ``` ================================================ FILE: docs/configuration/use_stdin.md ================================================ --- title: "use_stdin" --- # `use_stdin` ::: callout info Note With many commands or scripts having `use_stdin: true`, only one will receive the data. The others will have nothing. If you need to pass the data from stdin to every command or script, please, submit a [feature request](https://github.com/evilmartians/lefthook/issues/new?assignees=&labels=feature+request&projects=&template=feature_request.md). ::: Pass the stdin from the OS to the command/script. #### Example Use this option for the `pre-push` hook when you have a script that does `while read ...`. Without this option lefthook will hang: lefthook uses [pseudo TTY](https://github.com/creack/pty) by default, and it doesn't close stdin when all data is read. ```bash # .lefthook/pre-push/do-the-magic.sh remote="$1" url="$2" while read local_ref local_oid remote_ref remote_oid; do # ... done ``` ```yml # lefthook.yml pre-push: scripts: "do-the-magic.sh": runner: bash use_stdin: true ``` ================================================ FILE: docs/configuration.md ================================================ --- title: "Configuration" --- # Config file name Lefthook supports the following file names for the main config: | Format | Acceptable config names | |-------|-----------| | YAML |`lefthook.yml`
`lefthook.yaml`
`.lefthook.yml`
`.lefthook.yaml`
`.config/lefthook.yml`
`.config/lefthook.yaml` | | TOML | `lefthook.toml`
`.lefthook.toml`
`.config/lefthook.toml` | | JSON | `lefthook.json`
`.lefthook.json`
`.config/lefthook.json` | | JSONC | `lefthook.jsonc`
`.lefthook.jsonc`
`.config/lefthook.jsonc` | If there are more than 1 file in the project, only one will be used, and you'll never know which one. So, please, use one format in a project. Filenames without the leading dot will also be looked up from the [`.config` subdirectory](https://github.com/pi0/config-dir). Lefthook also merges an extra config with the name `lefthook-local`. All supported formats can be applied to this `-local` config. If you name your main config with the leading dot, like `.lefthook.json`, the `-local` config also must be named with the leading dot: `.lefthook-local.json`. The `-local` config can be used without a main config file. This is useful when you want to use lefthook locally without imposing it on your teammates – just create a `lefthook-local.yml` file and add it to your global `.gitignore`. ================================================ FILE: docs/examples/commitlint.md ================================================ # Commitlint and commitzen Use lefthook to generate commit messages using commitzen and validate them with commitlint. ## Install dependencies ```bash yarn add -D @commitlint/cli @commitlint/config-conventional # For commitzen yarn add -D commitizen cz-conventional-changelog ``` ## Configure Setup `commitlint.config.js`. Conventional configuration: ```js // commitlint.config.js module.exports = {extends: ['@commitlint/config-conventional']}; ``` If you are using commitzen, make sure to add this in `package.json`: ```json "config": { "commitizen": { "path": "./node_modules/cz-conventional-changelog" } } ``` Configure lefthook: ```yml # lefthook.yml # Build commit messages prepare-commit-msg: commands: commitzen: interactive: true run: yarn run cz --hook # Or npx cz --hook env: LEFTHOOK: 0 # Validate commit messages commit-msg: commands: "lint commit message": run: yarn run commitlint --edit {1} ``` ## Test it ```bash # You can type it without message, if you are using commitzen git commit # Or provide a commit message is using only commitlint git commit -am 'fix: typo' ``` ================================================ FILE: docs/examples/filters.md ================================================ # Filters Files passed to your hooks can be filtered with the following options - [`glob`](../configuration/glob.md) - [`exclude`](../configuration/exclude.md) - [`file_types`](../configuration/file_types.md) - [`root`](../configuration/root.md) In this example all **staged files** will pass through these filters. ```yml # lefthook.yml pre-commit: commands: lint: run: yarn lint {staged_files} --fix glob: "*.{js,ts}" root: frontend exclude: - *.config.js - *.config.ts file_types: - not executable ``` Imagine you've staged the following files ```bash backend/asset.js frontend/src/index.ts frontend/bin/cli.js # <- executable frontend/eslint.config.js frontend/README.md ``` After all filters applied the `lint` command will execute the following: ```bash yarn lint frontend/src/index.ts --fix ``` ================================================ FILE: docs/examples/lefthook-local.md ================================================ # lefthook-local.yml ::: callout tip Tip You can put `lefthook-local.yml` into your `~/.gitignore`, so in every project you can have your local-only overrides. ::: `lefthook-local.yml` overrides and extends the configuration of your main `lefthook.yml`. ```yml # lefthook.yml pre-commit: commands: lint: run: bundle exec rubocop -- {staged_files} glob: "*.rb" check-links: run: lychee -- {staged_files} ``` ```yml # lefthook-local.yml pre-commit: parallel: true # run all commands concurrently commands: lint: run: docker-compose run backend {cmd} # wrap the original command with docker-compose check-links: skip: true # skip checking links # Add another hook post-merge: files: "git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD" commands: dependencies: glob: "Gemfile*" run: docker-compose run backend bundle install ``` --- ### The merged config lefthook will use ```yml pre-commit: parallel: true commands: lint: run: docker-compose run backend bundle exec rubocop -- {staged_files} glob: "*.rb" check-links: run: lychee -- {staged_files} skip: true post-merge: files: "git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD" commands: dependencies: glob: "Gemfile*" run: docker-compose run backend bundle install ``` ================================================ FILE: docs/examples/remotes.md ================================================ # Remotes Use configurations from other Git repositories via `remotes` feature. Lefthook will automatically download the remote config files and merge them into existing configuration. ```yml remotes: - git_url: https://github.com/evilmartians/lefthook configs: - examples/remote/ping.yml ``` ================================================ FILE: docs/examples/skip.md ================================================ # Skip or run on condition Here are two hooks. `pre-commit` hook will only be executed when you're committing something on a branch starting with `dev/` prefix. In `pre-push` hook: - `test` command will be skipped if `NO_TEST` env variable is set to `1` - `lint` command will only be executed if you're pushing the `main` branch ```yml # lefthook.yml pre-commit: only: - ref: dev/* commands: lint: run: yarn lint {staged_files} --fix glob: "*.{ts,js}" test: run: yarn test pre-push: commands: test: run: yarn test skip: - run: test "$NO_TEST" -eq 1 lint: run: yarn lint only: - ref: main ``` ================================================ FILE: docs/examples/stage_fixed.md ================================================ # Stage fixed files > Works only for `pre-commit` Git hook Sometimes your linter fixes the changes and you usually want to commit them automatically. To enable auto-staging of the fixed files use [`stage_fixed`](/configuration/stage_fixed.md) option. ```yml # lefthook.yml pre-commit: commands: lint: run: yarn lint {staged_files} --fix stage_fixed: true ``` ================================================ FILE: docs/examples/wrap-commands.md ================================================ # Wrap commands in local config Wrapping some commands defined in a main config with `dip`[^1]. ```yml # lefthook.yml pre-commit: jobs: - name: rubocop run: bundle exec rubocop -A -- {staged_files} ``` ```yml # lefthook-local.yml pre-commit: jobs: - name: rubocop run: dip {cmd} ``` [^1]: [dip](https://github.com/bibendi/dip) – dockerized dev experience with, similar to `docker-compose run` ================================================ FILE: docs/index.md ================================================ --- title: "What is Lefthook?" description: "Welcome to Lefthook documentation" --- **Lefthook** is a Git hooks manager. It is - Fast - Powerful - Simple ## How lefthook works? You - Configure [`lefthook.yml`](./configuration.md) - Run `lefthook install` Lefthook installs the configured hooks into `.git/hooks/`. Hook is a simple script that calls `lefthook run {hook-name}` when executed. ## How to install lefthook? The most common way is to use the package manager of your project, e.g. [gem](./installation/ruby.md) or [npm package](./installation/node.md). You can also install lefthook via [Homebrew](./installation/homebrew.md), [`winget`](./installation/winget.md), [`yum`](./installation/rpm.md), [`apt`](./installation/deb.md), [`apk`](./installation/alpine.md), [`scoop`](./installation/scoop.md) ## Example configuration Run linters on `pre-commit` hook. ```yml # lefthook.yml pre-commit: parallel: true jobs: - run: yarn run stylelint --fix {staged_files} glob: "*.css" stage_fixed: true - run: yarn run eslint --fix "{staged_files}" glob: - "*.ts" - "*.js" - "*.tsx" - "*.jsx" stage_fixed: true ``` --- Sponsored by Evil Martians ================================================ FILE: docs/install.md ================================================ --- title: "Install Lefthook" --- Lefthook distributes as a standalone, no-deps binary. There are multiple ways to install lefthook but the most common is via package manager for your programming language (see the options in the dropdown on the left). You can also download just the [binary](https://github.com/evilmartians/lefthook/releases/latest) for your OS and architecture and put it somewhere in your `$PATH` and update it with ``` lefthook self-update ``` ================================================ FILE: docs/installation/alpine.md ================================================ --- title: "Alpine" --- # APK packages for Alpine ```sh sudo apk add --no-cache bash curl curl -1sLf 'https://dl.cloudsmith.io/public/evilmartians/lefthook/setup.alpine.sh' | sudo -E bash sudo apk add lefthook ``` See all instructions: https://cloudsmith.io/~evilmartians/repos/lefthook/setup/#formats-alpine [![Hosted By: Cloudsmith](https://img.shields.io/badge/OSS%20hosting%20by-cloudsmith-blue?logo=cloudsmith&style=flat-square)](https://cloudsmith.com "RPM package repository hosting is graciously provided by Cloudsmith") ================================================ FILE: docs/installation/arch.md ================================================ --- title: "Arch Linux" --- # AUR for Arch - Official [AUR package](https://aur.archlinux.org/packages/lefthook) (compiles from sources) - Community [AUR package](https://aur.archlinux.org/packages/lefthook-bin) (delivers pre-compiled binaries) ```sh # To compile from sources yay -S lefthook # To install only executable yay -S lefthook-bin ``` ================================================ FILE: docs/installation/deb.md ================================================ --- title: "Debian-based" --- # APT packages for Debian/Ubuntu Linux ```sh curl -1sLf 'https://dl.cloudsmith.io/public/evilmartians/lefthook/setup.deb.sh' | sudo -E bash sudo apt install lefthook ``` See all instructions: https://cloudsmith.io/~evilmartians/repos/lefthook/setup/#formats-deb [![Hosted By: Cloudsmith](https://img.shields.io/badge/OSS%20hosting%20by-cloudsmith-blue?logo=cloudsmith&style=flat-square)](https://cloudsmith.com "Debian package repository hosting is graciously provided by Cloudsmith") ================================================ FILE: docs/installation/devbox.md ================================================ # Devbox Add lefthook in the devbox environment. lefthook already exists in the [Nix package](https://search.nixos.org/packages?channel=25.05&show=lefthook&from=0&size=50&sort=relevance&type=packages&query=lefthook) ```bash devbox add lefthook@latest ``` ::: callout info Note The devbox plugin for lefthook is maintained by the community. While we appreciate their contribution, the lefthook team cannot provide direct support for devbox-specific installation issues. ::: ================================================ FILE: docs/installation/go.md ================================================ # Go The minimum Go version required is 1.26 and you can install - as global package ```bash go install github.com/evilmartians/lefthook/v2@v2.1.4 ``` - or as a go tool in your project ```bash go get -tool github.com/evilmartians/lefthook/v2 ``` ================================================ FILE: docs/installation/homebrew.md ================================================ --- title: "Homebrew" --- # Homebrew for MacOS and Linux ```bash brew install lefthook ``` ================================================ FILE: docs/installation/manual.md ================================================ --- title: "Manual" --- # Manual installation with prebuilt executable Download binaries from [latest release](https://github.com/evilmartians/lefthook/releases/latest) and install manually. ================================================ FILE: docs/installation/mise.md ================================================ # Mise > See [https://github.com/jdx/mise](https://github.com/jdx/mise) ```bash mise use lefthook@latest ``` ::: callout info Note The mise plugin for lefthook is maintained by the community. While we appreciate their contribution, the lefthook team cannot provide direct support for mise-specific installation issues. ::: ================================================ FILE: docs/installation/node.md ================================================ --- title: "NPM" --- # NPM package ```bash npm install --save-dev lefthook ``` ```bash yarn add --dev lefthook ``` ```bash pnpm add -D lefthook ``` ::: callout info Note If you use `pnpm` package manager make sure to update `pnpm-workspace.yaml`s `onlyBuiltDependencies` with `lefthook` and add `lefthook` to `pnpm.onlyBuiltDependencies` in your root `package.json`, otherwise the `postinstall` script of the `lefthook` package won't be executed and hooks won't be installed. ::: ## Choose right package Lefthook supports three NPM packages with different ways to deliver the executables 1. [lefthook](https://www.npmjs.com/package/lefthook) installs one executable for your system ```bash npm install --save-dev lefthook ``` 1. **legacy**[^1] [@evilmartians/lefthook](https://www.npmjs.com/package/@evilmartians/lefthook) installs executables for all OS ```bash npm install --save-dev @evilmartians/lefthook ``` 1. **legacy**[^1] [@evilmartians/lefthook-installer](https://www.npmjs.com/package/@evilmartians/lefthook-installer) fetches the right executable on installation ```bash npm install --save-dev @evilmartians/lefthook-installer ``` [^1]: Legacy distributions are still maintained but they will be shut down in the future. ================================================ FILE: docs/installation/python.md ================================================ # Python ```sh python -m pip install --user lefthook ``` ```sh uv add --dev lefthook ``` ```sh pipx install lefthook ``` ================================================ FILE: docs/installation/rpm.md ================================================ --- title: "RPM-based" --- # RPM packages for CentOS/Fedora Linux ```sh curl -1sLf 'https://dl.cloudsmith.io/public/evilmartians/lefthook/setup.rpm.sh' | sudo -E bash sudo yum install lefthook ``` See all instructions: https://cloudsmith.io/~evilmartians/repos/lefthook/setup/#repository-setup-yum [![Hosted By: Cloudsmith](https://img.shields.io/badge/OSS%20hosting%20by-cloudsmith-blue?logo=cloudsmith&style=flat-square)](https://cloudsmith.com "RPM package repository hosting is graciously provided by Cloudsmith") ================================================ FILE: docs/installation/ruby.md ================================================ # Ruby ```ruby # Gemfile group :development do gem "lefthook", require: false end ``` Or globally ```bash gem install lefthook ``` **Troubleshooting** If you see the error `lefthook: command not found` you need to check your $PATH. Also try to restart your terminal. ================================================ FILE: docs/installation/scoop.md ================================================ --- title: "Scoop" --- # Scoop for Windows ```sh scoop install lefthook ``` ================================================ FILE: docs/installation/snap.md ================================================ --- title: "Snap" --- # Snap for Linux ```sh snap install --classic lefthook ``` ================================================ FILE: docs/installation/swift.md ================================================ # Swift You can find the Swift wrapper plugin [here](https://github.com/csjones/lefthook-plugin). Utilize lefthook in your Swift project using Swift Package Manager: ```swift .package(url: "https://github.com/csjones/lefthook-plugin.git", exact: "2.1.4"), ``` Or, with [mint](https://github.com/yonaskolb/Mint): ```bash mint run csjones/lefthook-plugin ``` ================================================ FILE: docs/installation/winget.md ================================================ --- title: "Winget" --- # Winget for Windows ```sh winget install evilmartians.lefthook ``` ================================================ FILE: docs/misc/contributors.md ================================================ # Contributors - [Arkweid](https://github.com/Arkweid) - [Envek](https://github.com/Envek) - [mrexox](https://github.com/mrexox) - [skryukov](https://github.com/skryukov) - [scop](https://github.com/scop) - [hyperupcall](https://github.com/hyperupcall) - [MartijnCuppens](https://github.com/MartijnCuppens) - [palkan](https://github.com/palkan) - [markovichecha](https://github.com/markovichecha) - [technicalpickles](https://github.com/technicalpickles) - [aminya](https://github.com/aminya) - [prog-supdex](https://github.com/prog-supdex) - [HellSquirrel](https://github.com/HellSquirrel) - [Evilweed](https://github.com/Evilweed) - [PikachuEXE](https://github.com/PikachuEXE) - [jsmestad](https://github.com/jsmestad) - [DmitryTsepelev](https://github.com/DmitryTsepelev) - [pmirecki](https://github.com/pmirecki) - [0legovich](https://github.com/0legovich) - [zachahn](https://github.com/zachahn) - [sitiom](https://github.com/sitiom) - [spearmootz](https://github.com/spearmootz) - [pwinckles](https://github.com/pwinckles) - [pablobirukov](https://github.com/pablobirukov) - [nihalgonsalves](https://github.com/nihalgonsalves) - [nesk](https://github.com/nesk) - [jaydorsey](https://github.com/jaydorsey) - [fantua](https://github.com/fantua) - [orsinium](https://github.com/orsinium) - [fabn](https://github.com/fabn) If you feel you’re missing from this list, feel free to add yourself in a PR. ================================================ FILE: docs/usage/commands/add.md ================================================ --- title: "lefthook add" --- ## `lefthook add` Installs the given hook to Git hook. With argument `--dirs` creates a directory `.git/hooks//` if it doesn't exist. Use it before adding a script to configuration. #### Example ```bash $ lefthook add pre-push --dirs ``` Describe pre-push commands in `lefthook.yml`: ```yml pre-push: jobs: - script: "audit.sh" runner: bash ``` Edit the script: ```bash $ vim .lefthook/pre-push/audit.sh ... ``` Run `git push` and lefthook will run `bash audit.sh` as a pre-push hook. ================================================ FILE: docs/usage/commands/check-install.md ================================================ --- title: "lefthook check-install" --- ## `lefthook check-install` Checks if Git hooks are installed and synchronized. Returns: - `0` if hooks installed and synchronized - `1` if hooks not installed or need a sync ================================================ FILE: docs/usage/commands/dump.md ================================================ --- title: "lefthook dump" --- ## `lefthook dump` Prints the whole configuration after merging all secondary configs. This is the actual config lefthook uses, it can be build from the main config (`lefthook.yml`), remotes, extends, and `lefthook-local.yml` overrides. ================================================ FILE: docs/usage/commands/install.md ================================================ --- title: "lefthook install" --- ## `lefthook install` Creates an empty `lefthook.yml` if a configuration file does not exist. Installs configured hooks to Git hooks. ::: callout info Note Reinstall is not required when you modify `lefthook.yml`, the configuration file is read every time a git hook is run. ::: ::: callout info Note NPM package `lefthook` installs the hooks in a postinstall script automatically. For projects not using NPM package run `lefthook install` after cloning the repo. ::: ### Installing specific hooks You can install only specific hooks by running `lefthook install ...`. ================================================ FILE: docs/usage/commands/run.md ================================================ --- title: "lefthook run" --- ## `lefthook run` Executes the commands and scripts configured for a given hook. Installed Git hooks call `lefthook run` implicitly. #### Example ```yml # lefthook.yml pre-commit: jobs: - name: lint run: yarn lint --fix {staged_files} test: jobs: - name: test run: yarn test ``` Install the hook. ```bash $ lefthook install ``` ```bash $ lefthook run test # will run 'yarn test' $ git commit # will run pre-commit hook ('yarn lint --fix') $ lefthook run pre-commit # will run pre-commit hook (`yarn lint --fix`) ``` ### Run specific jobs You can specify which jobs to run (also `--tag` supported). ```bash $ lefthook run pre-commit --job lints --job pretty --tag checks ``` ### Specify files You can force replacing files templates (like `{staged_files}`) with either all files (will acts as `{all_files}` template) or a list of files. ```bash $ lefthook run pre-commit --all-files $ lefthook run pre-commit --file file1.js --file file2.js ``` (if both are specified, `--all-files` is ignored) ================================================ FILE: docs/usage/commands/self-update.md ================================================ --- title: "lefthook self-update" --- ## `lefthook self-update` Updates the binary with the latest lefthook release on Github. This command is available only if you install lefthook from sources or download the binary from the Github Releases. For other ways use package-specific commands to update lefthook. ================================================ FILE: docs/usage/commands/uninstall.md ================================================ --- title: "lefthook uninstall" --- ## `lefthook uninstall` Clears Git hooks installed by lefthook. ================================================ FILE: docs/usage/commands/validate.md ================================================ --- title: "lefthook validate" --- ## `lefthook validate` Validates your lefthook configuration. Use `lefthook dump` to see it. It uses JSON schema from the lefthook Github repo. ================================================ FILE: docs/usage/commands/version.md ================================================ --- title: "lefthook version" --- ## `lefthook version` `lefthook version` prints the current binary version. Print the commit hash with `lefthook version --full` #### Example ```bash $ lefthook version --full 1.1.3 bb099d13c24114d2859815d9d23671a32932ffe2 ``` ================================================ FILE: docs/usage/envs/CI.md ================================================ --- title: "CI" --- ## `CI` When using NPM package `lefthook`, set `CI=true` in your CI (if it does not set it automatically) to prevent lefthook from installing hooks in the postinstall script: ```bash CI=true npm install CI=true yarn install CI=true pnpm install ``` ::: callout info Note Set `LEFTHOOK=1` or `LEFTHOOK=true` to override this behavior and install hooks in the postinstall script (despite `CI=true`). ::: ================================================ FILE: docs/usage/envs/CLICOLOR_FORCE.md ================================================ --- title: "CLICOLOR_FORCE" --- ## `CLICOLOR_FORCE` Set `CLICOLOR_FORCE=true` to force colored output in lefthook and all subcommands. ================================================ FILE: docs/usage/envs/LEFTHOOK.md ================================================ --- title: "LEFTHOOK" --- ## `LEFTHOOK` Use `LEFTHOOK=0 git ...` or `LEFTHOOK=false git ...` to disable lefthook when running git commands. #### Example ```bash LEFTHOOK=0 git commit -am "Lefthook skipped" ``` When using NPM package `lefthook` in CI, and your CI sets `CI=true` automatically, use `LEFTHOOK=1` or `LEFTHOOK=true` to install hooks in the postinstall script: #### Example ```bash LEFTHOOK=1 npm install LEFTHOOK=1 yarn install LEFTHOOK=1 pnpm install ``` ================================================ FILE: docs/usage/envs/LEFTHOOK_BIN.md ================================================ --- title: "LEFTHOOK_BIN" --- ## `LEFTHOOK_BIN` Set `LEFTHOOK_BIN` to a location where lefthook is installed to use that instead of trying to detect from the it the PATH or from a package manager. Useful for cases when: - lefthook is installed multiple ways, and you want to be explicit about which one is used (example: installed through homebrew, but also is in Gemfile but you are using a ruby version manager like rbenv that prepends it to the path) - debugging and/or developing lefthook ================================================ FILE: docs/usage/envs/LEFTHOOK_CONFIG.md ================================================ --- title: "LEFTHOOK_CONFIG" --- ## `LEFTHOOK_CONFIG` Override the main lefthook config with `LEFTHOOK_CONFIG=~/global_lefthook.yml`. Note: local config, specified extends, and remotes will still be loaded. ================================================ FILE: docs/usage/envs/LEFTHOOK_EXCLUDE.md ================================================ --- title: "LEFTHOOK_EXCLUDE" --- ## `LEFTHOOK_EXCLUDE` Use `LEFTHOOK_EXCLUDE={list of tags or command names to be excluded}` to skip some commands or scripts by tag or name (for commands only). See the [`exclude_tags`](../../configuration/exclude_tags.md) configuration option for more details. #### Example ```bash LEFTHOOK_EXCLUDE=ruby,security,lint git commit -am "Skip some tag checks" ``` ================================================ FILE: docs/usage/envs/LEFTHOOK_OUTPUT.md ================================================ --- title: "LEFTHOOK_OUTPUT" --- ## `LEFTHOOK_OUTPUT` Use `LEFTHOOK_OUTPUT={list of output values}` to specify what to print in your output. You can also set `LEFTHOOK_OUTPUT=false` to disable all output except for errors. Refer to the [`output`](../../configuration/output.md) configuration option for more details. #### Example ```bash $ LEFTHOOK_OUTPUT=summary lefthook run pre-commit summary: (done in 0.52 seconds) ✔️ lint ``` ================================================ FILE: docs/usage/envs/LEFTHOOK_VERBOSE.md ================================================ --- title: "LEFTHOOK_VERBOSE" --- ## `LEFTHOOK_VERBOSE` Set `LEFTHOOK_VERBOSE=1` or `LEFTHOOK_VERBOSE=true` to enable verbose printing. #### Example ```bash LEFTHOOK_VERBOSE=1 lefthook run pre-commit ``` ================================================ FILE: docs/usage/envs/NO_COLOR.md ================================================ --- title: "NO_COLOR" --- ## `NO_COLOR` Set `NO_COLOR=true` to disable colored output in lefthook and all subcommands that lefthook calls. ================================================ FILE: docs/usage/features/git-args.md ================================================ ## Capture ARGS from git in the script Lefthook passes Git arguments to your commands and scripts. ``` ├── .lefthook │   └── prepare-commit-msg │   └── message.sh └── lefthook.yml ``` ```yml # lefthook.yml prepare-commit-msg: jobs: - script: "message.sh" runner: bash - run: echo "Git args: {1} {2} {3}" ``` ```bash # .lefthook/prepare-commit-msg/message.sh # Arguments get passed from Git COMMIT_MSG_FILE=$1 COMMIT_SOURCE=$2 SHA1=$3 # ... ``` ================================================ FILE: docs/usage/features/git-lfs.md ================================================ ## Git LFS support ::: callout info Note If git-lfs binary is not installed and not required in your project, LFS hooks won't be executed, and you won't be warned about it. Git LFS hooks may be slow. Disable them with the global `skip_lfs: true` setting. ::: Lefthook runs LFS hooks internally for the following hooks: - post-checkout - post-commit - post-merge - pre-push Errors are suppressed if git LFS is not required for the project. You can use [`LEFTHOOK_VERBOSE`](../envs/LEFTHOOK_VERBOSE.md) ENV to make lefthook show git LFS output. To avoid calling LFS hooks set [`skip_lfs: true`](../../configuration/skip_lfs.md) in lefthook.yml or lefthook-local.yml ================================================ FILE: docs/usage/features/interactive.md ================================================ ## Using an interactive command or script When you need to interact with user – specify [`interactive: true`](../../configuration/interactive.md). Lefthook will connect to the current TTY and forward it to your command's or script's stdin. ================================================ FILE: docs/usage/features/local.md ================================================ ## Local config You can extend and override options of your main configuration with `lefthook-local.yml`. Don't forget to add the file to `.gitignore`. You can also use `lefthook-local.yml` without a main config file. This is useful when you want to use lefthook locally without imposing it on your teammates. ```yml # lefthook.yml (committed into your repo) pre-commit: jobs: - name: linter run: yarn lint - name: tests run: yarn test ``` ```yml # lefthook-local.yml (ignored by git) pre-commit: jobs: - name: tests skip: true # don't want to run tests on every commit - name: linter run: yarn lint {staged_files} # lint only staged files ``` ================================================ FILE: docs/usage/features/pass-stdin.md ================================================ ## Pass stdin to a command or script When you need to read the data from stdin – specify [`use_stdin: true`](../../configuration/use_stdin.md). This option is good when you write a command or script that receives data from git using stdin (for the `pre-push` hook, for example). ================================================ FILE: docs/usage.md ================================================ # Usage Here are the most common usage cases. You can find more info in the docs. ## Basic CLI commands ```bash # Create/update Git hooks based on lefthook.yml, or create an empty lefthook.yml lefthook install # Run pre-commit hook commands and scripts (requires lefthook.yml) lefthook run pre-commit # Validate the configuration lefthook validate # Dump the configuration (useful when you have remotes, extends that overwrite the configuration) lefthook dump ``` ## Skip running lefthook when committing changes ```bash LEFTHOOK=0 git commit ``` ================================================ FILE: examples/commitlint/README.md ================================================ # Use commitlint and/or commitzen ## Install dependencies ```bash yarn add -D @commitlint/cli @commitlint/config-conventional # If using commitzen yarn add -D commitizen cz-conventional-changelog ``` ## Configure Setup `commitlint.config.js`. Conventional configuration: ```bash echo "module.exports = {extends: ['@commitlint/config-conventional']};" > commitlint.config.js ``` If you are using commitzen, make sure to add this in `package.json`: ```json "config": { "commitizen": { "path": "./node_modules/cz-conventional-changelog" } } ``` ## Test it ```bash # You can type it without message, if you are using commitzen git commit # Or provide a commit message is using only commitlint git commit -am 'fix: typo' ``` ================================================ FILE: examples/commitlint/commitlint.config.js ================================================ module.exports = {extends: ['@commitlint/config-conventional']}; ================================================ FILE: examples/commitlint/lefthook.yml ================================================ # Use this to build commit messages prepare-commit-msg: commands: commitzen: interactive: true run: yarn run cz --hook # Or npx cz --hook env: LEFTHOOK: 0 # Use this to validate commit messages commit-msg: commands: "lint commit message": run: yarn run commitlint --edit {1} ================================================ FILE: examples/complete/lefthook.yml ================================================ commit-msg: scripts: "template_checker": runner: bash pre-commit: commands: stylelint: tags: frontend style glob: "*.js" run: yarn stylelint {staged_files} stage_fixed: true rubocop: tags: backend style glob: "*.rb" exclude: - "*/application.rb" - "*/routes.rb" run: bundle exec rubocop --force-exclusion -- {all_files} stage_fixed: true scripts: "good_job.js": runner: node pre-push: parallel: true commands: stylelint: tags: frontend style files: git diff --name-only master glob: "*.js" run: yarn stylelint {files} rubocop: tags: backend style files: git diff --name-only master glob: "*.rb" run: bundle exec rubocop --force-exclusion -- {files} scripts: "verify": runner: sh ================================================ FILE: examples/remote/ping.yml ================================================ # Test `remotes` config of lefthook. # # # lefthook.yml # # remotes: # - git_url: git@github.com:evilmartians/lefthook # configs: # - examples/remote/ping.yml # # $ lefthook run pre-commit pre-commit: commands: ping: run: echo pong ================================================ FILE: examples/verbose/lefthook.yml ================================================ --- # lefthook.yml # This hook executes on `git commit` pre-commit: parallel: true # All commands will be executed concurrently commands: # Commands section # `js-lint` will call `npx eslit --fix` only on staged files. # It will filter staged files by glob. # If there are no files left after filtering, this command will be skipped js-lint: glob: "*.{js,ts}" run: npx eslint --fix -- {staged_files} && git add -- {staged_files} # `ruby-test` will skip execution only when in a merging or rebasing state. ruby-test: skip: - merge - rebase run: bundle exec rspec fail_text: Run bundle install # `ruby-lint` has `files` option which is a git command for replacing # the {files} template. Then lefthook applies glob pattern to the result. # If the final list is empty, the command will be skipped. # Otherwise the {files} templace will be replaces with list. # # Note: if a template has surrounding quotes, they will be used to wrap # each file in the list. # Double quotes `"` and single quotes `'` are supported. ruby-lint: glob: "*.rb" files: git diff-tree -r --name-only --diff-filter=CDMR HEAD origin/master run: bundle exec rubocop --force-exclusion --parallel -- '{files}' # You can provide more hooks. pre-push: commands: spelling: files: git diff --name-only HEAD @{push} glob: "*.md" run: npx yaspeller -- {files} ================================================ FILE: examples/with_scripts/lefthook.yml ================================================ pre-commit: scripts: "good_job.js": runner: node ================================================ FILE: gen/jsonschema.go ================================================ package main import ( "encoding/json" "fmt" "os" "reflect" "time" "github.com/invopop/jsonschema" "github.com/evilmartians/lefthook/v2/internal/config" ) //go:generate go run jsonschema.go func main() { r := new(jsonschema.Reflector) r.ExpandedStruct = true r.AdditionalFields = func(t reflect.Type) []reflect.StructField { if t == reflect.TypeFor[config.Config]() { return reflect.VisibleFields(reflect.TypeFor[struct { Schema string `json:"$schema,omitempty"` PreCommit *config.Hook `json:"pre-commit,omitempty"` ApplypatchMsg *config.Hook `json:"applypatch-msg,omitempty"` PreApplypatch *config.Hook `json:"pre-applypatch,omitempty"` PostApplypatch *config.Hook `json:"post-applypatch,omitempty"` PreMergeCommit *config.Hook `json:"pre-merge-commit,omitempty"` PrepareCommitMsg *config.Hook `json:"prepare-commit-msg,omitempty"` CommitMsg *config.Hook `json:"commit-msg,omitempty"` PostCommit *config.Hook `json:"post-commit,omitempty"` PreRebase *config.Hook `json:"pre-rebase,omitempty"` PostCheckout *config.Hook `json:"post-checkout,omitempty"` PostMerge *config.Hook `json:"post-merge,omitempty"` PrePush *config.Hook `json:"pre-push,omitempty"` PreReceive *config.Hook `json:"pre-receive,omitempty"` Update *config.Hook `json:"update,omitempty"` ProcReceive *config.Hook `json:"proc-receive,omitempty"` PostReceive *config.Hook `json:"post-receive,omitempty"` PostUpdate *config.Hook `json:"post-update,omitempty"` ReferenceTransaction *config.Hook `json:"reference-transaction,omitempty"` PushToCheckout *config.Hook `json:"push-to-checkout,omitempty"` PreAutoGc *config.Hook `json:"pre-auto-gc,omitempty"` PostRewrite *config.Hook `json:"post-rewrite,omitempty"` SendemailValidate *config.Hook `json:"sendemail-validate,omitempty"` FsmonitorWatchman *config.Hook `json:"fsmonitor-watchman,omitempty"` P4Changelist *config.Hook `json:"p4-changelist,omitempty"` P4PrepareChangelist *config.Hook `json:"p4-prepare-changelist,omitempty"` P4PostChangelist *config.Hook `json:"p4-post-changelist,omitempty"` P4PreSubmit *config.Hook `json:"p4-pre-submit,omitempty"` PostIndexChange *config.Hook `json:"post-index-change,omitempty"` }]()) } return []reflect.StructField{} } schema := r.Reflect(&config.Config{}) if hookDef, ok := schema.Definitions["Hook"]; ok { schema.AdditionalProperties = hookDef } schema.ID = "https://json.schemastore.org/lefthook.json" schema.Comments = "Last updated on " + time.Now().Format("2006.01.02") + "." dumped, err := json.MarshalIndent(schema, "", " ") if err != nil { _, _ = fmt.Fprintf(os.Stderr, "failed to generate json: %s", err) os.Exit(1) } _, _ = os.Stdout.Write(dumped) } ================================================ FILE: go.mod ================================================ module github.com/evilmartians/lefthook/v2 go 1.26 toolchain go1.26.0 require ( github.com/bmatcuk/doublestar/v4 v4.10.0 github.com/briandowns/spinner v1.23.2 github.com/charmbracelet/lipgloss v1.1.0 github.com/creack/pty v1.1.24 github.com/gabriel-vasile/mimetype v1.4.13 github.com/gobwas/glob v0.2.3 github.com/goccy/go-yaml v1.19.2 github.com/invopop/jsonschema v0.13.0 github.com/kaptinlin/jsonschema v0.7.5 github.com/knadh/koanf/maps v0.1.2 github.com/knadh/koanf/parsers/json v1.0.0 github.com/knadh/koanf/parsers/toml/v2 v2.2.0 github.com/knadh/koanf/parsers/yaml v1.1.0 github.com/knadh/koanf/providers/fs v1.0.0 github.com/knadh/koanf/providers/rawbytes v1.0.0 github.com/knadh/koanf/v2 v2.3.2 github.com/mattn/go-tty v0.0.7 github.com/mitchellh/mapstructure v1.5.0 github.com/rogpeppe/go-internal v1.14.1 github.com/schollz/progressbar/v3 v3.19.0 github.com/spf13/afero v1.15.0 github.com/stretchr/testify v1.11.1 github.com/tidwall/jsonc v0.3.2 github.com/urfave/cli/v3 v3.7.0 golang.org/x/mod v0.33.0 ) require ( github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.1 // indirect github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect github.com/charmbracelet/x/ansi v0.8.0 // indirect github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect github.com/charmbracelet/x/term v0.2.1 // indirect github.com/clipperhouse/uax29/v2 v2.2.0 // indirect github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect github.com/go-viper/mapstructure/v2 v2.4.0 // indirect github.com/kaptinlin/go-i18n v0.2.12 // indirect github.com/kaptinlin/jsonpointer v0.4.17 // indirect github.com/kaptinlin/messageformat-go v0.4.18 // indirect github.com/kr/pretty v0.3.1 // indirect github.com/mailru/easyjson v0.7.7 // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/mitchellh/copystructure v1.2.0 // indirect github.com/mitchellh/reflectwalk v1.0.2 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/wk8/go-ordered-map/v2 v2.1.8 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect golang.org/x/tools v0.41.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) require ( github.com/alessio/shellescape v1.4.1 github.com/fatih/color v1.18.0 // indirect github.com/lucasb-eyer/go-colorful v1.2.0 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 github.com/mattn/go-runewidth v0.0.20 github.com/muesli/termenv v0.16.0 // indirect github.com/pelletier/go-toml/v2 v2.2.4 github.com/rivo/uniseg v0.4.7 // indirect go.yaml.in/yaml/v3 v3.0.4 golang.org/x/sys v0.41.0 // indirect golang.org/x/term v0.40.0 golang.org/x/text v0.34.0 // indirect ) ================================================ FILE: go.sum ================================================ github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0= github.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= github.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs= github.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w= github.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM= github.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs= github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs= github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE= github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8= github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= github.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM= github.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY= github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY= github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao= github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg= github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs= github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM= github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y= github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E= github.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0= github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= github.com/kaptinlin/go-i18n v0.2.12 h1:ywDsvb4KDFddMC2dpI/rrIzGU2mWUSvHmWUm9BMsdl4= github.com/kaptinlin/go-i18n v0.2.12/go.mod h1:pVcu9qsW5pOIOoZFJXesRYmLos1vMQrby70JPAoWmJU= github.com/kaptinlin/jsonpointer v0.4.17 h1:mY9k8ciWncxbsECyaxKnR0MdmxamNdp2tLQkAKVrtSk= github.com/kaptinlin/jsonpointer v0.4.17/go.mod h1:SsfsjqnHG5zuKo1DTBzk1VknaHlL4osHw+X9kZKukpU= github.com/kaptinlin/jsonschema v0.7.5 h1:jkK4a3NyzNoGlvu12CsL3IcqNMVa5sL51HPVa0nWcPY= github.com/kaptinlin/jsonschema v0.7.5/go.mod h1:3gIWnptl+SWMyfMR2r4TXXd0xsQZ1m50AKrwmcUONSg= github.com/kaptinlin/messageformat-go v0.4.18 h1:RBlHVWgZyoxTcUgGWBsl2AcyScq/urqbLZvzgryTmSI= github.com/kaptinlin/messageformat-go v0.4.18/go.mod h1:ntI3154RnqJgr7GaC+vZBnIExl2V3sv9selvRNNEM24= github.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo= github.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI= github.com/knadh/koanf/parsers/json v1.0.0 h1:1pVR1JhMwbqSg5ICzU+surJmeBbdT4bQm7jjgnA+f8o= github.com/knadh/koanf/parsers/json v1.0.0/go.mod h1:zb5WtibRdpxSoSJfXysqGbVxvbszdlroWDHGdDkkEYU= github.com/knadh/koanf/parsers/toml/v2 v2.2.0 h1:2nV7tHYJ5OZy2BynQ4mOJ6k5bDqbbCzRERLUKBytz3A= github.com/knadh/koanf/parsers/toml/v2 v2.2.0/go.mod h1:JpjTeK1Ge1hVX0wbof5DMCuDBriR8bWgeQP98eeOZpI= github.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1yILuTXRdM4= github.com/knadh/koanf/parsers/yaml v1.1.0/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg= github.com/knadh/koanf/providers/fs v1.0.0 h1:tvn4MrduLgdOSUqqEHULUuIcELXf6xDOpH8GUErpYaY= github.com/knadh/koanf/providers/fs v1.0.0/go.mod h1:FksHET+xXFNDozvj8ZCdom54OnZ6eGKJtC5FhZJKx/8= github.com/knadh/koanf/providers/rawbytes v1.0.0 h1:MrKDh/HksJlKJmaZjgs4r8aVBb/zsJyc/8qaSnzcdNI= github.com/knadh/koanf/providers/rawbytes v1.0.0/go.mod h1:KxwYJf1uezTKy6PBtfE+m725NGp4GPVA7XoNTJ/PtLo= github.com/knadh/koanf/v2 v2.3.2 h1:Ee6tuzQYFwcZXQpc2MiVeC6qHMandf5SMUJJNoFp/c4= github.com/knadh/koanf/v2 v2.3.2/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ= github.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-tty v0.0.7 h1:KJ486B6qI8+wBO7kQxYgmmEFDaFEE96JMBQ7h400N8Q= github.com/mattn/go-tty v0.0.7/go.mod h1:f2i5ZOvXBU/tCABmLmOfzLz9azMo5wdAaElRNnJKr+k= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc= github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/schollz/progressbar/v3 v3.19.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc= github.com/schollz/progressbar/v3 v3.19.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec= github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/tidwall/jsonc v0.3.2 h1:ZTKrmejRlAJYdn0kcaFqRAKlxxFIC21pYq8vLa4p2Wc= github.com/tidwall/jsonc v0.3.2/go.mod h1:dw+3CIxqHi+t8eFSpzzMlcVYxKp08UP5CD8/uSFCyJE= github.com/urfave/cli/v3 v3.7.0 h1:AGSnbUyjtLiM+WJUb4dzXKldl/gL+F8OwmRDtVr6g2U= github.com/urfave/cli/v3 v3.7.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso= github.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc= github.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E= golang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE= golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc= golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= ================================================ FILE: integration_test.go ================================================ //go:build integration package main_test import ( "fmt" "os" "path/filepath" "testing" "github.com/rogpeppe/go-internal/testscript" ) func TestLefthook(t *testing.T) { testscript.Run(t, testscript.Params{ Dir: filepath.Join("tests", "integration"), Setup: func(env *testscript.Env) error { env.Vars = append(env.Vars, fmt.Sprintf("GOCOVERDIR=%s", os.Getenv("GOCOVERDIR"))) return nil }, }) } ================================================ FILE: internal/command/add.go ================================================ package command import ( "context" "fmt" "path/filepath" "github.com/evilmartians/lefthook/v2/internal/config" "github.com/evilmartians/lefthook/v2/internal/templates" ) const defaultDirMode = 0o755 type AddArgs struct { Hook string CreateDirs, Force bool } // Add creates a hook, given in args. The hook is a Lefthook hook. func (l *Lefthook) Add(_ctx context.Context, args AddArgs) error { if !config.KnownHook(args.Hook) { return fmt.Errorf("skip adding, hook is unavailable: %s", args.Hook) } err := l.cleanHook(args.Hook, args.Force) if err != nil { return err } if err = l.ensureHooksDirExists(); err != nil { return err } err = l.addHook(args.Hook, templates.Args{}) if err != nil { return err } if args.CreateDirs { global, local := l.getSourceDirs() sourceDir := filepath.Join(l.repo.RootPath, global, args.Hook) sourceDirLocal := filepath.Join(l.repo.RootPath, local, args.Hook) if err = l.fs.MkdirAll(sourceDir, defaultDirMode); err != nil { return err } if err = l.fs.MkdirAll(sourceDirLocal, defaultDirMode); err != nil { return err } } return nil } func (l *Lefthook) getSourceDirs() (global, local string) { global = config.DefaultSourceDir local = config.DefaultSourceDirLocal cfg, err := l.LoadConfig() if err == nil { if len(cfg.SourceDir) > 0 { global = cfg.SourceDir } if len(cfg.SourceDirLocal) > 0 { local = cfg.SourceDirLocal } } return global, local } ================================================ FILE: internal/command/add_test.go ================================================ package command import ( "fmt" "path/filepath" "testing" "github.com/spf13/afero" "github.com/evilmartians/lefthook/v2/internal/git" ) func TestLefthookAdd(t *testing.T) { root, err := filepath.Abs("src") if err != nil { t.Errorf("unexpected error: %s", err) } configPath := filepath.Join(root, "lefthook.yml") hooksPath := filepath.Join(root, ".git", "hooks") hookPath := func(hook string) string { return filepath.Join(root, ".git", "hooks", hook) } for n, tt := range [...]struct { name string args AddArgs existingHooks map[string]string config string wantExist, wantNotExist []string wantError bool }{ { name: "default empty repository", args: AddArgs{Hook: "pre-commit"}, wantExist: []string{ hookPath("pre-commit"), }, wantNotExist: []string{ filepath.Join(root, ".lefthook"), filepath.Join(root, ".lefthook-local"), }, }, { name: "unavailable hook", args: AddArgs{Hook: "super-star"}, wantError: true, wantNotExist: []string{ hookPath("super-star"), filepath.Join(root, ".lefthook"), filepath.Join(root, ".lefthook-local"), }, }, { name: "with create dirs arg", args: AddArgs{Hook: "post-commit", CreateDirs: true}, wantExist: []string{ hookPath("post-commit"), filepath.Join(root, ".lefthook"), filepath.Join(root, ".lefthook-local"), }, }, { name: "with configured source dirs", args: AddArgs{Hook: "post-commit", CreateDirs: true}, config: ` source_dir: .source_dir source_dir_local: .source_dir_local `, wantExist: []string{ hookPath("post-commit"), filepath.Join(root, ".source_dir", "post-commit"), filepath.Join(root, ".source_dir_local", "post-commit"), }, }, { name: "with existing hook", args: AddArgs{Hook: "post-commit"}, existingHooks: map[string]string{ "post-commit": "custom script", }, wantExist: []string{ hookPath("post-commit"), hookPath("post-commit.old"), }, }, { name: "with existing lefthook hook", args: AddArgs{Hook: "post-commit"}, existingHooks: map[string]string{ "post-commit": "LEFTHOOK file", }, wantExist: []string{ hookPath("post-commit"), }, wantNotExist: []string{ hookPath("post-commit.old"), }, }, { name: "with existing .old hook", args: AddArgs{Hook: "post-commit"}, existingHooks: map[string]string{ "post-commit": "custom hook", "post-commit.old": "custom old hook", }, wantError: true, wantExist: []string{ hookPath("post-commit"), hookPath("post-commit.old"), }, }, { name: "with existing .old hook, forced", args: AddArgs{Hook: "post-commit", Force: true}, existingHooks: map[string]string{ "post-commit": "custom hook", "post-commit.old": "custom old hook", }, wantExist: []string{ hookPath("post-commit"), hookPath("post-commit.old"), }, }, } { t.Run(fmt.Sprintf("%d: %s", n, tt.name), func(t *testing.T) { fs := afero.NewMemMapFs() lefthook := &Lefthook{ fs: fs, repo: &git.Repository{ Fs: fs, HooksPath: hooksPath, RootPath: root, }, } if len(tt.config) > 0 { err := afero.WriteFile(fs, configPath, []byte(tt.config), 0o644) if err != nil { t.Errorf("unexpected error: %s", err) } } for hook, content := range tt.existingHooks { path := hookPath(hook) if err := fs.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Errorf("unexpected error: %s", err) } if err := afero.WriteFile(fs, path, []byte(content), 0o644); err != nil { t.Errorf("unexpected error: %s", err) } } err := lefthook.Add(t.Context(), tt.args) if tt.wantError && err == nil { t.Errorf("expected an error") } else if !tt.wantError && err != nil { t.Errorf("unexpected error: %s", err) } for _, file := range tt.wantExist { ok, err := afero.Exists(fs, file) if err != nil { t.Errorf("unexpected error: %s", err) } if !ok { t.Errorf("expected %s to exist", file) } } // Test files that should not exist for _, file := range tt.wantNotExist { ok, err := afero.Exists(fs, file) if err != nil { t.Errorf("unexpected error: %s", err) } if ok { t.Errorf("expected %s to not exist", file) } } }) } } ================================================ FILE: internal/command/check_install.go ================================================ package command import ( "context" "os" ) type installationStatus int const ( installed installationStatus = iota notInstalled ) func (l *Lefthook) CheckInstall(_ctx context.Context) error { check, err := l.checkInstall() if err != nil { return err } switch check { case installed: os.Exit(0) case notInstalled: os.Exit(1) } return nil } func (l *Lefthook) checkInstall() (installationStatus, error) { if !l.configExists(l.repo.RootPath) { return notInstalled, nil } cfg, err := l.LoadConfig() if err != nil { return notInstalled, err } ok, _ := l.checkHooksSynchronized(cfg) if !ok { return notInstalled, nil } return installed, nil } ================================================ FILE: internal/command/dump.go ================================================ package command import ( "context" "fmt" "os" "github.com/evilmartians/lefthook/v2/internal/config" ) type DumpArgs struct { Format string } func (l *Lefthook) Dump(_ctx context.Context, args DumpArgs) error { cfg, err := l.LoadConfig() if err != nil { return fmt.Errorf("couldn't load config: %w", err) } var format config.DumpFormat switch args.Format { case "yaml": format = config.YAMLFormat case "json": format = config.JSONFormat case "toml": format = config.TOMLFormat default: format = config.YAMLFormat } if err := cfg.Dump(format, os.Stdout); err != nil { return fmt.Errorf("couldn't dump config: %w", err) } return nil } ================================================ FILE: internal/command/install.go ================================================ package command import ( "bufio" "context" "errors" "fmt" "os" "path/filepath" "regexp" "slices" "strconv" "strings" "time" "github.com/gobwas/glob" "github.com/spf13/afero" "github.com/evilmartians/lefthook/v2/internal/config" "github.com/evilmartians/lefthook/v2/internal/git" "github.com/evilmartians/lefthook/v2/internal/log" "github.com/evilmartians/lefthook/v2/internal/templates" ) const ( configFileMode = 0o666 checksumFileMode = 0o644 hooksDirMode = 0o755 timestampBase = 10 timestampBitsize = 64 ) var ( lefthookChecksumRegexp = regexp.MustCompile(`(\w+)\s+(\d+)(?:\s+([\w,-]+))?`) errNoConfig = errors.New("no lefthook config found") ) type InstallArgs struct { Force bool ResetHooksPath bool } func (l *Lefthook) Install(ctx context.Context, args InstallArgs, hooks []string) error { if err := l.ensureHooksPathUnset(args.Force, args.ResetHooksPath); err != nil { return err } cfg, err := l.readOrCreateConfig() if err != nil { return err } var remotesSynced bool for _, remote := range cfg.Remotes { if remote.Configured() { if err = l.repo.SyncRemote(remote.GitURL, remote.Ref, args.Force); err != nil { log.Warnf("Couldn't sync from %s. Will continue anyway: %s", remote.GitURL, err) continue } remotesSynced = true } } if remotesSynced { // Reread the config file with synced remotes cfg, err = l.readOrCreateConfig() if err != nil { return err } } return l.createHooksIfNeeded(cfg, hooks, args.Force) } func (l *Lefthook) readOrCreateConfig() (*config.Config, error) { log.Debug("config dir: ", l.repo.RootPath) if !l.configExists(l.repo.RootPath) { log.Info("Config not found, creating...") if err := l.createConfig(l.repo.RootPath); err != nil { return nil, err } } return l.LoadConfig() } func (l *Lefthook) configExists(path string) bool { configPath, _ := l.findMainConfig(path) return configPath != "" } func (l *Lefthook) findMainConfig(path string) (string, error) { configOverride := os.Getenv("LEFTHOOK_CONFIG") if len(configOverride) != 0 { if !filepath.IsAbs(configOverride) { configOverride = filepath.Join(path, configOverride) } if ok, _ := afero.Exists(l.fs, configOverride); !ok { return "", fmt.Errorf("couldn't find config from LEFTHOOK_CONFIG: %s", configOverride) } return configOverride, nil } for _, name := range config.MainConfigNames { for _, extension := range config.Extensions { configPath := filepath.Join(path, name+extension) if ok, _ := afero.Exists(l.fs, configPath); ok { return configPath, nil } } } return "", errNoConfig } func (l *Lefthook) createConfig(path string) error { file := filepath.Join(path, config.DefaultConfigName) err := afero.WriteFile(l.fs, file, templates.Config(), configFileMode) if err != nil { return err } log.Info("Added config:", file) return nil } func (l *Lefthook) syncHooks(cfg *config.Config, fetchRemotes bool) (*config.Config, error) { var remotesSynced bool //nolint:nestif if fetchRemotes { fetchedRemotes := make(map[string]struct{}) for _, remote := range cfg.Remotes { if !remote.Configured() { continue } if l.shouldRefetch(remote) { if serr := l.repo.SyncRemote(remote.GitURL, remote.Ref, false); serr != nil { ref, err := l.findAvailableRemoteRef(remote.GitURL) if err != nil { log.Warnf("Couldn't sync from %s. Will continue without that remote.", remote.GitURL) continue } if ref != "" { log.Warnf("Couldn't sync %s %s. Will continue with fallback version: %s.", remote.GitURL, remote.Ref, ref) } else { log.Warnf("Couldn't sync %s %s. Will continue with old version.", remote.GitURL, remote.Ref) } remote.Ref = ref } remotesSynced = true } fetchedRemotes[l.repo.RemoteFolder(remote.GitURL, remote.Ref)] = struct{}{} } if remotesSynced { var err error // Reread the config file with synced remotes cfg, err = l.reloadConfig(cfg) if err != nil { return nil, fmt.Errorf("failed to reread the config: %w", err) } } if len(fetchedRemotes) > 0 { // Delete stale remotes entries, err := afero.ReadDir(l.fs, l.repo.RemotesFolder()) if err != nil { return nil, err } for _, entry := range entries { remotePath := filepath.Join(l.repo.RemotesFolder(), entry.Name()) if _, ok := fetchedRemotes[remotePath]; !ok { log.Debug("Removing stale remote: ", remotePath) if err = l.fs.RemoveAll(remotePath); err != nil { log.Error("failed to drop stale remote path: ", remotePath) } } } } } ok, hooks := l.checkHooksSynchronized(cfg) if ok { return cfg, nil } // Don't rely on config checksum if remotes were refetched return cfg, l.createHooksIfNeeded(cfg, hooks, false) } func (l *Lefthook) shouldRefetch(remote *config.Remote) bool { if remote.Refetch || remote.RefetchFrequency == "always" { return true } if remote.RefetchFrequency == "never" { return false } var lastFetchTime time.Time remotePath := l.repo.RemoteFolder(remote.GitURL, remote.Ref) info, err := l.fs.Stat(filepath.Join(remotePath, ".git", "FETCH_HEAD")) if err != nil { if errors.Is(err, os.ErrNotExist) { return true } log.Warnf("Failed to detect last fetch time: %s", err) return false } if len(remote.RefetchFrequency) == 0 { return false } lastFetchTime = info.ModTime() timedelta, err := time.ParseDuration(remote.RefetchFrequency) if err != nil { log.Warnf("Couldn't parse refetch frequency %s. Will continue anyway: %s", remote.RefetchFrequency, err) return false } return time.Now().After(lastFetchTime.Add(timedelta)) } func (l *Lefthook) findAvailableRemoteRef(url string) (string, error) { entries, err := afero.ReadDir(l.fs, l.repo.RemotesFolder()) if err != nil { return "", err } repoName := git.RemoteDirectoryName(url, "") g := glob.MustCompile(repoName + "*") for _, info := range slices.Backward(entries) { if g.Match(info.Name()) { if info.Name() == repoName { return "", nil } oldRef := strings.Replace(info.Name(), repoName, "", 1) return oldRef[1:], nil } } return "", errors.New("not found") } func (l *Lefthook) createHooksIfNeeded(cfg *config.Config, hooks []string, force bool) error { onlyHooks := make(map[string]struct{}) for _, hook := range hooks { onlyHooks[hook] = struct{}{} } var success bool defer func() { if !success { log.Info(log.Cyan("sync hooks: ❌")) } }() checksum, err := cfg.Md5() if err != nil { return fmt.Errorf("could not calculate checksum: %w", err) } if err = l.ensureHooksDirExists(); err != nil { return fmt.Errorf("could not create hooks dir: %w", err) } rootsMap := make(map[string]struct{}) for _, hook := range cfg.Hooks { for _, command := range hook.Commands { if len(command.Root) > 0 { root := strings.Trim(command.Root, "/") if _, ok := rootsMap[root]; !ok { rootsMap[root] = struct{}{} } } } collectAllJobRoots(rootsMap, hook.Jobs) } roots := make([]string, 0, len(rootsMap)) for root := range rootsMap { roots = append(roots, root) } hookNames := make([]string, 0, len(cfg.Hooks)+1) for hook := range cfg.Hooks { if _, ok := onlyHooks[hook]; len(onlyHooks) > 0 && !ok { log.Debug("skip installing: ", hook) continue } if err = l.cleanHook(hook, force); err != nil { return fmt.Errorf("could not replace the hook: %w", err) } if _, ok := config.AvailableHooks[hook]; !ok && !cfg.InstallNonGitHooks { continue } hookNames = append(hookNames, hook) templateArgs := templates.Args{ Rc: cfg.Rc, AssertLefthookInstalled: cfg.AssertLefthookInstalled, Roots: roots, LefthookPath: cfg.Lefthook, } if err = l.addHook(hook, templateArgs); err != nil { return fmt.Errorf("could not add the hook: %w", err) } } if len(onlyHooks) == 0 && len(cfg.Hooks) == 0 { templateArgs := templates.Args{ Rc: cfg.Rc, AssertLefthookInstalled: cfg.AssertLefthookInstalled, Roots: roots, LefthookPath: cfg.Lefthook, } if err = l.addHook(config.GhostHookName, templateArgs); err != nil { return nil } } if err = l.addChecksumFile(checksum, hooks); err != nil { return fmt.Errorf("could not create a checksum file: %w", err) } success = true if len(hookNames) > 0 { log.Info(log.Cyan("sync hooks: ✔️"), log.Gray("("+strings.Join(hookNames, ", ")+")")) } else { log.Info(log.Cyan("sync hooks: ✔️ ")) } return nil } func collectAllJobRoots(roots map[string]struct{}, jobs []*config.Job) { for _, job := range jobs { if len(job.Root) > 0 { root := strings.Trim(job.Root, "/") if _, ok := roots[root]; !ok { roots[root] = struct{}{} } } if job.Group != nil { collectAllJobRoots(roots, job.Group.Jobs) } } } // checkHooksSynchronized checks is config hooks synchronized and returns the // list of hooks which are synchronized. func (l *Lefthook) checkHooksSynchronized(cfg *config.Config) (bool, []string) { // Check checksum in a checksum file file, err := l.fs.Open(l.checksumFilePath()) if err != nil { return false, nil } defer func() { if cErr := file.Close(); cErr != nil { log.Warnf("Could not close %s: %s", file.Name(), cErr) } }() scanner := bufio.NewScanner(file) var storedChecksum string var storedTimestamp int64 var storedHooks []string // Checksum format: // for scanner.Scan() { match := lefthookChecksumRegexp.FindStringSubmatch(scanner.Text()) if match != nil { storedChecksum = match[1] storedTimestamp, err = strconv.ParseInt(match[2], timestampBase, timestampBitsize) if err != nil { return false, nil } if len(match[3]) > 0 { storedHooks = strings.Split(match[3], ",") } break } } if err = scanner.Err(); err != nil { log.Warnf("Could not read %s: %s", file.Name(), err) return false, nil } if len(storedChecksum) == 0 { return false, storedHooks } configTimestamp, err := l.configLastUpdateTimestamp() if err != nil { return false, storedHooks } if storedTimestamp == configTimestamp { return true, storedHooks } configChecksum, err := cfg.Md5() if err != nil { return false, storedHooks } return storedChecksum == configChecksum, storedHooks } func (l *Lefthook) configLastUpdateTimestamp() (int64, error) { configPath, err := l.findMainConfig(l.repo.RootPath) if err != nil { return 0, err } config, err := l.fs.Stat(configPath) if err != nil { return 0, err } return config.ModTime().Unix(), nil } func (l *Lefthook) addChecksumFile(checksum string, hooks []string) error { timestamp, err := l.configLastUpdateTimestamp() if err != nil { return fmt.Errorf("unable to get config update timestamp: %w", err) } return afero.WriteFile( l.fs, l.checksumFilePath(), templates.Checksum(checksum, timestamp, hooks), checksumFileMode, ) } func (l *Lefthook) checksumFilePath() string { return filepath.Join(l.repo.InfoPath, config.ChecksumFileName) } func (l *Lefthook) ensureHooksDirExists() error { exists, err := afero.Exists(l.fs, l.repo.HooksPath) if !exists || err != nil { err = l.fs.MkdirAll(l.repo.HooksPath, hooksDirMode) if err != nil { return err } } return nil } // getHooksPathConfig checks if core.hooksPath is configured locally or globally. func (l *Lefthook) getHooksPathConfig() (local, global string) { local, _ = l.repo.Git.Cmd([]string{"git", "config", "--local", "core.hooksPath"}) global, _ = l.repo.Git.Cmd([]string{"git", "config", "--global", "core.hooksPath"}) return } // ensureHooksPathUnset ensures core.hooksPath is not configured. // // In general using lefthook doesn't make sense with global hooks. // Local hooks make sense only in terms of migratio from other hook managers. func (l *Lefthook) ensureHooksPathUnset(force, resetHooksPath bool) error { local, global := l.getHooksPathConfig() hasLocal := len(local) > 0 hasGlobal := len(global) > 0 if !hasLocal && !hasGlobal { return nil } // If neither force nor resetHooksPath, returns an error with instructions. if !force && !resetHooksPath { return formatHooksPathError(local, global) } if hasLocal { log.Warnf("core.hooksPath is set locally to '%s'", local) } if hasGlobal { log.Warnf("core.hooksPath is set globally to '%s'", global) } if resetHooksPath { return l.unsetHooksPathConfig(local, global) } // Local setting takes precedence. path := local if !hasLocal && hasGlobal { path = global } log.Warnf("Installing hooks anyway in '%s'", path) return nil } // formatHooksPathError formats an error message for core.hooksPath conflicts. func formatHooksPathError(local, global string) error { var errMsg strings.Builder var hints []string hasLocal := len(local) > 0 hasGlobal := len(global) > 0 if hasLocal { fmt.Fprintf(&errMsg, "core.hooksPath is set locally to '%s'\n", local) hints = append(hints, "hint: git config --unset-all --local core.hooksPath") } if hasGlobal { fmt.Fprintf(&errMsg, "core.hooksPath is set globally to '%s'\n", global) hints = append(hints, "hint: git config --unset-all --global core.hooksPath") } errMsg.WriteString("\n") errMsg.WriteString("hint: Unset it:\n") errMsg.WriteString(strings.Join(hints, "\n")) errMsg.WriteString("\nhint:\n") errMsg.WriteString("hint: Run 'lefthook install --reset-hooks-path' to automatically unset it.\n") // Determine path: use global path if only global is defined, otherwise use local path path := local if !hasLocal && hasGlobal { path = global } errMsg.WriteString("hint:\n") fmt.Fprintf(&errMsg, "hint: Run 'lefthook install --force' to install hooks anyway in '%s'.", path) return errors.New(errMsg.String()) } // unsetHooksPathConfig removes core.hooksPath configuration. func (l *Lefthook) unsetHooksPathConfig(local, global string) error { if len(local) > 0 { if _, err := l.repo.Git.Cmd([]string{"git", "config", "--local", "--unset-all", "core.hooksPath"}); err != nil { return fmt.Errorf("failed to unset local core.hooksPath: %w", err) } log.Warn("local core.hooksPath has been unset.") } if len(global) > 0 { if _, err := l.repo.Git.Cmd([]string{"git", "config", "--global", "--unset-all", "core.hooksPath"}); err != nil { return fmt.Errorf("failed to unset global core.hooksPath: %w", err) } log.Warn("global core.hooksPath has been unset.") } return nil } ================================================ FILE: internal/command/install_test.go ================================================ package command import ( "fmt" "path/filepath" "testing" "time" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/evilmartians/lefthook/v2/internal/config" "github.com/evilmartians/lefthook/v2/tests/helpers/cmdtest" "github.com/evilmartians/lefthook/v2/tests/helpers/gittest" ) func TestLefthookInstall(t *testing.T) { root, err := filepath.Abs("src") assert.NoError(t, err) configPath := filepath.Join(root, "lefthook.yml") hookPath := func(hook string) string { return filepath.Join(gittest.GitPath(root), "hooks", hook) } infoPath := func(file string) string { return filepath.Join(gittest.GitPath(root), "info", file) } for n, tt := range [...]struct { name, config, checksum string force bool hooks []string git []cmdtest.Out existingFiles map[string]string wantExist, wantNotExist []string wantError bool }{ { name: "without a config file", wantExist: []string{configPath}, }, { name: "simple default config", config: ` pre-commit: commands: tests: run: yarn test post-commit: commands: notify: run: echo 'Done!' `, wantExist: []string{ configPath, hookPath("pre-commit"), hookPath("post-commit"), infoPath(config.ChecksumFileName), }, wantNotExist: []string{ hookPath(config.GhostHookName), }, }, { name: "with given hook", config: ` pre-commit: commands: tests: run: yarn test post-commit: commands: notify: run: echo 'Done!' `, hooks: []string{"pre-commit"}, wantExist: []string{ configPath, hookPath("pre-commit"), infoPath(config.ChecksumFileName), }, wantNotExist: []string{ hookPath("post-commit"), hookPath(config.GhostHookName), }, }, { name: "with non-git hook", config: ` test: jobs: - run: echo test `, wantNotExist: []string{ hookPath("test"), }, }, { name: "with non-git hook", config: ` install_non_git_hooks: true test: jobs: - run: echo test `, wantExist: []string{ hookPath("test"), }, }, { name: "with existing hooks", config: ` pre-commit: commands: tests: run: yarn test post-commit: commands: notify: run: echo 'Done!' `, existingFiles: map[string]string{ hookPath("pre-commit"): "", }, wantExist: []string{ configPath, hookPath("pre-commit"), hookPath("pre-commit.old"), hookPath("post-commit"), infoPath(config.ChecksumFileName), }, wantNotExist: []string{ hookPath(config.GhostHookName), }, }, { name: "with existing lefthook hooks", config: ` pre-commit: commands: tests: run: yarn test post-commit: commands: notify: run: echo 'Done!' `, existingFiles: map[string]string{ hookPath("pre-commit"): "# LEFTHOOK file", }, wantExist: []string{ configPath, hookPath("pre-commit"), hookPath("post-commit"), infoPath(config.ChecksumFileName), }, wantNotExist: []string{ hookPath("pre-commit.old"), hookPath(config.GhostHookName), }, }, { name: "with stale timestamp and checksum", config: ` pre-commit: commands: tests: run: yarn test post-commit: commands: notify: run: echo 'Done!' `, checksum: "8b2c9fc6b3391b3cf020b97ab7037c62 1555894310\n", wantExist: []string{ configPath, hookPath("pre-commit"), hookPath("post-commit"), infoPath(config.ChecksumFileName), }, wantNotExist: []string{ hookPath(config.GhostHookName), }, }, { name: "with existing hook and .old file", config: ` pre-commit: commands: tests: run: yarn test post-commit: commands: notify: run: echo 'Done!' `, existingFiles: map[string]string{ hookPath("pre-commit"): "", hookPath("pre-commit.old"): "", }, wantError: true, wantExist: []string{ configPath, hookPath("pre-commit"), hookPath("pre-commit.old"), }, wantNotExist: []string{ infoPath(config.ChecksumFileName), }, }, { name: "with existing hook and .old file, but forced", force: true, config: ` pre-commit: commands: tests: run: yarn test post-commit: commands: notify: run: echo 'Done!' `, existingFiles: map[string]string{ hookPath("pre-commit"): "", hookPath("pre-commit.old"): "", }, wantExist: []string{ configPath, hookPath("pre-commit"), hookPath("pre-commit.old"), hookPath("post-commit"), infoPath(config.ChecksumFileName), }, }, { name: "with custom hook", config: ` my-custom-hook: commands: custom: run: echo 'Hello from custom!' `, wantNotExist: []string{ hookPath("my-custom-hook"), }, }, { name: "with custom existing hook", config: ` my-custom-hook: commands: custom: run: echo 'Hello from custom!' `, existingFiles: map[string]string{ hookPath("my-custom-hook"): "", }, wantExist: []string{ hookPath("my-custom-hook.old"), }, wantNotExist: []string{ hookPath("my-custom-hook"), }, }, { name: "with unfetched remote", config: ` remotes: - git_url: https://github.com/evilmartians/lefthook configs: - lefthook.yml `, git: []cmdtest.Out{ { Command: "git -C " + filepath.Join(root, ".git", "info", "lefthook-remotes") + " clone --quiet --origin origin --depth 1 https://github.com/evilmartians/lefthook lefthook", }, }, }, { name: "needs refetching", config: ` remotes: - git_url: https://github.com/evilmartians/lefthook ref: v2.0.0 configs: - lefthook.yml `, existingFiles: map[string]string{ filepath.Join(root, ".git", "info", "lefthook-remotes", "lefthook-v2.0.1", ".git", "FETCH_HEAD"): "", }, git: []cmdtest.Out{ { Command: "git -C " + filepath.Join(root, ".git", "info", "lefthook-remotes") + " clone --quiet --origin origin --depth 1 --branch v2.0.0 https://github.com/evilmartians/lefthook lefthook-v2.0.0", }, { Command: "git -C " + filepath.Join(root, ".git", "info", "lefthook-remotes", "lefthook-v2.0.0") + " fetch --quiet --depth 1 origin -- v2.0.0", }, { Command: "git -C " + filepath.Join(root, ".git", "info", "lefthook-remotes", "lefthook-v2.0.0") + " checkout FETCH_HEAD", }, }, }, } { fs := afero.NewMemMapFs() t.Run(fmt.Sprintf("%d: %s", n, tt.name), func(t *testing.T) { assert := assert.New(t) // Prepend git config commands required by getHooksPathConfig() in install.go. // These commands are always called at the start of Install() to detect core.hooksPath conflicts. gitCmds := tt.git if len(gitCmds) == 0 || gitCmds[0].Command != "git config --local core.hooksPath" { gitCmds = append([]cmdtest.Out{ {Command: "git config --local core.hooksPath"}, {Command: "git config --global core.hooksPath"}, }, gitCmds...) } repo := gittest.NewRepositoryBuilder(). Root(root). Fs(fs). Cmd(cmdtest.NewOrdered(t, gitCmds)). Build() lefthook := &Lefthook{ fs: fs, repo: repo, } // Create configuration file if len(tt.config) > 0 { assert.NoError(afero.WriteFile(fs, configPath, []byte(tt.config), 0o644)) timestamp := time.Date(2022, time.June, 22, 10, 40, 10, 1, time.UTC) assert.NoError(fs.Chtimes(configPath, timestamp, timestamp)) } if len(tt.checksum) > 0 { assert.NoError(afero.WriteFile(fs, lefthook.checksumFilePath(), []byte(tt.checksum), 0o644)) } // Create files that should exist for path, content := range tt.existingFiles { assert.NoError(fs.MkdirAll(filepath.Dir(path), 0o755)) assert.NoError(afero.WriteFile(fs, path, []byte(content), 0o755)) } // Do install err := lefthook.Install(t.Context(), InstallArgs{Force: tt.force}, tt.hooks) if tt.wantError { assert.Error(err) } else { assert.NoError(err) } // Test files that should exist for _, file := range tt.wantExist { ok, err := afero.Exists(fs, file) assert.NoError(err) assert.Equal(true, ok) } // Test files that should not exist for _, file := range tt.wantNotExist { ok, err := afero.Exists(fs, file) assert.NoError(err) assert.Equal(false, ok) } }) } } func Test_syncHooks(t *testing.T) { root, err := filepath.Abs("src") assert.NoError(t, err) configPath := filepath.Join(root, "lefthook.yml") hookPath := func(hook string) string { return filepath.Join(root, ".git", "hooks", hook) } infoPath := func(file string) string { return filepath.Join(root, ".git", "info", file) } for n, tt := range [...]struct { name, config, checksum string existingFiles map[string]string git []cmdtest.Out wantExist, wantNotExist []string wantError bool }{ { name: "with synchronized hooks", config: ` pre-commit: commands: tests: run: yarn test post-commit: commands: notify: run: echo 'Done!' `, checksum: "8b2c9fc6b3391b3cf020b97ab7037c61 1655894410\n", wantExist: []string{ configPath, infoPath(config.ChecksumFileName), }, wantNotExist: []string{ hookPath("pre-commit"), hookPath("post-commit"), hookPath(config.GhostHookName), }, }, { name: "with stale timestamp but synchronized", config: ` pre-commit: commands: tests: run: yarn test post-commit: commands: notify: run: echo 'Done!' `, checksum: "939f59e3f706df65f379a9ff5ce0119b 1555894310\n", wantExist: []string{ configPath, infoPath(config.ChecksumFileName), }, wantNotExist: []string{ hookPath("pre-commit"), hookPath("post-commit"), hookPath(config.GhostHookName), }, }, { name: "unsynchronized", config: ` pre-commit: commands: tests: run: yarn test post-commit: commands: notify: run: echo 'Done!' commit-msg: jobs: - run: echo 'commit-msg' `, checksum: "00000000f706df65f379a9ff5ce0119b 1555894311\n", wantExist: []string{ configPath, hookPath("pre-commit"), hookPath("post-commit"), hookPath("commit-msg"), infoPath(config.ChecksumFileName), }, wantNotExist: []string{ hookPath(config.GhostHookName), }, }, { name: "unsynchronized with selected hooks", config: ` pre-commit: commands: tests: run: yarn test post-commit: commands: notify: run: echo 'Done!' commit-msg: jobs: - run: echo 'commit-msg' `, checksum: "00000000f706df65f379a9ff5ce0119b 1555894310 pre-commit,post-commit\n", wantExist: []string{ configPath, hookPath("pre-commit"), hookPath("post-commit"), infoPath(config.ChecksumFileName), }, wantNotExist: []string{ hookPath("commit-msg"), hookPath(config.GhostHookName), }, }, { name: "with unfetched remote", config: ` remotes: - git_url: https://github.com/evilmartians/lefthook configs: - lefthook.yml `, git: []cmdtest.Out{ { Command: "git -C " + filepath.Join(root, ".git", "info", "lefthook-remotes") + " clone --quiet --origin origin --depth 1 https://github.com/evilmartians/lefthook lefthook", }, }, }, { name: "no need to refetch", config: ` remotes: - git_url: https://github.com/evilmartians/lefthook ref: v2.0.1 configs: - lefthook.yml `, existingFiles: map[string]string{ filepath.Join(root, ".git", "info", "lefthook-remotes", "lefthook-v2.0.1", ".git", "FETCH_HEAD"): "", }, }, { name: "needs refetching", config: ` remotes: - git_url: https://github.com/evilmartians/lefthook ref: v2.0.0 configs: - lefthook.yml `, existingFiles: map[string]string{ filepath.Join(root, ".git", "info", "lefthook-remotes", "lefthook-v2.0.1", ".git", "FETCH_HEAD"): "", }, git: []cmdtest.Out{ { Command: "git -C " + filepath.Join(root, ".git", "info", "lefthook-remotes") + " clone --quiet --origin origin --depth 1 --branch v2.0.0 https://github.com/evilmartians/lefthook lefthook-v2.0.0", }, { Command: "git -C " + filepath.Join(root, ".git", "info", "lefthook-remotes", "lefthook-v2.0.0") + " fetch --quiet --depth 1 origin -- v2.0.0", }, { Command: "git -C " + filepath.Join(root, ".git", "info", "lefthook-remotes", "lefthook-v2.0.0") + " checkout FETCH_HEAD", }, }, wantNotExist: []string{ filepath.Join(root, ".git", "info", "lefthook-remotes", "lefthook-v2.0.1"), }, }, } { fs := afero.NewMemMapFs() t.Run(fmt.Sprintf("%d: %s", n, tt.name), func(t *testing.T) { assert := assert.New(t) repo := gittest.NewRepositoryBuilder().Root(root).Fs(fs).Cmd(cmdtest.NewOrdered(t, tt.git)).Build() lefthook := &Lefthook{ fs: fs, repo: repo, } // Create configuration file if len(tt.config) > 0 { assert.NoError(afero.WriteFile(fs, configPath, []byte(tt.config), 0o644)) timestamp := time.Date(2022, time.June, 22, 10, 40, 10, 1, time.UTC) assert.NoError(fs.Chtimes(configPath, timestamp, timestamp)) } if len(tt.checksum) > 0 { assert.NoError(afero.WriteFile(fs, lefthook.checksumFilePath(), []byte(tt.checksum), 0o644)) } // Create files that should exist for path, content := range tt.existingFiles { assert.NoError(fs.MkdirAll(filepath.Dir(path), 0o755)) assert.NoError(afero.WriteFile(fs, path, []byte(content), 0o755)) } cfg, err := config.Load(lefthook.fs, repo) assert.NoError(err) // Create hooks _, err = lefthook.syncHooks(cfg, true) if tt.wantError { assert.Error(err) } else { assert.NoError(err) } // Test files that should exist for _, file := range tt.wantExist { ok, err := afero.Exists(fs, file) assert.NoError(err) assert.Equal(true, ok, file) } // Test files that should not exist for _, file := range tt.wantNotExist { ok, err := afero.Exists(fs, file) assert.NoError(err) assert.Equal(false, ok, file) } }) } } func TestShouldRefetch(t *testing.T) { root, err := filepath.Abs("src") assert.NoError(t, err) configPath := filepath.Join(root, "lefthook.yml") fetchHeadPath := func(lefthook *Lefthook, remote *config.Remote) string { remotePath := lefthook.repo.RemoteFolder(remote.GitURL, remote.Ref) return filepath.Join(remotePath, ".git", "FETCH_HEAD") } for n, tt := range [...]struct { name, config string shouldRefetchInitially, shouldRefetchAfter, shouldRefetchBefore bool }{ { name: "with refetch frequency configured to always", config: ` remotes: - git_url: https://github.com/evilmartians/lefthook refetch_frequency: always configs: - examples/remote/ping.yml `, shouldRefetchInitially: true, shouldRefetchAfter: true, shouldRefetchBefore: true, }, { name: "with refetch frequency configured to 1 minute", config: ` remotes: - git_url: https://github.com/evilmartians/lefthook refetch_frequency: 1m configs: - examples/remote/ping.yml `, shouldRefetchInitially: true, shouldRefetchAfter: true, shouldRefetchBefore: false, }, { name: "with refetch frequency configured to never", config: ` remotes: - git_url: https://github.com/evilmartians/lefthook refetch_frequency: never configs: - examples/remote/ping.yml `, shouldRefetchInitially: false, shouldRefetchAfter: false, shouldRefetchBefore: false, }, { name: "with refetch frequency not configured", config: ` remotes: - git_url: https://github.com/evilmartians/lefthook configs: - examples/remote/ping.yml `, shouldRefetchInitially: true, shouldRefetchAfter: false, shouldRefetchBefore: false, }, } { fs := afero.NewMemMapFs() repo := gittest.NewRepositoryBuilder().Root(root).Fs(fs).Build() lefthook := &Lefthook{ fs: fs, repo: repo, } t.Run(fmt.Sprintf("%d: %s", n, tt.name), func(t *testing.T) { assert := assert.New(t) // Create configuration file if len(tt.config) > 0 { assert.NoError(afero.WriteFile(fs, configPath, []byte(tt.config), 0o644)) timestamp := time.Date(2022, time.June, 22, 10, 40, 10, 1, time.UTC) assert.NoError(fs.Chtimes(configPath, timestamp, timestamp)) } cfg, err := config.Load(lefthook.fs, repo) assert.NoError(err) remote := cfg.Remotes[0] assert.Equal(lefthook.shouldRefetch(remote), tt.shouldRefetchInitially) assert.NoError(afero.WriteFile(fs, fetchHeadPath(lefthook, remote), []byte(""), 0o644)) firstFetchTime := time.Now().Add(-2 * time.Minute) assert.NoError(fs.Chtimes(fetchHeadPath(lefthook, remote), firstFetchTime, firstFetchTime)) assert.Equal(lefthook.shouldRefetch(remote), tt.shouldRefetchAfter) assert.NoError(fs.Chtimes(fetchHeadPath(lefthook, remote), firstFetchTime, time.Now())) assert.Equal(lefthook.shouldRefetch(remote), tt.shouldRefetchBefore) }) } } func TestLefthookInstallWithCoreHooksPath(t *testing.T) { root, err := filepath.Abs("src") assert.NoError(t, err) configPath := filepath.Join(root, "lefthook.yml") hookPath := func(hook string) string { return filepath.Join(gittest.GitPath(root), "hooks", hook) } infoPath := func(file string) string { return filepath.Join(gittest.GitPath(root), "info", file) } configContent := ` pre-commit: commands: tests: run: yarn test ` for n, tt := range [...]struct { name string force bool resetHooksPath bool git []cmdtest.Out wantError bool wantErrorMsg string wantExist []string }{ { name: "with local and global core.hooksPath without flags", force: false, resetHooksPath: false, git: []cmdtest.Out{ { Command: "git config --local core.hooksPath", Output: ".custom-hooks", }, { Command: "git config --global core.hooksPath", Output: "/usr/local/hooks", }, }, wantError: true, wantErrorMsg: "core.hooksPath", }, { name: "with local and global core.hooksPath with --force", force: true, resetHooksPath: false, git: []cmdtest.Out{ { Command: "git config --local core.hooksPath", Output: ".custom-hooks", }, { Command: "git config --global core.hooksPath", Output: "/usr/local/hooks", }, }, wantError: false, wantExist: []string{ configPath, hookPath("pre-commit"), infoPath(config.ChecksumFileName), }, }, { name: "with local and global core.hooksPath with --reset-hooks-path", force: false, resetHooksPath: true, git: []cmdtest.Out{ { Command: "git config --local core.hooksPath", Output: ".custom-hooks", }, { Command: "git config --global core.hooksPath", Output: "/usr/local/hooks", }, { Command: "git config --local --unset-all core.hooksPath", }, { Command: "git config --global --unset-all core.hooksPath", }, }, wantError: false, wantExist: []string{ configPath, hookPath("pre-commit"), infoPath(config.ChecksumFileName), }, }, { name: "with only global core.hooksPath with --force", force: true, resetHooksPath: false, git: []cmdtest.Out{ { Command: "git config --local core.hooksPath", Output: "", }, { Command: "git config --global core.hooksPath", Output: "/usr/local/hooks", }, }, wantError: false, wantExist: []string{ configPath, hookPath("pre-commit"), infoPath(config.ChecksumFileName), }, }, { name: "with only local core.hooksPath with --reset-hooks-path", force: false, resetHooksPath: true, git: []cmdtest.Out{ { Command: "git config --local core.hooksPath", Output: ".custom-hooks", }, { Command: "git config --global core.hooksPath", Output: "", }, { Command: "git config --local --unset-all core.hooksPath", }, }, wantError: false, wantExist: []string{ configPath, hookPath("pre-commit"), infoPath(config.ChecksumFileName), }, }, } { fs := afero.NewMemMapFs() t.Run(fmt.Sprintf("%d: %s", n, tt.name), func(t *testing.T) { assert := assert.New(t) repo := gittest.NewRepositoryBuilder(). Root(root). Fs(fs). Cmd(cmdtest.NewOrdered(t, tt.git)). Build() lefthook := &Lefthook{ fs: fs, repo: repo, } // Create configuration file assert.NoError(afero.WriteFile(fs, configPath, []byte(configContent), 0o644)) timestamp := time.Date(2022, time.June, 22, 10, 40, 10, 1, time.UTC) assert.NoError(fs.Chtimes(configPath, timestamp, timestamp)) // Do install err := lefthook.Install(t.Context(), InstallArgs{Force: tt.force, ResetHooksPath: tt.resetHooksPath}, nil) if tt.wantError { if assert.Error(err) && tt.wantErrorMsg != "" { assert.Contains(err.Error(), tt.wantErrorMsg) } } else { assert.NoError(err) // Test files that should exist for _, file := range tt.wantExist { ok, err := afero.Exists(fs, file) assert.NoError(err) assert.True(ok) } } }) } } ================================================ FILE: internal/command/lefthook.go ================================================ package command import ( "bufio" "bytes" "fmt" "os" "path/filepath" "slices" "strings" "github.com/knadh/koanf/parsers/json" "github.com/knadh/koanf/providers/rawbytes" "github.com/knadh/koanf/v2" "github.com/spf13/afero" "github.com/urfave/cli/v3" "github.com/evilmartians/lefthook/v2/internal/config" "github.com/evilmartians/lefthook/v2/internal/git" "github.com/evilmartians/lefthook/v2/internal/log" "github.com/evilmartians/lefthook/v2/internal/system" "github.com/evilmartians/lefthook/v2/internal/templates" ) const ( EnvVerbose = "LEFTHOOK_VERBOSE" // keep all output envNoColor = "NO_COLOR" envClicolorForce = "CLICOLOR_FORCE" envClicolor = "CLICOLOR" hookFileMode = 0o755 oldHookPostfix = ".old" hookContentFingerprint = "LEFTHOOK" ) type Lefthook struct { fs afero.Fs repo *git.Repository colors string } // NewLefthook returns an instance of Lefthook. func NewLefthook(verbose bool, colors string) (*Lefthook, error) { fs := afero.NewOsFs() if isEnvEnabled(EnvVerbose) { verbose = true } if verbose { log.SetLevel(log.DebugLevel) } switch colors { case "auto", "": if isEnvEnabled(envClicolorForce) { colors = "on" } if isEnvEnabled(envNoColor) { colors = "off" } case "on": // Try to overwrite the lipgloss ENV handling. _ = os.Unsetenv(envNoColor) _ = os.Unsetenv(envClicolor) } log.SetColors(colors) repo, err := git.NewRepository(fs, git.NewExecutor(system.Cmd)) if err != nil { return nil, err } return &Lefthook{fs: fs, repo: repo, colors: colors}, nil } func (l *Lefthook) LoadConfig() (*config.Config, error) { cfg, err := config.Load(l.fs, l.repo) // Reset colors log.SetColors(l.colors) return cfg, err } func (l *Lefthook) reloadConfig(cfg *config.Config) (*config.Config, error) { log.Debug("Reloading config...") buffer := new(bytes.Buffer) if err := cfg.Dump(config.JSONCompactFormat, buffer); err != nil { return nil, err } main := koanf.New(".") if err := main.Load(rawbytes.Provider(buffer.Bytes()), json.Parser()); err != nil { return nil, err } secondary, err := config.LoadSecondary(main, l.fs, l.repo) if err != nil { return nil, err } return config.Unmarshal(main, secondary) } // Tests a file whether it is a lefthook-created file. func (l *Lefthook) isLefthookFile(path string) bool { file, err := l.fs.Open(path) if err != nil { return false } defer func() { if cErr := file.Close(); cErr != nil { log.Warnf("Could not close %s: %s", file.Name(), cErr) } }() scanner := bufio.NewScanner(file) for scanner.Scan() { if strings.Contains(scanner.Text(), hookContentFingerprint) { return true } } if err = scanner.Err(); err != nil { log.Warnf("Could not read %s: %s", file.Name(), err) } return false } // Removes the hook from hooks path, saving non-lefthook hooks with .old suffix. func (l *Lefthook) cleanHook(hook string, force bool) error { hookPath := filepath.Join(l.repo.HooksPath, hook) exists, err := afero.Exists(l.fs, hookPath) if err != nil { return err } if !exists { return nil } // Just remove lefthook hook if l.isLefthookFile(hookPath) { return l.fs.Remove(hookPath) } // Check if .old file already exists before renaming. exists, err = afero.Exists(l.fs, hookPath+oldHookPostfix) if err != nil { return err } if exists { if force { log.Infof("\nFile %s.old already exists, overwriting\n", hook) } else { return fmt.Errorf("can't rename %s to %s.old - file already exists", hook, hook) } } err = l.fs.Rename(hookPath, hookPath+oldHookPostfix) if err != nil { return err } log.Infof("Renamed %s to %s.old\n", hookPath, hookPath) return nil } // Creates a hook file using hook template. func (l *Lefthook) addHook(hook string, args templates.Args) error { hookPath := filepath.Join(l.repo.HooksPath, hook) return afero.WriteFile( l.fs, hookPath, templates.Hook(hook, args), hookFileMode, ) } func isEnvEnabled(name string) bool { value := os.Getenv(name) if len(value) > 0 && value != "0" && value != "false" { return true } return false } func ShellCompleteHookNames() { l, err := NewLefthook(false, "off") if err != nil { return } cfg, err := l.LoadConfig() if err != nil { return } for hook := range cfg.Hooks { fmt.Println(hook) //nolint:forbidigo // undecorated stdout is a must } } func ShellCompleteFlags(cmd *cli.Command) { given := cmd.FlagNames() flags: for _, f := range cmd.VisibleFlags() { toAdd := make([]string, 0, len(f.Names())) for _, fn := range f.Names() { // Exclude all aliases of a flag if any of them is already given if slices.Contains(given, fn) { continue flags } // Do not bother with single letter flags. // If the user knows what they're for, they can just write them (hit the letter instead of tab), // no need to clutter the output with them. if len(fn) != 1 { toAdd = append(toAdd, fn) } } for _, fn := range toAdd { fmt.Println("--" + fn) //nolint:forbidigo // undecorated stdout is a must } } } ================================================ FILE: internal/command/run.go ================================================ package command import ( "context" "errors" "fmt" "io" "os" "os/signal" "path/filepath" "time" "github.com/evilmartians/lefthook/v2/internal/config" "github.com/evilmartians/lefthook/v2/internal/git" "github.com/evilmartians/lefthook/v2/internal/log" "github.com/evilmartians/lefthook/v2/internal/run" "github.com/evilmartians/lefthook/v2/internal/run/result" "github.com/evilmartians/lefthook/v2/internal/version" ) const ( envEnabled = "LEFTHOOK" // "0", "false" envOutput = "LEFTHOOK_OUTPUT" // "meta,success,failure,summary,skips,execution,execution_out,execution_info" ) var errPipedAndParallelSet = errors.New("conflicting options 'piped' and 'parallel' are set to 'true', remove one of this option from hook group") type RunArgs struct { NoTTY bool AllFiles bool FilesFromStdin bool Force bool NoAutoInstall bool NoStageFixed bool SkipLFS bool Verbose bool FailOnChanges *bool FailOnChangesDiff *bool Hook string Exclude []string Files []string RunOnlyCommands []string RunOnlyJobs []string RunOnlyTags []string GitArgs []string } func (l *Lefthook) Run(ctx context.Context, args RunArgs) error { if os.Getenv(envEnabled) == "0" || os.Getenv(envEnabled) == "false" { return nil } waitPrecompute := l.repo.Precompute() defer waitPrecompute() if args.Verbose { log.SetLevel(log.DebugLevel) } // Load config cfg, err := l.LoadConfig() if err != nil { var errNotFound config.ConfigNotFoundError if ok := errors.As(err, &errNotFound); ok { log.Warn(err.Error()) return nil } return err } if err = checkVersion(cfg.MinVersion); err != nil { return err } // Suppress prepare-commit-msg output if the hook doesn't exist in config. // prepare-commit-msg hook is used for seamless synchronization of hooks with config. // See: internal/lefthook/install.go _, ok := cfg.Hooks[args.Hook] isGhostHook := args.Hook == config.GhostHookName && !ok && !args.Verbose if isGhostHook { log.SetLevel(log.WarnLevel) } enableLogTags := os.Getenv(envOutput) log.InitSettings() log.ApplySettings(enableLogTags, cfg.Output) if log.Settings.LogMeta() { log.LogMeta(args.Hook) } if !args.NoAutoInstall && !cfg.NoAutoInstall { // This line controls updating the git hook if config has changed var newCfg *config.Config newCfg, err = l.syncHooks(cfg, !isGhostHook) if err != nil { log.Warnf( "⚠️ There was a problem with synchronizing git hooks. Run 'lefthook install' manually.\n Error: %s", err, ) } else { cfg = newCfg } } hook, err := resolveHook(cfg, args.Hook) if err != nil { return err } if hook == nil { return nil } files, err := getFiles(l.repo, args) if err != nil { return err } args.Files = files sourceDirs := getSourceDirs(l.repo, cfg) failOnChanges, err := shouldFailOnChanges(args.FailOnChanges, hook.FailOnChanges) if err != nil { return err } failOnChangesDiff := shouldFailOnChangesDiff(args.FailOnChangesDiff, hook.FailOnChangesDiff) // Convert Commands and Scripts into Jobs hook.Jobs = append(hook.Jobs, config.CommandsToJobs(hook.Commands)...) hook.Commands = nil hook.Jobs = append(hook.Jobs, config.ScriptsToJobs(hook.Scripts)...) hook.Scripts = nil args.RunOnlyJobs = append(args.RunOnlyJobs, args.RunOnlyCommands...) return runHook(ctx, hook, l.repo, run.Options{ DisableTTY: cfg.NoTTY || args.NoTTY, SkipLFS: cfg.SkipLFS || args.SkipLFS, Templates: cfg.Templates, GlobMatcher: cfg.GlobMatcher, GitArgs: args.GitArgs, ExcludeFiles: args.Exclude, Files: args.Files, Force: args.Force, NoStageFixed: args.NoStageFixed, RunOnlyJobs: args.RunOnlyJobs, RunOnlyTags: args.RunOnlyTags, SourceDirs: sourceDirs, FailOnChanges: failOnChanges, FailOnChangesDiff: failOnChangesDiff, }) } func resolveHook(cfg *config.Config, hookName string) (*config.Hook, error) { hook, ok := cfg.Hooks[hookName] if !ok { if config.KnownHook(hookName) { log.Debugf("[lefthook] skip: Hook %s doesn't exist in the config", hookName) return nil, nil } return nil, fmt.Errorf("hook %s doesn't exist in the config", hookName) } if hook.Parallel && hook.Piped { return nil, errPipedAndParallelSet } return hook, nil } func getFiles(repo *git.Repository, args RunArgs) ([]string, error) { if args.FilesFromStdin { paths, err := io.ReadAll(os.Stdin) if err != nil { return nil, fmt.Errorf("failed to read the files from standard input: %w", err) } return append(args.Files, parseFilesFromString(string(paths))...), nil } else if args.AllFiles { files, err := repo.AllFiles() if err != nil { return nil, fmt.Errorf("failed to get all files: %w", err) } return append(args.Files, files...), nil } return args.Files, nil } func getSourceDirs(repo *git.Repository, cfg *config.Config) []string { sourceDirs := []string{ filepath.Join(repo.RootPath, cfg.SourceDir), filepath.Join(repo.RootPath, cfg.SourceDirLocal), // Additional source dirs to support .config/ filepath.Join(repo.RootPath, ".config", "lefthook"), filepath.Join(repo.RootPath, ".config", "lefthook-local"), } for _, remote := range cfg.Remotes { if remote.Configured() { // Append only source_dir, because source_dir_local doesn't make sense sourceDirs = append( sourceDirs, filepath.Join( repo.RemoteFolder(remote.GitURL, remote.Ref), cfg.SourceDir, ), ) } } return sourceDirs } func shouldFailOnChanges(fromArg *bool, fromHook string) (bool, error) { if fromArg != nil { return *fromArg, nil } switch fromHook { case "never", "false", "0", "": return false, nil case "always", "true", "1": return true, nil case "ci": _, ok := os.LookupEnv("CI") return ok, nil case "non-ci": _, ok := os.LookupEnv("CI") return !ok, nil default: return false, fmt.Errorf("invalid value for fail_on_changes: %s", fromHook) } } func shouldFailOnChangesDiff(fromArg *bool, fromHook *bool) bool { if fromArg != nil { return *fromArg } if fromHook != nil { return *fromHook } _, ok := os.LookupEnv("CI") return ok } func runHook(ctx context.Context, hook *config.Hook, repo *git.Repository, opts run.Options) error { ctx, stop := signal.NotifyContext(ctx, os.Interrupt) defer stop() startTime := time.Now() results, err := run.Run(ctx, hook, repo, opts) if err != nil { var failOnChangesErr *run.FailOnChangesError if errors.As(err, &failOnChangesErr) { return err } return fmt.Errorf("failed to run the hook: %w", err) } if ctx.Err() != nil { return errors.New("Interrupted") } printSummary(time.Since(startTime), results) for _, result := range results { if result.Failure() { return errors.New("") // No error should be printed } } return nil } func printSummary( duration time.Duration, results []result.Result, ) { if log.Settings.LogSummary() { summaryPrint := log.Separate if !log.Settings.LogExecution() { summaryPrint = func(s string) { log.Info(s) } } if len(results) == 0 { if log.Settings.LogEmptySummary() { summaryPrint( fmt.Sprintf( "%s %s %s", log.Cyan("summary:"), log.Gray("(skip)"), log.Yellow("empty"), ), ) } return } summaryPrint( log.Cyan("summary: ") + log.Gray(fmt.Sprintf("(done in %.2f seconds)", duration.Seconds())), ) } logResults(0, results) } func logResults(indent int, results []result.Result) { if log.Settings.LogSuccess() { for _, result := range results { if !result.Success() { continue } log.Success(indent, result.Name, result.Duration) if len(result.Sub) > 0 { logResults(indent+1, result.Sub) } } } if log.Settings.LogFailure() { for _, result := range results { if !result.Failure() { continue } log.Failure(indent, result.Name, result.Text(), result.Duration) if len(result.Sub) > 0 { logResults(indent+1, result.Sub) } } } } // parseFilesFromString parses both `\0`-separated files. func parseFilesFromString(paths string) []string { var result []string start := 0 for i, c := range paths { if c == 0 { result = append(result, paths[start:i]) start = i + 1 } } result = append(result, paths[start:]) return result } func checkVersion(minVersion string) error { if len(minVersion) == 0 { return nil } if err := version.Check(minVersion, version.Version(false)); err != nil { if errors.Is(err, version.ErrInvalidVersion) { return errors.New("format of 'min_version' setting is incorrect") } execPath, oserr := os.Executable() if oserr != nil { execPath = "" } return fmt.Errorf("required lefthook version (%s) is higher than current (%s) at %s", minVersion, version.Version(false), execPath) } return nil } ================================================ FILE: internal/command/run_test.go ================================================ package command import ( "fmt" "path/filepath" "testing" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/evilmartians/lefthook/v2/tests/helpers/cmdtest" "github.com/evilmartians/lefthook/v2/tests/helpers/gittest" ) func TestRun(t *testing.T) { root, err := filepath.Abs("src") if err != nil { t.Errorf("unexpected error: %s", err) } gitPath := gittest.GitPath(root) configPath := filepath.Join(root, "lefthook.yml") for i, tt := range [...]struct { name, hook, config string gitArgs []string envs map[string]string existingDirs []string error bool }{ { name: "Skip case", hook: "pre-commit", envs: map[string]string{ "LEFTHOOK": "0", }, error: false, }, { name: "Skip case", hook: "pre-commit", envs: map[string]string{ "LEFTHOOK": "false", }, error: false, }, { name: "Invalid version", hook: "pre-commit", config: ` min_version: 23.0.1 `, error: true, }, { name: "Valid version, no hook", hook: "pre-commit", config: ` min_version: 0.7.9 `, error: false, }, { name: "Invalid hook", hook: "pre-commit", config: ` pre-commit: parallel: true piped: true `, error: true, }, { name: "Valid hook", hook: "pre-commit", config: ` pre-commit: parallel: false piped: true `, error: false, }, { name: "When in git rebase-merge flow", hook: "pre-commit", config: ` pre-commit: parallel: false piped: true commands: echo: skip: - rebase - merge run: echo 'SHOULD NEVER RUN' `, existingDirs: []string{ filepath.Join(gitPath, "rebase-merge"), }, error: false, }, { name: "When in git rebase-apply flow", hook: "pre-commit", config: ` pre-commit: parallel: false piped: true commands: echo: skip: - rebase - merge run: echo 'SHOULD NEVER RUN' `, existingDirs: []string{ filepath.Join(gitPath, "rebase-apply"), }, error: false, }, { name: "When not in rebase flow", hook: "post-commit", config: ` post-commit: parallel: false piped: true commands: echo: skip: - rebase - merge run: echo 'SHOULD RUN' `, error: true, }, } { t.Run(fmt.Sprintf("%d: %s", i, tt.name), func(t *testing.T) { assert := assert.New(t) fs := afero.NewMemMapFs() lefthook := &Lefthook{ fs: fs, repo: gittest.NewRepositoryBuilder().Cmd(cmdtest.NewDumb()).Fs(fs).Root(root).Build(), } lefthook.repo.Setup() // Create files that should exist for _, path := range tt.existingDirs { assert.NoError(fs.MkdirAll(path, 0o755)) } assert.NoError(afero.WriteFile(fs, configPath, []byte(tt.config), 0o644)) for env, value := range tt.envs { t.Setenv(env, value) } err = lefthook.Run(t.Context(), RunArgs{Hook: tt.hook, GitArgs: tt.gitArgs}) if tt.error { assert.Error(err) } else { assert.NoError(err) } }) } } ================================================ FILE: internal/command/uninstall.go ================================================ package command import ( "context" "errors" "os" "path/filepath" "github.com/spf13/afero" "github.com/evilmartians/lefthook/v2/internal/config" "github.com/evilmartians/lefthook/v2/internal/log" ) type UninstallArgs struct { Force, RemoveConfig bool } func (l *Lefthook) Uninstall(_ctx context.Context, args UninstallArgs) error { if err := l.deleteHooks(args.Force); err != nil { return err } err := l.fs.Remove(l.checksumFilePath()) switch { case err == nil: log.Debugf("%s removed", l.checksumFilePath()) case errors.Is(err, os.ErrNotExist): log.Debugf("%s not found, skipping\n", l.checksumFilePath()) default: log.Errorf("Failed removing %s: %s\n", l.checksumFilePath(), err) } if args.RemoveConfig { for _, name := range append(config.MainConfigNames, config.LocalConfigNames...) { for _, extension := range []string{ ".yml", ".yaml", ".toml", ".json", } { l.removeFile(filepath.Join(l.repo.RootPath, name+extension)) } } } return l.fs.RemoveAll(l.repo.RemotesFolder()) } func (l *Lefthook) deleteHooks(force bool) error { hooks, err := afero.ReadDir(l.fs, l.repo.HooksPath) if err != nil { return err } for _, file := range hooks { hookFile := filepath.Join(l.repo.HooksPath, file.Name()) // Skip non-lefthook files if removal not forced if !l.isLefthookFile(hookFile) && !force { continue } if err := l.fs.Remove(hookFile); err == nil { log.Debugf("%s removed", hookFile) } else { log.Errorf("Failed removing %s: %s\n", hookFile, err) } // Recover .old file if exists oldHookFile := filepath.Join(l.repo.HooksPath, file.Name()+".old") if exists, _ := afero.Exists(l.fs, oldHookFile); !exists { continue } if err := l.fs.Rename(oldHookFile, hookFile); err == nil { log.Debug(oldHookFile, "renamed to", file.Name()) } else { log.Errorf("Failed renaming %s: %s\n", oldHookFile, err) } } return nil } func (l *Lefthook) removeFile(glob string) { paths, err := afero.Glob(l.fs, glob) if err != nil { log.Errorf("Failed removing configuration files: %s\n", err) return } for _, fileName := range paths { if err := l.fs.Remove(fileName); err == nil { log.Debugf("%s removed", fileName) } else { log.Errorf("Failed removing file %s: %s\n", fileName, err) } } } ================================================ FILE: internal/command/uninstall_test.go ================================================ package command import ( "fmt" "path/filepath" "testing" "github.com/spf13/afero" "github.com/evilmartians/lefthook/v2/internal/config" "github.com/evilmartians/lefthook/v2/tests/helpers/gittest" ) func TestLefthookUninstall(t *testing.T) { root, err := filepath.Abs("src") if err != nil { t.Errorf("unexpected error: %s", err) } configPath := filepath.Join(root, "lefthook.yml") checksumPath := filepath.Join(gittest.GitPath(root), "info", config.ChecksumFileName) hookPath := func(hook string) string { return filepath.Join(gittest.GitPath(root), "hooks", hook) } for n, tt := range [...]struct { name, config string args UninstallArgs existingHooks map[string]string wantExist, wantNotExist []string }{ { name: "simple defaults", existingHooks: map[string]string{ "pre-commit": "not a lefthook hook", "post-commit": `"$LEFTHOOK" file`, }, config: "# empty", wantExist: []string{ configPath, hookPath("pre-commit"), }, wantNotExist: []string{ checksumPath, hookPath("post-commit"), }, }, { name: "with force", args: UninstallArgs{Force: true}, existingHooks: map[string]string{ "pre-commit": "not a lefthook hook", "post-commit": "\n# LEFTHOOK file\n", }, config: "# empty", wantExist: []string{configPath}, wantNotExist: []string{ checksumPath, hookPath("pre-commit"), hookPath("post-commit"), }, }, { name: "with --remove-configs option", args: UninstallArgs{RemoveConfig: true}, existingHooks: map[string]string{ "pre-commit": "not a lefthook hook", "post-commit": "# LEFTHOOK", }, config: "# empty", wantExist: []string{ hookPath("pre-commit"), }, wantNotExist: []string{ checksumPath, configPath, hookPath("post-commit"), }, }, { name: "with .old files", existingHooks: map[string]string{ "pre-commit": "not a lefthook hook", "post-commit": "LEFTHOOK file", "post-commit.old": "not a lefthook hook", }, config: "# empty", wantExist: []string{ configPath, hookPath("pre-commit"), hookPath("post-commit"), }, wantNotExist: []string{ checksumPath, hookPath("post-commit.old"), }, }, } { t.Run(fmt.Sprintf("%d: %s", n, tt.name), func(t *testing.T) { fs := afero.NewMemMapFs() lefthook := &Lefthook{ fs: fs, repo: gittest.NewRepositoryBuilder().Fs(fs).Root(root).Build(), } // Create config and checksum file err := afero.WriteFile(fs, configPath, []byte(tt.config), 0o644) if err != nil { t.Errorf("unexpected error: %s", err) } err = afero.WriteFile(fs, checksumPath, []byte("CHECKSUM"), 0o644) if err != nil { t.Errorf("unexpected error: %s", err) } // Prepare files that should exist for hook, content := range tt.existingHooks { path := hookPath(hook) if err = fs.MkdirAll(filepath.Dir(path), 0o755); err != nil { t.Errorf("unexpected error: %s", err) } if err = afero.WriteFile(fs, path, []byte(content), 0o755); err != nil { t.Errorf("unexpected error: %s", err) } } // Do uninstall err = lefthook.Uninstall(t.Context(), tt.args) if err != nil { t.Errorf("unexpected error: %s", err) } // Test files that should exist for _, file := range tt.wantExist { ok, err := afero.Exists(fs, file) if err != nil { t.Errorf("unexpected error: %s", err) } if !ok { t.Errorf("expected %s to exist", file) } } // Test files that should not exist for _, file := range tt.wantNotExist { ok, err := afero.Exists(fs, file) if err != nil { t.Errorf("unexpected error: %s", err) } if ok { t.Errorf("expected %s to not exist", file) } } }) } } ================================================ FILE: internal/command/validate.go ================================================ package command import ( "context" "errors" "strings" "github.com/kaptinlin/jsonschema" "github.com/evilmartians/lefthook/v2/internal/config" "github.com/evilmartians/lefthook/v2/internal/log" ) type ValidateArgs struct { SchemaPath string } func (l *Lefthook) Validate(_ctx context.Context, args ValidateArgs) error { main, secondary, err := config.LoadKoanf(l.fs, l.repo) if err != nil { return err } compiler := jsonschema.NewCompiler() schema, err := compiler.Compile(config.JsonSchema) if err != nil { return err } result := schema.Validate(main.Raw()) if !result.IsValid() { details := result.ToList() logValidationErrors(0, *details) return errors.New("validation failed for main config") } result = schema.Validate(secondary.Raw()) if !result.IsValid() { details := result.ToList() logValidationErrors(0, *details) return errors.New("validation failed for secondary config") } log.Info("All good") return nil } func logValidationErrors(indent int, details jsonschema.List) { if details.Valid { return } if len(details.InstanceLocation) > 0 { logDetail(indent, details) indent += 2 } for _, d := range details.Details { logValidationErrors(indent, d) } } func logDetail(indent int, details jsonschema.List) { var errors []string if len(details.Errors) > 0 { for _, err := range details.Errors { errors = append(errors, err) } } option := strings.Repeat(" ", indent) + strings.TrimLeft(details.InstanceLocation, "/") + ":" if len(errors) == 0 { option = log.Gray(option) } else { option = log.Yellow(option) } if len(details.Details) > 0 { log.Info(option) } else { log.Info(option, log.Red(strings.Join(errors, ","))) } } ================================================ FILE: internal/config/available_hooks.go ================================================ package config // ChecksumFileName - the file, which is used just to store the current config checksum version. const ChecksumFileName = "lefthook.checksum" // GhostHookName - the hook which logs are not shown and which is used for synchronizing hooks. const GhostHookName = "prepare-commit-msg" // AvailableHooks - list of hooks taken from https://git-scm.com/docs/githooks. // Keep the order of the hooks same here for easy syncing. var AvailableHooks = map[string]struct{}{ "applypatch-msg": {}, "pre-applypatch": {}, "post-applypatch": {}, "pre-commit": {}, "pre-merge-commit": {}, "prepare-commit-msg": {}, "commit-msg": {}, "post-commit": {}, "pre-rebase": {}, "post-checkout": {}, "post-merge": {}, "pre-push": {}, "pre-receive": {}, "update": {}, "proc-receive": {}, "post-receive": {}, "post-update": {}, "reference-transaction": {}, "push-to-checkout": {}, "pre-auto-gc": {}, "post-rewrite": {}, "sendemail-validate": {}, "fsmonitor-watchman": {}, "p4-changelist": {}, "p4-prepare-changelist": {}, "p4-post-changelist": {}, "p4-pre-submit": {}, "post-index-change": {}, } func HookUsesStagedFiles(hook string) bool { return hook == "pre-commit" } func HookUsesPushFiles(hook string) bool { return hook == "pre-push" } func KnownHook(hook string) bool { _, ok := AvailableHooks[hook] return ok } ================================================ FILE: internal/config/command.go ================================================ package config import ( "cmp" "errors" "slices" "strings" "time" ) var ErrFilesIncompatible = errors.New("one of your runners contains incompatible file types") type Command struct { Run string `json:"run" mapstructure:"run" toml:"run" yaml:"run"` Files string `json:"files,omitempty" mapstructure:"files" toml:"files,omitempty" yaml:",omitempty"` Root string `json:"root,omitempty" mapstructure:"root" toml:"root,omitempty" yaml:",omitempty"` FailText string `json:"fail_text,omitempty" koanf:"fail_text" mapstructure:"fail_text" toml:"fail_text,omitempty" yaml:"fail_text,omitempty"` Timeout time.Duration `json:"timeout,omitempty" jsonschema:"type=string,example=15s" mapstructure:"timeout" toml:"timeout,omitempty" yaml:",omitempty"` Skip any `json:"skip,omitempty" jsonschema:"oneof_type=boolean;array" mapstructure:"skip" toml:"skip,omitempty,inline" yaml:",omitempty"` Only any `json:"only,omitempty" jsonschema:"oneof_type=boolean;array" mapstructure:"only" toml:"only,omitempty,inline" yaml:",omitempty"` Tags []string `json:"tags,omitempty" jsonschema:"oneof_type=string;array" mapstructure:"tags" toml:"tags,omitempty" yaml:",omitempty"` FileTypes []string `json:"file_types,omitempty" jsonschema:"oneof_type=string;array" koanf:"file_types" mapstructure:"file_types" toml:"file_types,omitempty" yaml:"file_types,omitempty"` Glob []string `json:"glob,omitempty" jsonschema:"oneof_type=string;array" mapstructure:"glob" toml:"glob,omitempty" yaml:",omitempty"` Exclude []string `json:"exclude,omitempty" jsonschema:"oneof_type=string;array" mapstructure:"exclude" toml:"exclude,omitempty" yaml:",omitempty"` Env map[string]string `json:"env,omitempty" mapstructure:"env" toml:"env,omitempty" yaml:",omitempty"` Priority int `json:"priority,omitempty" mapstructure:"priority" toml:"priority,omitempty" yaml:",omitempty"` Interactive bool `json:"interactive,omitempty" mapstructure:"interactive" toml:"interactive,omitempty" yaml:",omitempty"` UseStdin bool `json:"use_stdin,omitempty" koanf:"use_stdin" mapstructure:"use_stdin" toml:"use_stdin,omitempty" yaml:"use_stdin,omitempty"` StageFixed bool `json:"stage_fixed,omitempty" koanf:"stage_fixed" mapstructure:"stage_fixed" toml:"stage_fixed,omitempty" yaml:"stage_fixed,omitempty"` } func CommandsToJobs(commands map[string]*Command) []*Job { jobs := make([]*Job, 0, len(commands)) for name, command := range commands { jobs = append(jobs, &Job{ Name: name, Run: command.Run, Glob: command.Glob, Root: command.Root, Files: command.Files, FailText: command.FailText, Timeout: command.Timeout, Tags: command.Tags, FileTypes: command.FileTypes, Env: command.Env, Interactive: command.Interactive, UseStdin: command.UseStdin, StageFixed: command.StageFixed, Exclude: command.Exclude, Skip: command.Skip, Only: command.Only, }) } // ASC slices.SortFunc(jobs, func(i, j *Job) int { a := commands[i.Name] b := commands[j.Name] if a.Priority != 0 || b.Priority != 0 { // Script without a priority must be the last if a.Priority == 0 { return 1 } if b.Priority == 0 { return -1 } return cmp.Compare(a.Priority, b.Priority) } iNum := parseNum(i.Name) jNum := parseNum(j.Name) if iNum == -1 && jNum == -1 { return strings.Compare(i.Name, j.Name) } if iNum == -1 { return 1 } if jNum == -1 { return -1 } return cmp.Compare(iNum, jNum) }) return jobs } ================================================ FILE: internal/config/command_executor.go ================================================ package config import ( "bytes" "strings" "github.com/evilmartians/lefthook/v2/internal/log" "github.com/evilmartians/lefthook/v2/internal/system" ) // commandExecutor implements execution of a skip checks passed in a `run` option. type commandExecutor struct { cmd system.Command } // cmd runs plain string command in a subshell returning the success of it. func (c *commandExecutor) execute(commandLine string) bool { if commandLine == "" { return false } sh, err := system.Sh() if err != nil { log.Errorf("`sh` executable not found: %s\n", err) return false } args := []string{sh, "-c", commandLine} stdout := new(bytes.Buffer) stderr := new(bytes.Buffer) err = c.cmd.Run(args, "", system.NullReader, stdout, stderr) b := log.Builder(log.DebugLevel, "[lefthook] "). Add("run: ", strings.Join(args, " ")). Add("out: ", stdout.String()). Add("err: ", stderr.String()) if err != nil { b.Add("!: ", err.Error()) } b.Log() return err == nil } ================================================ FILE: internal/config/command_test.go ================================================ package config import ( "testing" "time" "github.com/stretchr/testify/assert" ) func TestCommandsToJobs(t *testing.T) { commands := map[string]*Command{ "check": { Run: "echo", Priority: 150, }, "10lint": { Run: "echo", StageFixed: true, }, "first": { Run: "echo", Priority: 1, }, "2lint": { Run: "echo", StageFixed: true, }, "last": { Run: "echo", }, } jobs := CommandsToJobs(commands) assert.Equal(t, jobs, []*Job{ {Name: "first", Run: "echo"}, {Name: "check", Run: "echo"}, {Name: "2lint", Run: "echo", StageFixed: true}, {Name: "10lint", Run: "echo", StageFixed: true}, {Name: "last", Run: "echo"}, }) } func TestCommandsToJobsWithTimeout(t *testing.T) { commands := map[string]*Command{ "lint": { Run: "echo lint", Timeout: 60 * time.Second, Priority: 1, }, "test": { Run: "echo test", Timeout: 5 * time.Minute, }, } jobs := CommandsToJobs(commands) assert.Equal(t, jobs, []*Job{ {Name: "lint", Run: "echo lint", Timeout: 60 * time.Second}, {Name: "test", Run: "echo test", Timeout: 5 * time.Minute}, }) } ================================================ FILE: internal/config/config.go ================================================ package config import ( "bytes" "crypto/md5" "encoding/hex" "encoding/json" "errors" "fmt" "io" "github.com/mitchellh/mapstructure" "github.com/pelletier/go-toml/v2" "go.yaml.in/yaml/v3" ) type DumpFormat int const ( YAMLFormat DumpFormat = iota TOMLFormat JSONFormat JSONCompactFormat yamlIndent = 2 ) type Config struct { MinVersion string `json:"min_version,omitempty" jsonschema:"description=Specify a minimum version for the lefthook binary" koanf:"min_version" mapstructure:"min_version,omitempty"` Lefthook string `json:"lefthook,omitempty" jsonschema:"description=Lefthook executable path or command" mapstructure:"lefthook,omitempty"` SourceDir string `json:"source_dir,omitempty" jsonschema:"default=.lefthook/,description=Change a directory for script files. Directory for script files contains folders with git hook names which contain script files." koanf:"source_dir" mapstructure:"source_dir,omitempty"` SourceDirLocal string `json:"source_dir_local,omitempty" jsonschema:"default=.lefthook-local/,description=Change a directory for local script files (not stored in VCS)" koanf:"source_dir_local" mapstructure:"source_dir_local,omitempty"` Rc string `json:"rc,omitempty" jsonschema:"description=Provide an rc file - a simple sh script" mapstructure:"rc,omitempty"` Output any `json:"output,omitempty" jsonschema:"oneof_type=boolean;array,description=Manage verbosity by skipping the printing of output of some steps" mapstructure:"output,omitempty"` Colors any `json:"colors,omitempty" jsonschema:"description=Enable disable or set your own colors for lefthook output,default=true,oneof_type=boolean;object" mapstructure:"colors,omitempty"` Extends []string `json:"extends,omitempty" jsonschema:"description=Specify files to extend config with" mapstructure:"extends,omitempty"` NoTTY bool `json:"no_tty,omitempty" jsonschema:"description=Whether hide spinner and other interactive things" koanf:"no_tty" mapstructure:"no_tty,omitempty"` AssertLefthookInstalled bool `json:"assert_lefthook_installed,omitempty" koanf:"assert_lefthook_installed" mapstructure:"assert_lefthook_installed,omitempty"` SkipLFS bool `json:"skip_lfs,omitempty" jsonschema:"description=Skip running Git LFS hooks (enabled by default)" koanf:"skip_lfs" mapstructure:"skip_lfs,omitempty"` NoAutoInstall bool `json:"no_auto_install,omitempty" jsonschema:"description=Do not automatically install hooks when running lefthook" koanf:"no_auto_install" mapstructure:"no_auto_install,omitempty"` InstallNonGitHooks bool `json:"install_non_git_hooks,omitempty" jsonschema:"description=Install non-Git hooks to .git/hooks" koanf:"install_non_git_hooks" mapstructure:"install_non_git_hooks,omitempty"` GlobMatcher string `json:"glob_matcher,omitempty" jsonschema:"description=Choose the glob matching engine: 'gobwas' (default) or 'doublestar' (standard ** behavior),enum=gobwas,enum=doublestar,default=gobwas" koanf:"glob_matcher" mapstructure:"glob_matcher,omitempty"` Remotes []*Remote `json:"remotes,omitempty" jsonschema:"description=Provide multiple remote configs to use lefthook configurations shared across projects. Lefthook will automatically download and merge configurations into main config." mapstructure:"remotes,omitempty"` Templates map[string]string `json:"templates,omitempty" jsonschema:"description=Custom templates for replacements in run commands." mapstructure:"templates,omitempty"` Hooks map[string]*Hook `jsonschema:"-" mapstructure:"-"` } func (c *Config) Md5() (checksum string, err error) { configBytes := new(bytes.Buffer) err = c.Dump(JSONCompactFormat, configBytes) if err != nil { return checksum, err } hash := md5.New() _, err = io.Copy(hash, configBytes) if err != nil { return checksum, err } checksum = hex.EncodeToString(hash.Sum(nil)[:16]) return checksum, err } func (c *Config) Dump(format DumpFormat, out io.Writer) error { res := make(map[string]any) if err := mapstructure.Decode(c, &res); err != nil { return err } if c.SourceDir == DefaultSourceDir { delete(res, "source_dir") } if c.SourceDirLocal == DefaultSourceDirLocal { delete(res, "source_dir_local") } for hookName, hook := range c.Hooks { res[hookName] = hook } var dumper dumper switch format { case YAMLFormat: dumper = yamlDumper{} case TOMLFormat: dumper = tomlDumper{} case JSONFormat: dumper = jsonDumper{pretty: true} case JSONCompactFormat: dumper = jsonDumper{pretty: false} default: dumper = yamlDumper{} } return dumper.Dump(res, out) } type dumper interface { Dump(map[string]any, io.Writer) error } type yamlDumper struct{} func (yamlDumper) Dump(input map[string]any, out io.Writer) error { encoder := yaml.NewEncoder(out) encoder.SetIndent(yamlIndent) err := errors.Join(encoder.Encode(input), encoder.Close()) return err } type tomlDumper struct{} func (tomlDumper) Dump(input map[string]any, out io.Writer) error { encoder := toml.NewEncoder(out) err := encoder.Encode(input) if err != nil { return err } return nil } type jsonDumper struct { pretty bool } func (j jsonDumper) Dump(input map[string]any, out io.Writer) error { var res []byte var err error if j.pretty { res, err = json.MarshalIndent(input, "", " ") } else { res, err = json.Marshal(input) } if err != nil { return err } n, err := out.Write(res) if n != len(res) { return fmt.Errorf("file not written fully: %d/%d", n, len(res)) } if err != nil { return err } if j.pretty { _, _ = out.Write([]byte("\n")) } return nil } ================================================ FILE: internal/config/files.go ================================================ package config import "strings" const ( SubFiles string = "{files}" SubAllFiles string = "{all_files}" SubStagedFiles string = "{staged_files}" SubPushFiles string = "{push_files}" ) func IsRunFilesCompatible(run string) bool { return !strings.Contains(run, SubStagedFiles) || !strings.Contains(run, SubPushFiles) } ================================================ FILE: internal/config/hook.go ================================================ package config const CMD = "{cmd}" type Hook struct { Name string `json:"-" jsonschema:"-" koanf:"-" mapstructure:"-" toml:"-" yaml:"-"` Parallel bool `json:"parallel,omitempty" mapstructure:"parallel" toml:"parallel,omitempty" yaml:",omitempty"` Piped bool `json:"piped,omitempty" mapstructure:"piped" toml:"piped,omitempty" yaml:",omitempty"` Follow bool `json:"follow,omitempty" mapstructure:"follow" toml:"follow,omitempty" yaml:",omitempty"` FailOnChanges string `json:"fail_on_changes,omitempty" jsonschema:"enum=true,enum=1,enum=0,enum=false,enum=never,enum=always,enum=ci,enum=non-ci" koanf:"fail_on_changes" mapstructure:"fail_on_changes" toml:"fail_on_changes,omitempty" yaml:"fail_on_changes,omitempty"` FailOnChangesDiff *bool `json:"fail_on_changes_diff,omitempty" koanf:"fail_on_changes_diff" mapstructure:"fail_on_changes_diff" toml:"fail_on_changes_diff,omitempty" yaml:"fail_on_changes_diff,omitempty"` Files string `json:"files,omitempty" mapstructure:"files" toml:"files,omitempty" yaml:",omitempty"` ExcludeTags []string `json:"exclude_tags,omitempty" koanf:"exclude_tags" mapstructure:"exclude_tags" toml:"exclude_tags,omitempty" yaml:"exclude_tags,omitempty"` Exclude []string `json:"exclude,omitempty" koanf:"exclude" mapstructure:"exclude" toml:"exclude,omitempty" yaml:"exclude,omitempty"` Skip any `json:"skip,omitempty" jsonschema:"oneof_type=boolean;array" mapstructure:"skip" toml:"skip,omitempty,inline" yaml:",omitempty"` Only any `json:"only,omitempty" jsonschema:"oneof_type=boolean;array" mapstructure:"only" toml:"only,omitempty,inline" yaml:",omitempty"` Setup []*SetupInstruction `json:"setup,omitempty" mapstructure:"setup" toml:"setup,omitempty" yaml:",omitempty"` Jobs []*Job `json:"jobs,omitempty" mapstructure:"jobs" toml:"jobs,omitempty" yaml:",omitempty"` Commands map[string]*Command `json:"commands,omitempty" mapstructure:"-" toml:"commands,omitempty" yaml:",omitempty"` Scripts map[string]*Script `json:"scripts,omitempty" mapstructure:"-" toml:"scripts,omitempty" yaml:",omitempty"` } type SetupInstruction struct { Run string `json:"run,omitempty" jsonschema:"oneof_required=Run a command" mapstructure:"run" toml:"run,omitempty" yaml:",omitempty"` } ================================================ FILE: internal/config/job.go ================================================ package config import "time" type Job struct { Name string `json:"name,omitempty" mapstructure:"name" toml:"name,omitempty" yaml:",omitempty"` Run string `json:"run,omitempty" jsonschema:"oneof_required=Run a command" mapstructure:"run" toml:"run,omitempty" yaml:",omitempty"` Script string `json:"script,omitempty" jsonschema:"oneof_required=Run a script" mapstructure:"script" toml:"script,omitempty" yaml:",omitempty"` Runner string `json:"runner,omitempty" mapstructure:"runner" toml:"runner,omitempty" yaml:",omitempty"` Args string `json:"args,omitempty" mapstructure:"args" toml:"args,omitempty" yaml:",omitempty"` Root string `json:"root,omitempty" mapstructure:"root" toml:"root,omitempty" yaml:",omitempty"` Files string `json:"files,omitempty" mapstructure:"files" toml:"files,omitempty" yaml:",omitempty"` FailText string `json:"fail_text,omitempty" koanf:"fail_text" mapstructure:"fail_text" toml:"fail_text,omitempty" yaml:"fail_text,omitempty"` Timeout time.Duration `json:"timeout,omitempty" jsonschema:"type=string,example=15s" mapstructure:"timeout" toml:"timeout,omitempty" yaml:",omitempty"` Glob []string `json:"glob,omitempty" jsonschema:"oneof_type=string;array" mapstructure:"glob" toml:"glob,omitempty" yaml:",omitempty"` Exclude []string `json:"exclude,omitempty" jsonschema:"oneof_type=string;array" mapstructure:"exclude" toml:"exclude,omitempty" yaml:",omitempty"` Tags []string `json:"tags,omitempty" mapstructure:"tags" toml:"tags,omitempty" yaml:",omitempty"` FileTypes []string `json:"file_types,omitempty" jsonschema:"oneof_type=string;array" koanf:"file_types" mapstructure:"file_types" toml:"file_types,omitempty" yaml:"file_types,omitempty"` Env map[string]string `json:"env,omitempty" mapstructure:"env" toml:"env,omitempty" yaml:",omitempty"` Interactive bool `json:"interactive,omitempty" mapstructure:"interactive" toml:"interactive,omitempty" yaml:",omitempty"` UseStdin bool `json:"use_stdin,omitempty" koanf:"use_stdin" mapstructure:"use_stdin" toml:"use_stdin,omitempty" yaml:"use_stdin,omitempty"` StageFixed bool `json:"stage_fixed,omitempty" koanf:"stage_fixed" mapstructure:"stage_fixed" toml:"stage_fixed,omitempty" yaml:"stage_fixed,omitempty"` Skip any `json:"skip,omitempty" jsonschema:"oneof_type=boolean;array" mapstructure:"skip" toml:"skip,omitempty,inline" yaml:",omitempty"` Only any `json:"only,omitempty" jsonschema:"oneof_type=boolean;array" mapstructure:"only" toml:"only,omitempty,inline" yaml:",omitempty"` Group *Group `json:"group,omitempty" jsonschema:"oneof_required=Run a group" mapstructure:"group" toml:"group,omitempty" yaml:",omitempty"` } type Group struct { Root string `json:"root,omitempty" mapstructure:"root" toml:"root,omitempty" yaml:",omitempty"` Parallel bool `json:"parallel,omitempty" mapstructure:"parallel" toml:"parallel,omitempty" yaml:",omitempty"` Piped bool `json:"piped,omitempty" mapstructure:"piped" toml:"piped,omitempty" yaml:",omitempty"` Jobs []*Job `json:"jobs" mapstructure:"jobs" toml:"jobs" yaml:"jobs"` } func (job *Job) PrintableName(id string) string { if len(job.Name) != 0 { return job.Name } if len(job.Run) != 0 { return job.Run } if len(job.Script) != 0 { return job.Script } return "[" + id + "]" } ================================================ FILE: internal/config/jsonc_parser.go ================================================ package config import ( "encoding/json" "github.com/tidwall/jsonc" ) type JSONC struct{} func jsoncParser() *JSONC { return &JSONC{} } func (p *JSONC) Unmarshal(b []byte) (map[string]any, error) { var out map[string]any if err := json.Unmarshal(jsonc.ToJSON(b), &out); err != nil { return nil, err } return out, nil } // Marshal marshals the given config map to JSON bytes. func (p *JSONC) Marshal(o map[string]any) ([]byte, error) { return json.Marshal(o) } ================================================ FILE: internal/config/jsonschema.go ================================================ package config import ( _ "embed" ) //go:embed jsonschema.json var JsonSchema []byte ================================================ FILE: internal/config/jsonschema.json ================================================ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://json.schemastore.org/lefthook.json", "$defs": { "Command": { "properties": { "run": { "type": "string" }, "files": { "type": "string" }, "root": { "type": "string" }, "fail_text": { "type": "string" }, "timeout": { "type": "string", "examples": [ "15s" ] }, "skip": { "oneOf": [ { "type": "boolean" }, { "type": "array" } ] }, "only": { "oneOf": [ { "type": "boolean" }, { "type": "array" } ] }, "tags": { "oneOf": [ { "type": "string" }, { "type": "array" } ], "items": { "type": "string" } }, "file_types": { "oneOf": [ { "type": "string" }, { "type": "array" } ], "items": { "type": "string" } }, "glob": { "oneOf": [ { "type": "string" }, { "type": "array" } ], "items": { "type": "string" } }, "exclude": { "oneOf": [ { "type": "string" }, { "type": "array" } ], "items": { "type": "string" } }, "env": { "additionalProperties": { "type": "string" }, "type": "object" }, "priority": { "type": "integer" }, "interactive": { "type": "boolean" }, "use_stdin": { "type": "boolean" }, "stage_fixed": { "type": "boolean" } }, "additionalProperties": false, "type": "object", "required": [ "run" ] }, "Group": { "properties": { "root": { "type": "string" }, "parallel": { "type": "boolean" }, "piped": { "type": "boolean" }, "jobs": { "items": { "$ref": "#/$defs/Job" }, "type": "array" } }, "additionalProperties": false, "type": "object", "required": [ "jobs" ] }, "Hook": { "properties": { "parallel": { "type": "boolean" }, "piped": { "type": "boolean" }, "follow": { "type": "boolean" }, "fail_on_changes": { "type": "string", "enum": [ "true", "1", "0", "false", "never", "always", "ci", "non-ci" ] }, "fail_on_changes_diff": { "type": "boolean" }, "files": { "type": "string" }, "exclude_tags": { "items": { "type": "string" }, "type": "array" }, "exclude": { "items": { "type": "string" }, "type": "array" }, "skip": { "oneOf": [ { "type": "boolean" }, { "type": "array" } ] }, "only": { "oneOf": [ { "type": "boolean" }, { "type": "array" } ] }, "setup": { "items": { "$ref": "#/$defs/SetupInstruction" }, "type": "array" }, "jobs": { "items": { "$ref": "#/$defs/Job" }, "type": "array" }, "commands": { "additionalProperties": { "$ref": "#/$defs/Command" }, "type": "object" }, "scripts": { "additionalProperties": { "$ref": "#/$defs/Script" }, "type": "object" } }, "additionalProperties": false, "type": "object" }, "Job": { "oneOf": [ { "required": [ "run" ], "title": "Run a command" }, { "required": [ "script" ], "title": "Run a script" }, { "required": [ "group" ], "title": "Run a group" } ], "properties": { "name": { "type": "string" }, "run": { "type": "string" }, "script": { "type": "string" }, "runner": { "type": "string" }, "args": { "type": "string" }, "root": { "type": "string" }, "files": { "type": "string" }, "fail_text": { "type": "string" }, "timeout": { "type": "string", "examples": [ "15s" ] }, "glob": { "oneOf": [ { "type": "string" }, { "type": "array" } ], "items": { "type": "string" } }, "exclude": { "oneOf": [ { "type": "string" }, { "type": "array" } ], "items": { "type": "string" } }, "tags": { "items": { "type": "string" }, "type": "array" }, "file_types": { "oneOf": [ { "type": "string" }, { "type": "array" } ], "items": { "type": "string" } }, "env": { "additionalProperties": { "type": "string" }, "type": "object" }, "interactive": { "type": "boolean" }, "use_stdin": { "type": "boolean" }, "stage_fixed": { "type": "boolean" }, "skip": { "oneOf": [ { "type": "boolean" }, { "type": "array" } ] }, "only": { "oneOf": [ { "type": "boolean" }, { "type": "array" } ] }, "group": { "$ref": "#/$defs/Group" } }, "additionalProperties": false, "type": "object" }, "Remote": { "properties": { "git_url": { "type": "string", "description": "A URL to Git repository. It will be accessed with privileges of the machine lefthook runs on." }, "ref": { "type": "string", "description": "An optional *branch* or *tag* name" }, "configs": { "items": { "type": "string" }, "type": "array", "description": "An optional array of config paths from remote's root", "default": [ "lefthook.yml" ] }, "refetch": { "type": "boolean", "description": "Set to true if you want to always refetch the remote" }, "refetch_frequency": { "type": "string", "description": "Provide a frequency for the remotes refetches", "examples": [ "24h" ] } }, "additionalProperties": false, "type": "object" }, "Script": { "properties": { "runner": { "type": "string" }, "args": { "type": "string" }, "skip": { "oneOf": [ { "type": "boolean" }, { "type": "array" } ] }, "only": { "oneOf": [ { "type": "boolean" }, { "type": "array" } ] }, "tags": { "oneOf": [ { "type": "string" }, { "type": "array" } ], "items": { "type": "string" } }, "env": { "additionalProperties": { "type": "string" }, "type": "object" }, "priority": { "type": "integer" }, "fail_text": { "type": "string" }, "timeout": { "type": "string", "examples": [ "15s" ] }, "interactive": { "type": "boolean" }, "use_stdin": { "type": "boolean" }, "stage_fixed": { "type": "boolean" } }, "additionalProperties": false, "type": "object" }, "SetupInstruction": { "oneOf": [ { "required": [ "run" ], "title": "Run a command" } ], "properties": { "run": { "type": "string" } }, "additionalProperties": false, "type": "object" } }, "$comment": "Last updated on 2026.02.28.", "properties": { "min_version": { "type": "string", "description": "Specify a minimum version for the lefthook binary" }, "lefthook": { "type": "string", "description": "Lefthook executable path or command" }, "source_dir": { "type": "string", "description": "Change a directory for script files. Directory for script files contains folders with git hook names which contain script files.", "default": ".lefthook/" }, "source_dir_local": { "type": "string", "description": "Change a directory for local script files (not stored in VCS)", "default": ".lefthook-local/" }, "rc": { "type": "string", "description": "Provide an rc file - a simple sh script" }, "output": { "oneOf": [ { "type": "boolean" }, { "type": "array" } ], "description": "Manage verbosity by skipping the printing of output of some steps" }, "colors": { "oneOf": [ { "type": "boolean" }, { "type": "object" } ], "description": "Enable disable or set your own colors for lefthook output" }, "extends": { "items": { "type": "string" }, "type": "array", "description": "Specify files to extend config with" }, "no_tty": { "type": "boolean", "description": "Whether hide spinner and other interactive things" }, "assert_lefthook_installed": { "type": "boolean" }, "skip_lfs": { "type": "boolean", "description": "Skip running Git LFS hooks (enabled by default)" }, "no_auto_install": { "type": "boolean", "description": "Do not automatically install hooks when running lefthook" }, "install_non_git_hooks": { "type": "boolean", "description": "Install non-Git hooks to .git/hooks" }, "glob_matcher": { "type": "string", "enum": [ "gobwas", "doublestar" ], "description": "Choose the glob matching engine: 'gobwas' (default) or 'doublestar' (standard ** behavior)", "default": "gobwas" }, "remotes": { "items": { "$ref": "#/$defs/Remote" }, "type": "array", "description": "Provide multiple remote configs to use lefthook configurations shared across projects. Lefthook will automatically download and merge configurations into main config." }, "templates": { "additionalProperties": { "type": "string" }, "type": "object", "description": "Custom templates for replacements in run commands." }, "$schema": { "type": "string" }, "pre-commit": { "$ref": "#/$defs/Hook" }, "applypatch-msg": { "$ref": "#/$defs/Hook" }, "pre-applypatch": { "$ref": "#/$defs/Hook" }, "post-applypatch": { "$ref": "#/$defs/Hook" }, "pre-merge-commit": { "$ref": "#/$defs/Hook" }, "prepare-commit-msg": { "$ref": "#/$defs/Hook" }, "commit-msg": { "$ref": "#/$defs/Hook" }, "post-commit": { "$ref": "#/$defs/Hook" }, "pre-rebase": { "$ref": "#/$defs/Hook" }, "post-checkout": { "$ref": "#/$defs/Hook" }, "post-merge": { "$ref": "#/$defs/Hook" }, "pre-push": { "$ref": "#/$defs/Hook" }, "pre-receive": { "$ref": "#/$defs/Hook" }, "update": { "$ref": "#/$defs/Hook" }, "proc-receive": { "$ref": "#/$defs/Hook" }, "post-receive": { "$ref": "#/$defs/Hook" }, "post-update": { "$ref": "#/$defs/Hook" }, "reference-transaction": { "$ref": "#/$defs/Hook" }, "push-to-checkout": { "$ref": "#/$defs/Hook" }, "pre-auto-gc": { "$ref": "#/$defs/Hook" }, "post-rewrite": { "$ref": "#/$defs/Hook" }, "sendemail-validate": { "$ref": "#/$defs/Hook" }, "fsmonitor-watchman": { "$ref": "#/$defs/Hook" }, "p4-changelist": { "$ref": "#/$defs/Hook" }, "p4-prepare-changelist": { "$ref": "#/$defs/Hook" }, "p4-post-changelist": { "$ref": "#/$defs/Hook" }, "p4-pre-submit": { "$ref": "#/$defs/Hook" }, "post-index-change": { "$ref": "#/$defs/Hook" } }, "additionalProperties": { "properties": { "parallel": { "type": "boolean" }, "piped": { "type": "boolean" }, "follow": { "type": "boolean" }, "fail_on_changes": { "type": "string", "enum": [ "true", "1", "0", "false", "never", "always", "ci", "non-ci" ] }, "fail_on_changes_diff": { "type": "boolean" }, "files": { "type": "string" }, "exclude_tags": { "items": { "type": "string" }, "type": "array" }, "exclude": { "items": { "type": "string" }, "type": "array" }, "skip": { "oneOf": [ { "type": "boolean" }, { "type": "array" } ] }, "only": { "oneOf": [ { "type": "boolean" }, { "type": "array" } ] }, "setup": { "items": { "$ref": "#/$defs/SetupInstruction" }, "type": "array" }, "jobs": { "items": { "$ref": "#/$defs/Job" }, "type": "array" }, "commands": { "additionalProperties": { "$ref": "#/$defs/Command" }, "type": "object" }, "scripts": { "additionalProperties": { "$ref": "#/$defs/Script" }, "type": "object" } }, "additionalProperties": false, "type": "object" }, "type": "object" } ================================================ FILE: internal/config/load.go ================================================ package config import ( "errors" "fmt" "io/fs" "os" "path/filepath" "regexp" "slices" "strings" "github.com/knadh/koanf/maps" "github.com/knadh/koanf/parsers/json" "github.com/knadh/koanf/parsers/toml/v2" "github.com/knadh/koanf/parsers/yaml" kfs "github.com/knadh/koanf/providers/fs" "github.com/knadh/koanf/v2" "github.com/spf13/afero" "github.com/evilmartians/lefthook/v2/internal/git" "github.com/evilmartians/lefthook/v2/internal/log" ) const ( DefaultConfigName = "lefthook.yml" DefaultSourceDir = ".lefthook" DefaultSourceDirLocal = ".lefthook-local" ) var ( hookKeyRegexp = regexp.MustCompile(`^(?P[^.]+)\.(?:scripts|commands|jobs)`) LocalConfigNames = []string{"lefthook-local", ".lefthook-local", filepath.Join(".config", "lefthook-local")} MainConfigNames = []string{"lefthook", ".lefthook", filepath.Join(".config", "lefthook")} Extensions = []string{ ".yml", ".yaml", ".json", ".jsonc", ".toml", } parsers = map[string]koanf.Parser{ ".yml": yaml.Parser(), ".yaml": yaml.Parser(), ".json": json.Parser(), ".jsonc": jsoncParser(), ".toml": toml.Parser(), } mergeJobsOption = koanf.WithMergeFunc(mergeHooks) ) // ConfigNotFoundError. type ConfigNotFoundError struct { message string } func (err ConfigNotFoundError) Error() string { return err.message } // loadConfig loads the config at the given path. func loadConfig(k *koanf.Koanf, filesystem afero.Fs, path string) error { extension := filepath.Ext(path) log.Debug("loading config: ", path) if err := k.Load(kfs.Provider(newIOFS(filesystem), path), parsers[extension], mergeJobsOption); err != nil { return err } return nil } // loadFirst loads the first existing config from given names and supported extensions. func loadFirst(k *koanf.Koanf, filesystem afero.Fs, root string, names []string) error { for _, extension := range Extensions { for _, name := range names { config := filepath.Join(root, name+extension) if ok, _ := afero.Exists(filesystem, config); !ok { continue } return loadConfig(k, filesystem, config) } } return ConfigNotFoundError{fmt.Sprintf("No config files with names %q have been found in \"%s\"", names, root)} } // loadFirstMain loads the main config (e.g. lefthook.yml) or fallbacks to local config (e.g. lefthook-local.yml). func loadFirstMain(k *koanf.Koanf, filesystem afero.Fs, root string) error { err := loadFirst(k, filesystem, root, MainConfigNames) if ok := errors.As(err, &ConfigNotFoundError{}); ok { var hasLocalConfig bool OUT: for _, extension := range Extensions { for _, name := range LocalConfigNames { if ok, _ := afero.Exists(filesystem, filepath.Join(root, name+extension)); ok { hasLocalConfig = true break OUT } } } if !hasLocalConfig { return err } } else if err != nil { return err } return nil } func loadMain(filesystem afero.Fs, root string) (*koanf.Koanf, error) { main := koanf.New(".") configOverride := os.Getenv("LEFTHOOK_CONFIG") if len(configOverride) == 0 { if err := loadFirstMain(main, filesystem, root); err != nil { return nil, err } return main, nil } if !filepath.IsAbs(configOverride) { configOverride = filepath.Join(root, configOverride) } if ok, _ := afero.Exists(filesystem, configOverride); !ok { return nil, ConfigNotFoundError{fmt.Sprintf("Config file \"%s\" not found!", configOverride)} } if err := loadConfig(main, filesystem, configOverride); err != nil { return nil, err } return main, nil } func LoadSecondary(main *koanf.Koanf, filesystem afero.Fs, repo *git.Repository) (*koanf.Koanf, error) { // Save `extends` and `remotes` extends := main.Strings("extends") var remotes []*Remote if err := main.Unmarshal("remotes", &remotes); err != nil { return nil, err } secondary := koanf.New(".") // Load main `extends` if err := extend(secondary, filesystem, repo.RootPath, extends); err != nil { return nil, err } // Some extends required the other extends and changed the list // We don't want to load those extends again, so unsetting them. if !slices.Equal(secondary.Strings("extends"), extends) { secondary.Delete("extends") } // Load main `remotes` if err := loadRemotes(secondary, filesystem, repo, remotes); err != nil { return nil, err } // Don't allow to set `lefthook` field from a remote config secondary.Delete("lefthook") // Load optional local config (e.g. lefthook-local.yml) var noLocal bool if err := loadFirst(secondary, filesystem, repo.RootPath, LocalConfigNames); err != nil { if ok := errors.As(err, &ConfigNotFoundError{}); !ok { return nil, err } noLocal = true } // Load local `extends` localExtends := secondary.Strings("extends") if !noLocal && !slices.Equal(extends, localExtends) { if err := extend(secondary, filesystem, repo.RootPath, localExtends); err != nil { return nil, err } } return secondary, nil } func LoadKoanf(filesystem afero.Fs, repo *git.Repository) (*koanf.Koanf, *koanf.Koanf, error) { // Load main lefthook.yml main, err := loadMain(filesystem, repo.RootPath) if err != nil { return nil, nil, err } // Load secondary extends, remotes and lefthook-local.yml secondary, err := LoadSecondary(main, filesystem, repo) if err != nil { return nil, nil, err } return main, secondary, nil } // Load loads configs from the given directory with extensions. func Load(filesystem afero.Fs, repo *git.Repository) (*Config, error) { main, secondary, err := LoadKoanf(filesystem, repo) if err != nil { return nil, err } return Unmarshal(main, secondary) } func Unmarshal(main *koanf.Koanf, secondary *koanf.Koanf) (*Config, error) { var config Config config.SourceDir = DefaultSourceDir config.SourceDirLocal = DefaultSourceDirLocal if err := unmarshalConfigs(main, secondary, &config); err != nil { return nil, err } log.SetColors(config.Colors) return &config, nil } // loadRemotes merges remote configs to the current one. func loadRemotes(k *koanf.Koanf, filesystem afero.Fs, repo *git.Repository, remotes []*Remote) error { for _, remote := range remotes { if !remote.Configured() { continue } if len(remote.Configs) == 0 { remote.Configs = append(remote.Configs, DefaultConfigName) } for _, config := range remote.Configs { remotePath := repo.RemoteFolder(remote.GitURL, remote.Ref) configFile := config configPath := filepath.Join(remotePath, configFile) log.Debugf("Merging remote config: %s: %s", remote.GitURL, configPath) if ok, err := afero.Exists(filesystem, configPath); !ok || err != nil { continue } parser, ok := parsers[filepath.Ext(configPath)] if !ok { return fmt.Errorf("can't parse config '%[1]s', file has unsupported or no extension\nhint: rename %[1]s to %[1]s.yml", configPath) } if err := k.Load(kfs.Provider(newIOFS(filesystem), configPath), parser, mergeJobsOption); err != nil { return err } extends := k.Strings("extends") if err := extend(k, filesystem, filepath.Dir(configPath), extends); err != nil { return err } } // Reset extends to omit issues when extending with remote extends. if err := k.Set("extends", []string(nil)); err != nil { return err } } return nil } // extend merges all files listed in 'extends' option into the config. func extend(k *koanf.Koanf, filesystem afero.Fs, root string, extends []string) error { return extendRecursive(k, filesystem, root, extends, make(map[string]struct{})) } // extendRecursive merges extends. // If extends contain other extends they get merged too. func extendRecursive(k *koanf.Koanf, filesystem afero.Fs, root string, extends []string, visited map[string]struct{}) error { for _, pathOrGlob := range extends { if !filepath.IsAbs(pathOrGlob) { pathOrGlob = filepath.Join(root, pathOrGlob) } paths, err := afero.Glob(filesystem, pathOrGlob) if err != nil { return fmt.Errorf("bad glob syntax for '%s': %w", pathOrGlob, err) } for _, path := range paths { if _, contains := visited[path]; contains { return fmt.Errorf("possible recursion in extends: path %s is specified multiple times", path) } visited[path] = struct{}{} extent := koanf.New(".") parser, ok := parsers[filepath.Ext(path)] if !ok { return fmt.Errorf("can't parse config '%[1]s', file has unsupported or no extension\nhint: rename %[1]s to %[1]s.yml", path) } if err := extent.Load(kfs.Provider(newIOFS(filesystem), path), parser, mergeJobsOption); err != nil { return err } if err := extendRecursive(extent, filesystem, root, extent.Strings("extends"), visited); err != nil { return err } if err := k.Load(koanfProvider{extent}, nil, mergeJobsOption); err != nil { return err } } } return nil } func unmarshalConfigs(main, secondary *koanf.Koanf, c *Config) error { c.Hooks = make(map[string]*Hook) for hookName := range AvailableHooks { if !main.Exists(hookName) && !secondary.Exists(hookName) { continue } if err := addHook(hookName, main, secondary, c); err != nil { return err } } // For extra non-git hooks. // Notice that with append we're allowing extra hooks to be added in local config for _, maybeHook := range append(main.Keys(), secondary.Keys()...) { matches := hookKeyRegexp.FindStringSubmatch(maybeHook) if matches == nil { continue } hookName := matches[hookKeyRegexp.SubexpIndex("hookName")] if _, ok := c.Hooks[hookName]; ok { continue } if err := addHook(hookName, main, secondary, c); err != nil { return err } } // Merge config and unmarshal it if err := main.Merge(secondary); err != nil { return err } if err := main.Unmarshal("", c); err != nil { return err } return nil } func addHook(name string, main, secondary *koanf.Koanf, c *Config) error { mainHook := main.Cut(name) overrideHook := secondary.Cut(name) // Special merge func to support merging {cmd} templates options := koanf.WithMergeFunc(func(src, dest map[string]any) error { var destCommands map[string]string switch commands := dest["commands"].(type) { case map[string]any: destCommands = make(map[string]string, len(commands)) for cmdName, command := range commands { switch cmd := command.(type) { case map[string]any: switch run := cmd["run"].(type) { case string: destCommands[cmdName] = run default: } default: } } default: } var destJobs, srcJobs []any switch jobs := dest["jobs"].(type) { case []any: destJobs = jobs default: } switch jobs := src["jobs"].(type) { case []any: srcJobs = jobs default: } var destSetup, srcSetup []any switch setup := dest["setup"].(type) { case []any: destSetup = setup default: } switch setup := src["setup"].(type) { case []any: srcSetup = setup default: } destJobs = mergeJobsSlice(srcJobs, destJobs) destSetup = slices.Concat(srcSetup, destSetup) maps.Merge(src, dest) if len(destCommands) > 0 { switch commands := dest["commands"].(type) { case map[string]any: for cmdName, command := range commands { switch cmd := command.(type) { case map[string]any: switch run := cmd["run"].(type) { case string: newRun := strings.ReplaceAll(run, CMD, destCommands[cmdName]) command.(map[string]any)["run"] = newRun default: } default: } } default: } } if len(destJobs) > 0 { dest["jobs"] = destJobs } if len(destSetup) > 0 { dest["setup"] = destSetup } return nil }) if err := mainHook.Load(koanfProvider{overrideHook}, nil, options); err != nil { return err } var hook Hook if err := mainHook.Unmarshal("", &hook); err != nil { return err } // Assign custom hook name hook.Name = name if tags := os.Getenv("LEFTHOOK_EXCLUDE"); tags != "" { hook.ExcludeTags = append(hook.ExcludeTags, strings.Split(tags, ",")...) } c.Hooks[name] = &hook return nil } // Rewritten from afero.NewIOFS to support opening paths starting with '/'. type iofs struct { fs afero.Fs } func newIOFS(filesystem afero.Fs) iofs { return iofs{filesystem} } func (iofs iofs) Open(name string) (fs.File, error) { file, err := iofs.fs.Open(name) if err != nil { return nil, fmt.Errorf("open failed: %s: %w", name, err) } return file, nil } type koanfProvider struct { k *koanf.Koanf } func (k koanfProvider) Read() (map[string]any, error) { return k.k.Raw(), nil } func (k koanfProvider) ReadBytes() ([]byte, error) { panic("not implemented") } // mergeHooks merges `jobs` and `setup` settings. // // `jobs` settings get overwritten by name or get appended to the end. // `setup` always get prepended. func mergeHooks(src, dest map[string]any) error { srcJobs := make(map[string][]any) for name, maybeHook := range src { switch hook := maybeHook.(type) { case map[string]any: switch jobs := hook["jobs"].(type) { case []any: srcJobs[name] = jobs default: } default: } } destJobs := make(map[string][]any) for name, maybeHook := range dest { switch hook := maybeHook.(type) { case map[string]any: switch jobs := hook["jobs"].(type) { case []any: destJobs[name] = jobs default: } default: } } srcSetup := make(map[string][]any) for name, maybeHook := range src { switch hook := maybeHook.(type) { case map[string]any: switch setup := hook["setup"].(type) { case []any: srcSetup[name] = setup default: } default: } } destSetup := make(map[string][]any) for name, maybeHook := range dest { switch hook := maybeHook.(type) { case map[string]any: switch setup := hook["setup"].(type) { case []any: destSetup[name] = setup default: } default: } } if (len(srcJobs) == 0 || len(destJobs) == 0) && (len(srcSetup) == 0 || len(destSetup) == 0) { maps.Merge(src, dest) return nil } for hook, newJobs := range srcJobs { oldJobs, ok := destJobs[hook] if !ok { destJobs[hook] = newJobs continue } destJobs[hook] = mergeJobsSlice(newJobs, oldJobs) } for hook, newSetup := range srcSetup { oldSetup, ok := destSetup[hook] if !ok { destSetup[hook] = newSetup continue } destSetup[hook] = slices.Concat(oldSetup, newSetup) } maps.Merge(src, dest) for name, maybeHook := range dest { if jobs, ok := destJobs[name]; ok { switch hook := maybeHook.(type) { case map[string]any: hook["jobs"] = jobs default: } } } for name, maybeHook := range dest { if setup, ok := destSetup[name]; ok { switch hook := maybeHook.(type) { case map[string]any: hook["setup"] = setup default: } } } return nil } func mergeJobsSlice(src, dest []any) []any { mergeable := make(map[string]map[string]any) result := make([]any, 0, len(dest)) for _, maybeJob := range dest { switch destJob := maybeJob.(type) { case map[string]any: switch name := destJob["name"].(type) { case string: mergeable[name] = destJob default: } result = append(result, maybeJob) default: } } for _, maybeJob := range src { switch srcJob := maybeJob.(type) { case map[string]any: switch name := srcJob["name"].(type) { case string: destJob, ok := mergeable[name] if ok { var srcSubJobs []any var destSubJobs []any switch srcGroup := srcJob["group"].(type) { case map[string]any: switch subJobs := srcGroup["jobs"].(type) { case []any: srcSubJobs = subJobs default: } default: } switch destGroup := destJob["group"].(type) { case map[string]any: switch subJobs := destGroup["jobs"].(type) { case []any: destSubJobs = subJobs default: } default: } if len(destSubJobs) != 0 && len(srcSubJobs) != 0 { destSubJobs = mergeJobsSlice(srcSubJobs, destSubJobs) } // Replace possible {cmd} before merging the jobs switch srcRun := srcJob["run"].(type) { case string: switch destRun := destJob["run"].(type) { case string: newRun := strings.ReplaceAll(srcRun, CMD, destRun) srcJob["run"] = newRun default: } default: } maps.Merge(srcJob, destJob) if len(destSubJobs) != 0 { switch destGroup := destJob["group"].(type) { case map[string]any: switch destGroup["jobs"].(type) { case []any: destGroup["jobs"] = destSubJobs default: } default: } } continue } default: } result = append(result, maybeJob) default: } } return result } ================================================ FILE: internal/config/load_test.go ================================================ package config import ( "fmt" "path/filepath" "strings" "testing" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/evilmartians/lefthook/v2/tests/helpers/gittest" ) //gocyclo:ignore func TestLoad(t *testing.T) { root, err := filepath.Abs("") assert.NoError(t, err) for name, tt := range map[string]struct { files map[string]string remote string remoteConfigPath string pathOverride string result *Config }{ "with .lefthook.yml": { files: map[string]string{ ".lefthook.yml": ` pre-commit: commands: tests: run: yarn test `, }, result: &Config{ SourceDir: DefaultSourceDir, SourceDirLocal: DefaultSourceDirLocal, Colors: nil, Hooks: map[string]*Hook{ "pre-commit": { Name: "pre-commit", Parallel: false, Commands: map[string]*Command{ "tests": { Run: "yarn test", }, }, }, }, }, }, "with lefthook.yml": { files: map[string]string{ "lefthook.yml": ` pre-commit: commands: tests: run: yarn test `, }, result: &Config{ SourceDir: DefaultSourceDir, SourceDirLocal: DefaultSourceDirLocal, Colors: nil, Hooks: map[string]*Hook{ "pre-commit": { Name: "pre-commit", Parallel: false, Commands: map[string]*Command{ "tests": { Run: "yarn test", }, }, }, }, }, }, "with lefthook.yml and .lefthook.yml": { files: map[string]string{ ".lefthook.yml": ` pre-commit: commands: tests: run: yarn test1 `, "lefthook.yml": ` pre-commit: commands: tests: run: yarn test2 `, }, result: &Config{ SourceDir: DefaultSourceDir, SourceDirLocal: DefaultSourceDirLocal, Colors: nil, Hooks: map[string]*Hook{ "pre-commit": { Name: "pre-commit", Parallel: false, Commands: map[string]*Command{ "tests": { Run: "yarn test2", }, }, }, }, }, }, "simple": { files: map[string]string{ "lefthook.yml": ` pre-commit: commands: tests: run: yarn test `, "lefthook-local.yml": ` post-commit: commands: ping-done: run: curl -x POST status.com/done `, }, result: &Config{ SourceDir: DefaultSourceDir, SourceDirLocal: DefaultSourceDirLocal, Colors: nil, Hooks: map[string]*Hook{ "pre-commit": { Name: "pre-commit", Parallel: false, Commands: map[string]*Command{ "tests": { Run: "yarn test", }, }, }, "post-commit": { Name: "post-commit", Parallel: false, Commands: map[string]*Command{ "ping-done": { Run: "curl -x POST status.com/done", }, }, }, }, }, }, "with overrides": { files: map[string]string{ "lefthook.yml": ` min_version: 0.6.0 source_dir: $HOME/sources source_dir_local: $HOME/sources_local pre-commit: parallel: true commands: tests: run: bundle exec rspec tags: [backend, test] lint: run: bundle exec rubocop glob: "*.rb" tags: [backend, linter] scripts: "format.sh": runner: bash `, "lefthook-local.yml": ` min_version: 1.0.0 colors: false pre-commit: commands: tests: skip: true lint: run: docker exec -it ruby:2.7 {cmd} scripts: "format.sh": only: true pre-push: commands: rubocop: run: bundle exec rubocop tags: [backend, linter] `, }, result: &Config{ MinVersion: "1.0.0", Colors: false, SourceDir: "$HOME/sources", SourceDirLocal: "$HOME/sources_local", Hooks: map[string]*Hook{ "pre-commit": { Name: "pre-commit", Parallel: true, Commands: map[string]*Command{ "tests": { Skip: true, Run: "bundle exec rspec", Tags: []string{"backend", "test"}, }, "lint": { Glob: []string{"*.rb"}, Run: "docker exec -it ruby:2.7 bundle exec rubocop", Tags: []string{"backend", "linter"}, }, }, Scripts: map[string]*Script{ "format.sh": { Only: true, Runner: "bash", }, }, }, "pre-push": { Name: "pre-push", Commands: map[string]*Command{ "rubocop": { Run: "bundle exec rubocop", Tags: []string{"backend", "linter"}, }, }, }, }, }, }, "with overrides from .lefthook-local.yml": { files: map[string]string{ ".lefthook.yml": ` pre-push: scripts: "global-extend.sh": runner: bash `, ".lefthook-local.yml": ` pre-push: scripts: "local-extend.sh": runner: bash `, }, result: &Config{ SourceDir: DefaultSourceDir, SourceDirLocal: DefaultSourceDirLocal, Colors: nil, Hooks: map[string]*Hook{ "pre-push": { Name: "pre-push", Scripts: map[string]*Script{ "global-extend.sh": { Runner: "bash", }, "local-extend.sh": { Runner: "bash", }, }, }, }, }, }, "with overrides, dot, nodot": { files: map[string]string{ "lefthook.yml": ` pre-push: scripts: "global-extend": runner: bash `, ".lefthook-local.yml": ` pre-push: scripts: "local-extend": runner: bash `, }, result: &Config{ SourceDir: DefaultSourceDir, SourceDirLocal: DefaultSourceDirLocal, Colors: nil, Hooks: map[string]*Hook{ "pre-push": { Name: "pre-push", Scripts: map[string]*Script{ "global-extend": { Runner: "bash", }, "local-extend": { Runner: "bash", }, }, }, }, }, }, "with overrides, nodot has priority": { files: map[string]string{ "lefthook.yml": ` pre-push: scripts: "global-extend": runner: bash `, ".lefthook-local.yml": ` pre-push: scripts: "local-extend": runner: bash1 `, "lefthook-local.yml": ` pre-push: scripts: "local-extend": runner: bash2 `, }, result: &Config{ SourceDir: DefaultSourceDir, SourceDirLocal: DefaultSourceDirLocal, Colors: nil, Hooks: map[string]*Hook{ "pre-push": { Name: "pre-push", Scripts: map[string]*Script{ "global-extend": { Runner: "bash", }, "local-extend": { Runner: "bash2", }, }, }, }, }, }, "with extra hooks": { files: map[string]string{ "lefthook.yml": ` tests: commands: tests: run: go test ./... lints: scripts: "linter.sh": runner: bash `, }, result: &Config{ SourceDir: DefaultSourceDir, SourceDirLocal: DefaultSourceDirLocal, Colors: nil, Hooks: map[string]*Hook{ "tests": { Name: "tests", Parallel: false, Commands: map[string]*Command{ "tests": { Run: "go test ./...", }, }, }, "lints": { Name: "lints", Scripts: map[string]*Script{ "linter.sh": { Runner: "bash", }, }, }, }, }, }, "with extra hooks only in local config": { files: map[string]string{ "lefthook.yml": ` colors: yellow: '#FFE4B5' red: 196 tests: commands: tests: run: go test ./... `, "lefthook-local.yml": ` lints: scripts: "linter.sh": runner: bash `, }, result: &Config{ SourceDir: DefaultSourceDir, SourceDirLocal: DefaultSourceDirLocal, Colors: map[string]any{"yellow": "#FFE4B5", "red": 196}, Hooks: map[string]*Hook{ "tests": { Name: "tests", Parallel: false, Commands: map[string]*Command{ "tests": { Run: "go test ./...", }, }, }, "lints": { Name: "lints", Scripts: map[string]*Script{ "linter.sh": { Runner: "bash", }, }, }, }, }, }, "with remote": { files: map[string]string{ "lefthook.yml": ` remotes: - git_url: git@github.com:evilmartians/lefthook `, }, remote: ` pre-commit: commands: lint: run: yarn lint scripts: "test.sh": runner: bash `, remoteConfigPath: filepath.Join(root, ".git", "info", "lefthook-remotes", "lefthook", "lefthook.yml"), result: &Config{ SourceDir: DefaultSourceDir, SourceDirLocal: DefaultSourceDirLocal, Colors: nil, Remotes: []*Remote{ { GitURL: "git@github.com:evilmartians/lefthook", }, }, Hooks: map[string]*Hook{ "pre-commit": { Name: "pre-commit", Commands: map[string]*Command{ "lint": { Run: "yarn lint", }, }, Scripts: map[string]*Script{ "test.sh": { Runner: "bash", }, }, }, }, }, }, "with remote and custom config name": { files: map[string]string{ "lefthook.yml": ` remotes: - git_url: git@github.com:evilmartians/lefthook ref: v1.0.0 configs: - examples/custom.yml pre-commit: only: - ref: main commands: global: run: echo 'Global!' lint: run: this will be overwritten `, }, remote: ` pre-commit: commands: lint: only: - merge - rebase run: yarn lint scripts: "test.sh": skip: - merge runner: bash `, remoteConfigPath: filepath.Join(root, ".git", "info", "lefthook-remotes", "lefthook-v1.0.0", "examples", "custom.yml"), result: &Config{ SourceDir: DefaultSourceDir, SourceDirLocal: DefaultSourceDirLocal, Colors: nil, Remotes: []*Remote{ { GitURL: "git@github.com:evilmartians/lefthook", Ref: "v1.0.0", Configs: []string{"examples/custom.yml"}, }, }, Hooks: map[string]*Hook{ "pre-commit": { Name: "pre-commit", Only: []any{map[string]any{"ref": "main"}}, Commands: map[string]*Command{ "lint": { Run: "yarn lint", Only: []any{"merge", "rebase"}, }, "global": { Run: "echo 'Global!'", }, }, Scripts: map[string]*Script{ "test.sh": { Runner: "bash", Skip: []any{"merge"}, }, }, }, }, }, }, "with extends": { files: map[string]string{ "lefthook.yml": ` extends: - global-extend.yml remotes: - git_url: https://github.com/evilmartians/lefthook configs: - examples/config.yml pre-push: commands: global: run: echo global `, "lefthook-local.yml": ` extends: - local-extend.yml pre-push: commands: local: run: echo local `, "global-extend.yml": ` pre-push: scripts: "global-extend": runner: bash `, "local-extend.yml": ` pre-push: scripts: "local-extend": runner: bash `, ".git/info/lefthook-remotes/lefthook/remote-extend.yml": ` pre-push: scripts: "remote-extend": runner: bash `, }, remote: ` extends: - ../remote-extend.yml pre-push: commands: remote: run: echo remote `, remoteConfigPath: filepath.Join(root, ".git", "info", "lefthook-remotes", "lefthook", "examples", "config.yml"), result: &Config{ SourceDir: DefaultSourceDir, SourceDirLocal: DefaultSourceDirLocal, Colors: nil, Remotes: []*Remote{ { GitURL: "https://github.com/evilmartians/lefthook", Configs: []string{"examples/config.yml"}, }, }, Extends: []string{"local-extend.yml"}, Hooks: map[string]*Hook{ "pre-push": { Name: "pre-push", Commands: map[string]*Command{ "global": { Run: "echo global", }, "local": { Run: "echo local", }, "remote": { Run: "echo remote", }, }, Scripts: map[string]*Script{ "global-extend": { Runner: "bash", }, "local-extend": { Runner: "bash", }, "remote-extend": { Runner: "bash", }, }, }, }, }, }, "with extends and local": { files: map[string]string{ "lefthook.yml": ` extends: - global-extend.yml pre-commit: parallel: true exclude_tags: [linter] commands: global-lint: run: bundle exec rubocop glob: "*.rb" tags: [backend, linter] global-other: run: bundle exec rubocop tags: [other] `, "lefthook-local.yml": ` pre-commit: exclude_tags: [backend] `, "global-extend.yml": ` pre-commit: exclude_tags: [test] commands: extended-tests: run: bundle exec rspec tags: [backend, test] `, }, result: &Config{ SourceDir: DefaultSourceDir, SourceDirLocal: DefaultSourceDirLocal, Extends: []string{"global-extend.yml"}, Hooks: map[string]*Hook{ "pre-commit": { Name: "pre-commit", Parallel: true, ExcludeTags: []string{"backend"}, Commands: map[string]*Command{ "global-lint": { Run: "bundle exec rubocop", Tags: []string{"backend", "linter"}, Glob: []string{"*.rb"}, }, "global-other": { Run: "bundle exec rubocop", Tags: []string{"other"}, }, "extended-tests": { Run: "bundle exec rspec", Tags: []string{"backend", "test"}, }, }, }, }, }, }, "with glob in extends": { files: map[string]string{ "lefthook.yml": ` extends: - dir/*/config.yml `, "dir/a/config.yml": ` pre-commit: commands: a: run: echo A `, "dir/b/config.yml": ` pre-commit: commands: b: run: echo B `, "dir/b/c/config.yml": ` pre-commit: commands: c: run: echo C `, }, result: &Config{ SourceDir: DefaultSourceDir, SourceDirLocal: DefaultSourceDirLocal, Extends: []string{"dir/*/config.yml"}, Colors: nil, Hooks: map[string]*Hook{ "pre-commit": { Name: "pre-commit", Parallel: false, Commands: map[string]*Command{ "a": { Run: "echo A", }, "b": { Run: "echo B", }, }, }, }, }, }, "with jobs": { files: map[string]string{ "lefthook.yml": ` pre-commit: jobs: - run: 1 - run: 2 name: second `, "lefthook-local.yml": ` pre-commit: jobs: - run: 3 - run: local 2 name: second `, }, result: &Config{ SourceDir: ".lefthook", SourceDirLocal: ".lefthook-local", Hooks: map[string]*Hook{ "pre-commit": { Name: "pre-commit", Jobs: []*Job{ {Run: "1"}, {Run: "local 2", Name: "second"}, {Run: "3"}, }, }, }, }, }, "with nested jobs": { files: map[string]string{ "lefthook.yml": ` pre-commit: jobs: - name: group 1 group: jobs: - run: 1.1 - run: 1.2 - name: nested group: jobs: - run: 1.nested.1 - run: 1.nested.2 name: nested 2 `, "lefthook-local.yml": ` pre-commit: jobs: - name: group 1 glob: "*.rb" group: parallel: true jobs: - name: nested group: jobs: - run: 1.nested.2 local name: nested 2 - run: 1.nested.3 - run: 1.3 - run: 1.4 `, }, result: &Config{ SourceDir: ".lefthook", SourceDirLocal: ".lefthook-local", Hooks: map[string]*Hook{ "pre-commit": { Name: "pre-commit", Jobs: []*Job{ { Name: "group 1", Glob: []string{"*.rb"}, Group: &Group{ Parallel: true, Jobs: []*Job{ {Run: "1.1"}, {Run: "1.2"}, { Name: "nested", Group: &Group{ Jobs: []*Job{ {Run: "1.nested.1"}, {Run: "1.nested.2 local", Name: "nested 2"}, {Run: "1.nested.3"}, }, }, }, {Run: "1.3"}, {Run: "1.4"}, }, }, }, }, }, }, }, }, "with jobs overwrite": { files: map[string]string{ "lefthook.yml": ` pre-commit: jobs: - name: job 1 run: echo from job 1 `, "lefthook-local.yml": ` pre-commit: jobs: - name: job 1 run: wrap {cmd} `, }, result: &Config{ SourceDir: ".lefthook", SourceDirLocal: ".lefthook-local", Hooks: map[string]*Hook{ "pre-commit": { Name: "pre-commit", Jobs: []*Job{ { Name: "job 1", Run: "wrap echo from job 1", }, }, }, }, }, }, "with .config/lefthook.yml": { files: map[string]string{ filepath.Join(".config", "lefthook.yml"): ` pre-commit: jobs: - name: job 1 run: echo from job 1 `, filepath.Join(".config", "lefthook-local.yml"): ` pre-commit: jobs: - name: job 1 run: wrap {cmd} `, }, result: &Config{ SourceDir: ".lefthook", SourceDirLocal: ".lefthook-local", Hooks: map[string]*Hook{ "pre-commit": { Name: "pre-commit", Jobs: []*Job{ { Name: "job 1", Run: "wrap echo from job 1", }, }, }, }, }, }, "with lefthook-local.yml only": { files: map[string]string{ "lefthook-local.yml": ` pre-commit: commands: tests: run: yarn test post-commit: commands: ping-done: run: curl -x POST status.com/done `, }, result: &Config{ SourceDir: DefaultSourceDir, SourceDirLocal: DefaultSourceDirLocal, Colors: nil, Hooks: map[string]*Hook{ "pre-commit": { Name: "pre-commit", Parallel: false, Commands: map[string]*Command{ "tests": { Run: "yarn test", }, }, }, "post-commit": { Name: "post-commit", Parallel: false, Commands: map[string]*Command{ "ping-done": { Run: "curl -x POST status.com/done", }, }, }, }, }, }, "with .lefthook-local.yml only": { files: map[string]string{ ".lefthook-local.yml": ` pre-commit: commands: tests: run: yarn test `, }, result: &Config{ SourceDir: DefaultSourceDir, SourceDirLocal: DefaultSourceDirLocal, Colors: nil, Hooks: map[string]*Hook{ "pre-commit": { Name: "pre-commit", Parallel: false, Commands: map[string]*Command{ "tests": { Run: "yarn test", }, }, }, }, }, }, "custom config path": { files: map[string]string{ "lefthook_custom.yml": ` pre-commit: commands: tests: run: yarn test `, }, pathOverride: "lefthook_custom.yml", result: &Config{ SourceDir: DefaultSourceDir, SourceDirLocal: DefaultSourceDirLocal, Colors: nil, Hooks: map[string]*Hook{ "pre-commit": { Name: "pre-commit", Parallel: false, Commands: map[string]*Command{ "tests": { Run: "yarn test", }, }, }, }, }, }, "with setup instructions": { files: map[string]string{ "lefthook.yml": ` extends: - extend1.yml - extend2.yml pre-commit: setup: - run: 5 `, "lefthook-local.yml": ` pre-commit: setup: - run: 4 `, "extend1.yml": ` extends: - extend3.yml pre-commit: setup: - run: 1 `, "extend2.yml": ` pre-commit: setup: - run: 3 `, "extend3.yml": ` pre-commit: setup: - run: 2 `, }, result: &Config{ Extends: []string{ "extend1.yml", "extend2.yml", }, SourceDir: DefaultSourceDir, SourceDirLocal: DefaultSourceDirLocal, Hooks: map[string]*Hook{ "pre-commit": { Name: "pre-commit", Setup: []*SetupInstruction{ {Run: "1"}, {Run: "2"}, {Run: "3"}, {Run: "4"}, {Run: "5"}, }, }, }, }, }, } { fs := afero.Afero{Fs: afero.NewMemMapFs()} repo := gittest.NewRepositoryBuilder().Fs(fs).Root(root).Build() t.Run(name, func(t *testing.T) { assert := assert.New(t) for name, content := range tt.files { path := filepath.Join( root, filepath.Join(strings.Split(name, "/")...), ) dir := filepath.Dir(path) assert.NoError(fs.MkdirAll(dir, 0o775)) assert.NoError(fs.WriteFile(path, []byte(content), 0o644)) } if len(tt.remoteConfigPath) > 0 { assert.NoError(fs.MkdirAll(filepath.Base(tt.remoteConfigPath), 0o755)) assert.NoError(fs.WriteFile(tt.remoteConfigPath, []byte(tt.remote), 0o644)) } t.Setenv("LEFTHOOK_CONFIG", tt.pathOverride) result, err := Load(fs.Fs, repo) assert.NoError(err) assert.Equal(tt.result, result) }) } for i, tt := range [...]struct { name string yaml, json, toml string result *Config }{ { name: "simple configs", yaml: ` pre-commit: commands: echo: run: echo 1 `, json: ` { "pre-commit": { "commands": { "echo": { "run": "echo 1" } } } }`, toml: ` [pre-commit.commands.echo] run = "echo 1" `, result: &Config{ SourceDir: DefaultSourceDir, SourceDirLocal: DefaultSourceDirLocal, Hooks: map[string]*Hook{ "pre-commit": { Name: "pre-commit", Commands: map[string]*Command{ "echo": { Run: "echo 1", }, }, }, }, }, }, } { fs := afero.Afero{Fs: afero.NewMemMapFs()} repo := gittest.NewRepositoryBuilder().Root(root).Fs(fs).Build() t.Run(fmt.Sprintf("%d: %s", i, tt.name), func(t *testing.T) { assert := assert.New(t) // YAML yamlConfig := filepath.Join(root, "lefthook.yml") assert.NoError(fs.WriteFile(yamlConfig, []byte(tt.yaml), 0o644)) result, err := Load(fs.Fs, repo) assert.NoError(err) assert.Equal(result, tt.result) assert.NoError(fs.Remove(yamlConfig)) // JSON jsonConfig := filepath.Join(root, "lefthook.json") assert.NoError(fs.WriteFile(jsonConfig, []byte(tt.json), 0o644)) result, err = Load(fs.Fs, repo) assert.NoError(err) assert.Equal(result, tt.result) assert.NoError(fs.Remove(jsonConfig)) // TOML tomlConfig := filepath.Join(root, "lefthook.toml") assert.NoError(fs.WriteFile(tomlConfig, []byte(tt.toml), 0o644)) result, err = Load(fs.Fs, repo) assert.NoError(err) assert.Equal(result, tt.result) assert.NoError(fs.Remove(tomlConfig)) }) } type remote struct { RemoteConfigPath string Content string } for name, tt := range map[string]struct { files map[string]string remotes []remote result *Config }{ "with remotes, config and configs": { files: map[string]string{ "lefthook.yml": ` pre-commit: only: - ref: main commands: global: run: echo 'Global!' lint: run: this will be overwritten remotes: - git_url: https://github.com/evilmartians/lefthook ref: v1.0.0 configs: - examples/custom.yml - git_url: https://github.com/evilmartians/lefthook configs: - examples/remote/ping.yml ref: v1.5.5 `, }, remotes: []remote{ { RemoteConfigPath: filepath.Join(root, ".git", "info", "lefthook-remotes", "lefthook-v1.0.0", "examples", "custom.yml"), Content: ` pre-commit: commands: lint: only: - merge - rebase run: yarn lint scripts: "test.sh": skip: - merge runner: bash `, }, { RemoteConfigPath: filepath.Join(root, ".git", "info", "lefthook-remotes", "lefthook-v1.5.5", "examples", "remote", "ping.yml"), Content: ` pre-commit: commands: ping: run: echo pong `, }, }, result: &Config{ SourceDir: DefaultSourceDir, SourceDirLocal: DefaultSourceDirLocal, Colors: nil, Remotes: []*Remote{ { GitURL: "https://github.com/evilmartians/lefthook", Ref: "v1.0.0", Configs: []string{"examples/custom.yml"}, }, { GitURL: "https://github.com/evilmartians/lefthook", Ref: "v1.5.5", Configs: []string{ "examples/remote/ping.yml", }, }, }, Hooks: map[string]*Hook{ "pre-commit": { Name: "pre-commit", Only: []any{map[string]any{"ref": "main"}}, Commands: map[string]*Command{ "lint": { Run: "yarn lint", Only: []any{"merge", "rebase"}, }, "ping": { Run: "echo pong", }, "global": { Run: "echo 'Global!'", }, }, Scripts: map[string]*Script{ "test.sh": { Runner: "bash", Skip: []any{"merge"}, }, }, }, }, }, }, } { fs := afero.Afero{Fs: afero.NewMemMapFs()} repo := gittest.NewRepositoryBuilder().Root(root).Fs(fs).Build() t.Run(name, func(t *testing.T) { assert := assert.New(t) for name, content := range tt.files { path := filepath.Join( root, filepath.Join(strings.Split(name, "/")...), ) dir := filepath.Dir(path) assert.NoError(fs.MkdirAll(dir, 0o775)) assert.NoError(fs.WriteFile(path, []byte(content), 0o644)) } for _, remote := range tt.remotes { assert.NoError(fs.MkdirAll(filepath.Base(remote.RemoteConfigPath), 0o755)) assert.NoError(fs.WriteFile(remote.RemoteConfigPath, []byte(remote.Content), 0o644)) } result, err := Load(fs.Fs, repo) assert.NoError(err) assert.Equal(result, tt.result) }) } } ================================================ FILE: internal/config/remote.go ================================================ package config type Remote struct { GitURL string `json:"git_url,omitempty" jsonschema:"description=A URL to Git repository. It will be accessed with privileges of the machine lefthook runs on." koanf:"git_url" mapstructure:"git_url" toml:"git_url" yaml:"git_url"` Ref string `json:"ref,omitempty" jsonschema:"description=An optional *branch* or *tag* name" mapstructure:"ref,omitempty" toml:"ref,omitempty" yaml:",omitempty"` Configs []string `json:"configs,omitempty" jsonschema:"description=An optional array of config paths from remote's root,default=lefthook.yml" mapstructure:"configs,omitempty" toml:"configs,omitempty" yaml:",omitempty"` Refetch bool `json:"refetch,omitempty" jsonschema:"description=Set to true if you want to always refetch the remote" mapstructure:"refetch,omitempty" toml:"refetch,omitempty" yaml:",omitempty"` RefetchFrequency string `json:"refetch_frequency,omitempty" jsonschema:"description=Provide a frequency for the remotes refetches,example=24h" koanf:"refetch_frequency" mapstructure:"refetch_frequency,omitempty" toml:"refetch_frequency,omitempty" yaml:",omitempty"` } func (r *Remote) Configured() bool { if r == nil { return false } return len(r.GitURL) > 0 } ================================================ FILE: internal/config/script.go ================================================ package config import ( "cmp" "slices" "strconv" "strings" "time" "unicode" ) type Script struct { Runner string `json:"runner,omitempty" mapstructure:"runner" toml:"runner,omitempty" yaml:"runner,omitempty"` Args string `json:"args,omitempty" mapstructure:"args" toml:"args,omitempty" yaml:",omitempty"` Skip any `json:"skip,omitempty" jsonschema:"oneof_type=boolean;array" mapstructure:"skip" toml:"skip,omitempty,inline" yaml:",omitempty"` Only any `json:"only,omitempty" jsonschema:"oneof_type=boolean;array" mapstructure:"only" toml:"only,omitempty,inline" yaml:",omitempty"` Tags []string `json:"tags,omitempty" jsonschema:"oneof_type=string;array" mapstructure:"tags" toml:"tags,omitempty" yaml:",omitempty"` Env map[string]string `json:"env,omitempty" mapstructure:"env" toml:"env,omitempty" yaml:",omitempty"` Priority int `json:"priority,omitempty" mapstructure:"priority" toml:"priority,omitempty" yaml:",omitempty"` FailText string `json:"fail_text,omitempty" koanf:"fail_text" mapstructure:"fail_text" toml:"fail_text,omitempty" yaml:"fail_text,omitempty"` Timeout time.Duration `json:"timeout,omitempty" jsonschema:"type=string,example=15s" mapstructure:"timeout" toml:"timeout,omitempty" yaml:",omitempty"` Interactive bool `json:"interactive,omitempty" mapstructure:"interactive" toml:"interactive,omitempty" yaml:",omitempty"` UseStdin bool `json:"use_stdin,omitempty" koanf:"use_stdin" mapstructure:"use_stdin" toml:"use_stdin,omitempty" yaml:"use_stdin,omitempty"` StageFixed bool `json:"stage_fixed,omitempty" koanf:"stage_fixed" mapstructure:"stage_fixed" toml:"stage_fixed,omitempty" yaml:"stage_fixed,omitempty"` } func ScriptsToJobs(scripts map[string]*Script) []*Job { jobs := make([]*Job, 0, len(scripts)) for name, script := range scripts { jobs = append(jobs, &Job{ Name: name, Script: name, Runner: script.Runner, Args: script.Args, FailText: script.FailText, Timeout: script.Timeout, Tags: script.Tags, Env: script.Env, Interactive: script.Interactive, UseStdin: script.UseStdin, StageFixed: script.StageFixed, Skip: script.Skip, Only: script.Only, }) } // ASC slices.SortFunc(jobs, func(i, j *Job) int { a := scripts[i.Name] b := scripts[j.Name] if a.Priority != 0 || b.Priority != 0 { // Script without a priority must be the last if a.Priority == 0 { return 1 } if b.Priority == 0 { return -1 } return cmp.Compare(a.Priority, b.Priority) } iNum := parseNum(i.Name) jNum := parseNum(j.Name) if iNum == -1 && jNum == -1 { return strings.Compare(i.Name, j.Name) } if iNum == -1 { return 1 } if jNum == -1 { return -1 } return cmp.Compare(iNum, jNum) }) return jobs } func parseNum(str string) int { numEnds := -1 for idx, ch := range str { if unicode.IsDigit(ch) { numEnds = idx } else { break } } if numEnds == -1 { return -1 } num, err := strconv.Atoi(str[:numEnds+1]) if err != nil { return -1 } return num } ================================================ FILE: internal/config/script_test.go ================================================ package config import ( "testing" "time" "github.com/stretchr/testify/assert" ) func TestScriptsToJobs(t *testing.T) { scripts := map[string]*Script{ "check.sh": { Runner: "bash", Priority: 150, }, "10_test.sh": { Runner: "bash", StageFixed: true, }, "2_test.sh": { Runner: "bash", StageFixed: true, }, "first.sh": { Runner: "bash", Priority: 1, }, "last.sh": { Runner: "bash", }, } jobs := ScriptsToJobs(scripts) assert.Equal(t, jobs, []*Job{ {Name: "first.sh", Script: "first.sh", Runner: "bash"}, {Name: "check.sh", Script: "check.sh", Runner: "bash"}, {Name: "2_test.sh", Script: "2_test.sh", Runner: "bash", StageFixed: true}, {Name: "10_test.sh", Script: "10_test.sh", Runner: "bash", StageFixed: true}, {Name: "last.sh", Script: "last.sh", Runner: "bash"}, }) } func TestScriptsToJobsWithTimeout(t *testing.T) { scripts := map[string]*Script{ "lint.sh": { Runner: "bash", Timeout: 30 * time.Second, Priority: 1, }, "test.sh": { Runner: "bash", Timeout: 10 * time.Minute, }, } jobs := ScriptsToJobs(scripts) assert.Equal(t, jobs, []*Job{ {Name: "lint.sh", Script: "lint.sh", Runner: "bash", Timeout: 30 * time.Second}, {Name: "test.sh", Script: "test.sh", Runner: "bash", Timeout: 10 * time.Minute}, }) } ================================================ FILE: internal/config/skip_checker.go ================================================ package config import ( "github.com/gobwas/glob" "github.com/evilmartians/lefthook/v2/internal/git" "github.com/evilmartians/lefthook/v2/internal/log" "github.com/evilmartians/lefthook/v2/internal/system" ) type skipChecker struct { exec *commandExecutor } func NewSkipChecker(cmd system.Command) *skipChecker { return &skipChecker{&commandExecutor{cmd}} } // Check returns the result of applying a skip/only setting which can be a branch, git state, shell command, etc. func (sc *skipChecker) Check(state func() git.State, skip any, only any) bool { if skip == nil && only == nil { return false } if skip != nil { if sc.matches(state, skip) { return true } } if only != nil { return !sc.matches(state, only) } return false } func (sc *skipChecker) matches(state func() git.State, value any) bool { switch typedValue := value.(type) { case bool: return typedValue case string: return typedValue == state().State case []any: return sc.matchesSlices(state, typedValue) } return false } func (sc *skipChecker) matchesSlices(gitState func() git.State, slice []any) bool { for _, state := range slice { switch typedState := state.(type) { case string: if typedState == gitState().State { return true } case map[string]any: if sc.matchesRef(gitState, typedState) { return true } if sc.matchesCommands(typedState) { return true } } } return false } func (sc *skipChecker) matchesRef(state func() git.State, typedState map[string]any) bool { ref, ok := typedState["ref"].(string) if !ok { return false } branch := state().Branch if ref == branch { return true } g := glob.MustCompile(ref) return g.Match(branch) } func (sc *skipChecker) matchesCommands(typedState map[string]any) bool { commandLine, ok := typedState["run"].(string) if !ok { return false } result := sc.exec.execute(commandLine) log.Builder(log.DebugLevel, "[lefthook] "). Add("skip/only: ", commandLine). Add("result: ", result). Log() return result } ================================================ FILE: internal/config/skip_checker_test.go ================================================ package config import ( "errors" "io" "testing" "github.com/evilmartians/lefthook/v2/internal/git" "github.com/evilmartians/lefthook/v2/internal/system" ) type mockCmd struct{} func (mc mockCmd) WithoutEnvs(...string) system.Command { return mc } func (mc mockCmd) Run(cmd []string, _root string, _in io.Reader, _out io.Writer, _errOut io.Writer) error { if len(cmd) == 3 && cmd[2] == "success" { return nil } else { return errors.New("failure") } } func TestSkipChecker_Check(t *testing.T) { skipChecker := NewSkipChecker(mockCmd{}) for _, tt := range [...]struct { name string state func() git.State skip, only any skipped bool }{ { name: "when true", state: func() git.State { return git.State{} }, skip: true, skipped: true, }, { name: "when false", state: func() git.State { return git.State{} }, skip: false, skipped: false, }, { name: "when merge", state: func() git.State { return git.State{State: "merge"} }, skip: "merge", skipped: true, }, { name: "when merge-commit", state: func() git.State { return git.State{State: "merge-commit"} }, skip: "merge-commit", skipped: true, }, { name: "when rebase (but want merge)", state: func() git.State { return git.State{State: "rebase"} }, skip: "merge", skipped: false, }, { name: "when rebase", state: func() git.State { return git.State{State: "rebase"} }, skip: []any{"rebase"}, skipped: true, }, { name: "when rebase (but want merge)", state: func() git.State { return git.State{State: "rebase"} }, skip: []any{"merge"}, skipped: false, }, { name: "when branch", state: func() git.State { return git.State{Branch: "feat/skipme"} }, skip: []any{map[string]any{"ref": "feat/skipme"}}, skipped: true, }, { name: "when branch doesn't match", state: func() git.State { return git.State{Branch: "feat/important"} }, skip: []any{map[string]any{"ref": "feat/skipme"}}, skipped: false, }, { name: "when branch glob", state: func() git.State { return git.State{Branch: "feat/important"} }, skip: []any{map[string]any{"ref": "feat/*"}}, skipped: true, }, { name: "when branch glob doesn't match", state: func() git.State { return git.State{Branch: "feat"} }, skip: []any{map[string]any{"ref": "feat/*"}}, skipped: false, }, { name: "when only specified", state: func() git.State { return git.State{Branch: "feat"} }, only: []any{map[string]any{"ref": "feat"}}, skipped: false, }, { name: "when only branch doesn't match", state: func() git.State { return git.State{Branch: "dev"} }, only: []any{map[string]any{"ref": "feat"}}, skipped: true, }, { name: "when only branch with glob", state: func() git.State { return git.State{Branch: "feat/important"} }, only: []any{map[string]any{"ref": "feat/*"}}, skipped: false, }, { name: "when only merge", state: func() git.State { return git.State{State: "merge"} }, only: []any{"merge"}, skipped: false, }, { name: "when only and skip", state: func() git.State { return git.State{State: "rebase"} }, skip: []any{map[string]any{"ref": "feat/*"}}, only: "rebase", skipped: false, }, { name: "when only and skip applies skip", state: func() git.State { return git.State{State: "rebase"} }, skip: []any{"rebase"}, only: "rebase", skipped: true, }, { name: "when skip with run command", state: func() git.State { return git.State{} }, skip: []any{map[string]any{"run": "success"}}, skipped: true, }, { name: "when skip with multi-run command", state: func() git.State { return git.State{Branch: "feat"} }, skip: []any{map[string]any{"run": "success", "ref": "feat"}}, skipped: true, }, { name: "when only with run command", state: func() git.State { return git.State{} }, only: []any{map[string]any{"run": "fail"}}, skipped: true, }, } { t.Run(tt.name, func(t *testing.T) { if skipChecker.Check(tt.state, tt.skip, tt.only) != tt.skipped { t.Errorf("Expected: %v, Was %v", tt.skipped, !tt.skipped) } }) } } ================================================ FILE: internal/git/command_executor.go ================================================ package git import ( "bytes" "fmt" "path/filepath" "strings" "sync" "github.com/evilmartians/lefthook/v2/internal/log" "github.com/evilmartians/lefthook/v2/internal/system" ) // CommandExecutor provides some methods that take some effect on execution and/or result data. type CommandExecutor struct { mu *sync.Mutex cmd system.Command // Execute command in the specific directory root string // Split one command into multiple, respecting supported max command length maxCmdLen int // Print all logs in Debug level onlyDebugLogs bool // Do not trim leading and ending spaces noTrimOut bool } // NewExecutor returns an object that executes given commands in the OS. func NewExecutor(cmd system.Command) *CommandExecutor { return &CommandExecutor{ mu: new(sync.Mutex), cmd: cmd, maxCmdLen: system.MaxCmdLen(), } } func (c CommandExecutor) WithoutEnvs(envs ...string) CommandExecutor { c.cmd = c.cmd.WithoutEnvs(envs...) return c } func (c CommandExecutor) OnlyDebugLogs() CommandExecutor { c.onlyDebugLogs = true return c } func (c CommandExecutor) WithoutTrim() CommandExecutor { c.noTrimOut = true return c } // Cmd runs plain string command. func (c CommandExecutor) Cmd(cmd []string) (string, error) { out, err := c.execute(cmd, c.root) if err != nil { return "", err } if !c.noTrimOut { out = strings.TrimSpace(out) } return out, nil } // BatchedCmd runs the command with any number of appended arguments batched in chunks to match the OS limits. func (c CommandExecutor) BatchedCmd(cmd []string, args []string) (string, error) { result := strings.Builder{} argsBatched := batchByLength(args, c.maxCmdLen-len(cmd)) for i, batch := range argsBatched { out, err := c.Cmd(append(cmd, batch...)) if err != nil { return "", fmt.Errorf("error in batch %d: %w", i, err) } result.WriteString(strings.TrimRight(out, "\n")) result.WriteString("\n") } return result.String(), nil } // CmdLines runs plain string command, returns its output split by newline. func (c CommandExecutor) CmdLines(cmd []string) ([]string, error) { out, err := c.Cmd(cmd) if err != nil { return nil, err } return strings.Split(out, "\n"), nil } // CmdLinesWithinFolder runs plain string command, returns its output split by newline. func (c CommandExecutor) CmdLinesWithinFolder(cmd []string, folder string) ([]string, error) { root := filepath.Join(c.root, folder) out, err := c.execute(cmd, root) if err != nil { return nil, err } if !c.noTrimOut { out = strings.TrimSpace(out) } return strings.Split(out, "\n"), nil } func (c CommandExecutor) execute(cmd []string, root string) (string, error) { if len(cmd) > 0 && cmd[0] == "git" { // Preventing Git lock issues for all Git commands c.mu.Lock() defer c.mu.Unlock() } stdout := new(bytes.Buffer) stderr := new(bytes.Buffer) err := c.cmd.Run(cmd, root, system.NullReader, stdout, stderr) outString := stdout.String() errString := stderr.String() log.Builder(log.DebugLevel, "[lefthook] "). Add("git: ", strings.Join(cmd, " ")). Add("out: ", outString). Log() if err != nil { if len(errString) > 0 { logLevel := log.ErrorLevel if c.onlyDebugLogs { logLevel = log.DebugLevel } log.Builder(logLevel, "> "). Add("", strings.Join(cmd, " ")). Add("", errString). Log() } } return outString, err } func batchByLength(s []string, length int) [][]string { batches := make([][]string, 0) var acc, prev int for i := range s { acc += len(s[i]) if acc > length { if i == prev { batches = append(batches, s[prev:i+1]) prev = i + 1 } else { batches = append(batches, s[prev:i]) prev = i } acc = len(s[i]) } } if acc > 0 { batches = append(batches, s[prev:]) } return batches } ================================================ FILE: internal/git/command_executor_test.go ================================================ package git import ( "io" "sync" "testing" "github.com/stretchr/testify/assert" "github.com/evilmartians/lefthook/v2/internal/system" ) type mockCmd struct{} func (m mockCmd) WithoutEnvs(...string) system.Command { return mockCmd{} } func (m mockCmd) Run(cmd []string, root string, in io.Reader, out io.Writer, errOut io.Writer) error { for _, str := range cmd { _, _ = out.Write([]byte(str)) _, _ = out.Write([]byte("\n")) } return nil } func TestBatchedCmd(t *testing.T) { assert := assert.New(t) c := CommandExecutor{cmd: mockCmd{}, mu: new(sync.Mutex), maxCmdLen: 2} out, err := c.BatchedCmd([]string{"hello"}, []string{"1", "2", "3", "4"}) assert.NoError(err) assert.Equal("hello\n1\nhello\n2\nhello\n3\nhello\n4\n", out) } ================================================ FILE: internal/git/lfs.go ================================================ package git import ( "os/exec" ) const ( LFSRequiredFile = ".lfs-required" LFSConfigFile = ".lfsconfig" ) var lfsHooks = map[string]struct{}{ "post-checkout": {}, "post-commit": {}, "post-merge": {}, "pre-push": {}, } // IsLFSAvailable returns 'true' if git-lfs is installed. func IsLFSAvailable() bool { _, err := exec.LookPath("git-lfs") return err == nil } // IsLFSHook returns whether the hookName is supported by Git LFS. func IsLFSHook(hookName string) bool { _, ok := lfsHooks[hookName] return ok } ================================================ FILE: internal/git/remote.go ================================================ package git import ( "errors" "os" "path/filepath" "strings" "github.com/evilmartians/lefthook/v2/internal/log" ) const ( remotesFolder = "lefthook-remotes" remotesFolderMode = 0o755 ) // RemoteFolder returns the path to the folder where the remote // repository is located. func (r *Repository) RemoteFolder(url string, ref string) string { return filepath.Join( r.RemotesFolder(), RemoteDirectoryName(url, ref), ) } // RemotesFolder returns the path to the lefthook remotes folder. func (r *Repository) RemotesFolder() string { return filepath.Join(r.InfoPath, remotesFolder) } // SyncRemote clones or pulls the latest changes for a git repository that was // specified as a remote config repository. If successful, the path to the root // of the repository will be returned. func (r *Repository) SyncRemote(url, ref string, force bool) error { remotesPath := r.RemotesFolder() err := r.Fs.MkdirAll(remotesPath, remotesFolderMode) if err != nil && !errors.Is(err, os.ErrExist) { return err } log.SetName("fetching remotes") log.StartSpinner() defer log.StopSpinner() defer log.UnsetName("fetching remotes") directoryName := RemoteDirectoryName(url, ref) remotePath := filepath.Join(remotesPath, directoryName) if force { err = r.Fs.RemoveAll(remotePath) if err != nil { return err } } else { _, err = r.Fs.Stat(remotePath) if err == nil { return r.updateRemote(remotePath, ref) } } return r.cloneRemote(remotesPath, directoryName, url, ref) } func (r *Repository) updateRemote(path, ref string) error { log.Debugf("Updating remote config repository: %s", path) // This is overwriting ENVs for worktrees, otherwise it does not work. git := r.Git.WithoutEnvs("GIT_DIR", "GIT_INDEX_FILE").OnlyDebugLogs() if len(ref) != 0 { _, err := git.Cmd([]string{ "git", "-C", path, "fetch", "--quiet", "--depth", "1", "origin", "--", ref, }) if err != nil { return err } _, err = git.Cmd([]string{ "git", "-C", path, "checkout", "FETCH_HEAD", }) if err != nil { return err } } else { _, err := git.Cmd([]string{"git", "-C", path, "pull", "--quiet"}) if err != nil { return err } } return nil } func (r *Repository) cloneRemote(dest, directoryName, url, ref string) error { log.Debugf("Cloning remote config repository: %v/%v", dest, directoryName) cmdClone := []string{"git", "-C", dest, "clone", "--quiet", "--origin", "origin", "--depth", "1"} if len(ref) > 0 { cmdClone = append(cmdClone, "--branch", ref) } cmdClone = append(cmdClone, url, directoryName) git := r.Git.WithoutEnvs("GIT_DIR", "GIT_INDEX_FILE").OnlyDebugLogs() _, err := git.Cmd(cmdClone) if err != nil { return err } path := filepath.Join(dest, directoryName) if len(ref) != 0 { _, err := git.Cmd([]string{ "git", "-C", path, "fetch", "--quiet", "--depth", "1", "origin", "--", ref, }) if err != nil { return err } _, err = git.Cmd([]string{ "git", "-C", path, "checkout", "FETCH_HEAD", }) if err != nil { return err } } return nil } func RemoteDirectoryName(url, ref string) string { name := filepath.Base( strings.TrimSuffix(url, filepath.Ext(url)), ) if ref != "" { name = name + "-" + ref } return name } ================================================ FILE: internal/git/repository.go ================================================ package git import ( "bufio" "errors" "fmt" "os" "path/filepath" "regexp" "slices" "strconv" "strings" "sync" "github.com/spf13/afero" "github.com/evilmartians/lefthook/v2/internal/log" "github.com/evilmartians/lefthook/v2/internal/version" ) const ( minGitVersion = "2.31.0" stashMessage = "lefthook auto backup" unstagedPatchName = "lefthook-unstaged.patch" infoDirMode = 0o775 // The result of `git hash-object -t tree /dev/null`. emptyTreeSHA = "4b825dc642cb6eb9a060e54bf8d69288fbee4904" ) var ( reHeadBranch = regexp.MustCompile(`HEAD -> (?P.*)$`) reOriginHeadBranch = regexp.MustCompile(`ref: refs/remotes/origin/(?P.*)$`) reVersion = regexp.MustCompile(`\d+\.\d+\.(\d+|\w+)`) cmdPushFilesBase = []string{"git", "diff", "--name-only", "HEAD", "@{push}"} cmdPushFilesHead = []string{"git", "diff", "--name-only", "HEAD"} cmdStagedFiles = []string{"git", "diff", "--name-only", "--cached", "--diff-filter=ACMR"} cmdStagedFilesWithDeleted = []string{"git", "diff", "--name-only", "--cached", "--diff-filter=ACMRD"} cmdStatusShort = []string{"git", "status", "--short", "--porcelain", "-z"} cmdListStash = []string{"git", "stash", "list"} cmdPaths = []string{ "git", "rev-parse", "--path-format=absolute", "--show-toplevel", "--git-path", "hooks", "--git-path", "info", "--git-dir", } cmdAllFiles = []string{"git", "ls-files", "--cached"} cmdCreateStash = []string{"git", "stash", "create"} cmdStageFiles = []string{"git", "add", "--force", "--"} cmdRemotes = []string{"git", "branch", "--remotes"} cmdHideUnstaged = []string{"git", "checkout", "--force", "--"} cmdGitVersion = []string{"git", "version"} ) // Repository represents a git repository. type Repository struct { Fs afero.Fs Git *CommandExecutor HooksPath string RootPath string GitPath string InfoPath string unstagedPatchPath string headBranch string stagedFilesOnce func() ([]string, error) stagedFilesWithDeletedOnce func() ([]string, error) statusShortOnce func() ([]string, error) stateOnce func() State } // NewRepository returns a Repository or an error, if git repository it not initialized. func NewRepository(fs afero.Fs, git *CommandExecutor) (*Repository, error) { gitVersionOut, err := git.Cmd(cmdGitVersion) if err == nil { gitVersion := reVersion.FindString(gitVersionOut) if err = version.Check(minGitVersion, gitVersion); err != nil { log.Debugf("[lefthook] version check warning: %s %s", gitVersion, err) if errors.Is(err, version.ErrUncoveredVersion) { log.Warn("Git version is too old. Minimum supported version is " + minGitVersion) } } } paths, err := git.Cmd(cmdPaths) if err != nil { return nil, err } pathsSplit := strings.Split(paths, "\n") rootPath := pathsSplit[0] hooksPath := pathsSplit[1] infoPath := filepath.Clean(pathsSplit[2]) gitPath := pathsSplit[3] if exists, _ := afero.DirExists(fs, infoPath); !exists { err = fs.Mkdir(infoPath, infoDirMode) if err != nil { return nil, err } } git.root = rootPath r := &Repository{ Fs: fs, Git: git, HooksPath: hooksPath, RootPath: rootPath, GitPath: gitPath, InfoPath: infoPath, } r.Setup() return r, nil } // Precompute runs various Git commands in the background so the results are ready. // This returns a function which can be used to wait for the result. This should // be invoked to ensure we're not holding any locks on the Git repository. func (r *Repository) Precompute() func() { var wg sync.WaitGroup wg.Go(func() { _, _ = r.stagedFilesOnce() }) wg.Go(func() { _, _ = r.stagedFilesWithDeletedOnce() }) wg.Go(func() { _, _ = r.statusShortOnce() }) return wg.Wait } // Setup must be called after you've constructed a Repository directly. // It's not necessary to invoke if you've used NewRepository. // // This can also be called multiple times to reset the cache. func (r *Repository) Setup() { r.stagedFilesOnce = sync.OnceValues(func() ([]string, error) { return r.FindExistingFiles(cmdStagedFiles, "") }) r.stagedFilesWithDeletedOnce = sync.OnceValues(func() ([]string, error) { return r.FindAllFiles(cmdStagedFilesWithDeleted, "") }) r.statusShortOnce = sync.OnceValues(func() ([]string, error) { return r.statusShort() }) r.stateOnce = sync.OnceValue(func() State { return r.state() }) r.unstagedPatchPath = filepath.Join(r.InfoPath, unstagedPatchName) } // StagedFiles returns a list of staged files which exist on file system. func (r *Repository) StagedFiles() ([]string, error) { return r.stagedFilesOnce() } // StagedFilesWithDeleted returns a list of staged files with deleted files. func (r *Repository) StagedFilesWithDeleted() ([]string, error) { return r.stagedFilesWithDeletedOnce() } // AllFiles returns a list of all files in repository. func (r *Repository) AllFiles() ([]string, error) { return r.FindExistingFiles(cmdAllFiles, "") } // PushFiles returns a list of files that are ready to be pushed. func (r *Repository) PushFiles() ([]string, error) { // Try with @{push} lines, err := r.Git.OnlyDebugLogs().CmdLinesWithinFolder(cmdPushFilesBase, "") if err == nil { return r.extractFiles(lines, true) } // Try read .git/refs/origin/HEAD if len(r.headBranch) == 0 { r.headBranch = r.readOriginHead() } // Try walking through the remotes if len(r.headBranch) == 0 { branches, err := r.Git.CmdLines(cmdRemotes) if err != nil { return nil, err } for _, branch := range branches { matches := reHeadBranch.FindStringSubmatch(branch) if matches == nil { continue } r.headBranch = matches[reHeadBranch.SubexpIndex("name")] break } } // Nothing has been pushed yet or upstream is not set if len(r.headBranch) == 0 { r.headBranch = emptyTreeSHA } return r.FindExistingFiles(append(cmdPushFilesHead, r.headBranch), "") } // PartiallyStagedFiles returns the list of files that have both staged and // unstaged changes. // See https://git-scm.com/docs/git-status#_short_format. func (r *Repository) PartiallyStagedFiles() ([]string, error) { partiallyStaged := make([]string, 0) lines, err := r.statusShortOnce() if err != nil { return nil, err } r.parseStatusShort(lines, func(path string, index, worktree rune) { if index != ' ' && index != '?' && worktree != ' ' && worktree != '?' { partiallyStaged = append(partiallyStaged, path) } }) return partiallyStaged, nil } func (r *Repository) SaveUnstaged(files []string) error { _, err := r.Git.BatchedCmd( []string{ "git", "diff", "--binary", // support binary files "--unified=0", // do not add lines around diff for consistent behavior "--no-color", // disable colors for consistent behavior "--no-ext-diff", // disable external diff tools for consistent behavior "--src-prefix=a/", // force prefix for consistent behavior "--dst-prefix=b/", // force prefix for consistent behavior "--patch", // output a patch that can be applied "--submodule=short", // always use the default short format for submodules "--output", r.unstagedPatchPath, "--", }, files) return err } func (r *Repository) RevertUnstagedChanges(files []string) error { _, err := r.Git.BatchedCmd(cmdHideUnstaged, files) return err } func (r *Repository) RestoreUnstaged() error { if ok, _ := afero.Exists(r.Fs, r.unstagedPatchPath); !ok { return nil } stat, err := r.Fs.Stat(r.unstagedPatchPath) if err != nil { return err } if stat.Size() == 0 { err = r.Fs.Remove(r.unstagedPatchPath) if err != nil { return fmt.Errorf("couldn't remove the patch %s: %w", r.unstagedPatchPath, err) } return nil } _, err = r.Git.Cmd([]string{ "git", "apply", "-v", "--whitespace=nowarn", "--recount", "--unidiff-zero", "--", r.unstagedPatchPath, }) if err != nil { return fmt.Errorf("couldn't apply the patch %s: %w", r.unstagedPatchPath, err) } err = r.Fs.Remove(r.unstagedPatchPath) if err != nil { return fmt.Errorf("couldn't remove the patch %s: %w", r.unstagedPatchPath, err) } return nil } func (r *Repository) StashUnstaged() error { stashHash, err := r.Git.Cmd(cmdCreateStash) if err != nil { return err } _, err = r.Git.Cmd([]string{ "git", "stash", "store", "--quiet", "--message", stashMessage, stashHash, }) if err != nil { return err } return nil } func (r *Repository) DropUnstagedStash() error { lines, err := r.Git.CmdLines(cmdListStash) if err != nil { return err } stashRegexp := regexp.MustCompile(`^(?P[^ ]+):\s*` + stashMessage) for i := range lines { line := lines[len(lines)-i-1] matches := stashRegexp.FindStringSubmatch(line) if matches == nil { continue } stashID := stashRegexp.SubexpIndex("stash") if len(matches[stashID]) > 0 { _, err := r.Git.Cmd([]string{ "git", "stash", "drop", "--quiet", "--", matches[stashID], }) if err != nil { return err } } } return nil } func (r *Repository) AddFiles(files []string) error { if len(files) == 0 { return nil } _, err := r.Git.BatchedCmd(cmdStageFiles, files) return err } // Changeset returns a map of files and their hashes that are different from the index. // The hash for a deleted file is "deleted", and "directory" for a directory. func (r *Repository) Changeset() (map[string]string, error) { changeset := make(map[string]string) pathsToHash := make([]string, 0) lines, err := r.statusShort() if err != nil { return nil, err } r.parseStatusShort(lines, func(path string, index, worktree rune) { if index == 'D' || worktree == 'D' { changeset[path] = "deleted" return } if strings.HasSuffix(path, "/") { changeset[path] = "directory" return } pathsToHash = append(pathsToHash, path) }) if len(pathsToHash) == 0 { return changeset, nil } out, err := r.Git.BatchedCmd([]string{"git", "hash-object", "--"}, pathsToHash) if err != nil { return nil, err } hashes := strings.Split(strings.TrimSpace(out), "\n") for i, hash := range hashes { changeset[pathsToHash[i]] = hash } return changeset, nil } func (r *Repository) PrintDiff(files []string) { slices.Sort(files) diffCmd := make([]string, 0, 4) //nolint:mnd // 3 or 4 elements diffCmd = append(diffCmd, "git", "diff") if log.Colorized() { diffCmd = append(diffCmd, "--color") } diffCmd = append(diffCmd, "--") diff, err := r.Git.BatchedCmd(diffCmd, files) if err != nil { log.Warnf("Couldn't diff changed files: %s", err) return } log.Warn(diff) } func (r *Repository) statusShort() ([]string, error) { return r.Git.WithoutTrim().CmdLines(cmdStatusShort) } // parseStatusShort parses short NUL separated porcelain v1 status output. // https://git-scm.com/docs/git-status#_short_format func (r *Repository) parseStatusShort(lines []string, cb func(path string, index, worktree rune)) { output := strings.Join(lines, "") // there should be only one line with -z skip := false for item := range strings.SplitSeq(output, "\x00") { if skip { skip = false continue } rs := []rune(item) if len(rs) < 4 || rs[2] != ' ' { // two status characters, space, and a filename continue } if slices.ContainsFunc(rs[0:2], func(r rune) bool { return r == 'C' || r == 'R' }) { // Next item after a Copy or Rename one is expected to be the old name, which we ignore skip = true } cb(string(rs[3:]), rs[0], rs[1]) } } // FindAllFiles accepts git command and returns its result as a list of filepaths. func (r *Repository) FindAllFiles(command []string, folder string) ([]string, error) { lines, err := r.Git.CmdLinesWithinFolder(command, folder) if err != nil { return nil, err } return r.extractFiles(lines, false) } // FindExistingFiles accepts git command and returns its result as a list of filepaths. func (r *Repository) FindExistingFiles(command []string, folder string) ([]string, error) { lines, err := r.Git.CmdLinesWithinFolder(command, folder) if err != nil { return nil, err } return r.extractFiles(lines, true) } func (r *Repository) extractFiles(lines []string, checkExistence bool) ([]string, error) { var files []string for _, line := range lines { file := strings.TrimSpace(line) if len(file) == 0 { continue } unescaped, err := strconv.Unquote(file) if err == nil { file = unescaped } if !checkExistence { files = append(files, file) continue } isFile, err := r.isFile(file) if err != nil { return nil, err } if isFile { files = append(files, file) } } return files, nil } func (r *Repository) isFile(path string) (bool, error) { if !strings.HasPrefix(path, r.RootPath) { path = filepath.Join(r.RootPath, path) } stat, err := r.Fs.Stat(path) if err != nil { if os.IsNotExist(err) { return false, nil } return false, err } return !stat.IsDir(), nil } func (r *Repository) readOriginHead() string { originHead := filepath.Join(r.GitPath, "refs", "remotes", "origin", "HEAD") if _, err := r.Fs.Stat(originHead); os.IsNotExist(err) { return "" } file, err := r.Fs.Open(originHead) if err != nil { return "" } defer func() { if err := file.Close(); err != nil { log.Warnf("Could not close %s: %s", originHead, err) } }() scanner := bufio.NewScanner(file) _ = scanner.Scan() match := reOriginHeadBranch.FindStringSubmatch(scanner.Text()) if match == nil { return "" } return match[reHeadBranch.SubexpIndex("name")] } ================================================ FILE: internal/git/repository_test.go ================================================ package git import ( "fmt" "sync" "testing" "github.com/evilmartians/lefthook/v2/tests/helpers/cmdtest" ) func TestPartiallyStagedFiles(t *testing.T) { for i, tt := range [...]struct { name string git []cmdtest.Out error bool result []string }{ { git: []cmdtest.Out{ { Command: "git status --short --porcelain -z", Output: "RM new file\x00old-file\x00" + "M staged\x00" + "MM staged but changed\x00", }, }, result: []string{"new file", "staged but changed"}, }, } { t.Run(fmt.Sprintf("%d: %s", i, tt.name), func(t *testing.T) { repository := &Repository{ Git: &CommandExecutor{ mu: new(sync.Mutex), cmd: cmdtest.NewOrdered(t, tt.git), }, } repository.Setup() files, err := repository.PartiallyStagedFiles() if tt.error && err != nil { t.Errorf("expected an error") } if len(files) != len(tt.result) { t.Errorf("expected %d files, but %d returned", len(tt.result), len(files)) } for j, file := range files { if tt.result[j] != file { t.Errorf("file at index %d don't match: %s - %s", j, tt.result[j], file) } } }) } } func TestChangeset(t *testing.T) { for i, tt := range [...]struct { name string git []cmdtest.Out pathsToHash []string result map[string]string }{ { name: "no changes", git: []cmdtest.Out{ {Command: "git status --short --porcelain -z", Output: ""}, }, result: map[string]string{}, }, { name: "modified file", git: []cmdtest.Out{ {Command: "git status --short --porcelain -z", Output: " M modified.txt\x00"}, {Command: "git hash-object -- modified.txt", Output: "123456"}, }, pathsToHash: []string{"modified.txt"}, result: map[string]string{ "modified.txt": "123456", }, }, { name: "deleted file", git: []cmdtest.Out{ {Command: "git status --short --porcelain -z", Output: "D deleted.txt\x00"}, }, result: map[string]string{ "deleted.txt": "deleted", }, }, { name: "new file", git: []cmdtest.Out{ {Command: "git status --short --porcelain -z", Output: "?? new.txt\x00"}, {Command: "git hash-object -- new.txt", Output: "654321"}, }, pathsToHash: []string{"new.txt"}, result: map[string]string{ "new.txt": "654321", }, }, { name: "new dir", git: []cmdtest.Out{ {Command: "git status --short --porcelain -z", Output: "?? new-dir/\x00"}, }, pathsToHash: []string{}, result: map[string]string{ "new-dir/": "directory", }, }, { name: "mixed changes", git: []cmdtest.Out{ { Command: "git status --short --porcelain -z", Output: "M modified.txt\x00" + "CT copied to\x00copied from\x00" + " D deleted.txt\x00" + "?? new.txt\x00" + "?? new-dir/\x00" + "RM new-file\x00old-file\x00" + "A foo -> bar\x00" + "MM back\\slashes\x00" + "R this is the new filename\x00R this is really the old name, does it throw off the parser\x00" + "?? leading-space\x00", }, {Command: "git hash-object -- modified.txt copied to new.txt new-file foo -> bar back\\slashes this is the new filename leading-space", Output: "123456\nc0c0c0\n654321\n758213\nfbfbfb\nbbbbbb\nffffff\ncccccc\n"}, }, // pathsToHash: []string{"modified.txt", "copied to", "new.txt", "new-file", "foo -> bar", `back\slashes`, "this is the new filename", " leading-space"}, result: map[string]string{ "modified.txt": "123456", "copied to": "c0c0c0", "deleted.txt": "deleted", "new.txt": "654321", "new-dir/": "directory", "new-file": "758213", "foo -> bar": "fbfbfb", `back\slashes`: "bbbbbb", "this is the new filename": "ffffff", " leading-space": "cccccc", }, }, } { t.Run(fmt.Sprintf("%d: %s", i, tt.name), func(t *testing.T) { repository := &Repository{ Git: &CommandExecutor{ mu: new(sync.Mutex), cmd: cmdtest.NewOrdered(t, tt.git), maxCmdLen: 7000, }, } repository.Setup() changeset, err := repository.Changeset() if err != nil { t.Errorf("unexpected error: %s", err) } if len(changeset) != len(tt.result) { t.Errorf("expected %d files, but %d returned", len(tt.result), len(changeset)) } for file, hash := range tt.result { if changeset[file] != hash { t.Errorf("expected hash %s for file %s, but got %s", hash, file, changeset[file]) } } }) } } ================================================ FILE: internal/git/state.go ================================================ package git import ( "bufio" "os" "path/filepath" "regexp" "strings" "github.com/evilmartians/lefthook/v2/internal/log" ) type State struct { Branch, State string } const ( Nil string = "" Merge string = "merge" MergeCommit string = "merge-commit" Rebase string = "rebase" ) var ( refBranchRegexp = regexp.MustCompile(`^ref:\s*refs/heads/(.+)$`) cmdParentCommits = []string{"git", "show", "--no-patch", `--format="%P"`} ) func (r *Repository) State() State { return r.stateOnce() } func (r *Repository) state() State { var state State branch := r.branch() if r.inMergeState() { state = State{ Branch: branch, State: Merge, } return state } if r.inRebaseState() { state = State{ Branch: branch, State: Rebase, } return state } if r.inMergeCommitState() { state = State{ Branch: branch, State: MergeCommit, } return state } state = State{ Branch: branch, State: Nil, } return state } func (r *Repository) branch() string { headFile := filepath.Join(r.GitPath, "HEAD") if _, err := r.Fs.Stat(headFile); os.IsNotExist(err) { return "" } file, err := r.Fs.Open(headFile) if err != nil { return "" } defer func() { if cErr := file.Close(); cErr != nil { log.Warnf("Could not close %s: %s", headFile, cErr) } }() scanner := bufio.NewScanner(file) for scanner.Scan() { match := refBranchRegexp.FindStringSubmatch(scanner.Text()) if match != nil { return match[1] } } if err = scanner.Err(); err != nil { log.Warnf("Could not read %s: %s", file.Name(), err) } return "" } func (r *Repository) inMergeState() bool { if _, err := r.Fs.Stat(filepath.Join(r.GitPath, "MERGE_HEAD")); os.IsNotExist(err) { return false } return true } func (r *Repository) inRebaseState() bool { if _, mergeErr := r.Fs.Stat(filepath.Join(r.GitPath, "rebase-merge")); os.IsNotExist(mergeErr) { if _, applyErr := r.Fs.Stat(filepath.Join(r.GitPath, "rebase-apply")); os.IsNotExist(applyErr) { return false } } return true } func (r *Repository) inMergeCommitState() bool { parents, err := r.Git.Cmd(cmdParentCommits) if err != nil { return false } return strings.Contains(parents, " ") } ================================================ FILE: internal/log/builder.go ================================================ package log import ( "fmt" "strings" ) type builder interface { Add(string, any) builder String() string Log() } type dummyBuilder struct{} type logBuilder struct { level Level prefix string builder strings.Builder } func Builder(level Level, prefix string) builder { if !std.IsLevelEnabled(level) { return dummyBuilder{} } return &logBuilder{ prefix: prefix, level: level, builder: strings.Builder{}, } } func (b *logBuilder) Add(prefix string, data any) builder { var lines []string switch v := data.(type) { case string: lines = strings.Split(strings.TrimSpace(v), "\n") case []string: lines = v default: lines = strings.Split(fmt.Sprint(data), "\n") } for i, line := range lines { line = strings.TrimSpace(line) if len(line) == 0 { continue } switch { case b.builder.Len() == 0: b.builder.WriteString(b.prefix + prefix + line + "\n") case i == 0: b.builder.WriteString(strings.Repeat(" ", len(b.prefix)) + prefix + line + "\n") default: b.builder.WriteString(strings.Repeat(" ", len(b.prefix)+len(prefix)) + line + "\n") } } return b } func (b *logBuilder) Log() { switch b.level { case DebugLevel: Debug(b.builder.String()) case InfoLevel: Info(b.builder.String()) case ErrorLevel: Error(b.builder.String()) case WarnLevel: Warn(b.builder.String()) } } func (b *logBuilder) String() string { return b.builder.String() } func (d dummyBuilder) Add(_ string, _ any) builder { return d } func (dummyBuilder) Log() {} func (dummyBuilder) String() string { return "" } ================================================ FILE: internal/log/execution.go ================================================ package log import ( "fmt" "io" "github.com/charmbracelet/lipgloss" ) const execLogPadding = 2 func Execution(name string, err error, out io.Reader) { if err == nil && !Settings.LogExecution() { return } var execLog string var color lipgloss.TerminalColor switch { case !Settings.LogExecutionInfo(): execLog = "" case err != nil: execLog = Red(fmt.Sprintf("%s ❯ ", name)) color = ColorRed default: execLog = Cyan(fmt.Sprintf("%s ❯ ", name)) color = ColorCyan } if execLog != "" { Styled(). WithLeftBorder(lipgloss.ThickBorder(), color). WithPadding(execLogPadding). Info(execLog) Info() } if err == nil && !Settings.LogExecutionOutput() { return } if out != nil { Info(out) } if err != nil { Infof("%s", err) } } ================================================ FILE: internal/log/log.go ================================================ package log import ( "fmt" "io" "os" "strconv" "strings" "sync" "time" "github.com/briandowns/spinner" "github.com/charmbracelet/lipgloss" "github.com/mattn/go-isatty" "github.com/mattn/go-runewidth" "golang.org/x/term" "github.com/evilmartians/lefthook/v2/internal/version" ) var ( ColorRed lipgloss.TerminalColor = lipgloss.CompleteColor{TrueColor: "#ff6347", ANSI256: "196", ANSI: "9"} ColorGreen lipgloss.TerminalColor = lipgloss.CompleteColor{TrueColor: "#32cd32", ANSI256: "148", ANSI: "2"} ColorYellow lipgloss.TerminalColor = lipgloss.CompleteColor{TrueColor: "#fada5e", ANSI256: "191", ANSI: "11"} ColorCyan lipgloss.TerminalColor = lipgloss.CompleteColor{TrueColor: "#70C0BA", ANSI256: "37", ANSI: "14"} GolorGray lipgloss.TerminalColor = lipgloss.CompleteColor{TrueColor: "#808080", ANSI256: "244", ANSI: "7"} colorBorder lipgloss.TerminalColor = lipgloss.Color("#383838") std = New() separatorWidth = 36 separatorMargin = 2 padding = 2 ) type Level uint32 const ( ErrorLevel Level = iota WarnLevel InfoLevel DebugLevel spinnerCharSet = 14 spinnerRefreshRate = 100 * time.Millisecond spinnerText = " waiting" ColorAuto = iota ColorOn ColorOff ) type StyleLogger struct { style lipgloss.Style } type Logger struct { level Level out io.Writer mu sync.Mutex colors int terminalWidth int names []string spinner *spinner.Spinner } func New() *Logger { return &Logger{ level: InfoLevel, out: os.Stdout, colors: ColorAuto, terminalWidth: terminalWidth(), spinner: spinner.New( spinner.CharSets[spinnerCharSet], spinnerRefreshRate, spinner.WithSuffix(spinnerText), ), } } func Colors() int { return std.colors } func Colorized() bool { return std.colors == ColorAuto || std.colors == ColorOn } func StartSpinner() { std.spinner.Start() } func StopSpinner() { std.spinner.Stop() } func Styled() StyleLogger { return StyleLogger{ style: lipgloss.NewStyle(), } } func (s StyleLogger) WithLeftBorder(border lipgloss.Border, color lipgloss.TerminalColor) StyleLogger { s.style = s.style.BorderStyle(border).BorderLeft(true).BorderForeground(color) return s } func (s StyleLogger) WithPadding(m int) StyleLogger { s.style = s.style.PaddingLeft(m) return s } func (s StyleLogger) Info(str string) { Info( lipgloss.JoinVertical( lipgloss.Left, s.style.Render(str), ), ) } func Debug(args ...any) { res := strings.TrimSpace(fmt.Sprint(args...)) std.Debug(color(GolorGray).Render(res)) } func Debugf(format string, args ...any) { Debug(fmt.Sprintf(format, args...)) } func Info(args ...any) { std.Info(args...) } func InfoPad(s string) { Info( lipgloss.NewStyle(). BorderStyle(lipgloss.NormalBorder()). BorderLeft(true). BorderForeground(ColorCyan). Render(s), ) } func Infof(format string, args ...any) { std.Infof(format, args...) } func Error(args ...any) { res := fmt.Sprint(args...) std.Error(Red(res)) } func Errorf(format string, args ...any) { Error(fmt.Sprintf(format, args...)) } func Warn(args ...any) { res := fmt.Sprint(args...) std.Warn(Yellow(res)) } func Warnf(format string, args ...any) { Warn(fmt.Sprintf(format, args...)) } func Println(args ...any) { std.Println(args...) } func Printf(format string, args ...any) { std.Printf(format, args...) } func SetLevel(level Level) { std.SetLevel(level) } func SetColors(colors any) { if colors == nil { return } switch typedColors := colors.(type) { case string: switch typedColors { case "on": std.colors = ColorOn setColor(lipgloss.CompleteColor{TrueColor: "#ff6347", ANSI256: "196", ANSI: "9"}, &ColorRed) setColor(lipgloss.CompleteColor{TrueColor: "#32cd32", ANSI256: "148", ANSI: "2"}, &ColorGreen) setColor(lipgloss.CompleteColor{TrueColor: "#fada5e", ANSI256: "191", ANSI: "11"}, &ColorYellow) setColor(lipgloss.CompleteColor{TrueColor: "#70C0BA", ANSI256: "37", ANSI: "14"}, &ColorCyan) setColor(lipgloss.CompleteColor{TrueColor: "#808080", ANSI256: "244", ANSI: "7"}, &GolorGray) setColor(lipgloss.Color("#383838"), &colorBorder) case "off": std.colors = ColorOff setColor(lipgloss.NoColor{}, &ColorRed) setColor(lipgloss.NoColor{}, &ColorGreen) setColor(lipgloss.NoColor{}, &ColorYellow) setColor(lipgloss.NoColor{}, &ColorCyan) setColor(lipgloss.NoColor{}, &GolorGray) setColor(lipgloss.NoColor{}, &colorBorder) default: std.colors = ColorAuto } case bool: if typedColors { std.colors = ColorOn return } std.colors = ColorOff setColor(lipgloss.NoColor{}, &ColorRed) setColor(lipgloss.NoColor{}, &ColorGreen) setColor(lipgloss.NoColor{}, &ColorYellow) setColor(lipgloss.NoColor{}, &ColorCyan) setColor(lipgloss.NoColor{}, &GolorGray) setColor(lipgloss.NoColor{}, &colorBorder) case map[string]any: std.colors = ColorOn setColor(typedColors["red"], &ColorRed) setColor(typedColors["green"], &ColorGreen) setColor(typedColors["yellow"], &ColorYellow) setColor(typedColors["cyan"], &ColorCyan) setColor(typedColors["gray"], &GolorGray) setColor(typedColors["gray"], &colorBorder) default: std.colors = ColorAuto } } func setColor(colorCode any, adaptiveColor *lipgloss.TerminalColor) { var code string switch typedCode := colorCode.(type) { case int: code = strconv.Itoa(typedCode) case string: code = typedCode case lipgloss.NoColor: *adaptiveColor = typedCode return case lipgloss.TerminalColor: *adaptiveColor = typedCode default: return } if len(code) == 0 { return } *adaptiveColor = lipgloss.Color(code) } func Cyan(s string) string { return color(ColorCyan).Render(s) } func Green(s string) string { return color(ColorGreen).Render(s) } func Red(s string) string { return color(ColorRed).Render(s) } func Yellow(s string) string { return color(ColorYellow).Render(s) } func Gray(s string) string { return color(GolorGray).Render(s) } func Bold(s string) string { if !Colorized() { return lipgloss.NewStyle().Render(s) } return lipgloss.NewStyle().Bold(true).Render(s) } func LogMeta(hookName string) { name := "🥊 lefthook " if !Colorized() { name = "lefthook " } box( Cyan(name)+Gray(fmt.Sprintf("v%s", version.Version(false))), Gray("hook: ")+Bold(hookName), ) } func Success(indent int, name string, duration time.Duration) { format := "%s✔️ %s %s\n" if !Colorized() { format = "%s✓ %s %s\n" } Infof( format, strings.Repeat(" ", indent), Green(name), Gray(fmt.Sprintf("(%.2f seconds)", duration.Seconds())), ) } func Failure(indent int, name, failText string, duration time.Duration) { if len(failText) != 0 { failText = fmt.Sprintf(": %s", failText) } format := "%s🥊 %s%s %s\n" if !Colorized() { format = "%s✗ %s%s %s\n" } Infof( format, strings.Repeat(" ", indent), Red(name), Red(failText), Gray(fmt.Sprintf("(%.2f seconds)", duration.Seconds())), ) } func box(left, right string) { Info( lipgloss.JoinHorizontal( lipgloss.Top, lipgloss.NewStyle(). Border(lipgloss.RoundedBorder(), true, false, true, true). BorderForeground(colorBorder). Padding(0, 1). Render(left), lipgloss.NewStyle(). Border(lipgloss.RoundedBorder(), true, true, true, false). BorderForeground(colorBorder). Padding(0, 1). Render(right), ), ) } func Separate(s string) { Info( lipgloss.JoinVertical( lipgloss.Left, lipgloss.NewStyle(). BorderStyle(lipgloss.NormalBorder()). BorderBottom(true). BorderForeground(colorBorder). Width(separatorWidth). MarginLeft(separatorMargin). Render(""), s, ), ) } func color(clr lipgloss.TerminalColor) lipgloss.Style { return lipgloss.NewStyle().Foreground(clr) } func SetOutput(out io.Writer) { std.SetOutput(out) } func ParseLevel(lvl string) (Level, error) { switch strings.ToLower(lvl) { case "error": return ErrorLevel, nil case "info": return InfoLevel, nil case "debug": return DebugLevel, nil } var l Level return l, fmt.Errorf("not a valid Level: %q", lvl) } func (l *Logger) SetLevel(level Level) { l.mu.Lock() defer l.mu.Unlock() l.level = level } func (l *Logger) SetOutput(out io.Writer) { l.mu.Lock() defer l.mu.Unlock() l.out = out } func (l *Logger) Info(args ...any) { l.Log(InfoLevel, args...) } func (l *Logger) Debug(args ...string) { leftBorder := lipgloss.NewStyle(). BorderStyle(lipgloss.NormalBorder()). BorderLeft(true). BorderForeground(colorBorder). PaddingLeft(padding). Render(args...) l.Log(DebugLevel, leftBorder) } func (l *Logger) Error(args ...string) { leftBorder := lipgloss.NewStyle(). BorderStyle(lipgloss.NormalBorder()). BorderLeft(true). BorderForeground(ColorRed). PaddingLeft(padding). Render(args...) l.Log(ErrorLevel, leftBorder) } func (l *Logger) Warn(args ...string) { leftBorder := lipgloss.NewStyle(). BorderStyle(lipgloss.NormalBorder()). BorderLeft(true). BorderForeground(ColorYellow). PaddingLeft(padding). Render(args...) l.Log(WarnLevel, leftBorder) } func (l *Logger) Infof(format string, args ...any) { l.Logf(InfoLevel, format, args...) } func (l *Logger) Debugf(format string, args ...any) { l.Logf(DebugLevel, format, args...) } func (l *Logger) Errorf(format string, args ...any) { l.Logf(ErrorLevel, format, args...) } func (l *Logger) Warnf(format string, args ...any) { l.Logf(WarnLevel, format, args...) } func (l *Logger) Log(level Level, args ...any) { if l.IsLevelEnabled(level) { l.Println(args...) } } func SetName(name string) { std.SetName(name) } func UnsetName(name string) { std.UnsetName(name) } func (l *Logger) SetName(name string) { l.mu.Lock() defer l.mu.Unlock() if l.spinner.Active() { l.spinner.Stop() defer l.spinner.Start() } l.names = append(l.names, name) l.spinner.Suffix = l.formatSpinnerSuffix(l.names) } func (l *Logger) UnsetName(name string) { l.mu.Lock() defer l.mu.Unlock() if l.spinner.Active() { l.spinner.Stop() defer l.spinner.Start() } capacity := len(l.names) if capacity > 0 { capacity-- } newNames := make([]string, 0, capacity) for _, n := range l.names { if n != name { newNames = append(newNames, n) } } l.names = newNames l.spinner.Suffix = l.formatSpinnerSuffix(l.names) } func (l *Logger) Logf(level Level, format string, args ...any) { if l.IsLevelEnabled(level) { l.Printf(format, args...) } } func (l *Logger) Println(args ...any) { l.mu.Lock() defer l.mu.Unlock() if l.spinner.Active() { l.spinner.Stop() defer l.spinner.Start() } _, _ = fmt.Fprintln(l.out, args...) } func (l *Logger) Printf(format string, args ...any) { l.mu.Lock() defer l.mu.Unlock() if l.spinner.Active() { l.spinner.Stop() defer l.spinner.Start() } _, _ = fmt.Fprintf(l.out, format, args...) } func (l *Logger) IsLevelEnabled(level Level) bool { return l.level >= level } // formatSpinnerSuffix creates a spinner suffix that respects terminal width constraints. func (l *Logger) formatSpinnerSuffix(names []string) string { if len(names) == 0 { return spinnerText } terminalWidth := l.terminalWidth if terminalWidth <= 0 { return fmt.Sprintf("%s: %s", spinnerText, strings.Join(names, ", ")) } // Width calculation: Reserve space for spinner character (1) + space (1) + padding (8) // This accounts for the spinning character and reasonable display margin const spinnerReservedWidth = 10 availableWidth := terminalWidth - spinnerReservedWidth // Strategy 1: Try to fit all names with full formatting fullSuffix := fmt.Sprintf("%s: %s", spinnerText, strings.Join(names, ", ")) if runewidth.StringWidth(fullSuffix) <= availableWidth { return fullSuffix } // Strategy 2: Try showing just the count countSuffix := fmt.Sprintf("%s: %d hook%s", spinnerText, len(names), pluralize(len(names))) if runewidth.StringWidth(countSuffix) <= availableWidth { return countSuffix } // Strategy 3: Show as many individual names as possible return formatWithPartialNames(names, availableWidth) } // terminalWidth attempts to detect the current terminal width. func terminalWidth() int { // Check if we're writing to a TTY if !isatty.IsTerminal(os.Stdout.Fd()) { return 0 // Not a terminal, don't constrain } // Try to get terminal size width, _, err := term.GetSize(int(os.Stdout.Fd())) if err != nil { return 0 // Can't determine size, don't constrain } return width } // formatWithPartialNames shows as many hook names as possible, then adds count for remaining. func formatWithPartialNames(names []string, availableWidth int) string { if len(names) == 0 { return spinnerText } baseText := spinnerText + ": " baseWidth := runewidth.StringWidth(baseText) remainingWidth := availableWidth - baseWidth // Try to fit names one by one var fittingNames []string currentWidth := 0 for i, name := range names { nameWidth := runewidth.StringWidth(name) // Add comma and space for all but first name if i > 0 { nameWidth += 2 // ", " } // Check if we need space for "... (N more)" suffix remainingCount := len(names) - i if remainingCount > 1 { moreSuffix := fmt.Sprintf(", ... (%d more)", remainingCount-1) moreSuffixWidth := runewidth.StringWidth(moreSuffix) if currentWidth+nameWidth+moreSuffixWidth > remainingWidth { // Add the "more" suffix and break if len(fittingNames) > 0 { return fmt.Sprintf("%s%s, ... (%d more)", baseText, strings.Join(fittingNames, ", "), remainingCount) } // If we can't fit even one name, just show count return fmt.Sprintf("%s%d hook%s", baseText, len(names), pluralize(len(names))) } } if currentWidth+nameWidth <= remainingWidth { fittingNames = append(fittingNames, name) currentWidth += nameWidth } else { // This name doesn't fit if len(fittingNames) == 0 { // Can't fit any names, just show count return fmt.Sprintf("%s%d hook%s", baseText, len(names), pluralize(len(names))) } // Show what we have plus count remainingCount := len(names) - len(fittingNames) return fmt.Sprintf("%s%s, ... (%d more)", baseText, strings.Join(fittingNames, ", "), remainingCount) } } // All names fit return fmt.Sprintf("%s%s", baseText, strings.Join(fittingNames, ", ")) } // pluralize returns "s" for counts != 1, empty string otherwise. func pluralize(count int) string { if count == 1 { return "" } return "s" } ================================================ FILE: internal/log/log_test.go ================================================ package log import ( "bytes" "fmt" "strings" "sync" "testing" "time" "github.com/briandowns/spinner" "github.com/stretchr/testify/assert" ) const ( // Test constants for concurrent access. testConcurrentGoroutines = 10 testOperationsPerGoroutine = 50 ) func TestLogger_SetName(t *testing.T) { for name, tt := range map[string]struct { initialNames []string nameToAdd string expectedNames []string expectedSuffix string }{ "add first name": { initialNames: []string{}, nameToAdd: "test-hook", expectedNames: []string{"test-hook"}, expectedSuffix: " waiting: test-hook", }, "add second name": { initialNames: []string{"first-hook"}, nameToAdd: "second-hook", expectedNames: []string{"first-hook", "second-hook"}, expectedSuffix: " waiting: first-hook, second-hook", }, "add multiple names": { initialNames: []string{"hook1", "hook2"}, nameToAdd: "hook3", expectedNames: []string{"hook1", "hook2", "hook3"}, expectedSuffix: " waiting: hook1, hook2, hook3", }, "add empty name": { initialNames: []string{"existing"}, nameToAdd: "", expectedNames: []string{"existing", ""}, expectedSuffix: " waiting: existing, ", }, "add duplicate name": { initialNames: []string{"hook1"}, nameToAdd: "hook1", expectedNames: []string{"hook1", "hook1"}, expectedSuffix: " waiting: hook1, hook1", }, "add name with spaces": { initialNames: []string{}, nameToAdd: "hook with spaces", expectedNames: []string{"hook with spaces"}, expectedSuffix: " waiting: hook with spaces", }, "add name with unicode": { initialNames: []string{}, nameToAdd: "🥊-hook", expectedNames: []string{"🥊-hook"}, expectedSuffix: " waiting: 🥊-hook", }, } { t.Run(name, func(t *testing.T) { assert := assert.New(t) logger := createTestLogger() // Set up initial state logger.names = make([]string, len(tt.initialNames)) copy(logger.names, tt.initialNames) // Call SetName logger.SetName(tt.nameToAdd) // Verify names slice assert.Equal(tt.expectedNames, logger.names) // Verify spinner suffix assert.Equal(tt.expectedSuffix, logger.spinner.Suffix) }) } } func TestLogger_UnsetName(t *testing.T) { for name, tt := range map[string]struct { initialNames []string nameToRemove string expectedNames []string expectedSuffix string }{ "remove only name": { initialNames: []string{"test-hook"}, nameToRemove: "test-hook", expectedNames: []string{}, expectedSuffix: " waiting", }, "remove first of two names": { initialNames: []string{"first-hook", "second-hook"}, nameToRemove: "first-hook", expectedNames: []string{"second-hook"}, expectedSuffix: " waiting: second-hook", }, "remove second of two names": { initialNames: []string{"first-hook", "second-hook"}, nameToRemove: "second-hook", expectedNames: []string{"first-hook"}, expectedSuffix: " waiting: first-hook", }, "remove middle name": { initialNames: []string{"hook1", "hook2", "hook3"}, nameToRemove: "hook2", expectedNames: []string{"hook1", "hook3"}, expectedSuffix: " waiting: hook1, hook3", }, "remove non-existent name": { initialNames: []string{"hook1", "hook2"}, nameToRemove: "hook3", expectedNames: []string{"hook1", "hook2"}, expectedSuffix: " waiting: hook1, hook2", }, "remove from empty list": { initialNames: []string{}, nameToRemove: "hook1", expectedNames: []string{}, expectedSuffix: " waiting", }, "remove empty name": { initialNames: []string{"hook1", "", "hook2"}, nameToRemove: "", expectedNames: []string{"hook1", "hook2"}, expectedSuffix: " waiting: hook1, hook2", }, "remove all duplicates": { initialNames: []string{"hook1", "hook1", "hook2"}, nameToRemove: "hook1", expectedNames: []string{"hook2"}, expectedSuffix: " waiting: hook2", }, "remove unicode name": { initialNames: []string{"🥊-hook", "normal-hook"}, nameToRemove: "🥊-hook", expectedNames: []string{"normal-hook"}, expectedSuffix: " waiting: normal-hook", }, } { t.Run(name, func(t *testing.T) { assert := assert.New(t) logger := createTestLogger() // Set up initial state logger.names = make([]string, len(tt.initialNames)) copy(logger.names, tt.initialNames) // Call UnsetName logger.UnsetName(tt.nameToRemove) // Verify names slice assert.Equal(tt.expectedNames, logger.names) // Verify spinner suffix assert.Equal(tt.expectedSuffix, logger.spinner.Suffix) }) } } func TestLogger_SetName_UnsetName_Integration(t *testing.T) { assert := assert.New(t) logger := createTestLogger() // Start with empty state assert.Equal([]string{}, logger.names) assert.Equal(" waiting", logger.spinner.Suffix) // Add first name logger.SetName("hook1") assert.Equal([]string{"hook1"}, logger.names) assert.Equal(" waiting: hook1", logger.spinner.Suffix) // Add second name logger.SetName("hook2") assert.Equal([]string{"hook1", "hook2"}, logger.names) assert.Equal(" waiting: hook1, hook2", logger.spinner.Suffix) // Add third name logger.SetName("hook3") assert.Equal([]string{"hook1", "hook2", "hook3"}, logger.names) assert.Equal(" waiting: hook1, hook2, hook3", logger.spinner.Suffix) // Remove middle name logger.UnsetName("hook2") assert.Equal([]string{"hook1", "hook3"}, logger.names) assert.Equal(" waiting: hook1, hook3", logger.spinner.Suffix) // Remove first name logger.UnsetName("hook1") assert.Equal([]string{"hook3"}, logger.names) assert.Equal(" waiting: hook3", logger.spinner.Suffix) // Remove last name logger.UnsetName("hook3") assert.Equal([]string{}, logger.names) assert.Equal(" waiting", logger.spinner.Suffix) } func TestLogger_LongHookNames(t *testing.T) { assert := assert.New(t) logger := createTestLogger() // This test documents current behavior that causes terminal wrapping. // See issue #1144 for planned terminal width handling. // Test with very long hook names that would exceed typical terminal width longNames := []string{ "very-long-hook-name-that-exceeds-normal-terminal-width-and-would-cause-wrapping-issues", "another-extremely-long-hook-name-with-many-hyphens-and-descriptive-text-that-goes-on-and-on", "packwerk_check_unused_dependencies_and_validate_all_boundaries_with_strict_mode_enabled", "eslint_with_typescript_support_and_custom_rules_for_react_components_and_styled_components", } // Add all long names for _, name := range longNames { logger.SetName(name) } // Verify all names are stored assert.Equal(longNames, logger.names) // Verify the suffix contains all names (this is the current behavior that causes the issue) expectedSuffix := " waiting: " + strings.Join(longNames, ", ") assert.Equal(expectedSuffix, logger.spinner.Suffix) // Document the current problematic behavior t.Logf("Current suffix length: %d characters", len(logger.spinner.Suffix)) t.Logf("This would cause wrapping issues in terminals narrower than %d columns", len(logger.spinner.Suffix)) } func TestLogger_ConcurrentAccess(t *testing.T) { assert := assert.New(t) logger := createTestLogger() var wg sync.WaitGroup // Start goroutines that concurrently add and remove names for i := range testConcurrentGoroutines { wg.Add(1) go func(id int) { defer wg.Done() for j := range testOperationsPerGoroutine { hookName := fmt.Sprintf("hook-%d-%d", id, j) // Add name logger.SetName(hookName) // Small delay to increase chance of race conditions time.Sleep(time.Microsecond) // Remove name logger.UnsetName(hookName) } }(i) } wg.Wait() // After all operations, names slice should be empty assert.Equal([]string{}, logger.names) assert.Equal(" waiting", logger.spinner.Suffix) } func TestLogger_SpinnerActiveHandling(t *testing.T) { assert := assert.New(t) logger := createTestLogger() // Test that SetName and UnsetName don't panic when spinner is active logger.spinner.Start() initialActive := logger.spinner.Active() // SetName should handle active spinner without panicking logger.SetName("test-hook") assert.Equal([]string{"test-hook"}, logger.names) assert.Equal(" waiting: test-hook", logger.spinner.Suffix) // UnsetName should handle active spinner without panicking logger.UnsetName("test-hook") assert.Equal([]string{}, logger.names) assert.Equal(" waiting", logger.spinner.Suffix) // Clean up logger.spinner.Stop() // Document the behavior for future reference t.Logf("Spinner was initially active: %v", initialActive) } func TestGlobalSetNameUnsetName(t *testing.T) { assert := assert.New(t) // Test the global functions that use the standard logger originalNames := make([]string, len(std.names)) copy(originalNames, std.names) originalSuffix := std.spinner.Suffix // Clean up after test defer func() { std.names = originalNames std.spinner.Suffix = originalSuffix }() // Reset to clean state std.names = []string{} std.spinner.Suffix = " waiting" // Test global SetName SetName("global-hook") assert.Equal([]string{"global-hook"}, std.names) assert.Equal(" waiting: global-hook", std.spinner.Suffix) // Test global UnsetName UnsetName("global-hook") assert.Equal([]string{}, std.names) assert.Equal(" waiting", std.spinner.Suffix) } // Helper function to create a test logger. func createTestLogger() *Logger { return &Logger{ level: InfoLevel, out: &bytes.Buffer{}, colors: ColorOff, names: []string{}, spinner: spinner.New( spinner.CharSets[spinnerCharSet], spinnerRefreshRate, spinner.WithSuffix(spinnerText), ), } } // Terminal width handling tests. func TestLogger_FormatSpinnerSuffix(t *testing.T) { tests := []struct { name string names []string terminalWidth int expected string }{ { name: "empty names", names: []string{}, terminalWidth: 80, expected: " waiting", }, { name: "single short name fits", names: []string{"test"}, terminalWidth: 80, expected: " waiting: test", }, { name: "multiple short names fit", names: []string{"hook1", "hook2", "hook3"}, terminalWidth: 80, expected: " waiting: hook1, hook2, hook3", }, { name: "names too long, show count", names: []string{"very-long-hook-name-1", "very-long-hook-name-2", "very-long-hook-name-3"}, terminalWidth: 30, expected: " waiting: 3 hooks", }, { name: "single hook, singular", names: []string{"hook1"}, terminalWidth: 20, expected: " waiting: 1 hook", }, { name: "short names all fit in available width", names: []string{"a", "b", "c", "d", "e"}, terminalWidth: 35, // All short names fit expected: " waiting: a, b, c, d, e", }, { name: "names too wide, fallback to count", names: []string{"hook", "test", "verylongname", "another", "final"}, terminalWidth: 30, // Too narrow, shows count instead expected: " waiting: 5 hooks", }, { name: "unicode characters handled correctly", names: []string{"🥊-hook", "test"}, terminalWidth: 80, expected: " waiting: 🥊-hook, test", }, { name: "no terminal width constraint (width 0)", names: []string{"hook1", "hook2", "very-long-name"}, terminalWidth: 0, expected: " waiting: hook1, hook2, very-long-name", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { logger := createTestLoggerWithWidth(tt.terminalWidth) result := logger.formatSpinnerSuffix(tt.names) // Debug output for failing tests if result != tt.expected { t.Logf("Terminal width: %d", tt.terminalWidth) t.Logf("Available width: %d", tt.terminalWidth-10) t.Logf("Expected: %q", tt.expected) t.Logf("Actual: %q", result) } assert.Equal(t, tt.expected, result) }) } } func TestLogger_FormatWithPartialNames(t *testing.T) { tests := []struct { name string names []string availableWidth int expectedResult string }{ { name: "empty names", names: []string{}, availableWidth: 50, expectedResult: " waiting", }, { name: "all names fit", names: []string{"a", "b", "c"}, availableWidth: 50, expectedResult: " waiting: a, b, c", }, { name: "partial names fit", names: []string{"hook1", "hook2", "very-long-hook-name"}, availableWidth: 30, expectedResult: " waiting: hook1, ... (2 more)", }, { name: "very narrow width, show count only", names: []string{"hook1", "hook2"}, availableWidth: 15, expectedResult: " waiting: 2 hooks", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { result := formatWithPartialNames(tt.names, tt.availableWidth) assert.Equal(t, tt.expectedResult, result) }) } } func TestPluralize(t *testing.T) { tests := [...]struct { count int expected string }{ {0, "s"}, {1, ""}, {2, "s"}, {10, "s"}, } for _, tt := range tests { t.Run(fmt.Sprintf("pluralize_%d", tt.count), func(t *testing.T) { result := pluralize(tt.count) assert.Equal(t, tt.expected, result) }) } } func TestLogger_TerminalWidthIntegration(t *testing.T) { // Test the integration of SetName/UnsetName with terminal width handling logger := createTestLoggerWithWidth(50) // Simulate 50-character terminal // Add hooks that would exceed width longHooks := []string{ "very-long-hook-name-1", "very-long-hook-name-2", "very-long-hook-name-3", "very-long-hook-name-4", } for _, hook := range longHooks { logger.SetName(hook) } // Should show count instead of all names assert.Contains(t, logger.spinner.Suffix, "hooks") assert.NotContains(t, logger.spinner.Suffix, "very-long-hook-name-4") // Remove some hooks logger.UnsetName("very-long-hook-name-1") logger.UnsetName("very-long-hook-name-2") // Should still be truncated assert.Contains(t, logger.spinner.Suffix, "waiting:") // Remove all hooks logger.UnsetName("very-long-hook-name-3") logger.UnsetName("very-long-hook-name-4") // Should be back to basic waiting assert.Equal(t, " waiting", logger.spinner.Suffix) } // Helper function to create a test logger with simulated terminal width. func createTestLoggerWithWidth(width int) *Logger { return &Logger{ level: InfoLevel, out: &bytes.Buffer{}, colors: ColorOff, terminalWidth: width, names: []string{}, spinner: spinner.New( spinner.CharSets[spinnerCharSet], spinnerRefreshRate, spinner.WithSuffix(spinnerText), ), } } ================================================ FILE: internal/log/settings.go ================================================ package log import ( "strings" ) const ( meta = 1 << iota success failure summary skips execution executionOutput executionInfo emptySummary setup ) const disableAll = 0 type LogSettings struct { bitmap int16 } var Settings LogSettings func InitSettings() { Settings = NewSettings() } func NewSettings() LogSettings { return LogSettings{^disableAll} } func ApplySettings(enableTags string, enable any) { Settings.Apply(enableTags, enable) } func (s *LogSettings) Apply(enableTags string, enable any) { if enableTags == "" && (enable == nil || enable == "") { s.enableAll() return } if enableOutput, ok := enable.(bool); ok && enableTags == "" { if enableOutput { s.enableAll() } else { s.disableAll() } return } if enableOptions, ok := enable.([]any); ok { if len(enableOptions) != 0 { s.bitmap = disableAll } for _, option := range enableOptions { if value, ok := option.(string); ok { s.enable(value) } } } if enableTags != "" { s.bitmap = disableAll for tag := range strings.SplitSeq(enableTags, ",") { s.enable(tag) } } } func (s *LogSettings) enable(setting string) { switch setting { case "meta": s.bitmap |= meta case "success": s.bitmap |= success case "failure": s.bitmap |= failure case "summary": s.bitmap |= summary | success | failure case "skips": s.bitmap |= skips case "execution", "jobs": s.bitmap |= execution | executionOutput | executionInfo case "execution_out", "jobs_out": s.bitmap |= executionOutput | execution case "execution_info", "jobs_info": s.bitmap |= executionInfo | execution case "empty_summary": s.bitmap |= emptySummary case "setup": s.bitmap |= setup } } func (s *LogSettings) enableAll() { s.bitmap = ^disableAll } func (s *LogSettings) disableAll() { s.bitmap = failure } // Checks the state of params. func (s LogSettings) isEnable(option int16) bool { return s.bitmap&option != 0 } func (s LogSettings) LogSuccess() bool { return s.isEnable(success) } func (s LogSettings) LogFailure() bool { return s.isEnable(failure) } func (s LogSettings) LogSummary() bool { return s.isEnable(summary) } func (s LogSettings) LogMeta() bool { return s.isEnable(meta) } func (s LogSettings) LogExecution() bool { return s.isEnable(execution) } func (s LogSettings) LogExecutionOutput() bool { return s.isEnable(executionOutput) } func (s LogSettings) LogExecutionInfo() bool { return s.isEnable(executionInfo) } func (s LogSettings) LogSkips() bool { return s.isEnable(skips) } func (s LogSettings) LogEmptySummary() bool { return s.isEnable(emptySummary) } func (s LogSettings) LogSetup() bool { return s.isEnable(setup) } ================================================ FILE: internal/log/settings_test.go ================================================ package log import ( "strconv" "testing" ) func TestSetting(t *testing.T) { for i, tt := range [...]struct { enableTags string enableSettings any results map[string]bool }{ { enableTags: "", enableSettings: []any{}, results: map[string]bool{ "meta": true, "summary": true, "success": true, "failure": true, "skips": true, "execution": true, "execution_out": true, "execution_info": true, "empty_summary": true, }, }, { enableTags: "", enableSettings: false, results: map[string]bool{ "failure": true, }, }, { enableTags: "", enableSettings: []any{"success"}, results: map[string]bool{ "success": true, }, }, { enableTags: "", enableSettings: []any{"summary"}, results: map[string]bool{ "summary": true, "success": true, "failure": true, }, }, { enableTags: "", enableSettings: []any{"failure", "execution"}, results: map[string]bool{ "failure": true, "execution": true, "execution_info": true, "execution_out": true, }, }, { enableTags: "", enableSettings: []any{"failure", "execution_out"}, results: map[string]bool{ "failure": true, "execution": true, "execution_out": true, }, }, { enableTags: "", enableSettings: []any{"failure", "execution_info"}, results: map[string]bool{ "failure": true, "execution": true, "execution_info": true, }, }, { enableTags: "", enableSettings: []any{ "meta", "summary", "success", "failure", "skips", "execution", "execution_out", "execution_info", "empty_summary", }, results: map[string]bool{ "meta": true, "summary": true, "success": true, "failure": true, "skips": true, "execution": true, "execution_out": true, "execution_info": true, "empty_summary": true, }, }, { enableTags: "", enableSettings: true, results: map[string]bool{ "meta": true, "summary": true, "success": true, "failure": true, "skips": true, "execution": true, "execution_out": true, "execution_info": true, "empty_summary": true, }, }, { enableTags: "meta,summary,skips,empty_summary", enableSettings: nil, results: map[string]bool{ "meta": true, "summary": true, "success": true, "failure": true, "skips": true, "empty_summary": true, }, }, } { t.Run(strconv.Itoa(i), func(t *testing.T) { settings := NewSettings() settings.Apply(tt.enableTags, tt.enableSettings) if settings.LogMeta() != tt.results["meta"] { t.Errorf("expected LogMeta to be %v", tt.results["meta"]) } if settings.LogSuccess() != tt.results["success"] { t.Errorf("expected LogSuccess to be %v", tt.results["success"]) } if settings.LogFailure() != tt.results["failure"] { t.Errorf("expected LogFailure to be %v", tt.results["failure"]) } if settings.LogSummary() != tt.results["summary"] { t.Errorf("expected LogSummary to be %v", tt.results["summary"]) } if settings.LogExecution() != tt.results["execution"] { t.Errorf("expected LogExecution to be %v", tt.results["execution"]) } if settings.LogExecutionOutput() != tt.results["execution_out"] { t.Errorf("expected LogExecutionOutput to be %v", tt.results["execution_out"]) } if settings.LogExecutionInfo() != tt.results["execution_info"] { t.Errorf("expected LogExecutionInfo to be %v", tt.results["execution_info"]) } if settings.LogEmptySummary() != tt.results["empty_summary"] { t.Errorf("expected LogEmptySummary to be %v", tt.results["empty_summary"]) } if settings.LogSkips() != tt.results["skips"] { t.Errorf("expected LogSkips to be %v", tt.results["skip"]) } }) } } ================================================ FILE: internal/log/setup.go ================================================ package log import ( "io" "os" "github.com/charmbracelet/lipgloss" ) func LogSetup(r io.Reader) { go func() { if !Settings.LogSetup() { _, _ = io.Copy(io.Discard, r) return } Styled(). WithLeftBorder(lipgloss.ThickBorder(), ColorYellow). WithPadding(execLogPadding). Info(Yellow("setup ❯ ")) _, _ = io.Copy(os.Stdout, r) }() } ================================================ FILE: internal/log/skip.go ================================================ package log import ( "github.com/charmbracelet/lipgloss" ) const skipLogPadding = 2 func Skip(name, reason string) { if !Settings.LogSkips() { return } Styled(). WithLeftBorder(lipgloss.NormalBorder(), ColorCyan). WithPadding(skipLogPadding). Info( Cyan(Bold(name)) + " " + Gray("(skip)") + " " + Yellow(reason), ) } ================================================ FILE: internal/run/controller/command/build.go ================================================ package command import ( "github.com/evilmartians/lefthook/v2/internal/config" "github.com/evilmartians/lefthook/v2/internal/git" ) type JobParams struct { Name string Run string Root string Runner string Args string Script string FilesCmd string FileTypes []string Tags []string Glob []string ExcludeFiles []string Only any Skip any } type BuilderOptions struct { HookName string GitArgs []string ForceFiles []string SourceDirs []string Templates map[string]string GlobMatcher string Force bool } type Builder struct { git *git.Repository opts BuilderOptions } func NewBuilder(repo *git.Repository, opts BuilderOptions) *Builder { return &Builder{ git: repo, opts: opts, } } // BuildCommands returns the list of commands and the list of files touched by the command. func (b *Builder) BuildCommands(params *JobParams) ([]string, []string, error) { if len(params.Run) != 0 { return b.buildCommand(params) } else { return b.buildScript(params) } } func (p *JobParams) validateCommand() error { if !config.IsRunFilesCompatible(p.Run) { return config.ErrFilesIncompatible } return nil } func (p *JobParams) validateScript() error { return nil } ================================================ FILE: internal/run/controller/command/build_command.go ================================================ package command import ( "strings" "github.com/alessio/shellescape" "github.com/evilmartians/lefthook/v2/internal/config" "github.com/evilmartians/lefthook/v2/internal/run/controller/command/replacer" "github.com/evilmartians/lefthook/v2/internal/run/controller/filter" "github.com/evilmartians/lefthook/v2/internal/system" ) func (b *Builder) buildCommand(params *JobParams) ([]string, []string, error) { if err := params.validateCommand(); err != nil { return nil, nil, err } replacer := b.buildReplacer(params) filter := b.buildFilter(params) command := strings.Join([]string{params.Run, params.Args}, " ") err := replacer.Discover(command, filter) if err != nil { return nil, nil, err } // Checking substitutions and skipping execution if it is empty. if !b.opts.Force && replacer.HasEmpty() { return nil, nil, SkipError{"no files for inspection"} } // Special case when `files` option specified but not referenced in `run`: return if the result is empty. if !b.opts.Force && len(params.FilesCmd) > 0 && replacer.Empty(config.SubFiles) { files, err := replacer.Files(config.SubFiles, filter) if err != nil { return nil, nil, err } if len(files) == 0 { return nil, nil, SkipError{"no files for inspection"} } } commands, replacedFiles := replacer.ReplaceAndSplit(command, system.MaxCmdLen()) if b.opts.Force || len(replacedFiles) != 0 { return commands, replacedFiles, nil } // Skip if no files were staged (including deleted) //nolint:nestif if config.HookUsesStagedFiles(b.opts.HookName) { files, err := replacer.Files(config.SubStagedFiles, filter) if err != nil { return nil, nil, err } if len(files) == 0 { files, err = b.git.StagedFilesWithDeleted() if err != nil { return nil, nil, err } if len(filter.Apply(files)) == 0 { return nil, nil, SkipError{"no matching staged files"} } } } // Skip if no files were to be pushed if config.HookUsesPushFiles(b.opts.HookName) { files, err := replacer.Files(config.SubPushFiles, filter) if err != nil { return nil, nil, err } if len(files) == 0 { return nil, nil, SkipError{"no matching push files"} } } return commands, replacedFiles, nil } // buildReplacer creates the replacer with all supported templates for files and arguments. func (b *Builder) buildReplacer(params *JobParams) replacer.Replacer { var r replacer.Replacer if len(b.opts.ForceFiles) > 0 { r = replacer.NewMocked(b.opts.ForceFiles) } else { r = replacer.New(b.git, params.Root, params.FilesCmd) } return r. AddTemplates(b.opts.Templates). AddTemplates(map[string]string{ "lefthook_job_name": shellescape.Quote(params.Name), }). AddGitArgs(b.opts.GitArgs) } func (b *Builder) buildFilter(params *JobParams) *filter.Filter { return filter.New(b.git.Fs, filter.Params{ Glob: params.Glob, ExcludeFiles: params.ExcludeFiles, Root: params.Root, FileTypes: params.FileTypes, GlobMatcher: b.opts.GlobMatcher, }) } ================================================ FILE: internal/run/controller/command/build_script.go ================================================ package command import ( "fmt" "os" "path/filepath" "strings" "github.com/alessio/shellescape" "github.com/evilmartians/lefthook/v2/internal/log" "github.com/evilmartians/lefthook/v2/internal/run/controller/command/replacer" "github.com/evilmartians/lefthook/v2/internal/system" ) const ( executableFileMode os.FileMode = 0o751 executableMask os.FileMode = 0o111 ) type scriptNotExistsError struct { scriptPath string } func (s scriptNotExistsError) Error() string { return fmt.Sprintf("script does not exist: %s", s.scriptPath) } func (b *Builder) buildScript(params *JobParams) ([]string, []string, error) { if err := params.validateScript(); err != nil { return nil, nil, err } var replacer replacer.Replacer if len(params.Args) > 0 { replacer = b.buildReplacer(params) filter := b.buildFilter(params) err := replacer.Discover(params.Args, filter) if err != nil { return nil, nil, err } if !b.opts.Force && replacer.HasEmpty() { return nil, nil, SkipError{"no files for inspection"} } } var scriptExists bool execs := make([]string, 0) for _, sourceDir := range b.opts.SourceDirs { scriptPath := filepath.Join(sourceDir, b.opts.HookName, params.Script) fileInfo, err := b.git.Fs.Stat(scriptPath) if os.IsNotExist(err) { log.Debugf("[lefthook] script doesn't exist: %s", scriptPath) continue } if err != nil { log.Errorf("Failed to get info about a script: %s", params.Script) return nil, nil, err } scriptExists = true if !fileInfo.Mode().IsRegular() { log.Debugf("[lefthook] script '%s' is not a regular file, skipping", scriptPath) return nil, nil, SkipError{"not a regular file"} } // Make sure file is executable if (fileInfo.Mode() & executableMask) == 0 { if err := b.git.Fs.Chmod(scriptPath, executableFileMode); err != nil { log.Errorf("Couldn't change file mode to make file executable: %s", err) return nil, nil, err } } var args []string if len(params.Runner) > 0 { args = append(args, params.Runner) } args = append(args, shellescape.Quote(scriptPath)) if len(params.Args) > 0 { args = append(args, params.Args) command := strings.Join(args, " ") commands, _ := replacer.ReplaceAndSplit(command, system.MaxCmdLen()) execs = append(execs, commands...) } else { args = append(args, b.opts.GitArgs...) execs = append(execs, strings.Join(args, " ")) } } if !scriptExists { return nil, nil, scriptNotExistsError{params.Script} } return execs, nil, nil } ================================================ FILE: internal/run/controller/command/replacer/replacer.go ================================================ package replacer import ( "fmt" "regexp" "runtime" "strconv" "strings" "github.com/alessio/shellescape" "github.com/evilmartians/lefthook/v2/internal/config" "github.com/evilmartians/lefthook/v2/internal/git" "github.com/evilmartians/lefthook/v2/internal/log" "github.com/evilmartians/lefthook/v2/internal/run/controller/filter" ) var surroundingQuotesRegexp = regexp.MustCompile(`^'(.*)'$`) type entry struct { items []string cnt int } type Replacer struct { cache map[string]*entry files map[string]func() ([]string, error) templates map[string]string } func New( git *git.Repository, root string, filesCmd string, ) Replacer { var ( staged = git.StagedFiles push = git.PushFiles all = git.AllFiles cmd = func() ([]string, error) { var cmd []string if runtime.GOOS == "windows" { cmd = strings.Split(filesCmd, " ") } else { cmd = []string{"sh", "-c", filesCmd} } return git.FindExistingFiles(cmd, root) } ) return Replacer{ cache: make(map[string]*entry), files: map[string]func() ([]string, error){ config.SubStagedFiles: staged, config.SubPushFiles: push, config.SubAllFiles: all, config.SubFiles: cmd, }, } } func (r Replacer) AddTemplates(templates map[string]string) Replacer { if r.templates == nil { r.templates = make(map[string]string) } for key, replacement := range templates { r.templates["{"+key+"}"] = replacement } return r } func (r Replacer) AddGitArgs(args []string) Replacer { if r.templates == nil { r.templates = make(map[string]string) } r.templates["{0}"] = strings.Join(args, " ") for i, arg := range args { r.templates["{"+strconv.Itoa(i+1)+"}"] = arg } return r } func NewMocked(files []string) Replacer { forceFilesFn := func() ([]string, error) { return files, nil } //nolint:unparam return Replacer{ cache: make(map[string]*entry), files: map[string]func() ([]string, error){ config.SubStagedFiles: forceFilesFn, config.SubPushFiles: forceFilesFn, config.SubAllFiles: forceFilesFn, config.SubFiles: forceFilesFn, }, } } // Discover finds patterns in `source` and caches the results. func (r Replacer) Discover(source string, filter *filter.Filter) error { for template, fn := range r.files { cnt := strings.Count(source, template) if cnt == 0 { continue } files, err := fn() if err != nil { return fmt.Errorf("error replacing %s: %w", template, err) } files = filter.Apply(files) r.cache[template] = &entry{items: files, cnt: cnt} } for template, replacement := range r.templates { cnt := strings.Count(source, template) if cnt == 0 { continue } r.cache[template] = &entry{items: []string{replacement}, cnt: cnt} } return nil } func (r Replacer) HasEmpty() bool { for _, entry := range r.cache { if len(entry.items) == 0 { return true } } return false } func (r Replacer) Cached(key string) bool { _, ok := r.cache[key] return ok } func (r Replacer) Empty(key string) bool { entry, ok := r.cache[key] if !ok { return true } return len(entry.items) == 0 } func (r Replacer) Files(template string, filter *filter.Filter) ([]string, error) { entry, ok := r.cache[template] if ok { return entry.items, nil } fn, ok := r.files[template] if !ok { panic("filtering: no such files template: " + template) } files, err := fn() if err != nil { return nil, err } return filter.Apply(files), nil } func (r Replacer) ReplaceAndSplit(command string, maxlen int) ([]string, []string) { if len(r.cache) == 0 { return []string{command}, nil } var cnt int allFiles := make([]string, 0) for template, entry := range r.cache { if entry.cnt == 0 { continue } cnt += entry.cnt maxlen += entry.cnt * len(template) if _, ok := r.files[template]; ok { allFiles = append(allFiles, entry.items...) // Only escape file templates, not custom templates entry.items = escapeFiles(entry.items) } } maxlen -= len(command) if cnt > 0 { maxlen /= cnt } var exhausted int commands := make([]string, 0) for { result := command for template, entry := range r.cache { added, rest := getNChars(entry.items, maxlen) if len(rest) == 0 { exhausted += 1 } else { entry.items = rest } result = replaceQuoted(result, template, added) } log.Debug("[lefthook] job: ", result) commands = append(commands, result) if exhausted >= len(r.cache) { break } } return commands, allFiles } // Escape file names to prevent unexpected bugs. func escapeFiles(files []string) []string { var filesEsc []string for _, fileName := range files { if len(fileName) > 0 { filesEsc = append(filesEsc, shellescape.Quote(fileName)) } } log.Builder(log.DebugLevel, "[lefthook] "). Add("files after escaping: ", filesEsc). Log() return filesEsc } func getNChars(s []string, n int) ([]string, []string) { if len(s) == 0 { return nil, nil } var cnt int for i, str := range s { cnt += len(str) if i > 0 { cnt += 1 // a space } if cnt > n { if i == 0 { i = 1 } return s[:i], s[i:] } } return s, nil } func replaceQuoted(source, substitution string, files []string) string { for _, elem := range [][]string{ {"\"", "\"" + substitution + "\""}, {"'", "'" + substitution + "'"}, {"", substitution}, } { quote := elem[0] sub := elem[1] if !strings.Contains(source, sub) { continue } quotedFiles := files if len(quote) != 0 { quotedFiles = make([]string, 0, len(files)) for _, fileName := range files { quotedFiles = append(quotedFiles, quote+surroundingQuotesRegexp.ReplaceAllString(fileName, "$1")+quote) } } source = strings.ReplaceAll( source, sub, strings.Join(quotedFiles, " "), ) } return source } ================================================ FILE: internal/run/controller/command/replacer/replacer_test.go ================================================ package replacer import ( "fmt" "testing" "github.com/stretchr/testify/assert" "github.com/evilmartians/lefthook/v2/internal/config" "github.com/evilmartians/lefthook/v2/internal/run/controller/filter" ) func Test_getNChars(t *testing.T) { for i, tt := range [...]struct { source, cut, rest []string n int }{ { source: []string{"str1", "str2", "str3"}, n: 0, cut: []string{"str1"}, rest: []string{"str2", "str3"}, }, { source: []string{"str1", "str2", "str3"}, n: 4, cut: []string{"str1"}, rest: []string{"str2", "str3"}, }, { source: []string{"str1", "str2", "str3"}, n: 6, cut: []string{"str1"}, rest: []string{"str2", "str3"}, }, { source: []string{"str1", "str2", "str3"}, n: 8, cut: []string{"str1"}, // because we need to add a space rest: []string{"str2", "str3"}, }, { source: []string{"str1", "str2", "str3"}, n: 9, cut: []string{"str1", "str2"}, rest: []string{"str3"}, }, { source: []string{"str1", "str2", "str3"}, n: 500, cut: []string{"str1", "str2", "str3"}, rest: nil, }, { source: nil, n: 2, cut: nil, rest: nil, }, } { t.Run(fmt.Sprintf("test %d", i), func(t *testing.T) { assert := assert.New(t) cut, rest := getNChars(tt.source, tt.n) assert.EqualValues(cut, tt.cut) assert.EqualValues(rest, tt.rest) }) } } func Test_ReplaceAndSplit(t *testing.T) { type result struct { commands []string files []string } for i, tt := range [...]struct { command string maxlen int cache map[string]*entry result result }{ { command: "echo {staged_files}", cache: map[string]*entry{ "{staged_files}": { items: []string{"file1", "file2", "file3"}, cnt: 1, }, }, maxlen: 300, result: result{ commands: []string{"echo file1 file2 file3"}, files: []string{"file1", "file2", "file3"}, }, }, { command: "echo {staged_files}", cache: map[string]*entry{ "{staged_files}": { items: []string{"file1", "file2", "file3"}, cnt: 1, }, }, maxlen: 10, result: result{ commands: []string{ "echo file1", "echo file2", "echo file3", }, files: []string{"file1", "file2", "file3"}, }, }, { command: "echo {files} && git add {files}", cache: map[string]*entry{ "{files}": { items: []string{"file1", "file2", "file3"}, cnt: 2, }, }, maxlen: 49, // (49 - 17(len of command without templates)) / 2 = 16, but we need 17 (3 words + 2 spaces) result: result{ commands: []string{ "echo file1 file2 && git add file1 file2", "echo file3 && git add file3", }, files: []string{"file1", "file2", "file3"}, }, }, { command: "echo {files} && git add {files}", cache: map[string]*entry{ "{files}": { items: []string{"file1", "file2", "file3"}, cnt: 2, }, }, maxlen: 51, result: result{ commands: []string{ "echo file1 file2 file3 && git add file1 file2 file3", }, files: []string{"file1", "file2", "file3"}, }, }, { command: "echo {push_files} && git add {files}", cache: map[string]*entry{ "{push_files}": { items: []string{"push-file"}, cnt: 1, }, "{files}": { items: []string{"file1", "file2"}, cnt: 1, }, }, maxlen: 10, result: result{ commands: []string{ "echo push-file && git add file1", "echo push-file && git add file2", }, files: []string{"push-file", "file1", "file2"}, }, }, { command: "echo {push_files} && git add {files}", cache: map[string]*entry{ "{push_files}": { items: []string{"push1", "push2", "push3"}, cnt: 1, }, "{files}": { items: []string{"file1", "file2"}, cnt: 1, }, }, maxlen: 27, result: result{ commands: []string{ "echo push1 && git add file1", "echo push2 && git add file2", "echo push3 && git add file2", }, files: []string{"push1", "push2", "push3", "file1", "file2"}, }, }, } { t.Run(fmt.Sprintf("test %d", i), func(t *testing.T) { assert := assert.New(t) r := Replacer{ cache: tt.cache, files: map[string]func() ([]string, error){ config.SubStagedFiles: func() ([]string, error) { return nil, nil }, config.SubPushFiles: func() ([]string, error) { return nil, nil }, config.SubAllFiles: func() ([]string, error) { return nil, nil }, config.SubFiles: func() ([]string, error) { return nil, nil }, }, } commands, files := r.ReplaceAndSplit(tt.command, tt.maxlen) assert.ElementsMatch(files, tt.result.files) assert.Equal(commands, tt.result.commands) }) } } func Test_ReplaceAndSplit_CustomTemplates(t *testing.T) { t.Run("custom templates should not be escaped", func(t *testing.T) { assert := assert.New(t) // Create a replacer with custom templates (note: keys include braces) r := NewMocked([]string{"file1.js"}).AddTemplates( map[string]string{ "use-mise": `eval "$(mise env)"`, }, ) // Discover templates in the command (use empty filter) emptyFilter := &filter.Filter{} err := r.Discover("{use-mise} prettier {staged_files}", emptyFilter) assert.NoError(err) // Replace templates commands, files := r.ReplaceAndSplit("{use-mise} prettier {staged_files}", 300) // Custom template should NOT be escaped (no quotes around it) assert.Equal([]string{`eval "$(mise env)" prettier file1.js`}, commands) assert.Equal([]string{"file1.js"}, files) }) t.Run("file templates should still be escaped", func(t *testing.T) { assert := assert.New(t) // Create a replacer with a file that needs escaping r := NewMocked([]string{"file with spaces.js"}) // Discover templates in the command (use empty filter) emptyFilter := &filter.Filter{} err := r.Discover("prettier {staged_files}", emptyFilter) assert.NoError(err) // Replace templates commands, _ := r.ReplaceAndSplit("prettier {staged_files}", 300) // File template SHOULD be escaped (with quotes) assert.Equal([]string{`prettier 'file with spaces.js'`}, commands) }) } func Test_replaceQuoted(t *testing.T) { for i, tt := range [...]struct { name, source, substitution string files []string result string }{ { name: "without substitutions", source: "echo", substitution: "{staged_files}", files: []string{"a", "b"}, result: "echo", }, { name: "with simple substitution", source: "echo {staged_files}", substitution: "{staged_files}", files: []string{"test.rb", "README"}, result: "echo test.rb README", }, { name: "with single quoted substitution", source: "echo '{staged_files}'", substitution: "{staged_files}", files: []string{"test.rb", "README"}, result: "echo 'test.rb' 'README'", }, { name: "with double quoted substitution", source: `echo "{staged_files}"`, substitution: "{staged_files}", files: []string{"test.rb", "README"}, result: `echo "test.rb" "README"`, }, { name: "with escaped files double quoted", source: `echo "{staged_files}"`, substitution: "{staged_files}", files: []string{"'test me.rb'", "README"}, result: `echo "test me.rb" "README"`, }, { name: "with escaped files single quoted", source: "echo '{staged_files}'", substitution: "{staged_files}", files: []string{"'test me.rb'", "README"}, result: `echo 'test me.rb' 'README'`, }, { name: "with escaped files", source: "echo {staged_files}", substitution: "{staged_files}", files: []string{"'test me.rb'", "README"}, result: `echo 'test me.rb' README`, }, { name: "with many substitutions", source: `echo "{staged_files}" {staged_files}`, substitution: "{staged_files}", files: []string{"'test me.rb'", "README"}, result: `echo "test me.rb" "README" 'test me.rb' README`, }, } { t.Run(fmt.Sprintf("%d: %s", i, tt.name), func(t *testing.T) { result := replaceQuoted(tt.source, tt.substitution, tt.files) assert.Equal(t, result, tt.result) }) } } ================================================ FILE: internal/run/controller/command/skip_error.go ================================================ package command // SkipError implements error interface but indicates that the execution needs to be skipped. type SkipError struct { reason string } func (r SkipError) Error() string { return r.reason } ================================================ FILE: internal/run/controller/controller.go ================================================ // Package controller handles ordering, filtering, substitutions while running // jobs for a given hook. package controller import ( "context" "io" "os" "strconv" "sync" "github.com/evilmartians/lefthook/v2/internal/config" "github.com/evilmartians/lefthook/v2/internal/git" "github.com/evilmartians/lefthook/v2/internal/log" "github.com/evilmartians/lefthook/v2/internal/run/controller/exec" "github.com/evilmartians/lefthook/v2/internal/run/controller/utils" "github.com/evilmartians/lefthook/v2/internal/run/result" "github.com/evilmartians/lefthook/v2/internal/system" ) type Controller struct { git *git.Repository cachedStdin io.Reader executor exec.Executor cmd system.CommandWithContext } type Options struct { GitArgs []string ExcludeFiles []string Files []string RunOnlyJobs []string RunOnlyTags []string SourceDirs []string Templates map[string]string GlobMatcher string DisableTTY bool FailOnChanges bool FailOnChangesDiff bool Force bool SkipLFS bool NoStageFixed bool } func NewController(repo *git.Repository) *Controller { return &Controller{ git: repo, // Some hooks use STDIN for parsing data from Git. To allow multiple commands // and scripts access the same Git data STDIN is cached via CachedReader. cachedStdin: utils.NewCachedReader(os.Stdin), // Executor interface for jobs executor: exec.CommandExecutor{}, // Command interface (for LFS hooks) cmd: system.Cmd, } } func (c *Controller) RunHook(ctx context.Context, opts Options, hook *config.Hook) ([]result.Result, error) { results := make([]result.Result, 0, len(hook.Jobs)) if config.NewSkipChecker(system.Cmd).Check(c.git.State, hook.Skip, hook.Only) { log.Skip(hook.Name, "hook setting") return results, nil } if !opts.SkipLFS { if err := c.runLFSHook(ctx, hook.Name, opts.GitArgs); err != nil { return results, err } } if err := c.setup(ctx, opts, hook.Setup); err != nil { log.Warnf("Failed to run setup: %s\n", err) } if !opts.DisableTTY && !hook.Follow { log.StartSpinner() defer log.StopSpinner() } guard := newGuard(c.git, !opts.NoStageFixed && config.HookUsesStagedFiles(hook.Name), opts.FailOnChanges, opts.FailOnChangesDiff) scope := newScope(hook, opts) err := guard.wrap(func() { if hook.Parallel { results = c.concurrently(ctx, scope, hook.Jobs) } else { results = c.sequentially(ctx, scope, hook.Jobs, hook.Piped) } }) return results, err } func (c *Controller) concurrently(ctx context.Context, scope *scope, jobs []*config.Job) []result.Result { var wg sync.WaitGroup results := make([]result.Result, 0, len(jobs)) resultsChan := make(chan result.Result, len(jobs)) for i, job := range jobs { id := strconv.Itoa(i) wg.Add(1) go func(job *config.Job) { defer wg.Done() resultsChan <- c.runJob(ctx, scope, id, job) }(job) } wg.Wait() close(resultsChan) for result := range resultsChan { results = append(results, result) } return results } func (c *Controller) sequentially(ctx context.Context, scope *scope, jobs []*config.Job, piped bool) []result.Result { results := make([]result.Result, 0, len(jobs)) var failPipe bool for i, job := range jobs { id := strconv.Itoa(i) if piped && failPipe { log.Skip(job.PrintableName(id), "broken pipe") continue } result := c.runJob(ctx, scope, id, job) if piped && result.Failure() { failPipe = true } results = append(results, result) } return results } ================================================ FILE: internal/run/controller/controller_test.go ================================================ package controller import ( "context" "errors" "io" "path/filepath" "regexp" "strings" "testing" "time" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/evilmartians/lefthook/v2/internal/config" "github.com/evilmartians/lefthook/v2/internal/run/controller/exec" "github.com/evilmartians/lefthook/v2/internal/run/result" "github.com/evilmartians/lefthook/v2/tests/helpers/cmdtest" "github.com/evilmartians/lefthook/v2/tests/helpers/configtest" "github.com/evilmartians/lefthook/v2/tests/helpers/gittest" ) type ( executor struct{} ) func succeeded(name string) result.Result { return result.Success(name, time.Second) } func failed(name, failText string) result.Result { return result.Failure(name, failText, time.Second) } func (e executor) Execute(_ctx context.Context, opts exec.Options, _in io.Reader, _out io.Writer) (err error) { if strings.HasPrefix(opts.Commands[0], "success") { err = nil } else { err = errors.New(opts.Commands[0]) } return err } // func (g *gitCmd) WithoutEnvs(...string) system.Command { // return g // } // // func (g *gitCmd) Run(cmd []string, _root string, _in io.Reader, out io.Writer, _errOut io.Writer) error { // g.mux.Lock() // g.commands = append(g.commands, strings.Join(cmd, " ")) // g.mux.Unlock() // // cmdLine := strings.Join(cmd, " ") // if cmdLine == "git diff --name-only --cached --diff-filter=ACMR" || // cmdLine == "git diff --name-only --cached --diff-filter=ACMRD" || // cmdLine == "git diff --name-only HEAD @{push}" { // root, _ := filepath.Abs("src") // _, err := out.Write([]byte(strings.Join([]string{ // filepath.Join(root, "scripts", "script.sh"), // filepath.Join(root, "README.md"), // }, "\n"))) // if err != nil { // return err // } // } // // return nil // } // // func (g *gitCmd) reset() { // g.mux.Lock() // g.commands = []string{} // g.mux.Unlock() // } func TestRunAll(t *testing.T) { root, err := filepath.Abs("src") assert.NoError(t, err) gitPath := gittest.GitPath(root) for name, tt := range map[string]struct { branch, hookName string args []string sourceDirs []string existingFiles []string hook *config.Hook success, fail []result.Result gitCommands []string force bool skipLFS bool }{ "empty hook": { hookName: "post-commit", hook: configtest.ParseHook(` piped: true `), }, "with simple command": { hookName: "post-commit", hook: configtest.ParseHook(` jobs: - name: test run: success `), success: []result.Result{succeeded("test")}, }, "with simple command in follow mode": { hookName: "post-commit", hook: configtest.ParseHook(` follow: true jobs: - name: test run: "success" `), success: []result.Result{succeeded("test")}, }, "with multiple commands ran in parallel": { hookName: "post-commit", hook: configtest.ParseHook(` jobs: - name: test run: success - name: lint run: success - name: type-check run: fail `), success: []result.Result{ succeeded("test"), succeeded("lint"), }, fail: []result.Result{failed("type-check", "")}, }, "with exclude tags": { hookName: "post-commit", hook: configtest.ParseHook(` exclude_tags: [test, formatter] jobs: - name: test run: success - name: formatter run: success - name: lint run: success `), success: []result.Result{succeeded("lint")}, }, "with skip=true": { hookName: "post-commit", hook: configtest.ParseHook(` jobs: - name: test run: success skip: true - name: lint run: success `), success: []result.Result{succeeded("lint")}, }, "with skip=merge": { hookName: "post-commit", existingFiles: []string{ filepath.Join(gitPath, "MERGE_HEAD"), }, hook: configtest.ParseHook(` jobs: - name: test run: success skip: merge - name: lint run: success `), success: []result.Result{succeeded("lint")}, }, "with only=merge match": { hookName: "post-commit", existingFiles: []string{ filepath.Join(gitPath, "MERGE_HEAD"), }, hook: configtest.ParseHook(` jobs: - name: test run: success only: merge - name: lint run: success skip: merge `), success: []result.Result{ succeeded("test"), }, }, "with only=merge no match": { hookName: "post-commit", hook: configtest.ParseHook(` jobs: - name: test run: success only: merge - name: lint run: success `), gitCommands: []string{`git show --no-patch --format="%P"`}, success: []result.Result{succeeded("lint")}, }, "with hook's skip=merge match": { hookName: "post-commit", existingFiles: []string{ filepath.Join(gitPath, "MERGE_HEAD"), }, hook: configtest.ParseHook(` skip: merge jobs: - name: test run: success - name: lint run: success `), success: []result.Result{}, }, "with hook's only=merge no match": { hookName: "post-commit", hook: configtest.ParseHook(` only: merge jobs: - name: test run: success - name: lint run: success `), gitCommands: []string{`git show --no-patch --format="%P"`}, success: []result.Result{}, }, "with hook's only=merge match": { hookName: "post-commit", existingFiles: []string{ filepath.Join(gitPath, "MERGE_HEAD"), }, hook: configtest.ParseHook(` only: merge jobs: - name: test run: success - name: lint run: success `), success: []result.Result{ succeeded("lint"), succeeded("test"), }, }, "with skip=[merge, rebase] match rebase": { hookName: "post-commit", existingFiles: []string{ filepath.Join(gitPath, "rebase-merge"), filepath.Join(gitPath, "rebase-apply"), }, hook: configtest.ParseHook(` jobs: - name: test run: success skip: - merge - rebase - name: lint run: success `), success: []result.Result{succeeded("lint")}, }, "with skip=ref match": { branch: "main", existingFiles: []string{ filepath.Join(gitPath, "HEAD"), }, hookName: "post-commit", hook: configtest.ParseHook(` skip: - merge - ref: main jobs: - name: test run: success - name: lint run: success `), gitCommands: []string{`git show --no-patch --format="%P"`}, success: []result.Result{}, }, "with hook's only=ref match": { branch: "main", existingFiles: []string{ filepath.Join(gitPath, "HEAD"), }, hookName: "post-commit", hook: configtest.ParseHook(` only: - merge - ref: main jobs: - name: test run: success - name: lint run: success `), gitCommands: []string{`git show --no-patch --format="%P"`}, success: []result.Result{ succeeded("lint"), succeeded("test"), }, }, "with hook's only=ref no match": { branch: "develop", existingFiles: []string{ filepath.Join(gitPath, "HEAD"), }, hookName: "post-commit", hook: configtest.ParseHook(` only: - merge - ref: main jobs: - name: test run: success - name: lint run: success `), gitCommands: []string{`git show --no-patch --format="%P"`}, success: []result.Result{}, }, "with hook's skip=ref no match": { branch: "fix", existingFiles: []string{ filepath.Join(gitPath, "HEAD"), }, hookName: "post-commit", hook: configtest.ParseHook(` skip: - merge - ref: main jobs: - name: test run: success - name: lint run: success `), gitCommands: []string{`git show --no-patch --format="%P"`}, success: []result.Result{ succeeded("test"), succeeded("lint"), }, }, "with fail": { hookName: "post-commit", hook: configtest.ParseHook(` jobs: - name: test run: fail fail_text: try 'success' `), fail: []result.Result{failed("test", "try 'success'")}, }, "with simple scripts": { sourceDirs: []string{filepath.Join(root, config.DefaultSourceDir)}, existingFiles: []string{ filepath.Join(root, config.DefaultSourceDir, "post-commit", "script.sh"), filepath.Join(root, config.DefaultSourceDir, "post-commit", "failing.js"), }, hookName: "post-commit", hook: configtest.ParseHook(` jobs: - script: "script.sh" runner: success - script: "failing.js" runner: fail fail_text: install node `), success: []result.Result{succeeded("script.sh")}, fail: []result.Result{failed("failing.js", "install node")}, }, "with simple scripts and only=merge match": { sourceDirs: []string{filepath.Join(root, config.DefaultSourceDir)}, existingFiles: []string{ filepath.Join(root, config.DefaultSourceDir, "post-commit", "script.sh"), filepath.Join(root, config.DefaultSourceDir, "post-commit", "failing.js"), filepath.Join(gitPath, "MERGE_HEAD"), }, hookName: "post-commit", hook: configtest.ParseHook(` jobs: - script: "script.sh" runner: success only: merge - script: "failing.js" only: merge runner: fail fail_text: install node `), success: []result.Result{succeeded("script.sh")}, fail: []result.Result{failed("failing.js", "install node")}, }, "with simple scripts and only=merge no match": { sourceDirs: []string{filepath.Join(root, config.DefaultSourceDir)}, existingFiles: []string{ filepath.Join(root, config.DefaultSourceDir, "post-commit", "script.sh"), filepath.Join(root, config.DefaultSourceDir, "post-commit", "failing.js"), }, hookName: "post-commit", hook: configtest.ParseHook(` jobs: - script: "script.sh" runner: success only: merge - script: "failing.js" only: merge runner: fail fail_text: install node `), gitCommands: []string{`git show --no-patch --format="%P"`}, success: []result.Result{}, fail: []result.Result{}, }, "with interactive=true, parallel=true": { sourceDirs: []string{filepath.Join(root, config.DefaultSourceDir)}, existingFiles: []string{ filepath.Join(root, config.DefaultSourceDir, "post-commit", "script.sh"), filepath.Join(root, config.DefaultSourceDir, "post-commit", "failing.js"), }, hookName: "post-commit", hook: configtest.ParseHook(` parallel: true jobs: - name: ok run: success interactive: true - name: fail run: fail - script: "script.sh" runner: success interactive: true - script: "failing.js" runner: fail `), success: []result.Result{succeeded("ok"), succeeded("script.sh")}, fail: []result.Result{failed("failing.js", ""), failed("fail", "")}, }, "with stage_fixed=true": { sourceDirs: []string{filepath.Join(root, config.DefaultSourceDir)}, existingFiles: []string{ filepath.Join(root, config.DefaultSourceDir, "post-commit", "success.sh"), filepath.Join(root, config.DefaultSourceDir, "post-commit", "failing.js"), }, hookName: "post-commit", hook: configtest.ParseHook(` jobs: - name: ok run: success stage_fixed: true - name: fail run: fail stage_fixed: true - script: "success.sh" runner: success stage_fixed: true - script: "failing.js" runner: fail stage_fixed: true `), success: []result.Result{succeeded("ok"), succeeded("success.sh")}, fail: []result.Result{failed("fail", ""), failed("failing.js", "")}, }, "with simple pre-commit": { hookName: "pre-commit", sourceDirs: []string{filepath.Join(root, config.DefaultSourceDir)}, existingFiles: []string{ filepath.Join(root, config.DefaultSourceDir, "pre-commit", "success.sh"), filepath.Join(root, config.DefaultSourceDir, "pre-commit", "failing.js"), filepath.Join(root, "scripts", "script.sh"), filepath.Join(root, "README.md"), }, hook: configtest.ParseHook(` jobs: - name: ok run: success stage_fixed: true - name: fail run: fail stage_fixed: true - script: "success.sh" runner: success stage_fixed: true - script: "failing.js" runner: fail stage_fixed: true `), success: []result.Result{succeeded("ok"), succeeded("success.sh")}, fail: []result.Result{failed("fail", ""), failed("failing.js", "")}, gitCommands: []string{ "git status --short", "git diff --name-only --cached --diff-filter=ACMR", "git add --force -- .*script.sh.*README.md", "git add --force -- .*script.sh.*README.md", }, }, "with pre-commit skip": { hookName: "pre-commit", existingFiles: []string{ filepath.Join(root, "README.md"), }, hook: configtest.ParseHook(` jobs: - name: ok run: success stage_fixed: true glob: - "*.md" - name: fail run: fail stage_fixed: true glob: - "*.txt" `), success: []result.Result{succeeded("ok")}, gitCommands: []string{ "git status --short", "git diff --name-only --cached --diff-filter=ACMR", "git add --force -- .*README.md", "git diff --name-only --cached --diff-filter=ACMRD", }, }, "with pre-commit skip but forced": { hookName: "pre-commit", existingFiles: []string{ filepath.Join(root, "README.md"), }, hook: configtest.ParseHook(` jobs: - name: ok run: success stage_fixed: true glob: - "*.md" - name: fail run: fail stage_fixed: true glob: - "*.sh" `), force: true, success: []result.Result{succeeded("ok")}, fail: []result.Result{failed("fail", "")}, gitCommands: []string{ "git status --short", "git diff --name-only --cached --diff-filter=ACMR", "git add --force -- .*README.md", }, }, "with pre-commit and stage_fixed=true under root": { hookName: "pre-commit", existingFiles: []string{ filepath.Join(root, "scripts", "script.sh"), filepath.Join(root, "README.md"), }, hook: &config.Hook{ Jobs: []*config.Job{{ Name: "ok", Run: "success", Root: filepath.Join(root, "scripts"), StageFixed: true, }}, }, success: []result.Result{succeeded("ok")}, gitCommands: []string{ "git status --short", "git diff --name-only --cached --diff-filter=ACMR", "git add --force -- .*scripts.*script.sh", }, }, "with pre-push skip": { hookName: "pre-push", existingFiles: []string{ filepath.Join(root, "README.md"), }, hook: configtest.ParseHook(` jobs: - name: ok run: success stage_fixed: true glob: - "*.md" - name: fail run: fail stage_fixed: true glob: - "*.sh" `), success: []result.Result{succeeded("ok")}, gitCommands: []string{ "git diff --name-only HEAD @{push}", "git diff --name-only HEAD @{push}", }, }, "with LFS disabled": { hookName: "post-checkout", skipLFS: true, existingFiles: []string{ filepath.Join(root, "README.md"), }, hook: configtest.ParseHook(` jobs: - name: ok run: success `), success: []result.Result{succeeded("ok")}, }, } { fs := afero.NewMemMapFs() cmdExecutor := cmdtest.NewTracking(func(command string, root string, out io.Writer) error { if command == "git diff --name-only --cached --diff-filter=ACMR" || command == "git diff --name-only --cached --diff-filter=ACMRD" || command == "git diff --name-only HEAD @{push}" { root, _ := filepath.Abs("src") _, err := out.Write([]byte(strings.Join([]string{ filepath.Join(root, "scripts", "script.sh"), filepath.Join(root, "README.md"), }, "\n"))) if err != nil { return err } } return nil }) repo := gittest.NewRepositoryBuilder(). Root(root). Cmd(cmdExecutor). Fs(fs). Build() controller := &Controller{ git: repo, executor: executor{}, cmd: cmdtest.NewTracking(nil), // lfs hooks ignored in this test } cmdExecutor.Reset() for _, file := range tt.existingFiles { assert.NoError(t, fs.MkdirAll(filepath.Dir(file), 0o755)) assert.NoError(t, afero.WriteFile(fs, file, []byte{}, 0o755)) } if len(tt.branch) > 0 { assert.NoError(t, afero.WriteFile(fs, filepath.Join(repo.GitPath, "HEAD"), []byte("ref: refs/heads/"+tt.branch), 0o644)) } t.Run(name, func(t *testing.T) { assert := assert.New(t) repo.Setup() cmdExecutor.Reset() opts := Options{ GitArgs: tt.args, Force: tt.force, SkipLFS: tt.skipLFS, SourceDirs: tt.sourceDirs, } tt.hook.Name = tt.hookName results, err := controller.RunHook(t.Context(), opts, tt.hook) assert.NoError(err) var success, fail []result.Result for _, result := range results { if result.Success() { success = append(success, succeeded(result.Name)) } else if result.Failure() { fail = append(fail, failed(result.Name, result.Text())) } } assert.ElementsMatch(success, tt.success) assert.ElementsMatch(fail, tt.fail) if len(tt.gitCommands) > 0 { assert.Len(cmdExecutor.Commands, len(tt.gitCommands)) for i, commandRe := range tt.gitCommands { re := regexp.MustCompile(commandRe) command := cmdExecutor.Commands[i] if !re.MatchString(command) { t.Errorf("wrong git command regexp #%d\nExpected: %s\nWas: %s", i, commandRe, command) } } } }) } } ================================================ FILE: internal/run/controller/exec/exec_unix.go ================================================ //go:build !windows package exec import ( "context" "fmt" "io" "os" "os/exec" "path/filepath" "github.com/creack/pty" "github.com/mattn/go-isatty" "github.com/evilmartians/lefthook/v2/internal/log" ) type CommandExecutor struct{} type executeArgs struct { in io.Reader out io.Writer envs []string root string interactive, useStdin bool } func (e CommandExecutor) Execute(ctx context.Context, opts Options, in io.Reader, out io.Writer) error { if opts.Interactive && !isatty.IsTerminal(os.Stdin.Fd()) { tty, err := os.Open("/dev/tty") if err == nil { defer func() { if cErr := tty.Close(); cErr != nil { log.Warnf("Could not close TTY input: %s\n", cErr) } }() in = tty } else { log.Errorf("Couldn't enable TTY input: %s\n", err) } } root, _ := filepath.Abs(opts.Root) envs := make([]string, 0, len(opts.Env)) for name, value := range opts.Env { envs = append( envs, fmt.Sprintf("%s=%s", name, os.ExpandEnv(value)), ) } switch log.Colors() { case log.ColorOn: envs = append(envs, "CLICOLOR_FORCE=true") case log.ColorOff: envs = append(envs, "NO_COLOR=true") } args := &executeArgs{ in: in, out: out, envs: envs, root: root, interactive: opts.Interactive, useStdin: opts.UseStdin, } // We can have one command split into separate to fit into shell command max length. // In this case we execute those commands one by one. for _, command := range opts.Commands { if err := e.execute(ctx, command, args); err != nil { return err } } return nil } func (e CommandExecutor) execute(ctx context.Context, cmdstr string, args *executeArgs) error { log.Debug("[lefthook] run: ", cmdstr) command := exec.CommandContext(ctx, "sh", "-c", cmdstr) command.Dir = args.root command.Env = append(os.Environ(), args.envs...) if args.interactive || args.useStdin { command.Stdout = args.out command.Stdin = args.in command.Stderr = os.Stderr err := command.Start() if err != nil { return err } } else { p, err := pty.Start(command) if err != nil { return err } defer func() { _ = p.Close() }() _, _ = io.Copy(args.out, p) } defer func() { _ = command.Process.Kill() }() return command.Wait() } ================================================ FILE: internal/run/controller/exec/exec_windows.go ================================================ package exec import ( "context" "fmt" "io" "os" "os/exec" "path/filepath" "syscall" "github.com/evilmartians/lefthook/v2/internal/log" "github.com/evilmartians/lefthook/v2/internal/system" "github.com/mattn/go-isatty" "github.com/mattn/go-tty" ) type CommandExecutor struct{} type executeArgs struct { in io.Reader out io.Writer envs []string root string } func (e CommandExecutor) Execute(ctx context.Context, opts Options, in io.Reader, out io.Writer) error { if opts.Interactive && !isatty.IsTerminal(os.Stdin.Fd()) { tty, err := tty.Open() if err == nil { defer tty.Close() in = tty.Input() } else { log.Errorf("Couldn't enable TTY input: %s\n", err) } } root, _ := filepath.Abs(opts.Root) envs := make([]string, len(opts.Env)) for name, value := range opts.Env { envs = append( envs, fmt.Sprintf("%s=%s", name, os.ExpandEnv(value)), ) } switch log.Colors() { case log.ColorOn: envs = append(envs, "CLICOLOR_FORCE=true") case log.ColorOff: envs = append(envs, "NO_COLOR=true") } args := &executeArgs{ in: in, out: out, envs: envs, root: root, } for _, command := range opts.Commands { if err := e.execute(ctx, command, args); err != nil { return err } } return nil } func (e CommandExecutor) execute(ctx context.Context, cmdstr string, args *executeArgs) error { sh, err := system.Sh() if err != nil { log.Errorf("Couldn't find sh.exe: %s\n", err) return err } // This change is breaking but might be useful. Consider quoting if it fixes all possible // options for {staged_files}, '{staged_files}', and "{staged_files}". // cmdStrQuoted := strings.ReplaceAll(strings.ReplaceAll(cmdstr, "\\", "\\\\"), "\"", "\\\"") cmdLine := "\"" + sh + "\"" + " -c " + "\"" + cmdstr + "\"" log.Debug("[lefthook] run: ", cmdLine) command := exec.CommandContext(ctx, sh) command.SysProcAttr = &syscall.SysProcAttr{ CmdLine: cmdLine, } command.Dir = args.root command.Env = append(os.Environ(), args.envs...) command.Stdout = args.out command.Stdin = args.in command.Stderr = os.Stderr err = command.Start() if err != nil { return err } defer func() { _ = command.Process.Kill() }() return command.Wait() } ================================================ FILE: internal/run/controller/exec/executor.go ================================================ package exec import ( "context" "io" ) // Options contains the data that controls the execution. type Options struct { Root string Commands []string Env map[string]string Interactive, UseStdin bool } // Executor provides an interface for command execution. // It is used here for testing purpose mostly. type Executor interface { Execute(context.Context, Options, io.Reader, io.Writer) error } ================================================ FILE: internal/run/controller/filter/detect_text.go ================================================ package filter import ( "bytes" ) // See: https://github.com/gabriel-vasile/mimetype/blob/6e3aeb1/internal/charset/charset.go var boms = [][]byte{ {0xEF, 0xBB, 0xBF}, // utf-8 {0x00, 0x00, 0xFE, 0xFF}, // utf-32be {0xFF, 0xFE, 0x00, 0x00}, // utf-32le {0xFE, 0xFF}, // utf-16be {0xFF, 0xFE}, // utf-16le } // hasBOM returns true if the charset declared in the BOM of content. func hasBOM(content []byte) bool { for _, bom := range boms { if bytes.HasPrefix(content, bom) { return true } } return false } // detectText checks if a sequence contains of a plain text bytes. // // This function does not parse BOM-less UTF16 and UTF32 files. Not really // sure it should. Linux file utility also requires a BOM for UTF16 and UTF32. func detectText(bytes []byte) bool { if hasBOM(bytes) { return true } // Binary data bytes as defined here: https://mimesniff.spec.whatwg.org/#binary-data-byte for _, b := range bytes { if b <= 0x08 || b == 0x0B || 0x0E <= b && b <= 0x1A || 0x1C <= b && b <= 0x1F { return false } } return true } ================================================ FILE: internal/run/controller/filter/detect_text_test.go ================================================ package filter import ( "fmt" "testing" ) func TestDetectText(t *testing.T) { for i, tt := range [...]struct { bytes []byte result bool }{ { bytes: []byte{}, result: true, }, { bytes: []byte{0xEF, 0xBB, 0xBF}, // utf-8 BOM result: true, }, { bytes: []byte{0x00, 0x00, 0xFE, 0xFF}, // utf-32be BOM result: true, }, { bytes: []byte{0xFF, 0xFE, 0x00, 0x00}, // utf-32le BOM result: true, }, { bytes: []byte{0xFE, 0xFF}, // utf-16be BOM result: true, }, { bytes: []byte{0xFF, 0xFE}, // utf-16le BOM result: true, }, { bytes: []byte{0xFA, 0xCF, 0xFE, 0xED, 0x00, 0x0C}, result: false, }, { bytes: []byte{0x70, 0x5B, 0x65, 0x72, 0x63, 0x2D}, // .lefthook.toml result: true, }, { bytes: []byte{0x5B, 0x21, 0x75, 0x42, 0x6C, 0x69, 0x20, 0x64, 0x74, 0x53, 0x74, 0x61, 0x73, 0x75, 0x28, 0x5D}, // README.md result: true, }, } { t.Run(fmt.Sprintf("#%d:", i), func(t *testing.T) { if detectText(tt.bytes) != tt.result { t.Error("results don't match; expected", tt.result) } }) } } ================================================ FILE: internal/run/controller/filter/filter.go ================================================ package filter import ( "errors" "io" "os" "strings" "github.com/bmatcuk/doublestar/v4" "github.com/gabriel-vasile/mimetype" "github.com/gobwas/glob" "github.com/spf13/afero" "github.com/evilmartians/lefthook/v2/internal/log" ) type fileTypeFilter struct { simpleTypes int mimeTypes []string } const ( typeExecutable int = 1 << iota typeNotExecutable typeSymlink typeNotSymlink typeText typeBinary detectTypes = typeText | typeBinary detectBufSize = 1024 executableMask = 0o111 ) type Params struct { Root string Glob []string FileTypes []string ExcludeFiles []string GlobMatcher string } type Filter struct { Params fs afero.Fs } func New(fs afero.Fs, params Params) *Filter { return &Filter{fs: fs, Params: params} } func (f *Filter) Apply(files []string) []string { if len(files) == 0 { return nil } b := log.Builder(log.DebugLevel, "[lefthook] "). Add("filtered [ ]: ", files) files = byGlob(files, f.Glob, f.GlobMatcher) files = byExclude(files, f.ExcludeFiles, f.GlobMatcher) files = byRoot(files, f.Root) files = byType(f.fs, files, f.FileTypes) b.Add("filtered [x]: ", files). Log() return files } func byGlob(vs []string, matchers []string, globMatcher string) []string { if len(matchers) == 0 { return vs } var hasNonEmpty bool vsf := make([]string, 0) for _, matcher := range matchers { if len(matcher) == 0 { continue } hasNonEmpty = true vsf = append(vsf, matchFiles(vs, matcher, globMatcher)...) } if !hasNonEmpty { return vs } return vsf } func matchFiles(vs []string, matcher string, globMatcher string) []string { var matched []string lowerMatcher := strings.ToLower(matcher) if globMatcher == "doublestar" { matched = matchFilesDoublestar(vs, lowerMatcher) } else { matched = matchFilesGobwas(vs, lowerMatcher) } return matched } func matchFilesDoublestar(vs []string, lowerMatcher string) []string { var matched []string for _, v := range vs { isMatched, err := doublestar.Match(lowerMatcher, strings.ToLower(v)) if err == nil && isMatched { matched = append(matched, v) } } return matched } func matchFilesGobwas(vs []string, lowerMatcher string) []string { var matched []string g := glob.MustCompile(lowerMatcher) for _, v := range vs { if g.Match(strings.ToLower(v)) { matched = append(matched, v) } } return matched } func byExclude(vs []string, exclude []string, globMatcher string) []string { if len(exclude) == 0 { return vs } if globMatcher == "doublestar" { return byExcludeDoublestar(vs, exclude) } return byExcludeGobwas(vs, exclude) } func byExcludeDoublestar(vs []string, exclude []string) []string { vsf := make([]string, 0) for _, v := range vs { if !matchesAnyDoublestar(v, exclude) { vsf = append(vsf, v) } } return vsf } func byExcludeGobwas(vs []string, exclude []string) []string { globs := make([]glob.Glob, 0, len(exclude)) for _, name := range exclude { globs = append(globs, glob.MustCompile(name)) } vsf := make([]string, 0) for _, v := range vs { if !matchesAnyGobwas(v, globs) { vsf = append(vsf, v) } } return vsf } func matchesAnyDoublestar(path string, patterns []string) bool { for _, pattern := range patterns { matched, err := doublestar.Match(pattern, path) if err == nil && matched { return true } } return false } func matchesAnyGobwas(path string, globs []glob.Glob) bool { for _, g := range globs { if g.Match(path) { return true } } return false } func byRoot(vs []string, matcher string) []string { if matcher == "" { return vs } vsf := make([]string, 0) for _, v := range vs { if strings.HasPrefix(v, matcher) { vsf = append(vsf, strings.Replace(v, matcher, "./", 1)) } } return vsf } func byType(fs afero.Fs, vs []string, types []string) []string { if len(types) == 0 { return vs } filter := parseFileTypeFilter(types) vsf := make([]string, 0) for _, v := range vs { var err error var fileInfo os.FileInfo lfs, ok := fs.(afero.Lstater) if ok { fileInfo, _, err = lfs.LstatIfPossible(v) } else { fileInfo, err = fs.Stat(v) } if err != nil { log.Errorf("Couldn't check file type of %s: %s", v, err) continue } isSymlink := fileInfo.Mode()&os.ModeSymlink != 0 isExecutable := fileInfo.Mode().Perm()&executableMask != 0 if filter.simpleTypes&typeSymlink != 0 && !isSymlink { continue } if filter.simpleTypes&typeNotSymlink != 0 && isSymlink { continue } if filter.simpleTypes&typeExecutable != 0 && (!isExecutable || isSymlink) { continue } if filter.simpleTypes&typeNotExecutable != 0 && (isExecutable && !isSymlink) { continue } if filter.simpleTypes&detectTypes != 0 { if !fileInfo.Mode().IsRegular() { continue } text := checkIsText(fs, v) binary := !text if filter.simpleTypes&typeText != 0 && binary { continue } if filter.simpleTypes&typeBinary != 0 && text { continue } } if len(filter.mimeTypes) != 0 { if !fileInfo.Mode().IsRegular() { continue } var found bool fileMimeType, err := mimetype.DetectFile(v) if err != nil { log.Errorf("Couldn't check mime type of file %s: %s", v, err) continue } for _, mime := range filter.mimeTypes { if fileMimeType.Is(mime) { found = true } } if !found { continue } } vsf = append(vsf, v) } return vsf } func parseFileTypeFilter(types []string) fileTypeFilter { var filter fileTypeFilter for _, t := range types { switch { case t == "executable": filter.simpleTypes |= typeExecutable case t == "symlink": filter.simpleTypes |= typeSymlink case t == "not executable": filter.simpleTypes |= typeNotExecutable case t == "not symlink": filter.simpleTypes |= typeNotSymlink case t == "binary": filter.simpleTypes |= typeBinary case t == "text": filter.simpleTypes |= typeText case strings.Contains(t, "/") && mimetype.Lookup(t) != nil: filter.mimeTypes = append(filter.mimeTypes, t) default: log.Warn("Unknown filter type: ", t) } } return filter } func checkIsText(fs afero.Fs, filepath string) bool { file, err := fs.Open(filepath) if err != nil { log.Error("Couldn't open file for content detecting: ", err) return false } buf := make([]byte, detectBufSize) n, err := io.ReadFull(file, buf) if err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) { log.Error("Couldn't read file for content detecting: ", err) return false } return detectText(buf[:n]) } ================================================ FILE: internal/run/controller/filter/filter_test.go ================================================ package filter import ( "fmt" "testing" ) func slicesEqual(a, b []string) bool { if len(a) != len(b) { return false } r := make(map[string]struct{}) for _, item := range a { r[item] = struct{}{} } for _, item := range b { if _, ok := r[item]; !ok { return false } } return true } func TestByGlob(t *testing.T) { for i, tt := range [...]struct { source, result []string glob []string globMatcher string }{ { source: []string{"folder/subfolder/0.rb", "1.txt", "2.RB", "3.rbs"}, glob: []string{}, globMatcher: "", result: []string{"folder/subfolder/0.rb", "1.txt", "2.RB", "3.rbs"}, }, { source: []string{"folder/subfolder/0.rb", "1.txt", "2.RB", "3.rbs"}, glob: []string{"*.rb"}, globMatcher: "", result: []string{"folder/subfolder/0.rb", "2.RB"}, }, { source: []string{"folder/subfolder/0.rb", "1.rbs"}, glob: []string{"**/*.rb"}, globMatcher: "", result: []string{"folder/subfolder/0.rb"}, }, { source: []string{"folder/0.rb", "1.rBs", "2.rbv"}, glob: []string{"*.rb?"}, globMatcher: "", result: []string{"1.rBs", "2.rbv"}, }, { source: []string{"f.a", "f.b", "f.c", "f.cn"}, glob: []string{"*.{a,b,cn}"}, globMatcher: "", result: []string{"f.a", "f.b", "f.cn"}, }, } { t.Run(fmt.Sprintf("%d:", i), func(t *testing.T) { res := byGlob(tt.source, tt.glob, tt.globMatcher) if !slicesEqual(res, tt.result) { t.Errorf("expected %v to be equal to %v", res, tt.result) } }) } } func TestByGlobDoublestar(t *testing.T) { for i, tt := range [...]struct { source, result []string glob []string globMatcher string }{ { source: []string{"0.rb", "folder/1.rb", "folder/subfolder/2.rb"}, glob: []string{"**/*.rb"}, globMatcher: "doublestar", result: []string{"0.rb", "folder/1.rb", "folder/subfolder/2.rb"}, }, { source: []string{"0.rb", "folder/1.rb", "folder/subfolder/2.rb"}, glob: []string{"**/*.rb"}, globMatcher: "", result: []string{"folder/1.rb", "folder/subfolder/2.rb"}, }, { source: []string{"a/b.go", "a/c/d.go", "e.go"}, glob: []string{"**/*.go"}, globMatcher: "doublestar", result: []string{"a/b.go", "a/c/d.go", "e.go"}, }, { source: []string{"a/b.go", "a/c/d.go", "e.go"}, glob: []string{"**/*.go"}, globMatcher: "", result: []string{"a/b.go", "a/c/d.go"}, }, { source: []string{"test.js", "src/app.js", "src/lib/util.js"}, glob: []string{"**/*.js"}, globMatcher: "doublestar", result: []string{"test.js", "src/app.js", "src/lib/util.js"}, }, } { t.Run(fmt.Sprintf("doublestar-%d:", i), func(t *testing.T) { res := byGlob(tt.source, tt.glob, tt.globMatcher) if !slicesEqual(res, tt.result) { t.Errorf("expected %v to be equal to %v", res, tt.result) } }) } } func TestByExclude(t *testing.T) { for i, tt := range [...]struct { source, result []string exclude []string globMatcher string }{ { source: []string{"folder/subfolder/0.rb", "1.txt", "2.RB", "3.rb"}, exclude: []string{}, globMatcher: "", result: []string{"folder/subfolder/0.rb", "1.txt", "2.RB", "3.rb"}, }, { source: []string{"f.a", "f.b", "f.c", "f.cn"}, exclude: []string{"*.a", "*.b", "*.cn"}, globMatcher: "", result: []string{"f.c"}, }, } { t.Run(fmt.Sprintf("%d:", i), func(t *testing.T) { res := byExclude(tt.source, tt.exclude, tt.globMatcher) if !slicesEqual(res, tt.result) { t.Errorf("expected %v to be equal to %v", res, tt.result) } }) } } func TestByExcludeDoublestar(t *testing.T) { for i, tt := range [...]struct { source, result []string exclude []string globMatcher string }{ { source: []string{"0.rb", "folder/1.rb", "folder/subfolder/2.rb", "test.js"}, exclude: []string{"**/*.rb"}, globMatcher: "doublestar", result: []string{"test.js"}, }, { source: []string{"0.rb", "folder/1.rb", "folder/subfolder/2.rb", "test.js"}, exclude: []string{"**/*.rb"}, globMatcher: "", result: []string{"0.rb", "test.js"}, }, { source: []string{"src/app.js", "src/lib/util.js", "test.py", "src/test.py"}, exclude: []string{"**/*.py"}, globMatcher: "doublestar", result: []string{"src/app.js", "src/lib/util.js"}, }, { source: []string{"a.go", "src/b.go", "src/lib/c.go"}, exclude: []string{"**/*.go"}, globMatcher: "doublestar", result: []string{}, }, } { t.Run(fmt.Sprintf("doublestar-%d:", i), func(t *testing.T) { res := byExclude(tt.source, tt.exclude, tt.globMatcher) if !slicesEqual(res, tt.result) { t.Errorf("expected %v to be equal to %v", res, tt.result) } }) } } func TestByRoot(t *testing.T) { for i, tt := range [...]struct { source, result []string path string }{ { source: []string{"folder/subfolder/0.rb", "1.txt", "2.RB", "3.rb"}, path: "", result: []string{"folder/subfolder/0.rb", "1.txt", "2.RB", "3.rb"}, }, { source: []string{"folder/subfolder/0.rb", "subfolder/1.txt", "folder/2.RB", "3.rbs"}, path: "folder", result: []string{".//subfolder/0.rb", ".//2.RB"}, }, { source: []string{"folder/subfolder/0.rb", "folder/1.rbs"}, path: "folder/subfolder", result: []string{".//0.rb"}, }, { source: []string{"folder/subfolder/0.rb", "folder/1.rbs"}, path: "folder/subfolder/", result: []string{"./0.rb"}, }, } { t.Run(fmt.Sprintf("%d:", i), func(t *testing.T) { res := byRoot(tt.source, tt.path) if !slicesEqual(res, tt.result) { t.Errorf("expected %v to be equal to %v", res, tt.result) } }) } } ================================================ FILE: internal/run/controller/guard.go ================================================ package controller import ( "errors" "fmt" "maps" "github.com/evilmartians/lefthook/v2/internal/git" "github.com/evilmartians/lefthook/v2/internal/log" ) type FailOnChangesError struct { changedFiles []string } func (e *FailOnChangesError) Error() string { return "files were modified by a hook, and fail_on_changes is enabled" } type guard struct { git *git.Repository stashUnstagedChanges bool failOnChanges bool failOnChangesDiff bool } func newGuard(repo *git.Repository, stashUnstagedChanges bool, failOnChanges bool, failOnChangesDiff bool) *guard { return &guard{ git: repo, stashUnstagedChanges: stashUnstagedChanges, failOnChanges: failOnChanges, failOnChangesDiff: failOnChangesDiff, } } func (g *guard) wrap(fn func()) error { if !g.failOnChanges && !g.stashUnstagedChanges { fn() return nil } return g.withHiddenUnstagedChanges( func() error { return g.withFailOnChanges(fn) }, ) } func (g *guard) withHiddenUnstagedChanges(fn func() error) error { if !g.stashUnstagedChanges { return fn() } partiallyStagedFiles, err := g.git.PartiallyStagedFiles() if err != nil { log.Warnf("Couldn't find partially staged files: %s\n", err) return err } if len(partiallyStagedFiles) == 0 { return fn() } log.Debug("[lefthook] saving partially staged files") if err := g.git.SaveUnstaged(partiallyStagedFiles); err != nil { log.Warnf("Couldn't save unstaged changes: %s\n", err) return err } if err := g.git.StashUnstaged(); err != nil { log.Warnf("Couldn't stash partially staged files: %s\n", err) return err } log.Builder(log.DebugLevel, "[lefthook] "). Add("hide partially staged files: ", partiallyStagedFiles). Log() if err := g.git.RevertUnstagedChanges(partiallyStagedFiles); err != nil { log.Warnf("Couldn't hide unstaged files: %s\n", err) return err } wrappedErr := fn() var failOnChangesErr *FailOnChangesError if errors.As(wrappedErr, &failOnChangesErr) { if err := g.git.RevertUnstagedChanges(failOnChangesErr.changedFiles); err != nil { log.Warnf("Couldn't hide unstaged files: %s\n", err) return wrappedErr } } if err := g.git.RestoreUnstaged(); err != nil { log.Warnf("Couldn't restore unstaged files: %s\n", err) return wrappedErr } if err := g.git.DropUnstagedStash(); err != nil { log.Warnf("Couldn't remove unstaged files backup: %s\n", err) return wrappedErr } return wrappedErr } func (g *guard) withFailOnChanges(fn func()) error { if !g.failOnChanges { fn() return nil } changesetBefore, err := g.git.Changeset() if err != nil { return fmt.Errorf("couldn't calculate changeset: %w", err) } fn() changesetAfter, err := g.git.Changeset() if err != nil { return fmt.Errorf("couldn't calculate changeset: %w", err) } if !maps.Equal(changesetBefore, changesetAfter) { changedFiles := g.printDiff(changesetBefore, changesetAfter) return &FailOnChangesError{changedFiles: changedFiles} } return nil } func (g *guard) printDiff(changesetBefore, changesetAfter map[string]string) []string { changedFiles := g.getChangedFiles(changesetBefore, changesetAfter) if g.failOnChangesDiff && len(changedFiles) > 0 { g.git.PrintDiff(changedFiles) } return changedFiles } func (g *guard) getChangedFiles(changesetBefore, changesetAfter map[string]string) []string { changed := make([]string, 0, len(changesetBefore)) for f, hashBefore := range changesetBefore { if hashAfter, ok := changesetAfter[f]; !ok || hashBefore != hashAfter { changed = append(changed, f) } } for f := range changesetAfter { if _, ok := changesetBefore[f]; !ok { changed = append(changed, f) } } return changed } ================================================ FILE: internal/run/controller/guard_test.go ================================================ package controller import ( "path/filepath" "testing" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/evilmartians/lefthook/v2/tests/helpers/cmdtest" "github.com/evilmartians/lefthook/v2/tests/helpers/gittest" ) func Test_guard_wrap(t *testing.T) { for name, tt := range map[string]struct { stashUnstagedChanges bool failOnChanges bool failOnChangesDiff bool commands []cmdtest.Out err error }{ "just call": { stashUnstagedChanges: false, failOnChanges: false, commands: []cmdtest.Out{}, }, "failOnChanges=true no files": { stashUnstagedChanges: false, failOnChanges: true, commands: []cmdtest.Out{ {Command: "git status --short --porcelain -z", Output: ""}, {Command: "git status --short --porcelain -z", Output: ""}, }, }, "failOnChanges=true no fail": { stashUnstagedChanges: false, failOnChanges: true, commands: []cmdtest.Out{ {Command: "git status --short --porcelain -z", Output: " M file1\x00 M file2\x00"}, {Command: "git hash-object -- file1 file2", Output: "0\n1\n"}, {Command: "git status --short --porcelain -z", Output: " M file1\x00 M file2\x00"}, {Command: "git hash-object -- file1 file2", Output: "0\n1\n"}, }, }, "failOnChanges=true fail with changeset different with diff": { stashUnstagedChanges: false, failOnChanges: true, failOnChangesDiff: true, commands: []cmdtest.Out{ {Command: "git status --short --porcelain -z", Output: " M file1\x00 M file2\x00"}, {Command: "git hash-object -- file1 file2", Output: "0\n1\n"}, {Command: "git status --short --porcelain -z", Output: " M file1\x00 M file2\x00"}, {Command: "git hash-object -- file1 file2", Output: "2\n3\n"}, {Command: "git diff --color -- file1 file2", Output: "diff --git a/file1 b/file1\n..."}, }, err: &FailOnChangesError{[]string{"file1", "file2"}}, }, "failOnChanges=true fail with extra files without diff": { stashUnstagedChanges: false, failOnChanges: true, commands: []cmdtest.Out{ {Command: "git status --short --porcelain -z", Output: ""}, {Command: "git status --short --porcelain -z", Output: " M file1\x00 M file2\x00"}, {Command: "git hash-object -- file1 file2", Output: "0\n1\n"}, }, err: &FailOnChangesError{[]string{"file1", "file2"}}, }, "stashUnstagedChanges=true no files": { stashUnstagedChanges: true, failOnChanges: false, commands: []cmdtest.Out{ {Command: "git status --short --porcelain -z", Output: ""}, }, }, "stashUnstagedChanges=true no unstaged": { stashUnstagedChanges: true, failOnChanges: false, commands: []cmdtest.Out{ {Command: "git status --short --porcelain -z", Output: "M file1\x00M file2\x00M file3\x00"}, }, }, "stashUnstagedChanges=true with partially staged": { stashUnstagedChanges: true, failOnChanges: false, commands: []cmdtest.Out{ {Command: "git status --short --porcelain -z", Output: "AM file1\x00 M file2\x00 A file3\x00"}, {Command: "git diff --binary --unified=0 --no-color --no-ext-diff --src-prefix=a/ --dst-prefix=b/ --patch --submodule=short --output " + filepath.Join("root", ".git", "info", "lefthook-unstaged.patch") + " -- file1", Output: ""}, {Command: "git stash create", Output: ""}, {Command: "git stash store --quiet --message lefthook auto backup ", Output: ""}, {Command: "git checkout --force -- file1", Output: ""}, {Command: "git stash list", Output: "0: my stash\n1: lefthook auto backup\n2: my second stash\n"}, {Command: "git stash drop --quiet -- 1", Output: ""}, }, }, "stashUnstagedChanges=true failOnChanges=true with partially staged no hook changes": { stashUnstagedChanges: true, failOnChanges: true, commands: []cmdtest.Out{ {Command: "git status --short --porcelain -z", Output: "AM file1\x00 M file2\x00"}, {Command: "git diff --binary --unified=0 --no-color --no-ext-diff --src-prefix=a/ --dst-prefix=b/ --patch --submodule=short --output " + filepath.Join("root", ".git", "info", "lefthook-unstaged.patch") + " -- file1", Output: ""}, {Command: "git stash create", Output: ""}, {Command: "git stash store --quiet --message lefthook auto backup ", Output: ""}, {Command: "git checkout --force -- file1", Output: ""}, {Command: "git status --short --porcelain -z", Output: "A file1\x00"}, // job run {Command: "git status --short --porcelain -z", Output: "A file1\x00"}, {Command: "git stash list", Output: "0: my stash\n1: lefthook auto backup\n2: my second stash\n"}, {Command: "git stash drop --quiet -- 1", Output: ""}, }, }, "stashUnstagedChanges=true failOnChanges=true with partially staged and hook changes with diff": { stashUnstagedChanges: true, failOnChanges: true, failOnChangesDiff: true, commands: []cmdtest.Out{ {Command: "git status --short --porcelain -z", Output: "AM file1\x00"}, {Command: "git diff --binary --unified=0 --no-color --no-ext-diff --src-prefix=a/ --dst-prefix=b/ --patch --submodule=short --output " + filepath.Join("root", ".git", "info", "lefthook-unstaged.patch") + " -- file1", Output: ""}, {Command: "git stash create", Output: ""}, {Command: "git stash store --quiet --message lefthook auto backup ", Output: ""}, {Command: "git checkout --force -- file1", Output: ""}, {Command: "git status --short --porcelain -z", Output: "A file1\x00"}, {Command: "git hash-object -- file1", Output: "hash1\n"}, // job run {Command: "git status --short --porcelain -z", Output: "AM file1\x00"}, {Command: "git hash-object -- file1", Output: "hash2\n"}, {Command: "git diff --color -- file1", Output: "diff --git a/file1 b/file1\n..."}, {Command: "git checkout --force -- file1", Output: ""}, {Command: "git stash list", Output: "0: my stash\n1: lefthook auto backup\n2: my second stash\n"}, {Command: "git stash drop --quiet -- 1", Output: ""}, }, err: &FailOnChangesError{[]string{"file2"}}, }, "failOnChanges=true with deleted file no change": { stashUnstagedChanges: false, failOnChanges: true, commands: []cmdtest.Out{ // Deleted file in before and after - same state, no change {Command: "git status --short --porcelain -z", Output: "D file1\x00"}, {Command: "git status --short --porcelain -z", Output: "D file1\x00"}, }, }, "failOnChanges=true with directory": { stashUnstagedChanges: false, failOnChanges: true, commands: []cmdtest.Out{ // Directory in before() - marked as "directory" {Command: "git status --short --porcelain -z", Output: "?? dir/\x00"}, // Directory still there in after() - same state, no change {Command: "git status --short --porcelain -z", Output: "?? dir/\x00"}, }, }, "failOnChanges=true with changeset error in before": { stashUnstagedChanges: false, failOnChanges: true, commands: []cmdtest.Out{ // Changeset() error in before() - empty output simulates error {Command: "git status --short --porcelain -z", Output: ""}, {Command: "git status --short --porcelain -z", Output: ""}, }, }, "failOnChanges=true failOnChangesDiff=true with no changed files": { stashUnstagedChanges: false, failOnChanges: true, failOnChangesDiff: true, commands: []cmdtest.Out{ {Command: "git status --short --porcelain -z", Output: ""}, {Command: "git status --short --porcelain -z", Output: ""}, }, }, "failOnChanges=true with deleted file in changeset": { stashUnstagedChanges: false, failOnChanges: true, commands: []cmdtest.Out{ {Command: "git status --short --porcelain -z", Output: " M file1\x00"}, {Command: "git hash-object -- file1", Output: "hash1\n"}, {Command: "git status --short --porcelain -z", Output: "D file1\x00"}, // file1 was deleted, so it's in changesetAfter but marked as "deleted" }, err: &FailOnChangesError{[]string{"file1"}}, }, } { t.Run(name, func(t *testing.T) { assert := assert.New(t) repo := gittest.NewRepositoryBuilder(). Cmd(cmdtest.NewOrdered(t, tt.commands)). Fs(afero.NewMemMapFs()). Root("root"). Build() repo.Setup() g := newGuard(repo, tt.stashUnstagedChanges, tt.failOnChanges, tt.failOnChangesDiff) var beenCalled bool err := g.wrap(func() { beenCalled = true }) if tt.err != nil { assert.ErrorAs(tt.err, &err) } else { assert.NoError(err) } assert.Equal(true, beenCalled) }) } } ================================================ FILE: internal/run/controller/job.go ================================================ package controller import ( "context" "errors" "maps" "path/filepath" "slices" "strings" "time" "github.com/evilmartians/lefthook/v2/internal/config" "github.com/evilmartians/lefthook/v2/internal/log" "github.com/evilmartians/lefthook/v2/internal/run/controller/command" "github.com/evilmartians/lefthook/v2/internal/run/controller/exec" "github.com/evilmartians/lefthook/v2/internal/run/controller/filter" "github.com/evilmartians/lefthook/v2/internal/run/controller/utils" "github.com/evilmartians/lefthook/v2/internal/run/result" "github.com/evilmartians/lefthook/v2/internal/system" ) const ( invalidJobError = "either `run`,`script`, or `group` must be provided for a job" emptyGroupError = "group must have `jobs`" ) func (c *Controller) runJob(ctx context.Context, scope *scope, id string, job *config.Job) result.Result { // Check if do job is properly configured if len(job.Run) > 0 && len(job.Script) > 0 { return result.Failure(job.PrintableName(id), invalidJobError, 0) } if len(job.Run) == 0 && len(job.Script) == 0 && job.Group == nil { return result.Failure(job.PrintableName(id), invalidJobError, 0) } startTime := time.Now() if job.Interactive && !scope.opts.DisableTTY && !scope.follow { log.StopSpinner() defer log.StartSpinner() } if len(job.Run) != 0 || len(job.Script) != 0 { if len(scope.opts.RunOnlyJobs) != 0 && !slices.Contains(scope.opts.RunOnlyJobs, job.Name) { return result.Skip(job.PrintableName(id)) } if len(scope.opts.RunOnlyTags) != 0 && (!utils.Intersect(scope.opts.RunOnlyTags, job.Tags) && !utils.Intersect(scope.opts.RunOnlyTags, scope.tags)) { return result.Skip(job.PrintableName(id)) } return c.runSingleJob(ctx, scope, id, job) } if job.Group != nil { extendedScope := scope.extend(job) groupName := utils.FirstNonBlank(job.Name, "group ("+id+")") if reason := c.skipReason(extendedScope, job, groupName); len(reason) > 0 { log.Skip(groupName, reason) return result.Skip(groupName) } extendedScope.names = append(extendedScope.names, groupName) if len(job.Group.Jobs) == 0 { return result.Failure(groupName, emptyGroupError, 0) } var results []result.Result if job.Group.Parallel { results = c.concurrently(ctx, extendedScope, job.Group.Jobs) } else { results = c.sequentially(ctx, extendedScope, job.Group.Jobs, job.Group.Piped) } return result.Group(groupName, results) } return result.Failure(job.PrintableName(id), invalidJobError, time.Since(startTime)) } func (c *Controller) runSingleJob(ctx context.Context, scope *scope, id string, job *config.Job) result.Result { startTime := time.Now() name := job.PrintableName(id) scope = scope.extend(job) if reason := c.skipReason(scope, job, name); len(reason) > 0 { log.Skip(name, reason) return result.Skip(name) } builder := command.NewBuilder(c.git, command.BuilderOptions{ HookName: scope.hookName, ForceFiles: scope.opts.Files, Force: scope.opts.Force, SourceDirs: scope.opts.SourceDirs, GitArgs: scope.opts.GitArgs, Templates: scope.opts.Templates, GlobMatcher: scope.opts.GlobMatcher, }) commands, files, err := builder.BuildCommands(&command.JobParams{ Name: name, Run: job.Run, Runner: job.Runner, Args: job.Args, Script: job.Script, Only: job.Only, Skip: job.Skip, Root: scope.root, FileTypes: scope.fileTypes, Glob: scope.glob, FilesCmd: scope.filesCmd, Tags: scope.tags, ExcludeFiles: scope.excludeFiles, }) if err != nil { log.Skip(name, err.Error()) var skipErr command.SkipError if errors.As(err, &skipErr) { return result.Skip(name) } return result.Failure(name, err.Error(), time.Since(startTime)) } env := maps.Clone(scope.env) maps.Copy(env, job.Env) if job.Timeout > 0 { var cancel context.CancelFunc ctx, cancel = context.WithTimeout(ctx, job.Timeout) defer cancel() } err = c.run(ctx, strings.Join(append(scope.names, name), " ❯ "), scope.follow, exec.Options{ Root: filepath.Join(c.git.RootPath, scope.root), Commands: commands, Interactive: job.Interactive && !scope.opts.DisableTTY, UseStdin: job.UseStdin, Env: env, }) executionTime := time.Since(startTime) if err != nil { if ctx.Err() == context.DeadlineExceeded { return result.Failure(name, "timeout ("+job.Timeout.String()+")", executionTime) } return result.Failure(name, job.FailText, executionTime) } if config.HookUsesStagedFiles(scope.hookName) && job.StageFixed && !scope.opts.NoStageFixed { if len(files) == 0 { var err error files, err = c.git.StagedFiles() if err != nil { log.Warn("Couldn't stage fixed files:", err) return result.Success(name, executionTime) } files = filter.New(c.git.Fs, filter.Params{ Glob: scope.glob, Root: scope.root, ExcludeFiles: scope.excludeFiles, FileTypes: scope.fileTypes, GlobMatcher: scope.opts.GlobMatcher, }).Apply(files) } if len(scope.root) > 0 { for i, file := range files { files[i] = filepath.Join(scope.root, file) } } c.addStagedFiles(files) } return result.Success(name, executionTime) } func (c *Controller) addStagedFiles(files []string) { if err := c.git.AddFiles(files); err != nil { log.Warn("Couldn't stage fixed files:", err) } } func (c *Controller) skipReason(scope *scope, job *config.Job, name string) string { skipChecker := config.NewSkipChecker(system.Cmd) if skipChecker.Check(c.git.State, job.Skip, job.Only) { return "by condition" } if utils.Intersect(scope.excludeTags, scope.tags) { return "tags" } if utils.Intersect(scope.excludeTags, []string{name}) { return "name" } return "" } ================================================ FILE: internal/run/controller/lfs.go ================================================ package controller import ( "bytes" "context" "errors" "path/filepath" "strings" "github.com/spf13/afero" "github.com/evilmartians/lefthook/v2/internal/git" "github.com/evilmartians/lefthook/v2/internal/log" ) func (c *Controller) runLFSHook(ctx context.Context, hookName string, args []string) error { if !git.IsLFSHook(hookName) { return nil } // Skip running git-lfs for pre-push hook when triggered manually if len(args) == 0 && hookName == "pre-push" { return nil } lfsRequiredFile := filepath.Join(c.git.RootPath, git.LFSRequiredFile) lfsConfigFile := filepath.Join(c.git.RootPath, git.LFSConfigFile) requiredExists, err := afero.Exists(c.git.Fs, lfsRequiredFile) if err != nil { return err } configExists, err := afero.Exists(c.git.Fs, lfsConfigFile) if err != nil { return err } if !git.IsLFSAvailable() { if requiredExists || configExists { log.Errorf( "This Repository requires Git LFS, but 'git-lfs' wasn't found.\n"+ "Install 'git-lfs' or consider reviewing the files:\n"+ " - %s\n"+ " - %s\n", lfsRequiredFile, lfsConfigFile, ) return errors.New("git-lfs is required") } return nil } log.Debugf( "[git-lfs] executing hook: git lfs %s %s", hookName, strings.Join(args, " "), ) out := new(bytes.Buffer) errOut := new(bytes.Buffer) err = c.cmd.RunWithContext( ctx, append( []string{"git", "lfs", hookName}, args..., ), "", c.cachedStdin, out, errOut, ) outString := strings.Trim(out.String(), "\n") if outString != "" { log.Debug("[git-lfs] stdout: ", outString) } errString := strings.Trim(errOut.String(), "\n") if errString != "" { log.Debug("[git-lfs] stderr: ", errString) } if err != nil { log.Debug("[git-lfs] error: ", err) } if err == nil && outString != "" { log.Info("[git-lfs] stdout: ", outString) } if err != nil && (requiredExists || configExists) { log.Warn("git-lfs command failed") if len(outString) > 0 { log.Warn("[git-lfs] stdout: ", outString) } if len(errString) > 0 { log.Warn("[git-lfs] stderr: ", errString) } return err } return nil } ================================================ FILE: internal/run/controller/run.go ================================================ package controller import ( "bytes" "context" "io" "os" "github.com/evilmartians/lefthook/v2/internal/log" "github.com/evilmartians/lefthook/v2/internal/run/controller/exec" "github.com/evilmartians/lefthook/v2/internal/system" ) func (c *Controller) run(ctx context.Context, name string, follow bool, opts exec.Options) error { log.SetName(name) defer log.UnsetName(name) // If the command does not explicitly `use_stdin` no input will be provided. var in io.Reader = system.NullReader if opts.UseStdin { in = c.cachedStdin } if (follow || opts.Interactive) && log.Settings.LogExecution() { log.Execution(name, nil, nil) var out io.Writer if log.Settings.LogExecutionOutput() { out = os.Stdout } else { out = io.Discard } return c.executor.Execute(ctx, opts, in, out) } out := new(bytes.Buffer) err := c.executor.Execute(ctx, opts, in, out) log.Execution(name, err, out) return err } ================================================ FILE: internal/run/controller/scope.go ================================================ package controller import ( "maps" "slices" "github.com/evilmartians/lefthook/v2/internal/config" "github.com/evilmartians/lefthook/v2/internal/run/controller/utils" ) type scope struct { follow bool glob []string tags []string excludeTags []string // Consider removing this setting names []string fileTypes []string excludeFiles []string env map[string]string root string hookName string filesCmd string opts Options } func newScope(hook *config.Hook, opts Options) *scope { excludeFiles := make([]string, len(opts.ExcludeFiles)+len(hook.Exclude)) i := 0 for _, e := range opts.ExcludeFiles { excludeFiles[i] = e i += 1 } for _, e := range hook.Exclude { excludeFiles[i] = e i += 1 } return &scope{ hookName: hook.Name, follow: hook.Follow, filesCmd: hook.Files, excludeTags: hook.ExcludeTags, excludeFiles: excludeFiles, env: make(map[string]string), opts: opts, } } func (s *scope) extend(job *config.Job) *scope { newScope := *s newScope.glob = slices.Concat(newScope.glob, job.Glob) newScope.tags = slices.Concat(newScope.tags, job.Tags) newScope.root = utils.FirstNonBlank(job.Root, s.root) newScope.filesCmd = utils.FirstNonBlank(job.Files, s.filesCmd) newScope.fileTypes = slices.Concat(newScope.fileTypes, job.FileTypes) if len(job.Exclude) > 0 { newScope.excludeFiles = append(newScope.excludeFiles, job.Exclude...) } // Overwrite --job option for nested groups: if group name given, run all its jobs if len(s.opts.RunOnlyJobs) != 0 && job.Group != nil && slices.Contains(s.opts.RunOnlyJobs, job.Name) { newScope.opts.RunOnlyJobs = []string{} } // Copy env, avoid race conditions if len(job.Env) > 0 { if len(newScope.env) > 0 { env := make(map[string]string) maps.Copy(env, newScope.env) maps.Copy(env, job.Env) newScope.env = env } else { newScope.env = job.Env } } return &newScope } ================================================ FILE: internal/run/controller/scope_test.go ================================================ package controller import ( "strconv" "testing" "github.com/stretchr/testify/assert" "github.com/evilmartians/lefthook/v2/internal/config" "github.com/evilmartians/lefthook/v2/tests/helpers/configtest" ) func Test_newScope(t *testing.T) { t.Run("with excluded files in hook and opts", func(t *testing.T) { opts := Options{ ExcludeFiles: []string{ "file1.txt", "file2.txt", }, } hook := &config.Hook{ Exclude: []string{ "file3.txt", "file4.txt", "file5.txt", }, } scope := newScope(hook, opts) assert.Equal(t, scope.excludeFiles, []string{ "file1.txt", "file2.txt", "file3.txt", "file4.txt", "file5.txt", }) assert.NotEqual(t, scope.env, nil) }) t.Run("without excluded files", func(t *testing.T) { opts := Options{} hook := &config.Hook{} scope := newScope(hook, opts) assert.Equal(t, scope.excludeFiles, []string{}) }) t.Run("without excluded files from hook only", func(t *testing.T) { opts := Options{} hook := &config.Hook{ Exclude: []string{ "file1.txt", "file2.txt", }, } scope := newScope(hook, opts) assert.Equal(t, scope.excludeFiles, []string{ "file1.txt", "file2.txt", }) }) } func TestScope_extend(t *testing.T) { for i, tt := range [...]struct { initial *scope job *config.Job result *scope }{ { initial: &scope{}, job: configtest.ParseJob(` run: echo glob: - "*.js" - "*.jsx" exclude: - "folder/*.sh" `), result: &scope{ glob: []string{"*.js", "*.jsx"}, excludeFiles: []string{"folder/*.sh"}, }, }, { initial: &scope{}, job: configtest.ParseJob(` run: echo glob: - "*.js" - "*.jsx" env: VERSION: 1 UI_ENABLE: false SERVICE_TOKEN: "secret" files: ls -A root: subdir/ `), result: &scope{ glob: []string{"*.js", "*.jsx"}, env: map[string]string{ "VERSION": "1", "UI_ENABLE": "false", "SERVICE_TOKEN": "secret", }, filesCmd: "ls -A", root: "subdir/", }, }, { initial: &scope{ fileTypes: []string{ "text", "not executable", }, }, job: configtest.ParseJob(` file_types: - not symlink `), result: &scope{ fileTypes: []string{ "text", "not executable", "not symlink", }, }, }, } { t.Run(strconv.Itoa(i), func(t *testing.T) { result := tt.initial.extend(tt.job) assert.Equal(t, tt.result, result) }) } } ================================================ FILE: internal/run/controller/setup.go ================================================ package controller import ( "context" "io" "github.com/evilmartians/lefthook/v2/internal/config" "github.com/evilmartians/lefthook/v2/internal/log" "github.com/evilmartians/lefthook/v2/internal/run/controller/command/replacer" "github.com/evilmartians/lefthook/v2/internal/run/controller/exec" "github.com/evilmartians/lefthook/v2/internal/system" ) func (c *Controller) setup( ctx context.Context, opts Options, setupInstructions []*config.SetupInstruction, ) error { if len(setupInstructions) == 0 { return nil } log.StopSpinner() defer log.StartSpinner() replacer := replacer.New(c.git, "", ""). AddTemplates(opts.Templates). AddGitArgs(opts.GitArgs) commands := make([]string, 0, len(setupInstructions)) for _, instr := range setupInstructions { if err := replacer.Discover(instr.Run, nil); err != nil { return err } rawCommands, _ := replacer.ReplaceAndSplit(instr.Run, system.MaxCmdLen()) commands = append(commands, rawCommands...) } r, w := io.Pipe() log.LogSetup(r) err := c.executor.Execute(ctx, exec.Options{Commands: commands}, system.NullReader, w) _ = w.Close() return err } ================================================ FILE: internal/run/controller/utils/cached_reader.go ================================================ package utils import ( "bytes" "io" ) // CachedReader reads from the provided `io.Reader` until `io.EOF` and saves // the read content into the inner buffer. // // After `io.EOF` it will be providing the read data again and again. type CachedReader struct { in io.Reader useBuffer bool buf []byte reader *bytes.Reader } func NewCachedReader(in io.Reader) *CachedReader { return &CachedReader{ in: in, buf: []byte{}, reader: bytes.NewReader([]byte{}), } } func (r *CachedReader) Read(p []byte) (int, error) { if r.useBuffer { n, err := r.reader.Read(p) if err == io.EOF { _, seekErr := r.reader.Seek(0, io.SeekStart) if seekErr != nil { panic(seekErr) } return n, err } return n, err } n, err := r.in.Read(p) r.buf = append(r.buf, p[:n]...) if err == io.EOF { r.useBuffer = true r.reader = bytes.NewReader(r.buf) } return n, err } ================================================ FILE: internal/run/controller/utils/cached_reader_test.go ================================================ package utils import ( "bytes" "io" "testing" ) func TestCachedReader(t *testing.T) { testSlice := []byte("Some example string\nMultiline") cachedReader := NewCachedReader(bytes.NewReader(testSlice)) for range 5 { res, err := io.ReadAll(cachedReader) if err != nil { t.Errorf("unexpected err: %s", err) } if !bytes.Equal(res, testSlice) { t.Errorf("expected %v to be equal to %v", res, testSlice) } } } ================================================ FILE: internal/run/controller/utils/firstNonBlank.go ================================================ package utils // FirstNonBlank returns first non-empty string from given args. func FirstNonBlank(args ...string) string { for _, a := range args { if len(a) > 0 { return a } } return "" } ================================================ FILE: internal/run/controller/utils/intersect.go ================================================ package utils // Intersect returns true if values of two slices have at least one similar value. func Intersect[K comparable](a, b []K) bool { intersections := make(map[K]struct{}, len(a)) for _, v := range a { intersections[v] = struct{}{} } for _, v := range b { if _, ok := intersections[v]; ok { return true } } return false } ================================================ FILE: internal/run/result/result.go ================================================ package result import "time" type status int8 const ( success status = iota failure skip ) // Result contains name of a command/script, an optional fail string, and execution duration. type Result struct { Sub []Result Name string text string status status Duration time.Duration } func (r Result) Success() bool { return r.status == success } func (r Result) Failure() bool { return r.status == failure } func (r Result) Text() string { return r.text } func Skip(name string) Result { return Result{Name: name, status: skip} } func Success(name string, duration time.Duration) Result { return Result{Name: name, status: success, Duration: duration} } func Failure(name, text string, duration time.Duration) Result { return Result{Name: name, status: failure, text: text, Duration: duration} } func Group(name string, results []Result) Result { stat := success allSkip := true var totalDuration time.Duration for _, res := range results { switch res.status { case success: allSkip = false case failure: stat = failure allSkip = false case skip: } totalDuration += res.Duration } if allSkip { stat = skip } return Result{Name: name, status: stat, Sub: results, Duration: totalDuration} } ================================================ FILE: internal/run/result/result_test.go ================================================ package result import ( "fmt" "testing" "time" "github.com/stretchr/testify/assert" ) func TestGroup(t *testing.T) { for i, tt := range [...]struct { name string results []Result expected Result }{ { name: "empty results", results: []Result{}, expected: Result{ Name: "test-group", status: skip, Sub: []Result{}, Duration: 0, }, }, { name: "all success results", results: []Result{ Success("cmd1", 100*time.Millisecond), Success("cmd2", 200*time.Millisecond), Success("cmd3", 150*time.Millisecond), }, expected: Result{ Name: "test-group", status: success, Sub: []Result{ Success("cmd1", 100*time.Millisecond), Success("cmd2", 200*time.Millisecond), Success("cmd3", 150*time.Millisecond), }, Duration: 450 * time.Millisecond, }, }, { name: "all skip results", results: []Result{ Skip("cmd1"), Skip("cmd2"), Skip("cmd3"), }, expected: Result{ Name: "test-group", status: skip, Sub: []Result{ Skip("cmd1"), Skip("cmd2"), Skip("cmd3"), }, Duration: 0, }, }, { name: "all failure results", results: []Result{ Failure("cmd1", "error 1", 50*time.Millisecond), Failure("cmd2", "error 2", 75*time.Millisecond), }, expected: Result{ Name: "test-group", status: failure, Sub: []Result{ Failure("cmd1", "error 1", 50*time.Millisecond), Failure("cmd2", "error 2", 75*time.Millisecond), }, Duration: 125 * time.Millisecond, }, }, { name: "mixed success and skip", results: []Result{ Success("cmd1", 100*time.Millisecond), Skip("cmd2"), Success("cmd3", 200*time.Millisecond), }, expected: Result{ Name: "test-group", status: success, Sub: []Result{ Success("cmd1", 100*time.Millisecond), Skip("cmd2"), Success("cmd3", 200*time.Millisecond), }, Duration: 300 * time.Millisecond, }, }, { name: "mixed success and failure", results: []Result{ Success("cmd1", 100*time.Millisecond), Failure("cmd2", "failed", 50*time.Millisecond), Success("cmd3", 75*time.Millisecond), }, expected: Result{ Name: "test-group", status: failure, Sub: []Result{ Success("cmd1", 100*time.Millisecond), Failure("cmd2", "failed", 50*time.Millisecond), Success("cmd3", 75*time.Millisecond), }, Duration: 225 * time.Millisecond, }, }, { name: "mixed skip and failure", results: []Result{ Skip("cmd1"), Failure("cmd2", "failed", 100*time.Millisecond), Skip("cmd3"), }, expected: Result{ Name: "test-group", status: failure, Sub: []Result{ Skip("cmd1"), Failure("cmd2", "failed", 100*time.Millisecond), Skip("cmd3"), }, Duration: 100 * time.Millisecond, }, }, { name: "all three statuses mixed", results: []Result{ Success("cmd1", 50*time.Millisecond), Skip("cmd2"), Failure("cmd3", "error", 25*time.Millisecond), Success("cmd4", 125*time.Millisecond), }, expected: Result{ Name: "test-group", status: failure, Sub: []Result{ Success("cmd1", 50*time.Millisecond), Skip("cmd2"), Failure("cmd3", "error", 25*time.Millisecond), Success("cmd4", 125*time.Millisecond), }, Duration: 200 * time.Millisecond, }, }, { name: "single success result", results: []Result{ Success("single-cmd", 300*time.Millisecond), }, expected: Result{ Name: "test-group", status: success, Sub: []Result{ Success("single-cmd", 300*time.Millisecond), }, Duration: 300 * time.Millisecond, }, }, { name: "single skip result", results: []Result{ Skip("single-cmd"), }, expected: Result{ Name: "test-group", status: skip, Sub: []Result{ Skip("single-cmd"), }, Duration: 0, }, }, { name: "single failure result", results: []Result{ Failure("single-cmd", "single error", 150*time.Millisecond), }, expected: Result{ Name: "test-group", status: failure, Sub: []Result{ Failure("single-cmd", "single error", 150*time.Millisecond), }, Duration: 150 * time.Millisecond, }, }, } { t.Run(fmt.Sprintf("test %d: %s", i, tt.name), func(t *testing.T) { assert := assert.New(t) result := Group("test-group", tt.results) assert.Equal(tt.expected.Name, result.Name) assert.Equal(tt.expected.status, result.status) assert.Equal(tt.expected.Duration, result.Duration) assert.EqualValues(tt.expected.Sub, result.Sub) // Test the status methods switch tt.expected.status { case success: assert.True(result.Success()) assert.False(result.Failure()) case failure: assert.False(result.Success()) assert.True(result.Failure()) case skip: assert.False(result.Success()) assert.False(result.Failure()) } }) } } ================================================ FILE: internal/run/run.go ================================================ package run import ( "context" "github.com/evilmartians/lefthook/v2/internal/config" "github.com/evilmartians/lefthook/v2/internal/git" "github.com/evilmartians/lefthook/v2/internal/run/controller" "github.com/evilmartians/lefthook/v2/internal/run/result" ) // FailOnChangesError is a special error that fails the hook if any project file was changed. // // Exported here to be handled separately on the caller side. type FailOnChangesError = controller.FailOnChangesError // Options contain hook arguments and special execution settings. type Options = controller.Options // Run executes the hook. func Run( ctx context.Context, hook *config.Hook, repo *git.Repository, opts Options, ) ([]result.Result, error) { return controller.NewController(repo).RunHook(ctx, opts, hook) } ================================================ FILE: internal/system/command.go ================================================ // Package system contains wrappers for OS interactions. package system import ( "context" "io" "os" "os/exec" "strings" ) type osCmd struct { excludeEnvs []string } var Cmd = osCmd{} type Command interface { WithoutEnvs(...string) Command Run([]string, string, io.Reader, io.Writer, io.Writer) error } type CommandWithContext interface { RunWithContext(context.Context, []string, string, io.Reader, io.Writer, io.Writer) error } func (c osCmd) WithoutEnvs(envs ...string) Command { c.excludeEnvs = envs return c } func (c osCmd) Run(command []string, root string, in io.Reader, out io.Writer, errOut io.Writer) error { return c.RunWithContext(context.Background(), command, root, in, out, errOut) } // RunWithContext runs system command with LEFTHOOK=0 in order to prevent calling // subsequent lefthook hooks. func (c osCmd) RunWithContext( ctx context.Context, command []string, root string, in io.Reader, out io.Writer, errOut io.Writer, ) error { cmd := exec.CommandContext(ctx, command[0], command[1:]...) if len(c.excludeEnvs) > 0 { loop: for _, env := range os.Environ() { for _, noenv := range c.excludeEnvs { if strings.HasPrefix(env, noenv) { continue loop } } cmd.Env = append(cmd.Env, env) } cmd.Env = append(cmd.Env, "LEFTHOOK=0") } else { cmd.Env = os.Environ() cmd.Env = append(cmd.Env, "LEFTHOOK=0") } if len(root) > 0 { cmd.Dir = root } cmd.Stdin = in cmd.Stdout = out cmd.Stderr = errOut err := cmd.Run() return err } ================================================ FILE: internal/system/limits.go ================================================ package system import "runtime" const ( // https://serverfault.com/questions/69430/what-is-the-maximum-length-of-a-command-line-in-mac-os-x // https://support.microsoft.com/en-us/help/830473/command-prompt-cmd-exe-command-line-string-limitation // https://unix.stackexchange.com/a/120652 maxCommandLengthDarwin = 260000 // 262144 maxCommandLengthWindows = 7000 // 8191, but see issues#655 maxCommandLengthLinux = 130000 // 131072 ) func MaxCmdLen() int { switch runtime.GOOS { case "windows": return maxCommandLengthWindows case "darwin": return maxCommandLengthDarwin default: return maxCommandLengthLinux } } ================================================ FILE: internal/system/null_reader.go ================================================ package system import "io" // nullReader always returns `io.EOF`. type nullReader struct{} var NullReader = nullReader{} // Read implements the io.Reader interface. func (nullReader) Read(b []byte) (int, error) { return 0, io.EOF } ================================================ FILE: internal/system/null_reader_test.go ================================================ package system import ( "bytes" "io" "testing" ) func TestNullReader(t *testing.T) { res, err := io.ReadAll(NullReader) if err != nil { t.Errorf("unexpected err: %s", err) } if !bytes.Equal(res, []byte{}) { t.Errorf("expected %v to be equal to %v", res, []byte{}) } } ================================================ FILE: internal/system/sh_unix.go ================================================ //go:build !windows package system // Sh returns `sh` executable name. func Sh() (string, error) { return "sh", nil } ================================================ FILE: internal/system/sh_windows.go ================================================ //go:build windows package system import ( "fmt" "os" "os/exec" "path/filepath" "sync" ) const ( sh = "sh" defaultShPath = `C:\Program Files\Git\bin\sh.exe` ) var fullPath = sync.OnceValues(func() (string, error) { if _, err := os.Stat(defaultShPath); err == nil { return defaultShPath, nil } shPath, _ := exec.LookPath("sh") if len(shPath) > 0 { return shPath, nil } gitPath, err := exec.LookPath("git") if err != nil { return "", err } shPath = filepath.Join(gitPath, "..", "..", "bin", "sh.exe") if _, err := os.Stat(shPath); err != nil { return "", err } return shPath, nil }) // Sh returns the path to a shell or an error if it can't find `sh` executable. func Sh() (string, error) { // In case Git runs lefthook from hooks. // Git hooks always setup GIT_INDEX env variable so here we check if we are in // a Git hook and can use `sh` without specifying the full path. This should cover most use cases. if len(os.Getenv("GIT_INDEX_FILE")) != 0 { return sh, nil } // In case you call `lefthook run ...` from the terminal shPath, err := fullPath() if err != nil { return "", fmt.Errorf("`sh` lookup failed: %w", err) } return shPath, nil } ================================================ FILE: internal/templates/config.tmpl ================================================ # EXAMPLE USAGE: # # Refer for explanation to following link: # https://lefthook.dev/configuration/ # # pre-push: # jobs: # - name: packages audit # tags: # - frontend # - security # run: yarn audit # # - name: gems audit # tags: # - backend # - security # run: bundle audit # # pre-commit: # parallel: true # jobs: # - run: yarn eslint {staged_files} # glob: "*.{js,ts,jsx,tsx}" # # - name: rubocop # glob: "*.rb" # exclude: # - config/application.rb # - config/routes.rb # run: bundle exec rubocop --force-exclusion -- {all_files} # # - name: govet # files: git ls-files -m # glob: "*.go" # run: go vet -- {files} # # - script: "hello.js" # runner: node # # - script: "hello.go" # runner: go run ================================================ FILE: internal/templates/hook.tmpl ================================================ #!/bin/sh if [ "$LEFTHOOK_VERBOSE" = "1" -o "$LEFTHOOK_VERBOSE" = "true" ]; then set -x fi if [ "$LEFTHOOK" = "0" ]; then exit 0 fi {{- if .Rc}} {{/* Load rc file, which may export ENV variables */}} [ -f {{.Rc}} ] && . {{.Rc}} {{- end}} call_lefthook() { if test -n "$LEFTHOOK_BIN" then "$LEFTHOOK_BIN" "$@" {{ if .LefthookPath -}} elif test -n "{{ .LefthookPath }}" then {{ .LefthookPath }} "$@" {{ end -}} elif lefthook{{.Extension}} -h >/dev/null 2>&1 then lefthook{{.Extension}} "$@" {{ if .Extension -}} {{/* Check if lefthook.bat exists. Ruby bundler creates such a wrapper */ -}} elif lefthook.bat -h >/dev/null 2>&1 then lefthook.bat "$@" {{ end -}} {{ if .LefthookPathCurrent -}} elif {{ .LefthookPathCurrent }} -h >/dev/null 2>&1 then {{ .LefthookPathCurrent }} "$@" {{ end -}} else dir="$(git rev-parse --show-toplevel)" osArch=$(uname | tr '[:upper:]' '[:lower:]') cpuArch=$(uname -m | sed 's/aarch64/arm64/;s/x86_64/x64/') if test -f "$dir/node_modules/lefthook-${osArch}-${cpuArch}/bin/lefthook{{.Extension}}" then "$dir/node_modules/lefthook-${osArch}-${cpuArch}/bin/lefthook{{.Extension}}" "$@" elif test -f "$dir/node_modules/@evilmartians/lefthook/bin/lefthook-${osArch}-${cpuArch}/lefthook{{.Extension}}" then "$dir/node_modules/@evilmartians/lefthook/bin/lefthook-${osArch}-${cpuArch}/lefthook{{.Extension}}" "$@" elif test -f "$dir/node_modules/@evilmartians/lefthook-installer/bin/lefthook{{.Extension}}" then "$dir/node_modules/@evilmartians/lefthook-installer/bin/lefthook{{.Extension}}" "$@" elif test -f "$dir/node_modules/lefthook/bin/index.js" then "$dir/node_modules/lefthook/bin/index.js" "$@" {{ $extension := .Extension -}} {{ range .Roots -}} elif test -f "$dir/{{.}}/node_modules/lefthook-${osArch}-${cpuArch}/bin/lefthook{{$extension}}" then "$dir/{{.}}/node_modules/lefthook-${osArch}-${cpuArch}/bin/lefthook{{$extension}}" "$@" elif test -f "$dir/{{.}}/node_modules/@evilmartians/lefthook/bin/lefthook-${osArch}-${cpuArch}/lefthook{{$extension}}" then "$dir/{{.}}/node_modules/@evilmartians/lefthook/bin/lefthook-${osArch}-${cpuArch}/lefthook{{$extension}}" "$@" elif test -f "$dir/{{.}}/node_modules/@evilmartians/lefthook-installer/bin/lefthook{{$extension}}" then "$dir/{{.}}/node_modules/@evilmartians/lefthook-installer/bin/lefthook{{$extension}}" "$@" elif test -f "$dir/{{.}}/node_modules/lefthook/bin/index.js" then "$dir/{{.}}/node_modules/lefthook/bin/index.js" "$@" {{ end -}} elif go tool lefthook -h >/dev/null 2>&1 then go tool lefthook "$@" elif bundle exec lefthook -h >/dev/null 2>&1 then bundle exec lefthook "$@" elif yarn lefthook -h >/dev/null 2>&1 then yarn lefthook "$@" elif pnpm lefthook -h >/dev/null 2>&1 then pnpm lefthook "$@" elif swift package lefthook >/dev/null 2>&1 then swift package --build-path .build/lefthook --disable-sandbox lefthook "$@" elif command -v mint >/dev/null 2>&1 then mint run csjones/lefthook-plugin "$@" elif uv run lefthook -h >/dev/null 2>&1 then uv run lefthook "$@" elif mise exec -- lefthook -h >/dev/null 2>&1 then mise exec -- lefthook "$@" elif devbox run lefthook -h >/dev/null 2>&1 then devbox run lefthook "$@" else echo "Can't find lefthook in PATH" {{- if .AssertLefthookInstalled}} echo "ERROR: Operation is aborted due to lefthook settings." echo "Make sure lefthook is available in your environment and re-try." echo "To skip these checks use --no-verify git argument or set LEFTHOOK=0 env variable." exit 1 {{- end}} fi fi } call_lefthook run "{{.HookName}}" "$@" ================================================ FILE: internal/templates/templates.go ================================================ package templates import ( "bytes" "embed" "fmt" "os" "runtime" "strings" "text/template" ) const checksumFormat = "%s %d %s\n" //go:embed * var templatesFS embed.FS type Args struct { Rc string LefthookPath string AssertLefthookInstalled bool Roots []string } type hookTmplData struct { HookName string Extension string LefthookPath string LefthookPathCurrent string Rc string Roots []string AssertLefthookInstalled bool } func Hook(hookName string, args Args) []byte { lefthookPathCurrent, err := os.Executable() if err != nil { lefthookPathCurrent = "" } buf := &bytes.Buffer{} t := template.Must(template.ParseFS(templatesFS, "hook.tmpl")) if err = t.Execute(buf, hookTmplData{ HookName: hookName, Extension: getExtension(), Rc: args.Rc, AssertLefthookInstalled: args.AssertLefthookInstalled, Roots: args.Roots, LefthookPath: strings.ReplaceAll(strings.TrimSpace(args.LefthookPath), "\n", ";"), LefthookPathCurrent: lefthookPathCurrent, }); err != nil { panic(err) } return buf.Bytes() } func Config() []byte { tmpl, err := templatesFS.ReadFile("config.tmpl") if err != nil { panic(err) } return tmpl } func Checksum(checksum string, timestamp int64, hooks []string) []byte { return fmt.Appendf(nil, checksumFormat, checksum, timestamp, strings.Join(hooks, ",")) } func getExtension() string { if runtime.GOOS == "windows" { return ".exe" } return "" } ================================================ FILE: internal/updater/updater.go ================================================ // Package updater contains the self-update implementation for the lefthook executable. package updater import ( "bufio" "context" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "io/fs" "net/http" "os" "path/filepath" "runtime" "strconv" "strings" "time" "github.com/schollz/progressbar/v3" "github.com/evilmartians/lefthook/v2/internal/log" "github.com/evilmartians/lefthook/v2/internal/version" ) const ( timeout = 120 * time.Second latestReleaseURL = "https://api.github.com/repos/evilmartians/lefthook/releases/latest" checksumsFilename = "lefthook_checksums.txt" checksumFields = 2 modExecutable os.FileMode = 0o755 ) var ( errNoAsset = errors.New("couldn't find an asset to download. Please submit an issue to https://github.com/evilmartians/lefthook") errInvalidHashsum = errors.New("SHA256 sums differ, it's not safe to use the downloaded binary.\nIf you have problems upgrading lefthook please submit an issue to https://github.com/evilmartians/lefthook") errUpdateFailed = errors.New("update failed") osNames = map[string]string{ "windows": "Windows", "darwin": "MacOS", "linux": "Linux", "freebsd": "Freebsd", "openbsd": "Openbsd", } archNames = map[string]string{ "amd64": "x86_64", "arm64": "arm64", "386": "i386", } ) type release struct { TagName string `json:"tag_name"` Assets []asset } type asset struct { Name string `json:"name"` DownloadURL string `json:"browser_download_url"` } type Options struct { Yes bool Force bool ExePath string } type Updater struct { client *http.Client releaseURL string } func New() *Updater { return &Updater{ client: &http.Client{Timeout: timeout}, releaseURL: latestReleaseURL, } } func (u *Updater) SelfUpdate(ctx context.Context, opts Options) error { rel, ferr := u.fetchLatestRelease(ctx) if ferr != nil { return fmt.Errorf("couldn't fetch latest release: %w", ferr) } latestVersion := strings.TrimPrefix(rel.TagName, "v") if latestVersion == version.Version(false) && !opts.Force { log.Infof("Up to date: %s\n", latestVersion) return nil } wantedAsset := fmt.Sprintf("lefthook_%s_%s_%s", latestVersion, osNames[runtime.GOOS], archNames[runtime.GOARCH]) if runtime.GOOS == "windows" { wantedAsset += ".exe" } log.Debugf("Searching assets for %s", wantedAsset) var downloadURL string var checksumURL string for i := range rel.Assets { asset := rel.Assets[i] if len(downloadURL) == 0 && asset.Name == wantedAsset { downloadURL = asset.DownloadURL if len(checksumURL) > 0 { break } } if len(checksumURL) == 0 && asset.Name == checksumsFilename { checksumURL = asset.DownloadURL if len(downloadURL) > 0 { break } } } if len(downloadURL) == 0 { log.Warnf("Couldn't find the right asset to download. Wanted: %s\n", wantedAsset) return errNoAsset } if len(checksumURL) == 0 { log.Warn("Couldn't find checksums") } if !opts.Yes { log.Infof("Update %s to %s? %s ", log.Cyan("lefthook"), log.Yellow(latestVersion), log.Gray("[Y/n]")) scanner := bufio.NewScanner(os.Stdin) scanner.Scan() ans := scanner.Text() if len(ans) > 0 && ans[0] != 'y' && ans[0] != 'Y' { log.Debug("Update rejected") return nil } } lefthookExePath := opts.ExePath if realPath, serr := filepath.EvalSymlinks(lefthookExePath); serr == nil { lefthookExePath = realPath } destPath := lefthookExePath + "." + latestVersion defer func() { if _, dErr := os.Stat(destPath); !errors.Is(dErr, fs.ErrNotExist) { if dErr = os.Remove(destPath); dErr != nil { log.Warnf("Could not remove %s: %s", destPath, dErr) } } }() ok, err := u.download(ctx, wantedAsset, downloadURL, checksumURL, destPath) if err != nil { return err } if !ok { return errInvalidHashsum } backupPath := lefthookExePath + ".bak" defer func() { if _, dErr := os.Stat(backupPath); !errors.Is(dErr, fs.ErrNotExist) { if dErr = os.Remove(backupPath); dErr != nil { log.Warnf("Could not remove %s: %s", backupPath, dErr) } } }() log.Debugf("mv %s %s", lefthookExePath, backupPath) if err = os.Rename(lefthookExePath, backupPath); err != nil { return fmt.Errorf("failed to backup lefthook executable: %w", err) } log.Debugf("mv %s %s", destPath, lefthookExePath) err = os.Rename(destPath, lefthookExePath) if err != nil { log.Errorf("Failed to replace the lefthook executable: %s", err) if err = os.Rename(backupPath, lefthookExePath); err != nil { return fmt.Errorf("failed to recover from backup: %w", err) } return errUpdateFailed } log.Debugf("chmod +x %s", lefthookExePath) if err = os.Chmod(lefthookExePath, modExecutable); err != nil { log.Errorf("Failed to set executable file mode: %s", err) if err = os.Rename(backupPath, lefthookExePath); err != nil { return fmt.Errorf("failed to recover from backup: %w", err) } return errUpdateFailed } return nil } func (u *Updater) fetchLatestRelease(ctx context.Context) (*release, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.releaseURL, nil) if err != nil { return nil, fmt.Errorf("failed to initialize a request: %w", err) } req.Header.Set("Accept", "application/vnd.github+json") req.Header.Set("X-GitHub-Api-Version", "2022-11-28") resp, err := u.client.Do(req) if err != nil { return nil, fmt.Errorf("request failed: %w", err) } if resp.StatusCode != http.StatusOK { httpErr := fmt.Errorf("response of the Github API was: %s", resp.Status) ms, perr := strconv.ParseInt(resp.Header.Get("X-RateLimit-Reset"), 10, 64) if perr != nil { return nil, httpErr } return nil, errors.Join(httpErr, errors.New(time.Unix(ms, 0).Format("Try later on 02.01.2006, at 15:04:05"))) } var rel release if err = errors.Join(json.NewDecoder(resp.Body).Decode(&rel), resp.Body.Close()); err != nil { return nil, fmt.Errorf("failed to parse the Github response: %w", err) } return &rel, nil } func (u *Updater) download(ctx context.Context, name, fileURL, checksumURL, path string) (bool, error) { log.Debugf("Downloading %s to %s", fileURL, path) filereq, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil) if err != nil { return false, fmt.Errorf("failed to build download request: %w", err) } sumreq, err := http.NewRequestWithContext(ctx, http.MethodGet, checksumURL, nil) if err != nil { return false, fmt.Errorf("failed to build checksum download request: %w", err) } resp, err := u.client.Do(filereq) if err != nil { return false, fmt.Errorf("download request failed: %w", err) } defer func() { if cErr := resp.Body.Close(); cErr != nil { log.Warnf("Could not close %s response body: %s", resp.Request.URL, cErr) } }() checksumResp, err := u.client.Do(sumreq) if err != nil { return false, fmt.Errorf("checksum download request failed: %w", err) } defer func() { if cErr := checksumResp.Body.Close(); cErr != nil { log.Warnf("Could not close %s response body: %s", checksumResp.Request.URL, cErr) } }() bar := progressbar.DefaultBytes(resp.ContentLength+checksumResp.ContentLength, name) file, err := os.Create(path) if err != nil { return false, fmt.Errorf("failed to create destination path (%s): %w", path, err) } fileHasher := sha256.New() _, err = io.Copy(io.MultiWriter(file, fileHasher, bar), resp.Body) if err = errors.Join(err, file.Close()); err != nil { return false, fmt.Errorf("failed to download the file: %w", err) } log.Debug() hashsum := hex.EncodeToString(fileHasher.Sum(nil)) scanner := bufio.NewScanner(checksumResp.Body) for scanner.Scan() { sums := strings.Fields(scanner.Text()) if len(sums) < checksumFields { continue } log.Debugf("Checking %s %s", sums[0], sums[1]) if sums[1] == name { if sums[0] == hashsum { if err = bar.Finish(); err != nil { log.Debugf("Progressbar error: %s", err) } log.Debugf("Match %s %s", sums[0], sums[1]) return true, nil } else { return false, nil } } } if err = scanner.Err(); err != nil { return false, fmt.Errorf("scan checksum response body: %w", err) } log.Debugf("No matches found for %s %s", name, hashsum) return false, nil } ================================================ FILE: internal/updater/updater_test.go ================================================ package updater import ( "encoding/json" "errors" "net/http" "net/http/httptest" "os" "path/filepath" "runtime" "testing" "github.com/stretchr/testify/assert" "github.com/evilmartians/lefthook/v2/internal/version" ) func TestUpdater_SelfUpdate(t *testing.T) { var extension string if runtime.GOOS == "windows" { extension = ".exe" } exePath := filepath.Join(os.TempDir(), "lefthook") for name, tt := range map[string]struct { latestRelease string assetName string checksums string opts Options asset []byte err error }{ "asset not found": { latestRelease: "v1.0.0", assetName: "lefthook_1.0.0_darwin_arm64", opts: Options{ Yes: true, Force: false, ExePath: exePath, }, err: errNoAsset, }, "no need to update": { latestRelease: "v" + version.Version(false), assetName: "lefthook_1.0.0_darwin_arm64", opts: Options{ Yes: true, Force: false, ExePath: exePath, }, err: nil, }, "forced update but asset not found": { latestRelease: "v" + version.Version(false), assetName: "lefthook_1.0.0_darwin_arm64", opts: Options{ Yes: true, Force: true, ExePath: exePath, }, err: errNoAsset, }, "invalid hashsum": { latestRelease: "v1.0.0", assetName: "lefthook_1.0.0_" + osNames[runtime.GOOS] + "_" + archNames[runtime.GOARCH] + extension, opts: Options{ Yes: true, Force: true, ExePath: exePath, }, asset: []byte{65, 54, 24, 32, 43, 67, 21}, checksums: ` 67a5740c6c66d986c5708cddd6bd0bc240db29451646fc4c1398b988dcf7cdfe lefthook_1.0.0_MacOS_arm64 67a5740c6c66d986c5708cddd6bd0bc240db29451646fc4c1398b988dcf7cdfe lefthook_1.0.0_MacOS_x86_64 67a5740c6c66d986c5708cddd6bd0bc240db29451646fc4c1398b988dcf7cdfe lefthook_1.0.0_Linux_x86_64 67a5740c6c66d986c5708cddd6bd0bc240db29451646fc4c1398b988dcf7cdfe lefthook_1.0.0_Linux_arm64 67a5740c6c66d986c5708cddd6bd0bc240db29451646fc4c1398b988dcf7cdfe lefthook_1.0.0_Windows_x86_64.exe `, err: errInvalidHashsum, }, "success": { latestRelease: "v1.0.0", assetName: "lefthook_1.0.0_" + osNames[runtime.GOOS] + "_" + archNames[runtime.GOARCH] + extension, opts: Options{ Yes: true, Force: true, ExePath: exePath, }, asset: []byte{65, 54, 24, 32, 43, 67, 21}, checksums: ` 0e1c97246ba1bc8bde78355ae986589545d3c69bf1264d2d3c1835ec072006f6 lefthook_1.0.0_MacOS_arm64 0e1c97246ba1bc8bde78355ae986589545d3c69bf1264d2d3c1835ec072006f6 lefthook_1.0.0_MacOS_x86_64 0e1c97246ba1bc8bde78355ae986589545d3c69bf1264d2d3c1835ec072006f6 lefthook_1.0.0_Linux_x86_64 0e1c97246ba1bc8bde78355ae986589545d3c69bf1264d2d3c1835ec072006f6 lefthook_1.0.0_Linux_arm64 0e1c97246ba1bc8bde78355ae986589545d3c69bf1264d2d3c1835ec072006f6 lefthook_1.0.0_Windows_x86_64.exe `, err: nil, }, } { t.Run(name, func(t *testing.T) { assert := assert.New(t) file, err := os.Create(tt.opts.ExePath) assert.NoError(err) assert.NoError(file.Close()) checksumServer := httptest.NewServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { n, werr := w.Write([]byte(tt.checksums)) assert.Equal(n, len(tt.checksums)) assert.NoError(werr) })) defer checksumServer.Close() assetServer := httptest.NewServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { n, werr := w.Write(tt.asset) assert.Equal(n, len(tt.asset)) assert.NoError(werr) })) defer assetServer.Close() releaseServer := httptest.NewServer( http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { assert.NoError(json.NewEncoder(w).Encode(map[string]any{ "tag_name": tt.latestRelease, "assets": []map[string]string{ { "name": tt.assetName, "browser_download_url": assetServer.URL, }, { "name": "lefthook_checksums.txt", "browser_download_url": checksumServer.URL, }, }, })) })) defer releaseServer.Close() upd := Updater{ client: releaseServer.Client(), releaseURL: releaseServer.URL, } err = upd.SelfUpdate(t.Context(), tt.opts) if tt.err != nil { if !errors.Is(err, tt.err) { t.Error(err) } } else { assert.NoError(err) if tt.asset != nil { content, err := os.ReadFile(tt.opts.ExePath) assert.NoError(err) assert.Equal(content, tt.asset) } } }) } } ================================================ FILE: internal/version/version.go ================================================ package version import ( "errors" "golang.org/x/mod/semver" ) const version = "2.1.4" var ( // Is set via -X github.com/evilmartians/lefthook/internal/version.commit={commit}. commit string ErrInvalidVersion = errors.New("invalid version format") ErrUncoveredVersion = errors.New("version is lower than required") ) func Version(verbose bool) string { if verbose { return version + " " + commit } return version } func Check(wanted, given string) error { if wanted[0] != 'v' { wanted = "v" + wanted } if given[0] != 'v' { given = "v" + given } if !semver.IsValid(wanted) { return ErrInvalidVersion } if !semver.IsValid(given) { return ErrInvalidVersion } if cmp := semver.Compare(wanted, given); cmp > 0 { return ErrUncoveredVersion } return nil } ================================================ FILE: internal/version/version_test.go ================================================ package version import ( "strconv" "testing" "github.com/stretchr/testify/assert" ) func TestCheck(t *testing.T) { for i, tt := range [...]struct { wanted, given string err error }{ { wanted: "1.0.0", given: "1.0.1", err: nil, }, { wanted: "v1.0.0", given: "1.0.1", err: nil, }, { wanted: "1.0.0", given: "v1.0.1", err: nil, }, { wanted: "1", given: "1.2", err: nil, }, { wanted: "3.0.0", given: "1.1", err: ErrUncoveredVersion, }, { wanted: "13", given: "10", err: ErrUncoveredVersion, }, { wanted: "10--.0-best", given: "10", err: ErrInvalidVersion, }, { wanted: "10", given: "vv10.0.0-best", err: ErrInvalidVersion, }, } { t.Run(strconv.Itoa(i), func(t *testing.T) { assert.Equal(t, tt.err, Check(tt.wanted, tt.given)) }) } } func TestVersion(t *testing.T) { assert.Equal(t, version, Version(false)) assert.Equal(t, version+" "+commit, Version(true)) } ================================================ FILE: main.go ================================================ package main import ( "context" "os" "github.com/evilmartians/lefthook/v2/cmd" "github.com/evilmartians/lefthook/v2/internal/log" ) func main() { if err := cmd.Lefthook().Run(context.Background(), os.Args); err != nil { if err.Error() != "" { log.Errorf("Error: %s", err) } os.Exit(1) } } ================================================ FILE: packaging/.gitignore ================================================ # Raku precompilation .precomp/ # Python precompilation __pycache__/ /registries/pypi/lefthook.egg-info/ # READMEs /registries/npm-*/README.md /registries/npm/*/README.md # schemas /registries/npm/*/schema.json /registries/npm-bundled/schema.json /registries/npm-installer/schema.json # binaries registries/pypi/lefthook/bin/ registries/pypi/build/ registries/rubygems/pkg/ registries/rubygems/libexec/ registries/npm-bundled/bin/ registries/npm/*/bin/ !registries/npm/*/package.json !registries/npm/lefthook/bin/index.js ================================================ FILE: packaging/registries/aur/lefthook/PKGBUILD ================================================ # Maintainer: Lefthook pkgname=lefthook pkgdesc="Git hooks manager" pkgver=2.1.4 pkgrel=1 arch=('x86_64' 'aarch64') url="https://github.com/evilmartians/lefthook" license=('MIT') makedepends=('go>=1.26') source=("https://github.com/evilmartians/lefthook/archive/v${pkgver}.tar.gz") sha256sums=('{{ sha256sum }}') build() { cd "$pkgname-$pkgver" go build \ -trimpath \ -buildmode=pie \ -mod=readonly \ -modcacherw \ -ldflags "-linkmode external -extldflags \"${LDFLAGS}\"" \ . } package() { cd "$pkgname-$pkgver" install -Dm755 $pkgname "$pkgdir"/usr/bin/$pkgname } ================================================ FILE: packaging/registries/aur/lefthook-bin/PKGBUILD ================================================ # Maintainer: Lefthook pkgname=lefthook-bin pkgdesc="Git hooks manager" pkgver=2.1.4 pkgrel=1 arch=('x86_64' 'aarch64') url="https://github.com/evilmartians/lefthook" license=('MIT') depends=() makedepends=() provides=('lefthook') conflicts=('lefthook') source_x86_64=("https://github.com/evilmartians/lefthook/releases/download/v${pkgver}/lefthook_${pkgver}_Linux_x86_64.gz") source_aarch64=("https://github.com/evilmartians/lefthook/releases/download/v${pkgver}/lefthook_${pkgver}_Linux_aarch64.gz") sha256sums_x86_64=('{{ sha256sum_linux_x86_64 }}') sha256sums_aarch64=('{{ sha256sum_linux_aarch64 }}') build() { cd "${srcdir}" mv "lefthook_${pkgver}_Linux_${CARCH}" lefthook chmod +x lefthook ./lefthook completion zsh >lefthook.zsh ./lefthook completion fish >lefthook.fish ./lefthook completion bash >lefthook.bash } package() { cd "${srcdir}" # Install lefthook install -D -m0755 lefthook \ "${pkgdir}/usr/bin/lefthook" # Install completions install -Dm644 lefthook.zsh "${pkgdir}/usr/share/zsh/site-functions/_lefthook" install -Dm644 lefthook.fish "${pkgdir}/usr/share/fish/completions/lefthook.fish" install -Dm644 lefthook.bash "${pkgdir}/usr/share/bash-completion/completions/lefthook" } ================================================ FILE: packaging/registries/npm/lefthook/bin/index.js ================================================ #!/usr/bin/env node var spawn = require('child_process').spawn; const { getExePath } = require('../get-exe'); var command_args = process.argv.slice(2); var child = spawn( getExePath(), command_args, { stdio: "inherit" }); child.on('close', function (code) { if (code !== 0) { process.exit(1); } }); ================================================ FILE: packaging/registries/npm/lefthook/get-exe.js ================================================ const path = require("path"); function getExePath() { // Detect OS // https://nodejs.org/api/process.html#process_process_platform let os = process.platform; let extension = ""; if (["win32", "cygwin"].includes(process.platform)) { os = "windows"; extension = ".exe"; } // Detect architecture // https://nodejs.org/api/process.html#process_process_arch let arch = process.arch; return require.resolve(`lefthook-${os}-${arch}/bin/lefthook${extension}`); } exports.getExePath = getExePath; ================================================ FILE: packaging/registries/npm/lefthook/package.json ================================================ { "name": "lefthook", "version": "2.1.4", "description": "Simple git hooks manager", "repository": { "type": "git", "url": "git+https://github.com/evilmartians/lefthook.git" }, "main": "bin/index.js", "files": [ "postinstall.js", "get-exe.js", "schema.json" ], "bin": { "lefthook": "bin/index.js" }, "keywords": [ "git", "hook", "manager" ], "author": "mrexox", "license": "MIT", "bugs": { "url": "https://github.com/evilmartians/lefthook/issues", "email": "lefthook@evilmartians.com" }, "homepage": "https://github.com/evilmartians/lefthook#readme", "optionalDependencies": { "lefthook-darwin-arm64": "2.1.4", "lefthook-darwin-x64": "2.1.4", "lefthook-linux-arm64": "2.1.4", "lefthook-linux-x64": "2.1.4", "lefthook-freebsd-arm64": "2.1.4", "lefthook-freebsd-x64": "2.1.4", "lefthook-openbsd-arm64": "2.1.4", "lefthook-openbsd-x64": "2.1.4", "lefthook-windows-arm64": "2.1.4", "lefthook-windows-x64": "2.1.4" }, "scripts": { "postinstall": "node postinstall.js" } } ================================================ FILE: packaging/registries/npm/lefthook/postinstall.js ================================================ const { spawnSync } = require("child_process"); const { getExePath } = require("./get-exe"); function install() { const isEnabled = (value) => value && value !== "0" && value !== "false"; if (isEnabled(process.env.CI) && !isEnabled(process.env.LEFTHOOK)) { return } spawnSync(getExePath(), ["install", "-f"], { cwd: process.env.INIT_CWD || process.cwd(), stdio: "inherit", }); } try { install(); } catch (e) { console.warn( "'lefthook install' command failed. Try running it manually.\n" + e, ); } ================================================ FILE: packaging/registries/npm/lefthook-darwin-arm64/package.json ================================================ { "name": "lefthook-darwin-arm64", "version": "2.1.4", "description": "The macOS ARM 64-bit binary for lefthook, git hooks manager.", "preferUnplugged": false, "repository": { "type": "git", "url": "git+https://github.com/evilmartians/lefthook.git" }, "license": "MIT", "bugs": { "url": "https://github.com/evilmartians/lefthook/issues", "email": "lefthook@evilmartians.com" }, "homepage": "https://github.com/evilmartians/lefthook#readme", "os": [ "darwin" ], "cpu": [ "arm64" ] } ================================================ FILE: packaging/registries/npm/lefthook-darwin-x64/package.json ================================================ { "name": "lefthook-darwin-x64", "version": "2.1.4", "description": "The macOS 64-bit binary for lefthook, git hooks manager.", "preferUnplugged": false, "repository": { "type": "git", "url": "git+https://github.com/evilmartians/lefthook.git" }, "license": "MIT", "bugs": { "url": "https://github.com/evilmartians/lefthook/issues", "email": "lefthook@evilmartians.com" }, "homepage": "https://github.com/evilmartians/lefthook#readme", "os": [ "darwin" ], "cpu": [ "x64" ] } ================================================ FILE: packaging/registries/npm/lefthook-freebsd-arm64/package.json ================================================ { "name": "lefthook-freebsd-arm64", "version": "2.1.4", "description": "The FreeBSD ARM 64-bit binary for lefthook, git hooks manager.", "preferUnplugged": false, "repository": { "type": "git", "url": "git+https://github.com/evilmartians/lefthook.git" }, "license": "MIT", "bugs": { "url": "https://github.com/evilmartians/lefthook/issues", "email": "lefthook@evilmartians.com" }, "homepage": "https://github.com/evilmartians/lefthook#readme", "os": [ "freebsd" ], "cpu": [ "arm64" ] } ================================================ FILE: packaging/registries/npm/lefthook-freebsd-x64/package.json ================================================ { "name": "lefthook-freebsd-x64", "version": "2.1.4", "description": "The FreeBSD 64-bit binary for lefthook, git hooks manager.", "preferUnplugged": false, "repository": { "type": "git", "url": "git+https://github.com/evilmartians/lefthook.git" }, "license": "MIT", "bugs": { "url": "https://github.com/evilmartians/lefthook/issues", "email": "lefthook@evilmartians.com" }, "homepage": "https://github.com/evilmartians/lefthook#readme", "os": [ "freebsd" ], "cpu": [ "x64" ] } ================================================ FILE: packaging/registries/npm/lefthook-linux-arm64/package.json ================================================ { "name": "lefthook-linux-arm64", "version": "2.1.4", "description": "The Linux ARM 64-bit binary for lefthook, git hooks manager.", "preferUnplugged": false, "repository": { "type": "git", "url": "git+https://github.com/evilmartians/lefthook.git" }, "license": "MIT", "bugs": { "url": "https://github.com/evilmartians/lefthook/issues", "email": "lefthook@evilmartians.com" }, "homepage": "https://github.com/evilmartians/lefthook#readme", "os": [ "linux" ], "cpu": [ "arm64" ] } ================================================ FILE: packaging/registries/npm/lefthook-linux-x64/package.json ================================================ { "name": "lefthook-linux-x64", "version": "2.1.4", "description": "The Linux 64-bit binary for lefthook, git hooks manager.", "preferUnplugged": false, "repository": { "type": "git", "url": "git+https://github.com/evilmartians/lefthook.git" }, "license": "MIT", "bugs": { "url": "https://github.com/evilmartians/lefthook/issues", "email": "lefthook@evilmartians.com" }, "homepage": "https://github.com/evilmartians/lefthook#readme", "os": [ "linux" ], "cpu": [ "x64" ] } ================================================ FILE: packaging/registries/npm/lefthook-openbsd-arm64/package.json ================================================ { "name": "lefthook-openbsd-arm64", "version": "2.1.4", "description": "The OpenBSD ARM 64-bit binary for lefthook, git hooks manager.", "preferUnplugged": false, "repository": { "type": "git", "url": "git+https://github.com/evilmartians/lefthook.git" }, "license": "MIT", "bugs": { "url": "https://github.com/evilmartians/lefthook/issues", "email": "lefthook@evilmartians.com" }, "homepage": "https://github.com/evilmartians/lefthook#readme", "os": [ "openbsd" ], "cpu": [ "arm64" ] } ================================================ FILE: packaging/registries/npm/lefthook-openbsd-x64/package.json ================================================ { "name": "lefthook-openbsd-x64", "version": "2.1.4", "description": "The OpenBSD 64-bit binary for lefthook, git hooks manager.", "preferUnplugged": false, "repository": { "type": "git", "url": "git+https://github.com/evilmartians/lefthook.git" }, "license": "MIT", "bugs": { "url": "https://github.com/evilmartians/lefthook/issues", "email": "lefthook@evilmartians.com" }, "homepage": "https://github.com/evilmartians/lefthook#readme", "os": [ "openbsd" ], "cpu": [ "x64" ] } ================================================ FILE: packaging/registries/npm/lefthook-windows-arm64/package.json ================================================ { "name": "lefthook-windows-arm64", "version": "2.1.4", "description": "The Windows ARM 64-bit binary for lefthook, git hooks manager.", "preferUnplugged": false, "repository": { "type": "git", "url": "git+https://github.com/evilmartians/lefthook.git" }, "license": "MIT", "bugs": { "url": "https://github.com/evilmartians/lefthook/issues", "email": "lefthook@evilmartians.com" }, "homepage": "https://github.com/evilmartians/lefthook#readme", "os": [ "win32" ], "cpu": [ "arm64" ] } ================================================ FILE: packaging/registries/npm/lefthook-windows-x64/package.json ================================================ { "name": "lefthook-windows-x64", "version": "2.1.4", "description": "The Windows 64-bit binary for lefthook, git hooks manager.", "preferUnplugged": false, "repository": { "type": "git", "url": "git+https://github.com/evilmartians/lefthook.git" }, "license": "MIT", "bugs": { "url": "https://github.com/evilmartians/lefthook/issues", "email": "lefthook@evilmartians.com" }, "homepage": "https://github.com/evilmartians/lefthook#readme", "os": [ "win32" ], "cpu": [ "x64" ] } ================================================ FILE: packaging/registries/npm-bundled/bin/index.js ================================================ #!/usr/bin/env node var spawn = require('child_process').spawn; const { getExePath } = require('../get-exe'); var command_args = process.argv.slice(2); var child = spawn( getExePath(), command_args, { stdio: "inherit" }); child.on('close', function (code) { if (code !== 0) { process.exit(1); } }); ================================================ FILE: packaging/registries/npm-bundled/get-exe.js ================================================ const path = require("path") function getExePath() { // Detect OS // https://nodejs.org/api/process.html#process_process_platform let goOS = process.platform; let extension = ''; if (['win32', 'cygwin'].includes(process.platform)) { goOS = 'windows'; extension = '.exe'; } // Detect architecture // https://nodejs.org/api/process.html#process_process_arch let goArch = process.arch; let suffix = ''; switch (process.arch) { case 'x32': case 'ia32': { goArch = '386'; break; } } const dir = path.join(__dirname, 'bin'); const executable = path.join( dir, `lefthook-${goOS}-${goArch}`, `lefthook${extension}` ); return executable; } exports.getExePath = getExePath; ================================================ FILE: packaging/registries/npm-bundled/package.json ================================================ { "name": "@evilmartians/lefthook", "version": "2.1.4", "description": "Simple git hooks manager", "main": "bin/index.js", "files": [ "postinstall.js", "get-exe.js", "schema.json", "bin/**/*" ], "bin": { "lefthook": "bin/index.js" }, "repository": { "type": "git", "url": "git+https://github.com/evilmartians/lefthook.git" }, "keywords": [ "git", "hook", "manager" ], "author": "mrexox", "license": "MIT", "bugs": { "url": "https://github.com/evilmartians/lefthook/issues", "email": "lefthook@evilmartians.com" }, "homepage": "https://github.com/evilmartians/lefthook#readme", "os": [ "darwin", "linux", "win32" ], "cpu": [ "x64", "arm64", "ia32" ], "scripts": { "postinstall": "node postinstall.js" } } ================================================ FILE: packaging/registries/npm-bundled/postinstall.js ================================================ const isEnabled = (value) => value && value !== "0" && value !== "false"; if (!isEnabled(process.env.CI) || isEnabled(process.env.LEFTHOOK)) { const { spawnSync } = require('child_process'); const { getExePath } = require('./get-exe'); // run install spawnSync(getExePath(), ['install', '-f'], { cwd: process.env.INIT_CWD || process.cwd(), stdio: 'inherit', }); } ================================================ FILE: packaging/registries/npm-installer/bin/index.js ================================================ #!/usr/bin/env node var spawn = require('child_process').spawn; const path = require("path") const extension = ["win32", "cygwin"].includes(process.platform) ? ".exe" : "" const exePath = path.join(__dirname, `lefthook${extension}`) var command_args = process.argv.slice(2); var child = spawn( exePath, command_args, { stdio: "inherit" }); child.on('close', function (code) { if (code !== 0) { process.exit(1); } }); ================================================ FILE: packaging/registries/npm-installer/install.js ================================================ const http = require('https') const fs = require('fs') const path = require("path") const chp = require("child_process") const iswin = ["win32", "cygwin"].includes(process.platform) async function install() { const isEnabled = (value) => value && value !== "0" && value !== "false"; if (isEnabled(process.env.CI) && !isEnabled(process.env.LEFTHOOK)) { return } const downloadURL = getDownloadURL() const extension = iswin ? ".exe" : "" const fileName = `lefthook${extension}` const exePath = path.join(__dirname, "bin", fileName) await downloadBinary(downloadURL, exePath) console.log('downloaded to', exePath) if (!iswin) { fs.chmodSync(exePath, "755") } // run install chp.spawnSync(exePath, ['install', '-f'], { cwd: process.env.INIT_CWD || process.cwd(), stdio: 'inherit', }) } function getDownloadURL() { // Detect OS // https://nodejs.org/api/process.html#process_process_platform let goOS = process.platform let extension = "" if (iswin) { goOS = "windows" extension = ".exe" } // Convert the goOS to the os name in the download URL let downloadOS = goOS === "darwin" ? "macOS" : goOS downloadOS = `${downloadOS.charAt(0).toUpperCase()}${downloadOS.slice(1)}` // Detect architecture // https://nodejs.org/api/process.html#process_process_arch let arch = process.arch switch (process.arch) { case "x64": { arch = "x86_64" break } } const version = require("./package.json").version return `https://github.com/evilmartians/lefthook/releases/download/v${version}/lefthook_${version}_${downloadOS}_${arch}${extension}` } async function downloadBinary(url, dest) { console.log('downloading', url) const file = fs.createWriteStream(dest) return new Promise((resolve, reject) => { http.get(url, function(response) { if (response.statusCode === 302 && response.headers.location) { // If the response is a 302 redirect, follow the new location downloadBinary(response.headers.location, dest) .then(resolve) .catch(reject) } else { response.pipe(file) file.on('finish', function() { file.close(() => { resolve(dest) }) }) } }).on('error', function(err) { fs.unlink(file, () => { reject(err) }) }) }) } // start: install().catch((e) => { throw e }) ================================================ FILE: packaging/registries/npm-installer/package.json ================================================ { "name": "@evilmartians/lefthook-installer", "version": "2.1.4", "description": "Simple git hooks manager", "main": "bin/index.js", "files": [ "install.js", "schema.json" ], "bin": { "lefthook": "bin/index.js" }, "repository": { "type": "git", "url": "git+https://github.com/evilmartians/lefthook.git" }, "keywords": [ "git", "hook", "manager" ], "author": "mrexox", "license": "MIT", "bugs": { "url": "https://github.com/evilmartians/lefthook/issues", "email": "lefthook@evilmartians.com" }, "homepage": "https://github.com/evilmartians/lefthook#readme", "os": [ "darwin", "linux", "win32" ], "cpu": [ "x64", "arm64" ], "scripts": { "install": "node install.js" } } ================================================ FILE: packaging/registries/pypi/LICENSE ================================================ The MIT License (MIT) Copyright (c) 2019 Arkweid Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: packaging/registries/pypi/README.md ================================================ ![Build Status](https://github.com/evilmartians/lefthook/actions/workflows/test.yml/badge.svg?branch=master) [![codecov](https://codecov.io/gh/evilmartians/lefthook/graph/badge.svg?token=d93ya8MfmB)](https://codecov.io/gh/evilmartians/lefthook) # Lefthook > The fastest polyglot Git hooks manager out there A Git hooks manager for Node.js, Ruby and many other types of projects. * **Fast.** It is written in Go. Can run commands in parallel. * **Powerful.** It allows to control execution and files you pass to your commands. * **Simple.** It is single dependency-free binary which can work in any environment. 📖 [Read the introduction post](https://evilmartians.com/chronicles/lefthook-knock-your-teams-code-back-into-shape?utm_source=lefthook) Sponsored by Evil Martians ## Install ```bash pip install lefthook ``` ## Usage Configure your hooks, install them once and forget about it: rely on the magic underneath. #### TL;DR ```bash # Configure your hooks vim lefthook.yml # Install them to the git project lefthook install # Enjoy your work with git git add -A && git commit -m '...' ``` #### More details - [**Configuration**](https://github.com/evilmartians/lefthook/blob/master/docs/configuration.md) for `lefthook.yml` config options. - [**Usage**](https://github.com/evilmartians/lefthook/blob/master/docs/usage.md) for **lefthook** CLI options, supported ENVs, and usage tips. - [**Discussions**](https://github.com/evilmartians/lefthook/discussions) for questions, ideas, suggestions. ================================================ FILE: packaging/registries/pypi/hatch_build.py ================================================ import atexit import os import platform import shutil import sys import tempfile from pathlib import Path from hatchling.builders.hooks.plugin.interface import BuildHookInterface PLATFORM_MAPPING = { 'linux': 'linux', 'linux2': 'linux', 'darwin': 'darwin', 'win32': 'windows', 'windows': 'windows', 'freebsd': 'freebsd', 'openbsd': 'openbsd', } ARCH_MAPPING = { 'x86_64': 'x86_64', 'amd64': 'x86_64', 'arm64': 'arm64', 'aarch64': 'arm64', } PEP425_TAGS = { ("linux", "x86_64"): "py3-none-manylinux_2_17_x86_64", ("linux", "arm64"): "py3-none-manylinux_2_17_aarch64", ("darwin", "x86_64"): "py3-none-macosx_10_15_x86_64", ("darwin", "arm64"): "py3-none-macosx_11_0_arm64", ("windows", "x86_64"): "py3-none-win_amd64", ("windows", "arm64"): "py3-none-win_arm64", } def normalize_platform(value: str) -> str: if not value: return value return PLATFORM_MAPPING.get(value.lower(), value.lower()) def normalize_arch(value: str) -> str: if not value: return value return ARCH_MAPPING.get(value.lower(), value.lower()) def get_platform_info(): target_platform = os.environ.get('LEFTHOOK_TARGET_PLATFORM') target_arch = os.environ.get('LEFTHOOK_TARGET_ARCH') if target_platform and target_arch: normalized_platform = normalize_platform(target_platform) normalized_arch = normalize_arch(target_arch) print(f"[HOOK] Using target: {normalized_platform}-{normalized_arch}") return normalized_platform, normalized_arch system = normalize_platform(sys.platform) or normalize_platform(platform.system()) machine = normalize_arch(platform.machine()) result = system, machine print(f"[HOOK] Auto-detected: {result[0]}-{result[1]}") return result class CustomBuildHook(BuildHookInterface): PLUGIN_NAME = "custom" def __init__(self, *args, **kwargs) -> None: super().__init__(*args, **kwargs) self.target_platform = None self.target_arch = None self._temp_dir = None self._moved_entries = [] self._restore_registered = False def initialize(self, version, build_data): target_platform, target_arch = get_platform_info() self.target_platform = target_platform self.target_arch = target_arch tag = PEP425_TAGS.get((target_platform, target_arch)) if tag: build_data["tag"] = tag self._prune_binaries() if not self._restore_registered: atexit.register(self._restore_binaries) self._restore_registered = True print(f"[HOOK] Building platform wheel {tag}") else: print( "[HOOK] No PEP425 tag for " f"{target_platform}-{target_arch}; building universal wheel." ) print(f"[HOOK] Initialized for {target_platform}-{target_arch}") def finalize(self, version, build_data, artifact_path) -> None: print(f"[HOOK] Built artifact: {artifact_path}") self._restore_binaries() def _prune_binaries(self): if not self.target_platform or not self.target_arch: raise RuntimeError("Target platform is not set before pruning binaries.") bin_dir = Path(self.root) / "lefthook" / "bin" if not bin_dir.is_dir(): raise RuntimeError(f"Bin directory not found: {bin_dir}") target_dir_name = f"lefthook-{self.target_platform}-{self.target_arch}" target_dir = bin_dir / target_dir_name if not target_dir.exists(): available = ", ".join(sorted(p.name for p in bin_dir.iterdir() if p.is_dir())) raise FileNotFoundError( f"Binary folder '{target_dir_name}' is missing. Available: {available or 'none'}" ) binaries = list(target_dir.glob("lefthook*")) if not binaries: raise FileNotFoundError( f"No lefthook binary found under {target_dir}." ) self._temp_dir = Path(tempfile.mkdtemp(prefix="lefthook-bin-backup-")) preserved = {target_dir_name, ".keep"} for entry in bin_dir.iterdir(): if entry.name in preserved: continue destination = self._temp_dir / entry.name shutil.move(str(entry), str(destination)) self._moved_entries.append((destination, entry)) print(f"[HOOK] Shipped binaries: {target_dir_name}") def _restore_binaries(self): while self._moved_entries: backup_path, original_path = self._moved_entries.pop() if backup_path.exists(): shutil.move(str(backup_path), str(original_path)) if self._temp_dir and self._temp_dir.exists(): shutil.rmtree(self._temp_dir, ignore_errors=True) self._temp_dir = None ================================================ FILE: packaging/registries/pypi/lefthook/__init__.py ================================================ ================================================ FILE: packaging/registries/pypi/lefthook/__main__.py ================================================ import sys from .main import main if __name__ == "__main__": sys.exit(main()) ================================================ FILE: packaging/registries/pypi/lefthook/bin/.keep ================================================ ================================================ FILE: packaging/registries/pypi/lefthook/main.py ================================================ import os import sys import platform import subprocess ISSUE_URL = "https://github.com/evilmartians/lefthook/issues/new/choose" ARCH_MAPPING = { 'amd64': 'x86_64', 'aarch64': 'arm64', } def main(): os_name = platform.system().lower() arch = platform.machine().lower() arch = ARCH_MAPPING.get(arch, arch) ext = os_name == "windows" and ".exe" or "" subfolder = f"lefthook-{os_name}-{arch}" executable = os.path.join(os.path.dirname(__file__), "bin", subfolder, "lefthook"+ext) if not os.path.isfile(executable): print(f"Couldn't find binary {executable}. Please create an issue: {ISSUE_URL}") return 1 result = subprocess.run([executable] + sys.argv[1:]) return result.returncode ================================================ FILE: packaging/registries/pypi/pyproject.toml ================================================ [project] name = "lefthook" version = "2.1.4" description = "Git hooks manager. Fast, powerful, simple." readme = "README.md" license = "MIT" license-files = ["LICENSE"] authors = [ {name = "Evil Martians", email = "lefthook@evilmartians.com"} ] requires-python = ">=3.6" classifiers = [ "Operating System :: OS Independent", "Topic :: Software Development :: Version Control :: Git", ] [project.urls] Homepage = "https://github.com/evilmartians/lefthook" [project.scripts] lefthook = "lefthook.main:main" [build-system] requires = ["hatchling"] build-backend = "hatchling.build" [tool.hatch.build.targets.wheel] packages = ["lefthook"] artifacts = ["lefthook"] [tool.hatch.build.hooks.custom] path = "hatch_build.py" [tool.hatch.build.targets.sdist] include = [ "lefthook/", ] [[tool.uv.index]] name = "pypi" url = "https://pypi.org/simple/" default = true ================================================ FILE: packaging/registries/rubygems/Gemfile ================================================ source "https://rubygems.org" # Specify your gem's dependencies in lefthook.gemspec gemspec ================================================ FILE: packaging/registries/rubygems/README.md ================================================ # Lefthook Ruby wrapper around [lefthook](https://github.com/evilmartians/lefthook) ================================================ FILE: packaging/registries/rubygems/Rakefile ================================================ require "bundler/gem_tasks" task :default => :spec ================================================ FILE: packaging/registries/rubygems/bin/lefthook ================================================ #!/usr/bin/env ruby require "rubygems" platform = Gem::Platform.new(RUBY_PLATFORM) arch = case platform.cpu.sub(/\Auniversal\./, '') when /\Aarm64/ then "arm64" # Apple reports arm64e on M1 macs when /aarch64/ then "arm64" when "x86_64" then "x64" when "x64" then "x64" # Windows with MINGW64 reports RUBY_PLATFORM as "x64-mingw32" else raise "Unknown architecture: #{platform.cpu}" end os = case platform.os when "linux" then "linux" when "darwin" then "darwin" # MacOS when "windows" then "windows" when "mingw32" then "windows" # Windows with MINGW64 reports RUBY_PLATFORM as "x64-mingw32" when "mingw" then "windows" when "freebsd" then "freebsd" when "openbsd" then "openbsd" else raise "Unknown OS: #{platform.os}" end binary = "lefthook-#{os}-#{arch}/lefthook" binary = "#{binary}.exe" if os == "windows" args = $*.map { |x| x.include?(' ') ? "'" + x + "'" : x } cmd = File.expand_path "#{File.dirname(__FILE__)}/../libexec/#{binary}" unless File.exist?(cmd) raise "Invalid platform. Lefthook wasn't build for #{RUBY_PLATFORM}" end pid = spawn("#{cmd} #{args.join(' ')}") Process.wait(pid) exit($?.exitstatus) ================================================ FILE: packaging/registries/rubygems/lefthook.gemspec ================================================ Gem::Specification.new do |spec| spec.name = "lefthook" spec.version = "2.1.4" spec.authors = ["A.A.Abroskin", "Evil Martians"] spec.email = ["lefthook@evilmartians.com"] spec.summary = "A single dependency-free binary to manage all your git hooks that works with any language in any environment, and in all common team workflows." spec.homepage = "https://github.com/evilmartians/lefthook" spec.post_install_message = "Lefthook installed! Run command in your project root directory 'lefthook install -f' to complete installation." spec.bindir = "bin" spec.executables << "lefthook" spec.require_paths = ["lib"] spec.files = %w( lib/lefthook.rb bin/lefthook ) + `find libexec/ -executable -type f -print0`.split("\x0") spec.licenses = ['MIT'] end ================================================ FILE: packaging/registries/rubygems/lib/lefthook.rb ================================================ ================================================ FILE: packaging/registries/rubygems/libexec/.keep ================================================ ================================================ FILE: packaging/scripts/META6.json ================================================ { "name": "Lefthook-Packager", "test-depends": [ "File::Temp" ] } ================================================ FILE: packaging/scripts/clean.raku ================================================ #! /usr/bin/env raku use v6; use lib $?FILE.IO.parent.child("lib"); use Packager; use Registry :Target; sub MAIN( Registry::Target :$target = all-registries, Bool :$dry-run = False, ) { Packager.new( target => $target, dry-run => $dry-run, ).clean; } ================================================ FILE: packaging/scripts/lib/Constants.rakumod ================================================ # Current lefthook version. constant VERSION = "2.1.4"; # Git root. constant REPO-ROOT = $?FILE.IO.parent(4); # /packages/registries/ constant PKG-ROOT = $?FILE.IO.parent(3).child("registries"); my constant DIST-ROOT = REPO-ROOT.child("dist"); # Supported platforms and architectures. constant %DISTS = ( amd64-linux => "{DIST-ROOT}/no_self_update_linux_amd64_v1/lefthook", amd64-windows => "{DIST-ROOT}/no_self_update_windows_amd64_v1/lefthook.exe", amd64-darwin => "{DIST-ROOT}/no_self_update_darwin_amd64_v1/lefthook", amd64-freebsd => "{DIST-ROOT}/no_self_update_freebsd_amd64_v1/lefthook", amd64-openbsd => "{DIST-ROOT}/no_self_update_openbsd_amd64_v1/lefthook", arm64-linux => "{DIST-ROOT}/no_self_update_linux_arm64_v8.0/lefthook", arm64-windows => "{DIST-ROOT}/no_self_update_windows_arm64_v8.0/lefthook.exe", arm64-darwin => "{DIST-ROOT}/no_self_update_darwin_arm64_v8.0/lefthook", arm64-freebsd => "{DIST-ROOT}/no_self_update_freebsd_arm64_v8.0/lefthook", arm64-openbsd => "{DIST-ROOT}/no_self_update_openbsd_arm64_v8.0/lefthook", ); ================================================ FILE: packaging/scripts/lib/Packager.rakumod ================================================ # Software development should not just bring profit, it also has to be fun. # Of course Raku isn't the perfect tool for scripting, but it is quite expressive, # it has types, and it feels like real magic. # # I hope that reading through these scripts will show you # a lot of interesting concepts... and definitely fun! unit class Packager; use System; use Registry; use Registries::NPM; use Registries::RubyGems; use Registries::PyPI; use Registries::AUR; use Registries::AUR-Bin; my constant @PACKAGE-TYPES = ( Registries::NPM, Registries::RubyGems, Registries::PyPI, Registries::AUR, Registries::AUR-Bin, ); has Bool $.dry-run is required; has Registry::Target $.target is required; method clean(--> Nil) { .clean for self!packages } method set-version(--> Nil) { .set-version for self!packages } method prepare(--> Nil) { .prepare for self!packages } method publish(--> Nil) { .publish for self!packages } method !packages(--> Seq) { my $sys = System.new(dry-run => $!dry-run); my @packages = @PACKAGE-TYPES.map({ .new(sys => $sys) }); return @packages.Seq if $!target == Registry::Target::all-registries; @packages.grep(*.target == $!target); } ================================================ FILE: packaging/scripts/lib/Registries/AUR/Publishing.rakumod ================================================ use Constants; use SystemAPI; unit module Registries::AUR::Publishing; # Updates the AUR Git repo with the new version. sub publish-aur-package( Str:D :$name!, :%sha256-urls!, IO() :$path-to-pkgbuild!, SystemAPI :$sys!, --> Nil ) is export { my $clone-to = PKG-ROOT.child("{$name}-aur"); my $dest-pkgbuild = $clone-to.child("PKGBUILD"); $sys.in-dir(PKG-ROOT, { clone-aur-repo($sys, $name, $clone-to); copy-pkgbuild($sys, $path-to-pkgbuild, $dest-pkgbuild); fill-sha256-sums($sys, $dest-pkgbuild, %sha256-urls); }); $sys.in-dir($clone-to, { $sys.run("sh", "-c", "makepkg --printsrcinfo > .SRCINFO"); $sys.run("makepkg", "--noconfirm"); $sys.run("makepkg", "--install", "--noconfirm"); $sys.run("git", "config", "user.name", "github-actions[bot]"); $sys.run("git", "config", "user.email", "github-actions[bot]@users.noreply.github.com"); $sys.run("git", "add", "PKGBUILD", ".SRCINFO"); $sys.run("git", "commit", "-m", "release v{VERSION}"); $sys.run("git", "push", "origin", "master"); }); } sub clone-aur-repo(SystemAPI $sys, Str:D $name, IO() $clone-to --> Nil) { $sys.run("git", "clone", "ssh://aur@aur.archlinux.org/{$name}.git", $clone-to); } sub copy-pkgbuild(SystemAPI $sys, IO() $from, IO() $to --> Nil) { $sys.cp($from, $to); } sub fill-sha256-sums( SystemAPI $sys, IO() $pkgbuild, %sha256-urls, --> Nil ) { for %sha256-urls.kv -> $template-name, $url { my $sha256sum = fetch-sha256($url); $sys.replace( file => $pkgbuild, regex => /'{{ ' $template-name ' }}'/, replacement => $sha256sum, ); } } # Fetches the binary data by $url and returns SHA256 on it. sub fetch-sha256(Str:D $url --> Str:D) { say "Fetching SHA256 for $url"; my $curl = run("curl", "-fsSL", $url, :out, :bin); my $sha256sum = run("sha256sum", "-", :in($curl.out), :out); $sha256sum.out.slurp(:close).words.head; } ================================================ FILE: packaging/scripts/lib/Registries/AUR-Bin.rakumod ================================================ use Registry; unit class Registries::AUR-Bin does Registry::Package; use Constants; use SystemAPI; use Registries::AUR::Publishing; my constant PKGBUILD = PKG-ROOT.child("aur").child("lefthook-bin").child("PKGBUILD"); has SystemAPI $.sys is required; method target(--> Registry::Target:D) { Registry::Target::aur-bin } method clean {} method set-version { $!sys.replace( file => PKGBUILD, regex => /pkgver\s*'='.*$/, replacement => "pkgver={VERSION}", ); } method prepare {} method publish { publish-aur-package( name => "lefthook-bin", sha256-urls => { sha256sum_linux_x86_64 => "https://github.com/evilmartians/lefthook/releases/download/v{VERSION}/lefthook_{VERSION}_Linux_x86_64.gz", sha256sum_linux_aarch64 => "https://github.com/evilmartians/lefthook/releases/download/v{VERSION}/lefthook_{VERSION}_Linux_aarch64.gz" }, path-to-pkgbuild => PKGBUILD, sys => $!sys, ); } ================================================ FILE: packaging/scripts/lib/Registries/AUR.rakumod ================================================ use Registry; unit class Registries::AUR does Registry::Package; use Constants; use SystemAPI; use Registries::AUR::Publishing; my constant PKGBUILD = PKG-ROOT.child("aur").child("lefthook").child("PKGBUILD"); has SystemAPI $.sys is required; method target(--> Registry::Target:D) { Registry::Target::aur } method clean {} method set-version { $!sys.replace( file => PKGBUILD, regex => /pkgver\s*'='.*$/, replacement => "pkgver={VERSION}", ); } method prepare {} method publish { publish-aur-package( name => "lefthook", sha256-urls => { sha256sum => "https://github.com/evilmartians/lefthook/archive/v{VERSION}.tar.gz", }, path-to-pkgbuild => PKGBUILD, sys => $!sys, ); } ================================================ FILE: packaging/scripts/lib/Registries/NPM.rakumod ================================================ use Registry; unit class Registries::NPM does Registry::Package; use Constants; use SystemAPI; my constant NPM = PKG-ROOT.child("npm"); my constant NPM-BUNDLED = PKG-ROOT.child("npm-bundled"); my constant NPM-INSTALLER = PKG-ROOT.child("npm-installer"); my constant @READMES = qq:to/END/.lines.map(*.trim); {NPM}/lefthook/README.md {NPM}/lefthook-darwin-arm64/README.md {NPM}/lefthook-darwin-x64/README.md {NPM}/lefthook-linux-arm64/README.md {NPM}/lefthook-linux-x64/README.md {NPM}/lefthook-windows-arm64/README.md {NPM}/lefthook-windows-x64/README.md {NPM}/lefthook-freebsd-arm64/README.md {NPM}/lefthook-freebsd-x64/README.md {NPM}/lefthook-openbsd-arm64/README.md {NPM}/lefthook-openbsd-x64/README.md {NPM-BUNDLED}/README.md {NPM-INSTALLER}/README.md END my constant @PACKAGES = qq:to/END/.lines.map(*.trim); {NPM}/lefthook-darwin-arm64/ {NPM}/lefthook-darwin-x64/ {NPM}/lefthook-linux-arm64/ {NPM}/lefthook-linux-x64/ {NPM}/lefthook-windows-arm64/ {NPM}/lefthook-windows-x64/ {NPM}/lefthook-freebsd-arm64/ {NPM}/lefthook-freebsd-x64/ {NPM}/lefthook-openbsd-arm64/ {NPM}/lefthook-openbsd-x64/ {NPM}/lefthook/ {NPM-BUNDLED} {NPM-INSTALLER} END my constant @PACKAGE-JSONS = @PACKAGES.map(*.IO.child("package.json")); my constant @SCHEMAS = qq:to/END/.lines.map(*.trim); {NPM}/lefthook/schema.json {NPM-BUNDLED}/schema.json {NPM-INSTALLER}/schema.json END has SystemAPI $.sys is required; my constant %NPM-DISTS = ( amd64-linux => "{NPM}/lefthook-linux-x64/bin/lefthook", amd64-windows => "{NPM}/lefthook-windows-x64/bin/lefthook.exe", amd64-darwin => "{NPM}/lefthook-darwin-x64/bin/lefthook", amd64-freebsd => "{NPM}/lefthook-freebsd-x64/bin/lefthook", amd64-openbsd => "{NPM}/lefthook-openbsd-x64/bin/lefthook", arm64-linux => "{NPM}/lefthook-linux-arm64/bin/lefthook", arm64-windows => "{NPM}/lefthook-windows-arm64/bin/lefthook.exe", arm64-darwin => "{NPM}/lefthook-darwin-arm64/bin/lefthook", arm64-freebsd => "{NPM}/lefthook-freebsd-arm64/bin/lefthook", arm64-openbsd => "{NPM}/lefthook-openbsd-arm64/bin/lefthook", ); my constant %NPM-BUNDLED-DISTS = ( amd64-linux => "{NPM-BUNDLED}/bin/lefthook-linux-x64/lefthook", amd64-windows => "{NPM-BUNDLED}/bin/lefthook-windows-x64/lefthook.exe", amd64-darwin => "{NPM-BUNDLED}/bin/lefthook-darwin-x64/lefthook", amd64-freebsd => "{NPM-BUNDLED}/bin/lefthook-freebsd-x64/lefthook", amd64-openbsd => "{NPM-BUNDLED}/bin/lefthook-openbsd-x64/lefthook", arm64-linux => "{NPM-BUNDLED}/bin/lefthook-linux-arm64/lefthook", arm64-windows => "{NPM-BUNDLED}/bin/lefthook-windows-arm64/lefthook.exe", arm64-darwin => "{NPM-BUNDLED}/bin/lefthook-darwin-arm64/lefthook", arm64-freebsd => "{NPM-BUNDLED}/bin/lefthook-freebsd-arm64/lefthook", arm64-openbsd => "{NPM-BUNDLED}/bin/lefthook-openbsd-arm64/lefthook", ); method target(--> Registry::Target:D) { Registry::Target::npm } method clean { $!sys.rm( |@READMES, |@SCHEMAS, |%NPM-DISTS.values, |%NPM-BUNDLED-DISTS.values, ) } method set-version { for @PACKAGE-JSONS -> $path { $!sys.replace( file => $path, regex => /'"version":' \s* '"' <[\d\w.]>+ '"'/, replacement => qq["version": "{VERSION}"], ); } # Update optional dependencies for the main lefthook package $!sys.replace( file => "{NPM}/lefthook/package.json", regex => /'"' $=(lefthook '-' <[\d\w-]>+) '":' \s* '"' <[\d\w.]>+ '"'/, replacement => -> $/ { qq["$": "{VERSION}"] }, ); } method prepare { $!sys.cp("{REPO-ROOT}/README.md", $_) for @READMES; $!sys.cp("{REPO-ROOT}/schema.json", $_) for @SCHEMAS; die "npm/ setup is not complete" unless %DISTS.keys.Set == %NPM-DISTS.keys.Set; die "NPM-BUNDLED/ setup is not complete" unless %DISTS.keys.Set == %NPM-BUNDLED-DISTS.keys.Set; for %DISTS.kv -> $platform, $source { $!sys.cp($source, %NPM-DISTS{$platform}); $!sys.cp($source, %NPM-BUNDLED-DISTS{$platform}); } } method publish { for @PACKAGES -> $package { say "Publish {$package.IO.basename}"; $!sys.in-dir($package, { $!sys.run("npm", "publish", "--access", "public"); }); } } ================================================ FILE: packaging/scripts/lib/Registries/PyPI.rakumod ================================================ use Registry; unit class Registries::PyPI does Registry::Package; use Constants; use SystemAPI; my constant PYPI = PKG-ROOT.child("pypi"); my constant %PYPI-DISTS = { amd64-linux => "{PYPI}/lefthook/bin/lefthook-linux-x86_64/lefthook", amd64-windows => "{PYPI}/lefthook/bin/lefthook-windows-x86_64/lefthook.exe", amd64-darwin => "{PYPI}/lefthook/bin/lefthook-darwin-x86_64/lefthook", amd64-freebsd => "{PYPI}/lefthook/bin/lefthook-freebsd-x86_64/lefthook", amd64-openbsd => "{PYPI}/lefthook/bin/lefthook-openbsd-x86_64/lefthook", arm64-linux => "{PYPI}/lefthook/bin/lefthook-linux-arm64/lefthook", arm64-windows => "{PYPI}/lefthook/bin/lefthook-windows-arm64/lefthook.exe", arm64-darwin => "{PYPI}/lefthook/bin/lefthook-darwin-arm64/lefthook", arm64-freebsd => "{PYPI}/lefthook/bin/lefthook-freebsd-arm64/lefthook", arm64-openbsd => "{PYPI}/lefthook/bin/lefthook-openbsd-arm64/lefthook", }; my constant @PLATFORMS = ( ("linux", "x86_64"), ("windows", "x86_64"), ("darwin", "x86_64"), ("linux", "arm64"), ("windows", "arm64"), ("darwin", "arm64"), ); has SystemAPI $.sys is required; method target(--> Registry::Target:D) { Registry::Target::pypi } method clean { $!sys.rm( "{PYPI}/lefthook/__pycache__/", "{PYPI}/lefthook/bin/".IO.dir.grep(*.basename ne ".keep"), "{PYPI}/lefthook.egg-info/", "{PYPI}/build/", ) } method set-version { $!sys.replace( file => "{PYPI}/pyproject.toml", regex => /^ \s* version \s* '=' .+ $/, replacement => qq[version = "{VERSION}"], ); } method prepare { die "PYPI/ setup is not complete" unless %PYPI-DISTS.keys.Set == %DISTS.keys.Set; for %DISTS.kv -> $platform, $source { $!sys.cp($source, %PYPI-DISTS{$platform}); } } method publish { $!sys.in-dir(PYPI, { for @PLATFORMS { my ($os, $arch) = $_; say "Build wheel for $os-$arch"; %*ENV = $os; %*ENV = $arch; $!sys.run("uv", "build", "--wheel"); } $!sys.run("uv", "publish"); }); } ================================================ FILE: packaging/scripts/lib/Registries/RubyGems.rakumod ================================================ use Registry; unit class Registries::RubyGems does Registry::Package; use Constants; use SystemAPI; my constant RUBYGEMS = PKG-ROOT.child("rubygems"); my constant %RUBYGEM-DISTS = ( amd64-linux => "{RUBYGEMS}/libexec/lefthook-linux-x64/lefthook", amd64-windows => "{RUBYGEMS}/libexec/lefthook-windows-x64/lefthook.exe", amd64-darwin => "{RUBYGEMS}/libexec/lefthook-darwin-x64/lefthook", amd64-freebsd => "{RUBYGEMS}/libexec/lefthook-freebsd-x64/lefthook", amd64-openbsd => "{RUBYGEMS}/libexec/lefthook-openbsd-x64/lefthook", arm64-linux => "{RUBYGEMS}/libexec/lefthook-linux-arm64/lefthook", arm64-windows => "{RUBYGEMS}/libexec/lefthook-windows-arm64/lefthook.exe", arm64-darwin => "{RUBYGEMS}/libexec/lefthook-darwin-arm64/lefthook", arm64-freebsd => "{RUBYGEMS}/libexec/lefthook-freebsd-arm64/lefthook", arm64-openbsd => "{RUBYGEMS}/libexec/lefthook-openbsd-arm64/lefthook", ); has SystemAPI $.sys is required; method target(--> Registry::Target:D) { Registry::Target::rubygems } method clean { $!sys.rm("{RUBYGEMS}/libexec/".IO.dir.grep(*.d)); $!sys.rm("{RUBYGEMS}/pkg/".IO); } method set-version { $!sys.replace( file => "{RUBYGEMS}/lefthook.gemspec", regex => /$=(spec '.' version \s* '=') .* $/, replacement => -> $/ { qq[$ "{VERSION}"] }, ); } method prepare { die "rubygems/ setup is not complete" unless %RUBYGEM-DISTS.keys.Set == %DISTS.keys.Set; for %DISTS.kv -> $platform, $source { $!sys.cp($source, %RUBYGEM-DISTS{$platform}); } } method publish { say "Publish lefthook gem"; $!sys.in-dir(RUBYGEMS, { $!sys.run("rake", "build"); }); my $pkg-dir = RUBYGEMS.child("pkg"); my $last-pkg = $pkg-dir.IO.dir.sort(*.basename).tail // die "no gem found in $pkg-dir"; $!sys.run("gem", "push", $last-pkg); } ================================================ FILE: packaging/scripts/lib/Registry.rakumod ================================================ unit module Registry; # Supported regitstries. enum Target is export(:Target) < all-registries npm rubygems pypi aur aur-bin >; # Abstract interface for a registry class to implement. role Package { method target(--> Target:D) { ... } method clean(--> Nil) { ... } method set-version(--> Nil) { ... } method prepare(--> Nil) { ... } method publish(--> Nil) { ... } } ================================================ FILE: packaging/scripts/lib/System.rakumod ================================================ use SystemAPI; # Provides wrappers for interaction with file system. class System does SystemAPI { has Bool $.dry-run is required; # Removes file or dir recursively. multi method rm(@paths --> Nil) { for @paths -> $path { next unless $path.IO.e; say "rm " ~ $path; next if $!dry-run; self!rm-r($path); }; } # Changes current dir and execute the &block. method in-dir(IO() $path, &block --> Nil) { my $old = $*CWD; say "cd $path"; chdir $path unless $!dry-run; LEAVE { say "cd $old"; chdir $old unless $!dry-run; } # like defer in Go block(); } # Copies a file. Creates parent dirs for $dest if needed. method cp(IO() $source, IO() $dest --> Nil) { say "cp $source -> $dest"; return if $!dry-run; mkdir $dest.dirname unless $dest.IO.parent.e; $source.IO.copy($dest) unless $!dry-run; } # Replaces text in a $file line-by-line. method replace(IO() :$file, Regex :$regex, :$replacement --> Nil) { say "replace in $file\n\t{$regex.gist} -> {$replacement.gist}"; return if $!dry-run; die "$file does not exist" unless $file.f; spurt $file, $file.slurp.lines.map({ .subst($regex, $replacement) }).join("\n") ~ "\n"; } # Runs the command. method run(*@argv --> Nil) { say "run {@argv.join(' ')}"; return if $!dry-run; my $proc = run(|@argv, :out, :err); my $out = $proc.out.slurp(:close); my $err = $proc.err.slurp(:close); print $out if $out.chars; note $err if $err.chars; die "failed: {@argv.join(' ')} --> {$proc.exitcode}" if $proc.exitcode != 0; } method !rm-r(IO() $path --> Nil) { return unless $path.e; if $path.f { $path.unlink; return; } die "not a file/dir: $path" unless $path.d; for $path.dir -> $entry { self!rm-r($entry); } $path.rmdir; } } ================================================ FILE: packaging/scripts/lib/SystemAPI.rakumod ================================================ unit role SystemAPI; multi method rm(*@paths --> Nil) { self.rm(@paths) } multi method rm(@paths --> Nil) { ... } method in-dir(IO() $path, &block --> Nil) { ... } method cp(IO() $source, IO() $dest --> Nil) { ... } method replace(IO() :$file, Regex :$regex, :$replacement --> Nil) { ... } method run(*@argv --> Nil) {... } ================================================ FILE: packaging/scripts/prepare.raku ================================================ #! /usr/bin/env raku use v6; use lib $?FILE.IO.parent.child("lib"); use Packager; use Registry :Target; sub MAIN( Registry::Target :$target = all-registries, Bool :$dry-run = False, ) { Packager.new( target => $target, dry-run => $dry-run, ).prepare; } ================================================ FILE: packaging/scripts/publish.raku ================================================ #! /usr/bin/env raku use v6; use lib $?FILE.IO.parent.child("lib"); use Packager; use Registry :Target; sub MAIN( Registry::Target :$target = all-registries, Bool :$dry-run = False, ) { Packager.new( target => $target, dry-run => $dry-run, ).publish; } ================================================ FILE: packaging/scripts/set-version.raku ================================================ #! /usr/bin/env raku use v6; use lib $?FILE.IO.parent.child("lib"); use Packager; use Registry :Target; sub MAIN( Registry::Target :$target = all-registries, Bool :$dry-run = False, ) { Packager.new( target => $target, dry-run => $dry-run, ).set-version; } ================================================ FILE: packaging/scripts/t/01-system.rakutest ================================================ use Test; use File::Temp; use System; my $sys = System.new(dry-run => False); subtest "rm(*@paths)", { my ($tmp-one) = tempfile; my ($tmp-two) = tempfile; my $tmp-dir = tempdir; my $sub1-dir = $tmp-dir.IO.child("sub1"); my $sub2-dir = $sub1-dir.child("sub2"); mkdir $sub2-dir; ok $tmp-one.IO.e, "exists"; ok $tmp-two.IO.e, "exists"; ok $sub1-dir.IO.e, "exists"; ok $sub2-dir.IO.e, "exists"; $sys.rm($tmp-one, $tmp-two, $sub1-dir); nok $tmp-one.IO.e, "removes $tmp-one"; nok $tmp-two.IO.e, "removes $tmp-two"; nok $sub1-dir.IO.e, "removes $sub1-dir"; nok $sub2-dir.IO.e, "removes $sub2-dir"; ok $tmp-dir.IO.e, "keeps parent $tmp-dir"; } subtest "rm(@paths)", { my ($tmp-one) = tempfile; my ($tmp-two) = tempfile; ok $tmp-one.IO.e, "exists"; ok $tmp-two.IO.e, "exists"; $sys.rm([$tmp-one, $tmp-two]); nok $tmp-one.IO.e, "removes $tmp-one"; nok $tmp-two.IO.e, "removes $tmp-two"; } subtest "in-dir", { my $dir = tempdir; isnt $*CWD, $dir, "not in a temp dir"; $sys.in-dir($dir, { is $*CWD, $dir, "in a temp dir"; }); isnt $*CWD, $dir, "not in a temp dir"; } subtest "cp", { my ($tmp-file) = tempfile; my $tmp-dir = tempdir; my $dest = $tmp-dir.IO.child('subdir').child($tmp-file.IO.basename); nok $dest.e, "not copied"; nok $dest.parent.e, "parent doesn't exist"; $sys.cp($tmp-file, $dest); ok $dest.e, "copied"; ok $dest.parent.e, "parent exists"; } subtest "replace", { my ($tmp-file) = tempfile; spurt $tmp-file.IO, qq:to/END/; version = "1.0.0" description = "lefthook is a Git hooks manager" END $sys.replace( file => $tmp-file, regex => /version \s* '=' .*$/, replacement => 'version = "2.0.0"', ); my $result = $tmp-file.IO.slurp; is $result, qq:to/END/, "replaces successfully"; version = "2.0.0" description = "lefthook is a Git hooks manager" END $sys.replace( file => $tmp-file, regex => /description \s* '=' \s* '"' $=(<[\w]>+).*/, replacement => -> $/ { qq[description = "$ is cool"] }, ); $result = $tmp-file.IO.slurp; is $result, qq:to/END/, "replaces successfully"; version = "2.0.0" description = "lefthook is cool" END } subtest "run", { my Str $said; temp $*OUT = class { method print(*@s) { $said ~= @s.join } } $sys.run("echo", "'Hello'"); is $said, q:to/END/, "called echo"; run echo 'Hello' 'Hello' END } done-testing; ================================================ FILE: packaging/scripts/t/02-npm.rakutest ================================================ use Test; use Constants; use Registries::NPM; use lib $?FILE.IO.parent.child("lib"); use TestRegistry; subtest "clean", { my ($sys, $npm) = new-registry(Registries::NPM); $npm.clean; is-deeply $sys.removed.Set, ( "{PKG-ROOT}/npm/lefthook/schema.json", "{PKG-ROOT}/npm-bundled/schema.json", "{PKG-ROOT}/npm-installer/schema.json", "{PKG-ROOT}/npm/lefthook/README.md", "{PKG-ROOT}/npm/lefthook-darwin-arm64/README.md", "{PKG-ROOT}/npm/lefthook-darwin-x64/README.md", "{PKG-ROOT}/npm/lefthook-linux-arm64/README.md", "{PKG-ROOT}/npm/lefthook-linux-x64/README.md", "{PKG-ROOT}/npm/lefthook-windows-arm64/README.md", "{PKG-ROOT}/npm/lefthook-windows-x64/README.md", "{PKG-ROOT}/npm/lefthook-freebsd-arm64/README.md", "{PKG-ROOT}/npm/lefthook-freebsd-x64/README.md", "{PKG-ROOT}/npm/lefthook-openbsd-arm64/README.md", "{PKG-ROOT}/npm/lefthook-openbsd-x64/README.md", "{PKG-ROOT}/npm-bundled/README.md", "{PKG-ROOT}/npm-installer/README.md", "{PKG-ROOT}/npm/lefthook-linux-x64/bin/lefthook", "{PKG-ROOT}/npm/lefthook-windows-x64/bin/lefthook.exe", "{PKG-ROOT}/npm/lefthook-darwin-x64/bin/lefthook", "{PKG-ROOT}/npm/lefthook-freebsd-x64/bin/lefthook", "{PKG-ROOT}/npm/lefthook-openbsd-x64/bin/lefthook", "{PKG-ROOT}/npm/lefthook-linux-arm64/bin/lefthook", "{PKG-ROOT}/npm/lefthook-windows-arm64/bin/lefthook.exe", "{PKG-ROOT}/npm/lefthook-darwin-arm64/bin/lefthook", "{PKG-ROOT}/npm/lefthook-freebsd-arm64/bin/lefthook", "{PKG-ROOT}/npm/lefthook-openbsd-arm64/bin/lefthook", "{PKG-ROOT}/npm-bundled/bin/lefthook-linux-x64/lefthook", "{PKG-ROOT}/npm-bundled/bin/lefthook-windows-x64/lefthook.exe", "{PKG-ROOT}/npm-bundled/bin/lefthook-darwin-x64/lefthook", "{PKG-ROOT}/npm-bundled/bin/lefthook-freebsd-x64/lefthook", "{PKG-ROOT}/npm-bundled/bin/lefthook-openbsd-x64/lefthook", "{PKG-ROOT}/npm-bundled/bin/lefthook-linux-arm64/lefthook", "{PKG-ROOT}/npm-bundled/bin/lefthook-windows-arm64/lefthook.exe", "{PKG-ROOT}/npm-bundled/bin/lefthook-darwin-arm64/lefthook", "{PKG-ROOT}/npm-bundled/bin/lefthook-freebsd-arm64/lefthook", "{PKG-ROOT}/npm-bundled/bin/lefthook-openbsd-arm64/lefthook", ).Set; } subtest "prepare", { my ($sys, $npm) = new-registry(Registries::NPM); $npm.prepare; is-deeply $sys.copied, { "{REPO-ROOT}/README.md" => ( "{PKG-ROOT}/npm/lefthook/README.md", "{PKG-ROOT}/npm/lefthook-darwin-arm64/README.md", "{PKG-ROOT}/npm/lefthook-darwin-x64/README.md", "{PKG-ROOT}/npm/lefthook-linux-arm64/README.md", "{PKG-ROOT}/npm/lefthook-linux-x64/README.md", "{PKG-ROOT}/npm/lefthook-windows-arm64/README.md", "{PKG-ROOT}/npm/lefthook-windows-x64/README.md", "{PKG-ROOT}/npm/lefthook-freebsd-arm64/README.md", "{PKG-ROOT}/npm/lefthook-freebsd-x64/README.md", "{PKG-ROOT}/npm/lefthook-openbsd-arm64/README.md", "{PKG-ROOT}/npm/lefthook-openbsd-x64/README.md", "{PKG-ROOT}/npm-bundled/README.md", "{PKG-ROOT}/npm-installer/README.md", ).SetHash, "{REPO-ROOT}/schema.json" => ( "{PKG-ROOT}/npm/lefthook/schema.json", "{PKG-ROOT}/npm-bundled/schema.json", "{PKG-ROOT}/npm-installer/schema.json", ).SetHash, "{REPO-ROOT}/dist/no_self_update_linux_amd64_v1/lefthook" => ( "{PKG-ROOT}/npm/lefthook-linux-x64/bin/lefthook", "{PKG-ROOT}/npm-bundled/bin/lefthook-linux-x64/lefthook", ).SetHash, "{REPO-ROOT}/dist/no_self_update_windows_amd64_v1/lefthook.exe" => ( "{PKG-ROOT}/npm/lefthook-windows-x64/bin/lefthook.exe", "{PKG-ROOT}/npm-bundled/bin/lefthook-windows-x64/lefthook.exe", ).SetHash, "{REPO-ROOT}/dist/no_self_update_darwin_amd64_v1/lefthook" => ( "{PKG-ROOT}/npm/lefthook-darwin-x64/bin/lefthook", "{PKG-ROOT}/npm-bundled/bin/lefthook-darwin-x64/lefthook", ).SetHash, "{REPO-ROOT}/dist/no_self_update_freebsd_amd64_v1/lefthook" => ( "{PKG-ROOT}/npm/lefthook-freebsd-x64/bin/lefthook", "{PKG-ROOT}/npm-bundled/bin/lefthook-freebsd-x64/lefthook", ).SetHash, "{REPO-ROOT}/dist/no_self_update_openbsd_amd64_v1/lefthook" => ( "{PKG-ROOT}/npm/lefthook-openbsd-x64/bin/lefthook", "{PKG-ROOT}/npm-bundled/bin/lefthook-openbsd-x64/lefthook", ).SetHash, "{REPO-ROOT}/dist/no_self_update_linux_arm64_v8.0/lefthook" => ( "{PKG-ROOT}/npm/lefthook-linux-arm64/bin/lefthook", "{PKG-ROOT}/npm-bundled/bin/lefthook-linux-arm64/lefthook", ).SetHash, "{REPO-ROOT}/dist/no_self_update_windows_arm64_v8.0/lefthook.exe" => ( "{PKG-ROOT}/npm/lefthook-windows-arm64/bin/lefthook.exe", "{PKG-ROOT}/npm-bundled/bin/lefthook-windows-arm64/lefthook.exe", ).SetHash, "{REPO-ROOT}/dist/no_self_update_darwin_arm64_v8.0/lefthook" => ( "{PKG-ROOT}/npm/lefthook-darwin-arm64/bin/lefthook", "{PKG-ROOT}/npm-bundled/bin/lefthook-darwin-arm64/lefthook", ).SetHash, "{REPO-ROOT}/dist/no_self_update_freebsd_arm64_v8.0/lefthook" => ( "{PKG-ROOT}/npm/lefthook-freebsd-arm64/bin/lefthook", "{PKG-ROOT}/npm-bundled/bin/lefthook-freebsd-arm64/lefthook", ).SetHash, "{REPO-ROOT}/dist/no_self_update_openbsd_arm64_v8.0/lefthook" => ( "{PKG-ROOT}/npm/lefthook-openbsd-arm64/bin/lefthook", "{PKG-ROOT}/npm-bundled/bin/lefthook-openbsd-arm64/lefthook", ).SetHash, }; } subtest "publish", { my ($sys, $npm) = new-registry(Registries::NPM); $npm.publish; is-deeply $sys.run-calls, [ ("npm publish --access public", "{PKG-ROOT}/npm/lefthook-darwin-arm64/".IO), ("npm publish --access public", "{PKG-ROOT}/npm/lefthook-darwin-x64/".IO), ("npm publish --access public", "{PKG-ROOT}/npm/lefthook-linux-arm64/".IO), ("npm publish --access public", "{PKG-ROOT}/npm/lefthook-linux-x64/".IO), ("npm publish --access public", "{PKG-ROOT}/npm/lefthook-windows-arm64/".IO), ("npm publish --access public", "{PKG-ROOT}/npm/lefthook-windows-x64/".IO), ("npm publish --access public", "{PKG-ROOT}/npm/lefthook-freebsd-arm64/".IO), ("npm publish --access public", "{PKG-ROOT}/npm/lefthook-freebsd-x64/".IO), ("npm publish --access public", "{PKG-ROOT}/npm/lefthook-openbsd-arm64/".IO), ("npm publish --access public", "{PKG-ROOT}/npm/lefthook-openbsd-x64/".IO), ("npm publish --access public", "{PKG-ROOT}/npm/lefthook/".IO), ("npm publish --access public", "{PKG-ROOT}/npm-bundled".IO), ("npm publish --access public", "{PKG-ROOT}/npm-installer".IO), ], "calls publishing in the right order"; } done-testing; ================================================ FILE: packaging/scripts/t/03-rubygems.rakutest ================================================ use Test; use Constants; use Registries::RubyGems; use lib $?FILE.IO.parent.child("lib"); use TestRegistry; subtest "clean", { my ($sys, $gem) = new-registry(Registries::RubyGems); $gem.clean; is-deeply $sys.removed.Set, ( "{PKG-ROOT}/rubygems/pkg/", ).Set; } subtest "prepare", { my ($sys, $gem) = new-registry(Registries::RubyGems); $gem.prepare; is-deeply $sys.copied, { "{REPO-ROOT}/dist/no_self_update_linux_amd64_v1/lefthook" => ( "{PKG-ROOT}/rubygems/libexec/lefthook-linux-x64/lefthook", ).SetHash, "{REPO-ROOT}/dist/no_self_update_windows_amd64_v1/lefthook.exe" => ( "{PKG-ROOT}/rubygems/libexec/lefthook-windows-x64/lefthook.exe", ).SetHash, "{REPO-ROOT}/dist/no_self_update_darwin_amd64_v1/lefthook" => ( "{PKG-ROOT}/rubygems/libexec/lefthook-darwin-x64/lefthook", ).SetHash, "{REPO-ROOT}/dist/no_self_update_freebsd_amd64_v1/lefthook" => ( "{PKG-ROOT}/rubygems/libexec/lefthook-freebsd-x64/lefthook", ).SetHash, "{REPO-ROOT}/dist/no_self_update_openbsd_amd64_v1/lefthook" => ( "{PKG-ROOT}/rubygems/libexec/lefthook-openbsd-x64/lefthook", ).SetHash, "{REPO-ROOT}/dist/no_self_update_linux_arm64_v8.0/lefthook" => ( "{PKG-ROOT}/rubygems/libexec/lefthook-linux-arm64/lefthook", ).SetHash, "{REPO-ROOT}/dist/no_self_update_windows_arm64_v8.0/lefthook.exe" => ( "{PKG-ROOT}/rubygems/libexec/lefthook-windows-arm64/lefthook.exe", ).SetHash, "{REPO-ROOT}/dist/no_self_update_darwin_arm64_v8.0/lefthook" => ( "{PKG-ROOT}/rubygems/libexec/lefthook-darwin-arm64/lefthook", ).SetHash, "{REPO-ROOT}/dist/no_self_update_freebsd_arm64_v8.0/lefthook" => ( "{PKG-ROOT}/rubygems/libexec/lefthook-freebsd-arm64/lefthook", ).SetHash, "{REPO-ROOT}/dist/no_self_update_openbsd_arm64_v8.0/lefthook" => ( "{PKG-ROOT}/rubygems/libexec/lefthook-openbsd-arm64/lefthook", ).SetHash, }; } subtest "publish", { my ($sys, $gem) = new-registry(Registries::RubyGems); my $tmp-gem1-file = "{PKG-ROOT}/rubygems/pkg/lefthook-0.0.1.gem"; my $tmp-gem2-file = "{PKG-ROOT}/rubygems/pkg/lefthook-0.0.2.gem"; spurt $tmp-gem1-file, ""; spurt $tmp-gem2-file, ""; LEAVE { .IO.unlink for ($tmp-gem1-file, $tmp-gem2-file) } $gem.publish; is-deeply $sys.run-calls, [ ("rake build", "{PKG-ROOT}/rubygems".IO), ("gem push {PKG-ROOT}/rubygems/pkg/lefthook-0.0.2.gem", "{PKG-ROOT}/rubygems".IO), ]; } ================================================ FILE: packaging/scripts/t/04-pypi.rakutest ================================================ use Test; use Constants; use Registries::PyPI; use lib $?FILE.IO.parent.child("lib"); use TestRegistry; subtest "clean", { my ($sys, $pypi) = new-registry(Registries::PyPI); $pypi.clean; is-deeply $sys.removed.Set, ( "{PKG-ROOT}/pypi/lefthook/__pycache__/", "{PKG-ROOT}/pypi/lefthook.egg-info/", "{PKG-ROOT}/pypi/build/", ).Set; } subtest "prepare", { my ($sys, $pypi) = new-registry(Registries::PyPI); $pypi.prepare; is-deeply $sys.copied, { "{REPO-ROOT}/dist/no_self_update_linux_amd64_v1/lefthook" => ( "{PKG-ROOT}/pypi/lefthook/bin/lefthook-linux-x86_64/lefthook", ).SetHash, "{REPO-ROOT}/dist/no_self_update_windows_amd64_v1/lefthook.exe" => ( "{PKG-ROOT}/pypi/lefthook/bin/lefthook-windows-x86_64/lefthook.exe", ).SetHash, "{REPO-ROOT}/dist/no_self_update_darwin_amd64_v1/lefthook" => ( "{PKG-ROOT}/pypi/lefthook/bin/lefthook-darwin-x86_64/lefthook", ).SetHash, "{REPO-ROOT}/dist/no_self_update_freebsd_amd64_v1/lefthook" => ( "{PKG-ROOT}/pypi/lefthook/bin/lefthook-freebsd-x86_64/lefthook", ).SetHash, "{REPO-ROOT}/dist/no_self_update_openbsd_amd64_v1/lefthook" => ( "{PKG-ROOT}/pypi/lefthook/bin/lefthook-openbsd-x86_64/lefthook", ).SetHash, "{REPO-ROOT}/dist/no_self_update_linux_arm64_v8.0/lefthook" => ( "{PKG-ROOT}/pypi/lefthook/bin/lefthook-linux-arm64/lefthook", ).SetHash, "{REPO-ROOT}/dist/no_self_update_windows_arm64_v8.0/lefthook.exe" => ( "{PKG-ROOT}/pypi/lefthook/bin/lefthook-windows-arm64/lefthook.exe", ).SetHash, "{REPO-ROOT}/dist/no_self_update_darwin_arm64_v8.0/lefthook" => ( "{PKG-ROOT}/pypi/lefthook/bin/lefthook-darwin-arm64/lefthook", ).SetHash, "{REPO-ROOT}/dist/no_self_update_freebsd_arm64_v8.0/lefthook" => ( "{PKG-ROOT}/pypi/lefthook/bin/lefthook-freebsd-arm64/lefthook", ).SetHash, "{REPO-ROOT}/dist/no_self_update_openbsd_arm64_v8.0/lefthook" => ( "{PKG-ROOT}/pypi/lefthook/bin/lefthook-openbsd-arm64/lefthook", ).SetHash, }; } subtest "publish", { my ($sys, $pypi) = new-registry(Registries::PyPI); $pypi.publish; is-deeply $sys.run-calls, [ ("uv build --wheel", "{PKG-ROOT}/pypi".IO), ("uv build --wheel", "{PKG-ROOT}/pypi".IO), ("uv build --wheel", "{PKG-ROOT}/pypi".IO), ("uv build --wheel", "{PKG-ROOT}/pypi".IO), ("uv build --wheel", "{PKG-ROOT}/pypi".IO), ("uv build --wheel", "{PKG-ROOT}/pypi".IO), ("uv publish", "{PKG-ROOT}/pypi".IO), ]; } ================================================ FILE: packaging/scripts/t/lib/FakeSystem.rakumod ================================================ use SystemAPI; # Mocks work with filesystem. class FakeSystem does SystemAPI { has Str @.removed; has %.copied; has @.run-calls = (); has $!cwd; multi method rm(@paths --> Nil) { @.removed.append(@paths.map(*.Str)); } method in-dir(IO() $path, &block --> Nil) { $!cwd = $path; block(); } method cp(IO() $source, IO() $dest --> Nil) { %.copied{$source} //= SetHash.new; %.copied{$source}.set($dest.Str); } method replace(IO() :$file, Regex :$regex, :$replacement --> Nil) { ... } method run(*@argv --> Nil) { @.run-calls.push((@argv.join(' '), $!cwd.clone)); } } ================================================ FILE: packaging/scripts/t/lib/TestRegistry.rakumod ================================================ unit module TestRegistry; use FakeSystem; use Registries::NPM; use Registries::RubyGems; use Registries::PyPI; use Registries::AUR; use Registries::AUR-Bin; subset RegistryClass where * ~~ ( | Registries::NPM | Registries::RubyGems | Registries::PyPI | Registries::AUR | Registries::AUR-Bin ); sub new-registry(RegistryClass $class --> List) is export { my $sys = FakeSystem.new; my $npm = $class.new(:$sys); ($sys, $npm); } ================================================ FILE: schema.json ================================================ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://json.schemastore.org/lefthook.json", "$defs": { "Command": { "properties": { "run": { "type": "string" }, "files": { "type": "string" }, "root": { "type": "string" }, "fail_text": { "type": "string" }, "timeout": { "type": "string", "examples": [ "15s" ] }, "skip": { "oneOf": [ { "type": "boolean" }, { "type": "array" } ] }, "only": { "oneOf": [ { "type": "boolean" }, { "type": "array" } ] }, "tags": { "oneOf": [ { "type": "string" }, { "type": "array" } ], "items": { "type": "string" } }, "file_types": { "oneOf": [ { "type": "string" }, { "type": "array" } ], "items": { "type": "string" } }, "glob": { "oneOf": [ { "type": "string" }, { "type": "array" } ], "items": { "type": "string" } }, "exclude": { "oneOf": [ { "type": "string" }, { "type": "array" } ], "items": { "type": "string" } }, "env": { "additionalProperties": { "type": "string" }, "type": "object" }, "priority": { "type": "integer" }, "interactive": { "type": "boolean" }, "use_stdin": { "type": "boolean" }, "stage_fixed": { "type": "boolean" } }, "additionalProperties": false, "type": "object", "required": [ "run" ] }, "Group": { "properties": { "root": { "type": "string" }, "parallel": { "type": "boolean" }, "piped": { "type": "boolean" }, "jobs": { "items": { "$ref": "#/$defs/Job" }, "type": "array" } }, "additionalProperties": false, "type": "object", "required": [ "jobs" ] }, "Hook": { "properties": { "parallel": { "type": "boolean" }, "piped": { "type": "boolean" }, "follow": { "type": "boolean" }, "fail_on_changes": { "type": "string", "enum": [ "true", "1", "0", "false", "never", "always", "ci", "non-ci" ] }, "fail_on_changes_diff": { "type": "boolean" }, "files": { "type": "string" }, "exclude_tags": { "items": { "type": "string" }, "type": "array" }, "exclude": { "items": { "type": "string" }, "type": "array" }, "skip": { "oneOf": [ { "type": "boolean" }, { "type": "array" } ] }, "only": { "oneOf": [ { "type": "boolean" }, { "type": "array" } ] }, "setup": { "items": { "$ref": "#/$defs/SetupInstruction" }, "type": "array" }, "jobs": { "items": { "$ref": "#/$defs/Job" }, "type": "array" }, "commands": { "additionalProperties": { "$ref": "#/$defs/Command" }, "type": "object" }, "scripts": { "additionalProperties": { "$ref": "#/$defs/Script" }, "type": "object" } }, "additionalProperties": false, "type": "object" }, "Job": { "oneOf": [ { "required": [ "run" ], "title": "Run a command" }, { "required": [ "script" ], "title": "Run a script" }, { "required": [ "group" ], "title": "Run a group" } ], "properties": { "name": { "type": "string" }, "run": { "type": "string" }, "script": { "type": "string" }, "runner": { "type": "string" }, "args": { "type": "string" }, "root": { "type": "string" }, "files": { "type": "string" }, "fail_text": { "type": "string" }, "timeout": { "type": "string", "examples": [ "15s" ] }, "glob": { "oneOf": [ { "type": "string" }, { "type": "array" } ], "items": { "type": "string" } }, "exclude": { "oneOf": [ { "type": "string" }, { "type": "array" } ], "items": { "type": "string" } }, "tags": { "items": { "type": "string" }, "type": "array" }, "file_types": { "oneOf": [ { "type": "string" }, { "type": "array" } ], "items": { "type": "string" } }, "env": { "additionalProperties": { "type": "string" }, "type": "object" }, "interactive": { "type": "boolean" }, "use_stdin": { "type": "boolean" }, "stage_fixed": { "type": "boolean" }, "skip": { "oneOf": [ { "type": "boolean" }, { "type": "array" } ] }, "only": { "oneOf": [ { "type": "boolean" }, { "type": "array" } ] }, "group": { "$ref": "#/$defs/Group" } }, "additionalProperties": false, "type": "object" }, "Remote": { "properties": { "git_url": { "type": "string", "description": "A URL to Git repository. It will be accessed with privileges of the machine lefthook runs on." }, "ref": { "type": "string", "description": "An optional *branch* or *tag* name" }, "configs": { "items": { "type": "string" }, "type": "array", "description": "An optional array of config paths from remote's root", "default": [ "lefthook.yml" ] }, "refetch": { "type": "boolean", "description": "Set to true if you want to always refetch the remote" }, "refetch_frequency": { "type": "string", "description": "Provide a frequency for the remotes refetches", "examples": [ "24h" ] } }, "additionalProperties": false, "type": "object" }, "Script": { "properties": { "runner": { "type": "string" }, "args": { "type": "string" }, "skip": { "oneOf": [ { "type": "boolean" }, { "type": "array" } ] }, "only": { "oneOf": [ { "type": "boolean" }, { "type": "array" } ] }, "tags": { "oneOf": [ { "type": "string" }, { "type": "array" } ], "items": { "type": "string" } }, "env": { "additionalProperties": { "type": "string" }, "type": "object" }, "priority": { "type": "integer" }, "fail_text": { "type": "string" }, "timeout": { "type": "string", "examples": [ "15s" ] }, "interactive": { "type": "boolean" }, "use_stdin": { "type": "boolean" }, "stage_fixed": { "type": "boolean" } }, "additionalProperties": false, "type": "object" }, "SetupInstruction": { "oneOf": [ { "required": [ "run" ], "title": "Run a command" } ], "properties": { "run": { "type": "string" } }, "additionalProperties": false, "type": "object" } }, "$comment": "Last updated on 2026.02.28.", "properties": { "min_version": { "type": "string", "description": "Specify a minimum version for the lefthook binary" }, "lefthook": { "type": "string", "description": "Lefthook executable path or command" }, "source_dir": { "type": "string", "description": "Change a directory for script files. Directory for script files contains folders with git hook names which contain script files.", "default": ".lefthook/" }, "source_dir_local": { "type": "string", "description": "Change a directory for local script files (not stored in VCS)", "default": ".lefthook-local/" }, "rc": { "type": "string", "description": "Provide an rc file - a simple sh script" }, "output": { "oneOf": [ { "type": "boolean" }, { "type": "array" } ], "description": "Manage verbosity by skipping the printing of output of some steps" }, "colors": { "oneOf": [ { "type": "boolean" }, { "type": "object" } ], "description": "Enable disable or set your own colors for lefthook output" }, "extends": { "items": { "type": "string" }, "type": "array", "description": "Specify files to extend config with" }, "no_tty": { "type": "boolean", "description": "Whether hide spinner and other interactive things" }, "assert_lefthook_installed": { "type": "boolean" }, "skip_lfs": { "type": "boolean", "description": "Skip running Git LFS hooks (enabled by default)" }, "no_auto_install": { "type": "boolean", "description": "Do not automatically install hooks when running lefthook" }, "install_non_git_hooks": { "type": "boolean", "description": "Install non-Git hooks to .git/hooks" }, "glob_matcher": { "type": "string", "enum": [ "gobwas", "doublestar" ], "description": "Choose the glob matching engine: 'gobwas' (default) or 'doublestar' (standard ** behavior)", "default": "gobwas" }, "remotes": { "items": { "$ref": "#/$defs/Remote" }, "type": "array", "description": "Provide multiple remote configs to use lefthook configurations shared across projects. Lefthook will automatically download and merge configurations into main config." }, "templates": { "additionalProperties": { "type": "string" }, "type": "object", "description": "Custom templates for replacements in run commands." }, "$schema": { "type": "string" }, "pre-commit": { "$ref": "#/$defs/Hook" }, "applypatch-msg": { "$ref": "#/$defs/Hook" }, "pre-applypatch": { "$ref": "#/$defs/Hook" }, "post-applypatch": { "$ref": "#/$defs/Hook" }, "pre-merge-commit": { "$ref": "#/$defs/Hook" }, "prepare-commit-msg": { "$ref": "#/$defs/Hook" }, "commit-msg": { "$ref": "#/$defs/Hook" }, "post-commit": { "$ref": "#/$defs/Hook" }, "pre-rebase": { "$ref": "#/$defs/Hook" }, "post-checkout": { "$ref": "#/$defs/Hook" }, "post-merge": { "$ref": "#/$defs/Hook" }, "pre-push": { "$ref": "#/$defs/Hook" }, "pre-receive": { "$ref": "#/$defs/Hook" }, "update": { "$ref": "#/$defs/Hook" }, "proc-receive": { "$ref": "#/$defs/Hook" }, "post-receive": { "$ref": "#/$defs/Hook" }, "post-update": { "$ref": "#/$defs/Hook" }, "reference-transaction": { "$ref": "#/$defs/Hook" }, "push-to-checkout": { "$ref": "#/$defs/Hook" }, "pre-auto-gc": { "$ref": "#/$defs/Hook" }, "post-rewrite": { "$ref": "#/$defs/Hook" }, "sendemail-validate": { "$ref": "#/$defs/Hook" }, "fsmonitor-watchman": { "$ref": "#/$defs/Hook" }, "p4-changelist": { "$ref": "#/$defs/Hook" }, "p4-prepare-changelist": { "$ref": "#/$defs/Hook" }, "p4-post-changelist": { "$ref": "#/$defs/Hook" }, "p4-pre-submit": { "$ref": "#/$defs/Hook" }, "post-index-change": { "$ref": "#/$defs/Hook" } }, "additionalProperties": { "properties": { "parallel": { "type": "boolean" }, "piped": { "type": "boolean" }, "follow": { "type": "boolean" }, "fail_on_changes": { "type": "string", "enum": [ "true", "1", "0", "false", "never", "always", "ci", "non-ci" ] }, "fail_on_changes_diff": { "type": "boolean" }, "files": { "type": "string" }, "exclude_tags": { "items": { "type": "string" }, "type": "array" }, "exclude": { "items": { "type": "string" }, "type": "array" }, "skip": { "oneOf": [ { "type": "boolean" }, { "type": "array" } ] }, "only": { "oneOf": [ { "type": "boolean" }, { "type": "array" } ] }, "setup": { "items": { "$ref": "#/$defs/SetupInstruction" }, "type": "array" }, "jobs": { "items": { "$ref": "#/$defs/Job" }, "type": "array" }, "commands": { "additionalProperties": { "$ref": "#/$defs/Command" }, "type": "object" }, "scripts": { "additionalProperties": { "$ref": "#/$defs/Script" }, "type": "object" } }, "additionalProperties": false, "type": "object" }, "type": "object" } ================================================ FILE: tea.yaml ================================================ # https://tea.xyz/what-is-this-file --- version: 1.0.0 codeOwners: - '0xb1a25Fe215747E1093901282dc2Ea68cE8c290D8' - '0x4d9B3A6207B48E31147327f8efaF31D5EFC3784e' quorum: 1 ================================================ FILE: tests/helpers/cmdtest/cmdtest.go ================================================ package cmdtest import ( "io" "testing" ) // NewOrdered returns executor that have the order defined in `outs`. func NewOrdered(t testing.TB, outs []Out) *OrderedCmd { return &OrderedCmd{t: t, outs: outs} } // NewTracking returns executor that collects the called commands. func NewTracking(cb func(string, string, io.Writer) error) *TrackingCmd { return &TrackingCmd{ Commands: make([]string, 0), callback: cb, } } // NewDumb returns executor that does simply nothing. func NewDumb() *DumbCmd { return &DumbCmd{} } ================================================ FILE: tests/helpers/cmdtest/dumb.go ================================================ package cmdtest import ( "io" "github.com/evilmartians/lefthook/v2/internal/system" ) type DumbCmd struct{} // WithoutEnvs does nothing. func (c *DumbCmd) WithoutEnvs(_ ...string) system.Command { return c } // Run does nothing. func (c *DumbCmd) Run(_ []string, _ string, _ io.Reader, _ io.Writer, _ io.Writer) error { return nil } ================================================ FILE: tests/helpers/cmdtest/ordered.go ================================================ package cmdtest import ( "io" "strings" "testing" "github.com/evilmartians/lefthook/v2/internal/system" ) type Out struct { Command string Output string } // OrderedCmd contains predefined list of commands and makes sure actual calls are the same. type OrderedCmd struct { t testing.TB outs []Out cnt int } // WithoutEnvs simply does nothing. func (c *OrderedCmd) WithoutEnvs(envs ...string) system.Command { return c } // Run makes sure command is executed correctly. func (c *OrderedCmd) Run(command []string, root string, in io.Reader, out io.Writer, err io.Writer) error { c.t.Helper() defer func() { c.cnt += 1 }() cmd := strings.Join(command, " ") if len(c.outs) == 0 { c.t.Errorf("expected: no command, called: %s", cmd) return nil } checkCmd := c.outs[0] if checkCmd.Command != cmd { c.t.Errorf("%d) expected: '%s', called: '%s'", c.cnt, checkCmd.Command, cmd) } _, _ = out.Write([]byte(checkCmd.Output)) c.outs = c.outs[1:] return nil } ================================================ FILE: tests/helpers/cmdtest/ordered_test.go ================================================ package cmdtest import ( "io" "testing" "github.com/stretchr/testify/assert" "github.com/evilmartians/lefthook/v2/internal/system" ) func TestOrderedCmd(t *testing.T) { var _ system.Command = (*OrderedCmd)(nil) cmd := NewOrdered( t, []Out{ {"A 1", ""}, {"B 2", ""}, {"C 3", ""}, }, ) _ = cmd.WithoutEnvs("OK") assert.NoError(t, cmd.Run([]string{"A", "1"}, "", system.NullReader, io.Discard, io.Discard)) assert.NoError(t, cmd.Run([]string{"B", "2"}, "", system.NullReader, io.Discard, io.Discard)) assert.NoError(t, cmd.Run([]string{"C", "3"}, "", system.NullReader, io.Discard, io.Discard)) } ================================================ FILE: tests/helpers/cmdtest/tracking.go ================================================ package cmdtest import ( "context" "io" "strings" "github.com/evilmartians/lefthook/v2/internal/system" ) type TrackingCmd struct { Commands []string callback func(cmd string, root string, out io.Writer) error } // WithoutEnvs simply does nothing. func (c *TrackingCmd) WithoutEnvs(envs ...string) system.Command { return c } // Run makes sure command is executed correctly. func (c *TrackingCmd) Run(command []string, root string, in io.Reader, out io.Writer, err io.Writer) error { cmd := strings.Join(command, " ") c.Commands = append(c.Commands, cmd) if c.callback != nil { return c.callback(cmd, root, out) } return nil } func (c *TrackingCmd) RunWithContext(_ context.Context, command []string, root string, in io.Reader, out io.Writer, err io.Writer) error { return c.Run(command, root, in, out, err) } func (c *TrackingCmd) Reset() { c.Commands = []string{} } ================================================ FILE: tests/helpers/cmdtest/tracking_test.go ================================================ package cmdtest import ( "io" "testing" "github.com/stretchr/testify/assert" "github.com/evilmartians/lefthook/v2/internal/system" ) func TestTrackingCmd(t *testing.T) { var _ system.Command = (*TrackingCmd)(nil) var _ system.CommandWithContext = (*TrackingCmd)(nil) commands := make([]string, 0, 3) cb := func(command string, root string, _ io.Writer) error { commands = append(commands, command) return nil } cmd := NewTracking(cb) assert.NoError(t, cmd.Run([]string{"A", "1"}, "", system.NullReader, io.Discard, io.Discard)) assert.NoError(t, cmd.Run([]string{"B", "2"}, "", system.NullReader, io.Discard, io.Discard)) assert.NoError(t, cmd.RunWithContext(t.Context(), []string{"C", "3"}, "", system.NullReader, io.Discard, io.Discard)) assert.Equal(t, []string{"A 1", "B 2", "C 3"}, cmd.Commands) assert.Equal(t, []string{"A 1", "B 2", "C 3"}, commands) _ = cmd.WithoutEnvs("OK") } ================================================ FILE: tests/helpers/configtest/config.go ================================================ package configtest import ( "bytes" "strings" "github.com/goccy/go-yaml" "github.com/evilmartians/lefthook/v2/internal/config" ) // ParseHook simplifies config.Hook definition with YAML string. func ParseHook(str string) *config.Hook { hook := config.Hook{} err := yaml.Unmarshal(stripPadding(str), &hook) if err != nil { panic("Failed to parse hook: " + err.Error()) } return &hook } // ParseJob simplifies config.Job definition with YAML string. func ParseJob(str string) *config.Job { job := config.Job{} err := yaml.Unmarshal(stripPadding(str), &job) if err != nil { panic("Failed to parse job: " + err.Error()) } return &job } func stripPadding(str string) []byte { str = strings.TrimRight(strings.Trim(str, "\n"), " \t") cleanBuffer := new(bytes.Buffer) var padding int var paddingSet bool for line := range strings.Lines(str) { var cleanLine string if !paddingSet { cleanLine = strings.TrimLeft(line, " \t") padding = len(line) - len(cleanLine) paddingSet = true } else { cleanLine = line[padding:] } cleanBuffer.WriteString(cleanLine) } return cleanBuffer.Bytes() } ================================================ FILE: tests/helpers/configtest/config_test.go ================================================ package configtest import ( "strconv" "testing" "github.com/stretchr/testify/assert" "github.com/evilmartians/lefthook/v2/internal/config" ) func TestParseHook(t *testing.T) { for i, tt := range [...]struct { raw string hook *config.Hook }{ { raw: ` parallel: true exclude_tags: - tag1 - tag2 jobs: - run: echo commands: simple: run: echo scripts: "dummy.sh": runner: bash `, hook: &config.Hook{ Parallel: true, ExcludeTags: []string{"tag1", "tag2"}, Jobs: []*config.Job{ { Run: "echo", }, }, Commands: map[string]*config.Command{ "simple": { Run: "echo", }, }, Scripts: map[string]*config.Script{ "dummy.sh": { Runner: "bash", }, }, }, }, } { t.Run(strconv.Itoa(i), func(t *testing.T) { parsed := ParseHook(tt.raw) assert.New(t).Equal(tt.hook, parsed) }) } } func TestParseJob(t *testing.T) { for i, tt := range [...]struct { raw string job *config.Job }{ { raw: ` name: test run: echo glob: - "*.sh" - "*.md" exclude: - "install.sh" - "README.md" root: docs/ use_stdin: true stage_fixed: true `, job: &config.Job{ Name: "test", Run: "echo", Glob: []string{"*.sh", "*.md"}, Exclude: []string{"install.sh", "README.md"}, Root: "docs/", UseStdin: true, StageFixed: true, }, }, } { t.Run(strconv.Itoa(i), func(t *testing.T) { parsed := ParseJob(tt.raw) assert.New(t).Equal(tt.job, parsed) }) } } ================================================ FILE: tests/helpers/gittest/gittest.go ================================================ package gittest import ( "path/filepath" "github.com/spf13/afero" "github.com/evilmartians/lefthook/v2/internal/git" "github.com/evilmartians/lefthook/v2/internal/system" ) type RepositoryBuilder struct { root string cmd system.Command fs afero.Fs } func NewRepositoryBuilder() *RepositoryBuilder { return &RepositoryBuilder{} } func (b *RepositoryBuilder) Root(root string) *RepositoryBuilder { b.root = root return b } func (b *RepositoryBuilder) Cmd(cmd system.Command) *RepositoryBuilder { b.cmd = cmd return b } func (b *RepositoryBuilder) Fs(fs afero.Fs) *RepositoryBuilder { b.fs = fs return b } func (b *RepositoryBuilder) Build() *git.Repository { return &git.Repository{ Fs: b.fs, Git: git.NewExecutor(b.cmd), RootPath: b.root, GitPath: GitPath(b.root), HooksPath: filepath.Join(GitPath(b.root), "hooks"), InfoPath: filepath.Join(GitPath(b.root), "info"), } } func GitPath(root string) string { return filepath.Join(root, ".git") } ================================================ FILE: tests/helpers/gittest/gittest_test.go ================================================ package gittest import ( "path/filepath" "testing" "github.com/spf13/afero" "github.com/stretchr/testify/assert" "github.com/evilmartians/lefthook/v2/internal/git" "github.com/evilmartians/lefthook/v2/internal/system" ) func TestBuilder(t *testing.T) { fs := afero.NewMemMapFs() cmd := system.Cmd repo := NewRepositoryBuilder().Root("root").Fs(fs).Cmd(cmd).Build() assert := assert.New(t) assert.Equal("root", repo.RootPath) assert.Equal(filepath.Join("root", ".git"), repo.GitPath) assert.Equal(filepath.Join("root", ".git", "info"), repo.InfoPath) assert.Equal(filepath.Join("root", ".git", "hooks"), repo.HooksPath) assert.Equal(git.NewExecutor(cmd), repo.Git) assert.Equal(fs, repo.Fs) } func TestGitPath(t *testing.T) { assert.Equal(t, filepath.Join("root", ".git"), GitPath("root")) } ================================================ FILE: tests/integration/add.txt ================================================ [windows] skip exec git init exec lefthook add pre-commit ! stderr . exists .git/hooks/pre-commit ! exists .lefthook/pre-commit exec lefthook add pre-push --dirs ! stderr . exists .git/hooks/pre-push exists .lefthook/pre-push exists .lefthook-local/pre-push ================================================ FILE: tests/integration/check_install.txt ================================================ ! exec lefthook check-install exec git init exec git config user.email "you@example.com" exec git config user.name "Your Name" exec git add -A ! exec lefthook check-install exec lefthook install exec lefthook check-install -- lefthook.yml -- pre-commit: jobs: - run: echo hello, test ================================================ FILE: tests/integration/cli_run_only.txt ================================================ exec git init exec git config user.email "you@example.com" exec git config user.name "Your Name" exec git add -A exec lefthook install exec lefthook run hook --job a --job c --job db --job lint stdout '\s*a\s*ca\s*cb\s*db\s*lint\s*' exec lefthook run hook --tag red stdout '\s*a\s*ca\s*cb\s*db\s*lint\s*' -- lefthook.yml -- output: - execution_out hook: jobs: - name: a tags: [red] run: echo a - name: b tags: [blue] run: echo b - name: c tags: [red] group: jobs: - run: echo ca tags: [blue] - run: echo cb - name: d group: jobs: - name: da run: echo da - name: db run: echo db tags: [red] commands: lint: run: echo lint tags: [red, blue] test: run: echo test tags: [blue] ================================================ FILE: tests/integration/dump.txt ================================================ [windows] skip exec git init exec lefthook dump cmp stdout lefthook-dumped.yml ! stderr . exec lefthook dump --format=json cmp stdout lefthook-dumped.json ! stderr . exec lefthook dump -f toml cmp stdout lefthook-dumped.toml ! stderr . -- lefthook.yml -- colors: cyan: 14 gray: 244 green: '#32CD32' red: '#FF1493' yellow: '#F0E68C' pre-commit: follow: true commands: lint: interactive: true skip: - merge - rebase - ref: main run: yarn lint {staged_files} test: skip: merge glob: "*.js" run: yarn test scripts: "my-script.sh": runner: bash use_stdin: true stage_fixed: true env: FOO: bar -- lefthook-dumped.yml -- colors: cyan: 14 gray: 244 green: '#32CD32' red: '#FF1493' yellow: '#F0E68C' pre-commit: follow: true commands: lint: run: yarn lint {staged_files} skip: - merge - rebase - ref: main interactive: true test: run: yarn test skip: merge glob: - '*.js' scripts: my-script.sh: runner: bash env: FOO: bar use_stdin: true stage_fixed: true -- lefthook-dumped.json -- { "colors": { "cyan": 14, "gray": 244, "green": "#32CD32", "red": "#FF1493", "yellow": "#F0E68C" }, "pre-commit": { "follow": true, "commands": { "lint": { "run": "yarn lint {staged_files}", "skip": [ "merge", "rebase", { "ref": "main" } ], "interactive": true }, "test": { "run": "yarn test", "skip": "merge", "glob": [ "*.js" ] } }, "scripts": { "my-script.sh": { "runner": "bash", "env": { "FOO": "bar" }, "use_stdin": true, "stage_fixed": true } } } } -- lefthook-dumped.toml -- [colors] cyan = 14 gray = 244 green = '#32CD32' red = '#FF1493' yellow = '#F0E68C' [pre-commit] follow = true [pre-commit.commands] [pre-commit.commands.lint] run = 'yarn lint {staged_files}' skip = ['merge', 'rebase', {ref = 'main'}] interactive = true [pre-commit.commands.test] run = 'yarn test' skip = 'merge' glob = ['*.js'] [pre-commit.scripts] [pre-commit.scripts.'my-script.sh'] runner = 'bash' use_stdin = true stage_fixed = true [pre-commit.scripts.'my-script.sh'.env] FOO = 'bar' ================================================ FILE: tests/integration/env_overwrite_issue_1137.txt ================================================ exec git init exec git config user.email "you@example.com" exec git config user.name "Your Name" exec git add -A exec lefthook install exec lefthook run test -- lefthook.yml -- output: - execution_out test: parallel: true jobs: - run: echo $E1 env: E1: e1 - run: echo $E2 env: E2: e2 - env: E1: e1 E2: e2 group: parallel: true jobs: - run: echo $E1 - run: echo $E2 env: E2: new-e2 ================================================ FILE: tests/integration/exclude.txt ================================================ exec git init exec lefthook install exec git config user.email "you@example.com" exec git config user.name "Your Name" exec git add -A exec lefthook run -f all stdout '^a.txt b.txt dir/a.txt dir/b.txt lefthook.yml\s*$' exec lefthook run -f oneline stdout '^lefthook.yml\s*$' exec lefthook run -f array stdout '^dir/a.txt dir/b.txt\s*$' exec lefthook run -f nested stdout '^lefthook.yml\s+dir/b.txt lefthook.yml\s+b.txt dir/b.txt lefthook.yml\s+b.txt dir/b.txt\s*$' -- lefthook.yml -- output: - execution_out all: commands: echo: run: echo {staged_files} oneline: commands: echo: run: echo {staged_files} exclude: '*.txt' array: jobs: - run: echo {staged_files} exclude: - a.txt - b.txt - '*.yml' nested: jobs: - exclude: - '*.txt' run: echo {staged_files} - exclude: - a.txt - dir/a.txt group: jobs: - exclude: - b.txt run: echo {staged_files} - group: jobs: - run: echo {staged_files} - group: jobs: - exclude: - '*.yml' run: echo {staged_files} -- a.txt -- a -- b.txt -- b -- dir/a.txt -- dir-a -- dir/b.txt -- dir-b ================================================ FILE: tests/integration/exclude_arg.txt ================================================ exec git init exec git config user.email "you@example.com" exec git config user.name "Your Name" exec git add -A exec lefthook install exec lefthook run test --exclude file1.txt stdout '\s*file2.txt\s*file2.txt\s*' exec lefthook run test --exclude file2.txt stdout '\s*file1.txt\s*file1.txt\s*' -- lefthook.yml -- output: - execution_out test: commands: list: run: echo {all_files} exclude: - lefthook.yml jobs: - run: echo {all_files} exclude: - lefthook.yml -- file1.txt -- Hello -- file2.txt -- Hi ================================================ FILE: tests/integration/fail_on_changes.txt ================================================ exec git init exec lefthook install exec git config user.email "you@example.com" exec git config user.name "Your Name" exec git add file.txt ! exec lefthook run pre-commit --fail-on-changes stdout '│ Error: files were modified by a hook, and fail_on_changes is enabled' ! exec lefthook run hook-setting stdout '│ Error: files were modified by a hook, and fail_on_changes is enabled' exec lefthook run hook-setting --fail-on-changes=false -- lefthook.yml -- pre-commit: commands: edit_file: run: echo newline >> file.txt stage_fixed: true hook-setting: fail_on_changes: true jobs: - name: edit_file run: echo newline >> file.txt stage_fixed: true -- file.txt -- 1 ================================================ FILE: tests/integration/fail_on_changes_issue_1125.txt ================================================ exec git init exec git config user.email "you@example.com" exec git config user.name "Your Name" exec git add . exec git commit -m "firstcommit" exec lefthook install # This should fail because README.md is modified ! exec lefthook run pre-commit --all-files stdout '│ Error: files were modified by a hook, and fail_on_changes is enabled' -- README.md -- This is a readme. -- lefthook.yml -- pre-commit: fail_on_changes: true jobs: - name: test-job run: echo 123 >> README.md ================================================ FILE: tests/integration/fail_on_changes_recover_previous_change.txt ================================================ exec git init exec git config user.email "you@example.com" exec git config user.name "Your Name" exec git add . exec git commit -m "firstcommit" exec lefthook install # same as echo "The good" > file.txt cp file.txt.changed file.txt exec cat file.txt cmp stdout file.txt.expected exec echo "The bad" > file.txt exec cat file.txt cmp stdout file.txt.expected ! exec git commit -m 'test' exec cat file.txt cmp stdout file.txt.expected -- file.txt -- Guess the film -- lefthook.yml -- pre-commit: fail_on_changes: "always" fail_on_changes_diff: true jobs: - run: echo "The evil" > file.txt -- file.txt.changed -- The good -- file.txt.expected -- The good ================================================ FILE: tests/integration/fail_text.txt ================================================ exec git init exec git config user.email "you@example.com" exec git config user.name "Your Name" exec git add -A exec lefthook install ! exec git commit -m 'test' stderr '\s*fails: no such command\s*' -- lefthook.yml -- output: - failure pre-commit: commands: fails: run: oops-no-such-command fail_text: no such command ================================================ FILE: tests/integration/files_override.txt ================================================ exec git init exec lefthook install exec git add -A exec lefthook run echo stdout 'a-file\.js' exec lefthook run echo --all-files stdout 'a-file\.js b_file\.go c,file\.rb' exec lefthook run echo --file a-file.js --file ghost.file stdout 'a-file\.js ghost\.file' -- lefthook.yml -- output: - execution_out echo: commands: echo: files: echo a-file.js run: echo "{files}" -- a-file.js -- a-file.js -- b_file.go -- b_file.go -- c,file.rb -- c,file.rb ================================================ FILE: tests/integration/files_skip_if_empty.txt ================================================ # https://github.com/evilmartians/lefthook/issues/1232 exec git init exec git config user.email "you@example.com" exec git config user.name "Your Name" exec lefthook run test ! stdout 'FILES DETECTED' -- lefthook.yml -- output: - execution_out test: jobs: - run: echo FILES DETECTED {files} files: echo - run: echo FILES DETECTED files: echo ================================================ FILE: tests/integration/filter_by_file_type.txt ================================================ [windows] skip exec git init exec git config user.email "you@example.com" exec git config user.name "Your Name" exec lefthook install chmod 777 executable symlink symlink -> results exec git add -A exec git commit -m 'test' exec lefthook run filters stdout '.*all ❯\s+executable lefthook.yml results symlink\s+┃.*' stdout '.*filter_text ❯\s+executable lefthook.yml results\s+┃.*' stdout '.*filter_executable ❯\s+executable\s+┃.*' stdout '.*filter_symlink ❯\s+symlink\s+┃.*' stdout '.*filter_not_symlink ❯\s+executable lefthook.yml results\s+┃.*' stdout '.*filter_not_executable ❯\s+lefthook.yml results symlink\s*' -- lefthook.yml -- output: - execution - skips filters: piped: true commands: all: run: echo {all_files} priority: 1 filter_text: run: echo {all_files} file_types: text priority: 2 filter_executable: run: echo {all_files} file_types: executable priority: 3 filter_symlink: run: echo {all_files} file_types: symlink priority: 4 filter_not_symlink: run: echo {all_files} file_types: not symlink priority: 5 filter_not_executable: run: echo {all_files} priority: 6 file_types: - not executable -- results -- some text -- executable -- #!/bin/sh echo 'Executable' ================================================ FILE: tests/integration/filter_by_mime_type.txt ================================================ [windows] skip exec git init exec git config user.email "you@example.com" exec git config user.name "Your Name" exec lefthook install exec git add -A exec git commit -m 'test' exec lefthook run filters stdout '.*all ❯\s+html lefthook.yml lua-script perl-script python-script shell-script\s+┃.*' stdout '.*shell ❯\s+shell-script\s+┃.*' stdout '.*perl ❯\s+perl-script\s+┃.*' stdout '.*python ❯\s+python-script\s+┃.*' stdout '.*html ❯\s+html\s+┃.*' stdout '.*scripts ❯\s+lua-script perl-script shell-script\s*' -- lefthook.yml -- output: - execution - skips filters: piped: true jobs: - name: all run: echo {all_files} - name: shell run: echo {all_files} file_types: text/x-sh - name: perl run: echo {all_files} file_types: text/x-perl - name: python run: echo {all_files} file_types: text/x-python - name: html run: echo {all_files} file_types: text/html - name: scripts run: echo {all_files} file_types: - text/x-shellscript - text/x-perl - text/x-lua -- shell-script -- #!/bin/sh echo 'Hello' -- perl-script -- #!/usr/bin/env perl say 'Hello' -- python-script -- #!/usr/bin/env python if __name__ == '__main__': print("Hello") -- html --

Hello

-- lua-script -- #!/usr/bin/env lua print("Hello") ================================================ FILE: tests/integration/group_envs.txt ================================================ exec git init exec git config user.email "you@example.com" exec git config user.name "Your Name" exec git add -A exec lefthook install exec lefthook run test stdout '\s*1\s*3\s*' -- lefthook.yml -- output: - execution_out test: jobs: - env: E1: 1 E2: 2 group: jobs: - run: echo $E1 - run: echo $E2 env: E2: 3 ================================================ FILE: tests/integration/hide_unstaged.txt ================================================ [windows] skip exec git init exec lefthook install exec git config user.email "you@example.com" exec git config user.name "Your Name" exec git add -A exec git commit -m 'initial commit' exec lefthook run edit_file exec git add -A exec lefthook run edit_file exec git status --short stdout 'AM newfile.txt' exec git commit -m 'test hide unstaged changes' exec git status --short stdout 'M newfile.txt' -- lefthook.yml -- min_version: 1.1.1 pre-commit: commands: edit_file: run: echo newline >> file.txt stage_fixed: true edit_file: commands: echo: run: echo newline >> newfile.txt -- file.txt -- firstline ================================================ FILE: tests/integration/install.txt ================================================ exec git init exec lefthook install exists lefthook.yml ! stderr . ================================================ FILE: tests/integration/install_specific.txt ================================================ exec git init exec lefthook install pre-commit post-commit ! stderr . exists lefthook.yml exists .git/hooks/pre-commit exists .git/hooks/post-commit ! exists .git/hooks/pre-push -- lefthook.yml -- pre-commit: jobs: - run: echo post-commit: jobs: - run: echo pre-push: jobs: - run: echo ================================================ FILE: tests/integration/job_fail_text.txt ================================================ exec git init exec git config user.email "you@example.com" exec git config user.name "Your Name" exec git add -A exec lefthook install ! exec git commit -m 'test' stderr '\s*fails: no such command\s*' -- lefthook.yml -- output: - failure pre-commit: jobs: - name: fails run: oops-no-such-command fail_text: no such command ================================================ FILE: tests/integration/job_filter_by_file_type.txt ================================================ [windows] skip exec git init exec git config user.email "you@example.com" exec git config user.name "Your Name" exec lefthook install chmod 777 executable symlink symlink -> results exec git add -A exec git commit -m 'test' exec lefthook run filters stdout '.*all ❯\s+executable lefthook.yml results symlink\s+┃.*' stdout '.*filter_text ❯\s+executable lefthook.yml results\s+┃.*' stdout '.*filter_executable ❯\s+executable\s+┃.*' stdout '.*filter_symlink ❯\s+symlink\s+┃.*' stdout '.*filter_not_symlink ❯\s+executable lefthook.yml results\s+┃.*' stdout '.*filter_not_executable ❯\s+lefthook.yml results symlink\s*' -- lefthook.yml -- output: - execution - skips filters: piped: true jobs: - name: all run: echo {all_files} - name: filter_text run: echo {all_files} file_types: text - name: filter_executable run: echo {all_files} file_types: executable - name: filter_symlink run: echo {all_files} file_types: symlink - name: filter_not_symlink run: echo {all_files} file_types: not symlink - name: filter_not_executable run: echo {all_files} file_types: - not executable -- results -- some text -- executable -- #!/bin/sh echo 'Executable' ================================================ FILE: tests/integration/job_merging.txt ================================================ [windows] skip exec git init exec lefthook dump cmp stdout dump.yml ! stderr . -- lefthook.yml -- extends: - extends/e1.yml pre-commit: jobs: - name: group group: jobs: - name: child run: named - run: 0 no-name - name: echo run: echo 0 - run: lefthook.yml -- extends/e1.yml -- extends: - extends/e2.yml pre-commit: jobs: - name: group group: jobs: - name: child run: child named - run: 1 no-name - name: echo run: echo 1 skip: true - run: e1 e1: jobs: - name: echo run: e1 -- extends/e2.yml -- extends: - extends/e3.yml pre-commit: jobs: - name: group glob: "*.rb" group: jobs: - name: child run: child named with glob - run: 2 no-name - name: echo run: echo 2 tags: ["backend"] - run: e2 e2: jobs: - name: echo run: e2 -- extends/e3.yml -- pre-commit: jobs: - name: group glob: "*.rb" group: jobs: - name: child stage_fixed: true - run: 3 no-name - name: echo glob: 3 - run: e3 e3: jobs: - name: echo run: e3 -- dump.yml -- e1: jobs: - name: echo run: e1 e2: jobs: - name: echo run: e2 e3: jobs: - name: echo run: e3 extends: - extends/e1.yml pre-commit: jobs: - name: group glob: - '*.rb' group: jobs: - name: child run: child named with glob stage_fixed: true - run: 0 no-name - run: 1 no-name - run: 2 no-name - run: 3 no-name - name: echo run: echo 2 glob: - "3" tags: - backend skip: true - run: lefthook.yml - run: e1 - run: e2 - run: e3 ================================================ FILE: tests/integration/job_stage_fixed.txt ================================================ exec git init exec lefthook install exec git config user.email "you@example.com" exec git config user.name "Your Name" exec git add -A exec git status --short exec git commit -m 'test stage_fixed' exec git status --short ! stdout . -- lefthook.yml -- min_version: 1.1.1 pre-commit: jobs: - stage_fixed: true run: | echo newline >> "[file].js" echo newline >> file.txt -- file.txt -- sometext -- [file].js -- somecode ================================================ FILE: tests/integration/lefthook_job_name_issue_1345.txt ================================================ exec git init exec lefthook install exec git config user.email "you@example.com" exec git config user.name "Your Name" exec git add -A exec git commit -m 'test' stderr 'Macbeth' -- lefthook.yml -- output: - execution_out pre-commit: jobs: - name: 'Macbeth' run: echo {lefthook_job_name} ================================================ FILE: tests/integration/lefthook_option.txt ================================================ exec git init exec lefthook install exec git config user.email "you@example.com" exec git config user.name "Your Name" exec git add -A exec git commit -m 'must show debug logs' stderr 'injected' stdout '[lefthook]' -- lefthook.yml -- lefthook: | echo 'injected' lefthook output: - execution_out pre-commit: jobs: - run: echo {all_files} glob: "*.txt" -- file.txt -- sometext -- file.js -- somecode ================================================ FILE: tests/integration/many_extends_levels.txt ================================================ [windows] skip exec git init exec lefthook dump cmp stdout dump.yml ! stderr . -- lefthook.yml -- extends: - extends/e1.yml pre-commit: commands: echo: run: echo 0 -- extends/e1.yml -- extends: - extends/e2.yml pre-commit: commands: echo: run: echo 1 skip: true e1: commands: echo: run: e1 -- extends/e2.yml -- extends: - extends/e3.yml pre-commit: commands: echo: run: echo 2 tags: ["backend"] e2: commands: echo: run: e2 -- extends/e3.yml -- pre-commit: commands: echo: glob: 3 e3: commands: echo: run: e3 -- dump.yml -- e1: commands: echo: run: e1 e2: commands: echo: run: e2 e3: commands: echo: run: e3 extends: - extends/e1.yml pre-commit: commands: echo: run: echo 2 skip: true tags: - backend glob: - "3" ================================================ FILE: tests/integration/min_version.txt ================================================ exec git init ! exec lefthook run pre-commit stdout 'required lefthook version \(v13.1.1\) is higher than current' -- lefthook.yml -- min_version: v13.1.1 pre-commit: commands: echo: run: echo ================================================ FILE: tests/integration/pre-commit_issue_919.txt ================================================ exec git init exec git add -A exec git config user.email "you@example.com" exec git config user.name "Your Name" exec git add -A exec git commit -m 'first commit' rm file.txt exec git add -A exec lefthook run pre-commit stdout '^\s*must be printed\s*$' -- lefthook.yml -- output: - execution_out pre-commit: jobs: - run: echo 'must be printed' - run: echo 'excluded txt' exclude: - '*.txt' - run: echo 'excluded by' {staged_files} -- file.txt -- will be deleted ================================================ FILE: tests/integration/remotes.txt ================================================ [windows] skip exec git init exec lefthook install exec lefthook dump cmp stdout lefthook-dump.yml -- lefthook.yml -- remotes: - git_url: https://github.com/evilmartians/lefthook configs: - examples/with_scripts/lefthook.yml ref: v1.4.0 - git_url: https://github.com/evilmartians/lefthook configs: - examples/verbose/lefthook.yml - examples/remote/ping.yml -- lefthook-dump.yml -- pre-commit: parallel: true commands: js-lint: run: npx eslint --fix -- {staged_files} && git add -- {staged_files} glob: - '*.{js,ts}' ping: run: echo pong ruby-lint: run: bundle exec rubocop --force-exclusion --parallel -- '{files}' files: git diff-tree -r --name-only --diff-filter=CDMR HEAD origin/master glob: - '*.rb' ruby-test: run: bundle exec rspec fail_text: Run bundle install skip: - merge - rebase scripts: good_job.js: runner: node pre-push: commands: spelling: run: npx yaspeller -- {files} files: git diff --name-only HEAD @{push} glob: - '*.md' remotes: - git_url: https://github.com/evilmartians/lefthook ref: v1.4.0 configs: - examples/with_scripts/lefthook.yml - git_url: https://github.com/evilmartians/lefthook configs: - examples/verbose/lefthook.yml - examples/remote/ping.yml ================================================ FILE: tests/integration/run_deleted_only.txt ================================================ exec git init exec git config user.email "you@example.com" exec git config user.name "Your Name" exec git add -A exec git commit -m 'initial' exec lefthook install exec rm A.txt exec git add -A exec git commit -m 'test' stderr 'no files for inspection' -- lefthook.yml -- pre-commit: jobs: - run: echo FILES DETECTED {staged_files} -- A.txt -- will be deleted ================================================ FILE: tests/integration/run_interrupt.txt ================================================ [windows] skip chmod 0700 hook.sh chmod 0700 commit-with-interrupt.sh exec git init exec git config user.email "you@example.com" exec git config user.name "Your Name" exec lefthook install exec git add -A exec git commit -m 'init' stderr 'hook-done' exec ./commit-with-interrupt.sh stderr 'script-done' ! stderr 'hook-done' stderr 'signal: killed' stderr 'Error: Interrupted' grep unstaged newfile.txt exec git stash list ! stdout 'lefthook auto backup' -- lefthook.yml -- pre-commit: commands: slow_job: run: ./hook.sh -- hook.sh -- #!/usr/bin/env bash sleep 2 >&2 echo hook-done -- newfile.txt -- staged -- commit-with-interrupt.sh -- #!/usr/bin/env bash echo staged >> newfile.txt git add newfile.txt echo unstaged >> newfile.txt # ctrl-c is emulated by sending SIGINT to a process group # so we first need to emulate being a terminal and enable # job monitoring so that new PGIDs are assigned. set -m nohup git commit -m test & pgid=$! sleep 1 kill -SIGINT -$pgid wait >&2 echo 'script-done' ================================================ FILE: tests/integration/run_json.txt ================================================ exec git init exec git config user.email "you@example.com" exec git config user.name "Your Name" exec git add -A exec lefthook install exec git commit -m 'test' stderr '\s*Hi there from Lefthook\s*' -- lefthook.json -- { "output": [ "execution" ], "pre-commit": { "commands": { "echo": { "run": "echo Hi there from Lefthook" } } } } ================================================ FILE: tests/integration/run_jsonc.txt ================================================ exec git init exec git config user.email "you@example.com" exec git config user.name "Your Name" exec git add -A exec lefthook install exec git commit -m 'test' stderr '\s*Hi there from Lefthook\s*' -- lefthook.jsonc -- { /* Prints only what's being executed */ "output": [ "execution" ], "pre-commit": { "commands": { "echo": { "run": "echo Hi there from Lefthook" // echoes Hi there from Lefthook } } } } ================================================ FILE: tests/integration/run_non_existing.txt ================================================ exec git init exec lefthook run pre-commit ! stdout 'Error.*' ! exec lefthook run no-a-hook stdout 'Error.*' -- lefthook.yml -- # empty ================================================ FILE: tests/integration/run_script.txt ================================================ exec git init exec git config user.email "you@example.com" exec git config user.name "Your Name" exec git add -A exec lefthook install exec git commit -m 'test' stderr '\s*Hi there from script\s*' -- lefthook.yml -- output: - execution_out pre-commit: scripts: "file.sh": runner: sh -- .lefthook/pre-commit/file.sh -- #!/usr/bin/env sh echo Hi there from scripts ================================================ FILE: tests/integration/run_script_with_args.txt ================================================ exec git init exec git config user.email "you@example.com" exec git config user.name "Your Name" exec git add -A exec lefthook install exec git commit -m 'test' stderr '\s*Args: lefthook.yml\s*' exec lefthook run pre-commit --file 'non-existent' stdout 'no files for inspection' -- lefthook.yml -- output: - execution_out - skips pre-commit: jobs: - script: echo.sh runner: sh args: "{files}" files: echo lefthook.yml glob: "*.yml" -- .lefthook/pre-commit/echo.sh -- #!/usr/bin/env sh echo "Args: $@" ================================================ FILE: tests/integration/run_toml.txt ================================================ exec git init exec git config user.email "you@example.com" exec git config user.name "Your Name" exec git add -A exec lefthook install exec git commit -m 'test' stderr '\s*Hi there from Lefthook\s*' -- lefthook.toml -- output = [ 'execution' ] [pre-commit.commands.echo] run = "echo Hi there from Lefthook" ================================================ FILE: tests/integration/run_yml.txt ================================================ exec git init exec git config user.email "you@example.com" exec git config user.name "Your Name" exec git add -A exec lefthook install exec git commit -m 'test' stderr '\s*Hi there from Lefthook\s*' -- lefthook.yml -- output: - execution_out pre-commit: commands: echo: run: echo Hi there from Lefthook ================================================ FILE: tests/integration/setup_instructions.txt ================================================ exec git init exec git config user.email "you@example.com" exec git config user.name "Your Name" exec git add -A exec lefthook install exec git commit -m 'test' stderr '\s*Setup 1\s*Setup 2\s*Hi there from Lefthook\s*' -- lefthook.yml -- output: - setup - execution_out pre-commit: setup: - run: echo 'Setup 1' - run: echo 'Setup 2' commands: echo: run: echo Hi there from Lefthook ================================================ FILE: tests/integration/sh_syntax_in_files.txt ================================================ [windows] skip exec git init exec lefthook install exec git config user.email "you@example.com" exec git config user.name "Your Name" exec lefthook run echo_files stdout '1.txt 10.txt' -- lefthook.yml -- output: - execution_out echo_files: commands: echo: files: ls | grep 1 run: echo {files} -- 1.txt -- 1.txt -- 10.txt -- 10.txt -- 20.txt -- 20.txt ================================================ FILE: tests/integration/skip_group_issue_1083.txt ================================================ exec git init exec git config user.email "you@example.com" exec git config user.name "Your Name" exec git add -A exec lefthook install exec lefthook run hook stdout '\s*1\s*2\s*' -- lefthook.yml -- output: - execution_out hook: jobs: - run: echo 1 - skip: true group: jobs: - run: echo must not be printed - run: echo must not be printed - group: jobs: - run: echo 2 - run: echo must not be printed skip: true - skip: true group: jobs: - run: echo must not be printed ================================================ FILE: tests/integration/skip_merge_commit.txt ================================================ exec git init exec git config user.email "you@example.com" exec git config user.name "Your Name" exec git add -A exec git commit -m 'commit 1' exec lefthook install exec git checkout -b merge-me exec touch file.A exec git add -A exec git commit -m 'commit A' exec lefthook run pre-commit --force stdout 'skip-merge-commit' exec git checkout - exec touch file.B exec git add -A exec git commit -m 'commit B' exec git merge merge-me exec lefthook run pre-commit --force ! stdout 'skip-merge-commit' -- lefthook.yml -- output: - execution_out pre-commit: commands: skip-merge-commit: skip: - merge-commit run: echo 'skip-merge-commit' ================================================ FILE: tests/integration/skip_run.txt ================================================ exec git init exec git add -A exec lefthook run skip ! stdout 'Ha-ha!' exec lefthook run no-skip stdout 'Ha-ha!' exec lefthook run skip-var ! stdout 'Ha-ha!' env VAR=1 exec lefthook run skip-var stdout 'Ha-ha!' -- lefthook.yml -- output: - execution_out skip: skip: - run: test 1 -eq 1 commands: echo: run: echo 'Ha-ha!' no-skip: skip: - run: "[ 1 -eq 0 ]" commands: echo: run: echo 'Ha-ha!' skip-var: skip: - run: test -z $VAR commands: echo: run: echo 'Ha-ha!' ================================================ FILE: tests/integration/stage_fixed.txt ================================================ exec git init exec lefthook install exec git config user.email "you@example.com" exec git config user.name "Your Name" exec git add -A exec git status --short exec git commit -m 'test stage_fixed' exec git status --short ! stdout . exec lefthook run pre-commit --force --no-stage-fixed exec git status --short stdout ' M \[file\].js' stdout ' M file.txt' -- lefthook.yml -- min_version: 1.1.1 pre-commit: commands: edit_file: run: | echo newline >> "[file].js" echo newline >> file.txt stage_fixed: true -- file.txt -- sometext -- [file].js -- somecode ================================================ FILE: tests/integration/stage_fixed_505.txt ================================================ exec git init exec lefthook install exec git config user.email "you@example.com" exec git config user.name "Your Name" exec git add -A exec git status --short exec git commit -m 'test stage_fixed' exec git status --short ! stdout . -- lefthook.yml -- pre-commit: commands: edit_file: run: echo "{staged_files}" && echo newline >> "[file].js" stage_fixed: true -- [file].js -- somecode ================================================ FILE: tests/integration/templates.txt ================================================ exec git init exec lefthook run test stdout '^\s*hello\s*$' -- lefthook.yml -- templates: message: hello output: - execution_out test: jobs: - run: echo {message} ================================================ FILE: tests/integration/timeout.txt ================================================ [windows] skip exec git init exec git config user.email "you@example.com" exec git config user.name "Your Name" exec git add -A exec lefthook install ! exec git commit -m 'test' stderr 'timeout \(100ms\)' -- lefthook.yml -- output: - failure pre-commit: commands: slow: timeout: 100ms run: sleep 10 ================================================ FILE: tests/integration/timeout_success.txt ================================================ [windows] skip exec git init exec git config user.email "you@example.com" exec git config user.name "Your Name" exec git add -A exec lefthook install exec git commit -m 'test' --allow-empty ! stderr 'timeout' -- lefthook.yml -- output: - failure - success pre-commit: commands: fast: timeout: 10s run: echo "done" ================================================ FILE: tests/integration/uninstall.txt ================================================ exec git init exec lefthook install exists .git/hooks/pre-push exec lefthook uninstall ! exists .git/hooks-pre-push exists lefthook.yml exists .lefthook-local.toml exec lefthook install exists .git/hooks/pre-push exec lefthook uninstall --remove-configs ! exists .git/hooks-pre-push ! exists lefthook.yml ! exists .lefthook-local.toml -- lefthook.yml -- pre-push: commands: echo: run: echo pre-push -- .lefthook-local.toml -- [pre-commit.commands.echo] run = "echo pre-commit" ================================================ FILE: tests/integration/validate.txt ================================================ exec git init exec lefthook validate -- lefthook.yml -- pre-commit: jobs: - run: echo test tags: - echo - test - integration ================================================ FILE: tests/integration/validate_fail.txt ================================================ exec git init ! exec lefthook validate -- lefthook.yml -- pre-commit: jobs: - run: echo test wait_what: emm ================================================ FILE: tests/integration/version.txt ================================================ exec lefthook version stdout \d+\.\d+\.\d+ ! stderr .