[
  {
    "path": ".github/CODEOWNERS",
    "content": "* @mrexox\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/bug_report.md",
    "content": "---\nname: 🐞 Report a bug\nabout: Found something broken? Let us know! If it's not yet reproducible, please `Ask a question` instead.\nlabels: 'bug'\n---\n\n### Description\n\n\n\n### `lefthook.yml`\n\n<!-- If the bug relates to some configuration options, please provide a config example -->\n\n### Commands to reproduce\n\n<!-- Don't forget to enable verbose logs. -->\n```bash\nexport LEFTHOOK_VERBOSE=true\n```\n\n### Lefthook version\n\n<!-- `lefthook version -f` -->\n\n### Possible solution\n\n<!-- Your ideas -->\n\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/config.yml",
    "content": "---\nblank_issues_enabled: false\n\ncontact_links:\n  - name: 💡 Discuss an idea\n    url: https://github.com/evilmartians/lefthook/discussions/new?category=ideas\n    about: Suggest a feature or an improvement.\n\n  - name: ❔ Ask a question\n    url: https://github.com/evilmartians/lefthook/discussions/new\n    about: Ask questions and discuss with other `lefthook` users or maintainers.\n\n  - name: 🙏 Request help\n    url: https://github.com/evilmartians/lefthook/discussions/new\n    about: Ask the `lefthook` community for help.\n"
  },
  {
    "path": ".github/ISSUE_TEMPLATE/feature_request.md",
    "content": "---\nname: ⭐ Feature request\nabout: 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.\nlabels: 'feature request'\n---\n\n### Description\n\n\n\n### What problem it is solving?\n\n\n"
  },
  {
    "path": ".github/PULL_REQUEST_TEMPLATE.md",
    "content": "Closes # (issue)\n\n### Context\n\n<!-- Brief description of what problem PR is solving -->\n\n### Changes\n\n<!-- Summary for changes in the code -->\n"
  },
  {
    "path": ".github/dependabot.yml",
    "content": "---\nversion: 2\nupdates:\n  - package-ecosystem: \"gomod\"\n    directory: \"/\"\n    target-branch: \"dependencies\"\n    schedule:\n      interval: \"weekly\"\n      day: \"monday\"\n      time: \"06:00\"  # 6:00 UTC\n    commit-message:\n      prefix: \"deps\"\n    assignees:\n      - \"mrexox\"\n"
  },
  {
    "path": ".github/workflows/codeql.yml",
    "content": "name: \"CodeQL\"\n\non:\n  push:\n    branches: [ \"master\" ]\n  pull_request:\n    # The branches below must be a subset of the branches above\n    branches: [ \"master\" ]\n  schedule:\n    # 6:00 UTC on Monday\n    - cron: '0 6 * * 1'\n\njobs:\n  analyze:\n    name: Analyze\n    runs-on: ubuntu-latest\n    permissions:\n      actions: read\n      contents: read\n      security-events: write\n\n    strategy:\n      fail-fast: false\n      matrix:\n        language: [ 'go' ]\n        # CodeQL supports [ 'cpp', 'csharp', 'go', 'java', 'javascript', 'python', 'ruby' ]\n        # Learn more about CodeQL language support at https://aka.ms/codeql-docs/language-support\n\n    steps:\n    - name: Checkout repository\n      uses: actions/checkout@v4\n\n    # Initializes the CodeQL tools for scanning.\n    - name: Initialize CodeQL\n      uses: github/codeql-action/init@v3\n      with:\n        languages: ${{ matrix.language }}\n        # If you wish to specify custom queries, you can do so here or in a config file.\n        # By default, queries listed here will override any specified in a config file.\n        # Prefix the list here with \"+\" to use these queries and those in the config file.\n\n        # 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\n        # queries: security-extended,security-and-quality\n\n\n    # Autobuild attempts to build any compiled languages  (C/C++, C#, Go, or Java).\n    # If this step fails, then you should remove it and run the build manually (see below)\n    - name: Autobuild\n      uses: github/codeql-action/autobuild@v3\n\n    # ℹ️ Command-line programs to run using the OS shell.\n    # 📚 See https://docs.github.com/en/actions/using-workflows/workflow-syntax-for-github-actions#jobsjob_idstepsrun\n\n    #   If the Autobuild fails above, remove it and uncomment the following three lines.\n    #   modify them (or add more) to build your code if your project, please refer to the EXAMPLE below for guidance.\n\n    # - run: |\n    #   echo \"Run, Build Application using script\"\n    #   ./location_of_script_within_repo/buildscript.sh\n\n    - name: Perform CodeQL Analysis\n      uses: github/codeql-action/analyze@v3\n      with:\n        category: \"/language:${{matrix.language}}\"\n\n"
  },
  {
    "path": ".github/workflows/gh-pages.yml",
    "content": "name: Publish Docs\n\non:\n  push:\n    branches:\n      - master\n  pull_request:\n\njobs:\n  gh-pages:\n    runs-on: ubuntu-latest\n    concurrency:\n      group: ${{ github.workflow }}-${{ github.ref }}\n    steps:\n      - uses: actions/checkout@v2\n\n      - name: Setup docmd\n        run: npm install -g @docmd/core@0.4.11\n\n      - run: docmd build\n\n      - name: Deploy\n        uses: peaceiris/actions-gh-pages@v3\n        if: ${{ github.ref == 'refs/heads/master' }}\n        with:\n          github_token: ${{ secrets.GITHUB_TOKEN }}\n          publish_dir: ./site\n          cname: lefthook.dev\n"
  },
  {
    "path": ".github/workflows/lint.yml",
    "content": "on:\n  push:\n    branches:\n      - master\n  pull_request:\n\nname: Lint\njobs:\n  golangci:\n    name: golangci-lint\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n      - name: Install Go\n        uses: actions/setup-go@v5\n        with:\n          go-version-file: go.mod\n      - name: golangci-lint\n        uses: golangci/golangci-lint-action@v8\n        with:\n          version-file: .tool-versions\n"
  },
  {
    "path": ".github/workflows/release.yml",
    "content": "name: release\n\non:\n  push:\n    tags:\n      - \"*\"\n\npermissions:\n  attestations: write\n  contents: write\n  id-token: write\n\njobs:\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout\n        uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - name: Fetch all tags\n        run: git fetch --force --tags\n\n      - name: Setup Go\n        uses: actions/setup-go@v5\n        with:\n          go-version-file: go.mod\n\n      - name: Install Snapcraft\n        uses: samuelmeuli/action-snapcraft@v2\n\n      - name: Prevent from snapcraft fail\n        run: |\n          mkdir -p $HOME/.cache/snapcraft/download\n          mkdir -p $HOME/.cache/snapcraft/stage-packages\n\n      - name: Run GoReleaser\n        uses: goreleaser/goreleaser-action@v6\n        with:\n          distribution: goreleaser\n          version: 'v2.10.2'\n          args: release --clean --verbose\n        env:\n          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}\n          SNAPCRAFT_STORE_CREDENTIALS: ${{ secrets.SNAPCRAFT_STORE_CREDENTIALS }}\n\n      - name: Generate artifact attestations\n        uses: actions/attest@v4\n        with:\n          subject-checksums: dist/lefthook_checksums.txt\n\n      - name: Preserve artifacts permissions with tar\n        run: tar -cvf dist.tar dist/\n      - uses: actions/upload-artifact@v4\n        with:\n          name: dist\n          path: dist.tar\n\n  publish-npm:\n    needs: build\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n      - run: git fetch --force --tags\n\n      - uses: actions/download-artifact@v4\n        with:\n          name: dist\n      - run: tar -xvf dist.tar\n\n      - uses: actions/setup-node@v4\n        with:\n          node-version: '20'\n          registry-url: 'https://registry.npmjs.org'\n\n      - name: Update npm\n        run: npm install -g npm@latest\n\n      - uses: Raku/setup-raku@v1\n      - name: Publish to NPM\n        env:\n          NPM_API_KEY: ${{ secrets.NPM_API_KEY }}\n        run: |\n          raku packaging/scripts/prepare.raku --target=npm\n          raku packaging/scripts/publish.raku --target=npm\n\n  publish-gem:\n    needs: build\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n      - run: git fetch --force --tags\n\n      - uses: actions/download-artifact@v4\n        with:\n          name: dist\n      - run: tar -xvf dist.tar\n\n      - uses: Raku/setup-raku@v1\n      - name: Publish to Rubygems\n        env:\n          RUBYGEMS_API_KEY: ${{ secrets.RUBYGEMS_API_KEY }}\n        run: |\n          mkdir -p ~/.gem/\n          cat << EOF > ~/.gem/credentials\n          ---\n          :rubygems_api_key: ${RUBYGEMS_API_KEY}\n          EOF\n          chmod 0600 ~/.gem/credentials\n          raku packaging/scripts/prepare.raku --target=rubygems\n          raku packaging/scripts/publish.raku --target=rubygems\n\n  publish-pypi:\n    needs: build\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n      - run: git fetch --force --tags\n\n      - uses: actions/download-artifact@v4\n        with:\n          name: dist\n      - run: tar -xvf dist.tar\n\n      - name: Setup uv with python\n        uses: astral-sh/setup-uv@v7\n        with:\n          enable-cache: false\n          python-version: \"3.12\"\n          version: \"latest\"\n\n      - uses: Raku/setup-raku@v1\n      - name: Publish to PyPI\n        env:\n          UV_PUBLISH_TOKEN: ${{ secrets.PYPI_API_KEY }}\n        run: |\n          raku packaging/scripts/prepare.raku --target=pypi\n          raku packaging/scripts/publish.raku --target=pypi\n\n  publish-homebrew:\n    needs: build\n    runs-on: ubuntu-latest\n    steps:\n      - name: Update Homebrew formula\n        uses: dawidd6/action-homebrew-bump-formula@v3\n        with:\n          formula: lefthook\n          token: ${{ secrets.HOMEBREW_TOKEN }}\n\n  publish-winget:\n    needs: build\n    runs-on: ubuntu-latest\n    steps:\n      - name: Publish to Winget\n        uses: vedantmgoyal2009/winget-releaser@v2\n        with:\n          identifier: evilmartians.lefthook\n          fork-user: mrexox\n          token: ${{ secrets.WINGET_TOKEN }}\n\n  publish-distro-packages:\n    needs: build\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - uses: actions/download-artifact@v4\n        with:\n          name: dist\n      - run: tar -xvf dist.tar\n\n      - name: Setup Python\n        uses: actions/setup-python@v5\n        with:\n          python-version: '3.12'\n      - run: python -m pip install --upgrade cloudsmith-cli\n\n      - name: Push packages to Cloudsmith\n        env:\n          CLOUDSMITH_API_KEY: ${{ secrets.CLOUDSMITH_API_KEY }}\n        run: |\n          cloudsmith push deb evilmartians/lefthook/any-distro/any-version dist/lefthook_*_amd64.deb\n          cloudsmith push deb evilmartians/lefthook/any-distro/any-version dist/lefthook_*_arm64.deb\n          cloudsmith push rpm evilmartians/lefthook/any-distro/any-version dist/lefthook_*_amd64.rpm\n          cloudsmith push rpm evilmartians/lefthook/any-distro/any-version dist/lefthook_*_arm64.rpm\n          cloudsmith push alpine evilmartians/lefthook/alpine/any-version dist/lefthook_*_amd64.apk\n          cloudsmith push alpine evilmartians/lefthook/alpine/any-version dist/lefthook_*_arm64.apk\n\n  publish-aur_lefthook:\n    needs: build\n    runs-on: ubuntu-latest\n    container:\n      image: archlinux:latest\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - uses: Raku/setup-raku@v1\n      - name: Update AUR package\n        run: |\n          pacman -Syu --noconfirm\n          pacman -S --noconfirm openssh git go base-devel curl\n\n          useradd -m -G wheel runner\n          echo \"%wheel ALL=(ALL) NOPASSWD: ALL\" >> /etc/sudoers\n\n          chown -R runner:runner .\n\n          su runner -c '\n            mkdir -p ~/.ssh\n            echo \"${{ secrets.AUR_SSH_KEY }}\" > ~/.ssh/aur\n            chmod 600 ~/.ssh/aur\n            echo \"Host aur.archlinux.org\" >> ~/.ssh/config\n            echo \"  IdentityFile ~/.ssh/aur\" >> ~/.ssh/config\n            echo \"  User aur\" >> ~/.ssh/config\n            ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts\n\n            raku packaging/scripts/publish.raku --target=aur\n          '\n\n  publish-aur_lefthook-bin:\n    needs: build\n    runs-on: ubuntu-latest\n    container:\n      image: archlinux:latest\n    steps:\n      - uses: actions/checkout@v4\n        with:\n          fetch-depth: 0\n\n      - uses: Raku/setup-raku@v1\n      - name: Update AUR package\n        run: |\n          pacman -Syu --noconfirm\n          pacman -S --noconfirm openssh git base-devel curl\n\n          useradd -m -G wheel runner\n          echo \"%wheel ALL=(ALL) NOPASSWD: ALL\" >> /etc/sudoers\n\n          chown -R runner:runner .\n\n          su runner -c '\n            mkdir -p ~/.ssh\n            echo \"${{ secrets.AUR_SSH_KEY }}\" > ~/.ssh/aur\n            chmod 600 ~/.ssh/aur\n            echo \"Host aur.archlinux.org\" >> ~/.ssh/config\n            echo \"  IdentityFile ~/.ssh/aur\" >> ~/.ssh/config\n            echo \"  User aur\" >> ~/.ssh/config\n            ssh-keyscan -H aur.archlinux.org >> ~/.ssh/known_hosts\n\n            raku packaging/scripts/publish.raku --target=aur-bin\n          '\n"
  },
  {
    "path": ".github/workflows/test.yml",
    "content": "on:\n  push:\n    branches:\n      - master\n  pull_request:\n\nname: Test\njobs:\n  unit:\n    strategy:\n      matrix:\n        os: [ubuntu-latest, macos-latest, windows-latest]\n    runs-on: ${{ matrix.os }}\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n      - name: Install Go\n        uses: actions/setup-go@v5\n        with:\n          go-version-file: go.mod\n      - name: Test\n        run: go test $(go list ./... | grep -v '/gen$') -coverprofile coverage.out\n      - name: Report coverage\n        uses: codecov/codecov-action@v5\n        with:\n          token: ${{ secrets.CODECOV_TOKEN }}\n          flags: unit\n          name: ${{ join(matrix.*, ' ') }}\n\n  integration:\n    strategy:\n      matrix:\n        os: [ubuntu-latest, macos-latest, windows-latest]\n    runs-on: ${{ matrix.os }}\n    env:\n      GOCOVERDIR: ${{ github.workspace }}/_icoverdir_\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n      - name: Install Go\n        uses: actions/setup-go@v5\n        with:\n          go-version-file: go.mod\n      - name: Prepare lefthook\n        run: |\n          mkdir _icoverdir_\n          go install -cover\n      - name: Run integration tests\n        uses: nick-fields/retry@v3\n        with:\n          timeout_minutes: 5\n          max_attempts: 3\n          command: go test integration_test.go -tags=integration\n      - name: Collect coverage\n        run: |\n          go tool covdata textfmt -i _icoverdir_ -o coverage.out\n      - name: Report coverage\n        uses: codecov/codecov-action@v5\n        with:\n          token: ${{ secrets.CODECOV_TOKEN }}\n          flags: integration\n          name: integration-${{ join(matrix.*, ' ') }}\n\n  packaging:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n      - name: Install Raku\n        uses: Raku/setup-raku@v1\n      - name: Run packaging tests\n        run: |\n          cd packaging/scripts/\n          zef install . --deps-only\n          raku -I lib t/*.rakutest\n\n          raku prepare.raku --target=npm --dry-run\n          raku prepare.raku --target=rubygems --dry-run\n          raku prepare.raku --target=pypi --dry-run\n          raku prepare.raku --target=aur --dry-run\n          raku prepare.raku --target=aur-bin --dry-run\n\n          raku publish.raku --target=npm --dry-run\n          mkdir -p ../registries/rubygems/pkg\n          touch ../registries/rubygems/pkg/lefthook_99.gem\n          raku publish.raku --target=rubygems --dry-run\n          raku publish.raku --target=pypi --dry-run\n          raku publish.raku --target=aur --dry-run\n          raku publish.raku --target=aur-bin --dry-run\n\n  build:\n    runs-on: ubuntu-latest\n    steps:\n      - name: Checkout code\n        uses: actions/checkout@v4\n      - name: Install Go\n        uses: actions/setup-go@v5\n        with:\n          go-version-file: go.mod\n      - name: Build binaries\n        uses: goreleaser/goreleaser-action@v6\n        with:\n          distribution: goreleaser\n          version: '~> v2'\n          args: release --snapshot --skip=publish --skip=snapcraft --skip=validate --clean --verbose\n      - name: Tar binaries to preserve executable bit\n        run: 'tar -cvf lefthook-binaries.tar --directory dist/ $(find dist/ -executable -type f -printf \"%P\\0\" | xargs --null)'\n      - name: Upload binaries as artifacts\n        uses: actions/upload-artifact@v4\n        with:\n          name: Executables\n          path: lefthook-binaries.tar\n"
  },
  {
    "path": ".gitignore",
    "content": "/lefthook\n/lefthook-local.yml\n/bin/\n/dist/\n/book/\n/site/\n/vscode/\n/.idea/\n\ntmp/\n"
  },
  {
    "path": ".golangci.yml",
    "content": "version: \"2\"\n\nlinters:\n  default: none\n  enable:\n    - asasalint\n    - asciicheck\n    - bidichk\n    - bodyclose\n    - containedctx\n    - contextcheck\n    - copyloopvar\n    - dogsled\n    - dupl\n    - dupword\n    - durationcheck\n    - errcheck\n    - errchkjson\n    - errname\n    - errorlint\n    - exhaustive\n    - forbidigo\n    - gochecknoinits\n    - goconst\n    - gocritic\n    - gocyclo\n    - godoclint\n    - godot\n    - godox\n    - goheader\n    - goprintffuncname\n    - govet\n    - ineffassign\n    - intrange\n    - makezero\n    - mirror\n    - misspell\n    - mnd\n    - modernize\n    - nestif\n    - noctx\n    - nolintlint\n    - perfsprint\n    - prealloc\n    - predeclared\n    - reassign\n    - revive\n    - staticcheck\n    - tagalign\n    - usetesting\n    - unconvert\n    - unparam\n    - unused\n    - usestdlibvars\n    - whitespace\n  settings:\n    gocritic:\n      disabled-checks:\n        - hugeParam\n      enabled-tags:\n        - performance\n    govet:\n      enable:\n        - shadow\n    goconst:\n      ignore-string-values:\n        - \"false\"\n    misspell:\n      locale: US\n    perfsprint:\n      strconcat: false\n    revive:\n      rules:\n        - name: unused-parameter\n          disabled: true\n    unused:\n      field-writes-are-uses: false\n      post-statements-are-reads: true\n      exported-fields-are-used: false\n      local-variables-are-used: false\n      generated-is-used: false\n\nformatters:\n  enable:\n    - gci\n    - gofumpt\n    - goimports\n  settings:\n    gci:\n      sections:\n        - standard\n        - default\n        - prefix(github.com/evilmartians/lefthook)\n"
  },
  {
    "path": ".goreleaser.yml",
    "content": "version: 2\nproject_name: lefthook\nbefore:\n  hooks:\n    - go generate ./...\nbuilds:\n  # Builds the binaries without `lefthook upgrade`\n  - id: no_self_update\n    tags:\n      - no_self_update\n    env:\n      - CGO_ENABLED=0\n    goos:\n      - linux\n      - darwin\n      - windows\n      - freebsd\n      - openbsd\n    goarch:\n      - amd64\n      - arm64\n      - 386\n    ignore:\n      - goos: darwin\n        goarch: 386\n      - goos: linux\n        goarch: 386\n      - goos: freebsd\n        goarch: 386\n      - goos: openbsd\n        goarch: 386\n    flags:\n      - -trimpath\n    ldflags:\n      - -s -w -X github.com/evilmartians/lefthook/internal/version.commit={{.Commit}}\n\n  # Full lefthook binary\n  - id: lefthook\n    env:\n      - CGO_ENABLED=0\n    goos:\n      - linux\n      - darwin\n      - windows\n      - freebsd\n      - openbsd\n    goarch:\n      - amd64\n      - arm64\n      - 386\n    ignore:\n      - goos: darwin\n        goarch: 386\n      - goos: linux\n        goarch: 386\n      - goos: freebsd\n        goarch: 386\n      - goos: openbsd\n        goarch: 386\n    flags:\n      - -trimpath\n    ldflags:\n      - -s -w -X github.com/evilmartians/lefthook/internal/version.commit={{.Commit}}\n\n  - id: lefthook-linux-aarch64\n    env:\n      - CGO_ENABLED=0\n    goos:\n      - linux\n    goarch:\n      - arm64\n    flags:\n      - -trimpath\n    ldflags:\n      - -s -w -X github.com/evilmartians/lefthook/internal/version.commit={{.Commit}}\n\narchives:\n  - id: lefthook\n    formats: [binary]\n    ids:\n      - lefthook\n    files:\n      - none*\n    name_template: >-\n      {{ .ProjectName }}_\n      {{- .Version }}_\n      {{- if eq .Os \"darwin\" }}MacOS\n      {{- else }}{{ title .Os }}{{ end }}_\n      {{- if eq .Arch \"amd64\" }}x86_64\n      {{- else if eq .Arch \"386\" }}i386\n      {{- else }}{{ .Arch }}{{ end }}\n\n  - id: lefthook-gz\n    formats: [gz]\n    ids:\n      - lefthook\n    files:\n    - none*\n    name_template: >-\n      {{ .ProjectName }}_\n      {{- .Version }}_\n      {{- if eq .Os \"darwin\" }}MacOS\n      {{- else }}{{ title .Os }}{{ end }}_\n      {{- if eq .Arch \"amd64\" }}x86_64\n      {{- else if eq .Arch \"386\" }}i386\n      {{- else }}{{ .Arch }}{{ end }}\n\n  - id: lefthook-linux-aarch64\n    formats: [binary]\n    ids:\n      - lefthook-linux-aarch64\n    files:\n      - none*\n    name_template: >-\n      {{ .ProjectName }}_\n      {{- .Version }}_\n      {{- if eq .Os \"darwin\" }}MacOS\n      {{- else }}{{ title .Os }}{{ end }}_\n      {{- if eq .Arch \"amd64\" }}x86_64\n      {{- else if eq .Arch \"386\" }}i386\n      {{- else if eq .Arch \"arm64\" }}aarch64\n      {{- else }}{{ .Arch }}{{ end }}\n\n  - id: lefthook-linux-aarch64-gz\n    formats: [gz]\n    ids:\n      - lefthook-linux-aarch64\n    files:\n      - none*\n    name_template: >-\n      {{ .ProjectName }}_\n      {{- .Version }}_\n      {{- if eq .Os \"darwin\" }}MacOS\n      {{- else }}{{ title .Os }}{{ end }}_\n      {{- if eq .Arch \"amd64\" }}x86_64\n      {{- else if eq .Arch \"386\" }}i386\n      {{- else if eq .Arch \"arm64\" }}aarch64\n      {{- else }}{{ .Arch }}{{ end }}\n\nchecksum:\n  name_template: \"{{ .ProjectName }}_checksums.txt\"\n  algorithm: sha256\n\nsnapshot:\n  version_template: \"{{ .Tag }}\"\n\nchangelog:\n  sort: asc\n  filters:\n    exclude:\n    - '^docs:'\n    - '^test:'\n    - '^spec:'\n    - '^tmp:'\n    - '^context:'\n    - '^\\d+\\.\\d+\\.\\d+:'\n\nsnapcrafts:\n  - summary: Fast and powerful Git hooks manager for any type of projects.\n    description: |\n      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.\n    grade: stable\n    confinement: classic\n    publish: true\n    license: MIT\n    ids:\n      - no_self_update\n\nnfpms:\n  - file_name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}'\n    homepage:  https://github.com/evilmartians/lefthook\n    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\n    maintainer: Evil Martians <lefthook@evilmartians.com>\n    license: MIT\n    vendor: Evil Martians\n    ids:\n      - no_self_update\n    formats:\n      - apk\n      - deb\n      - rpm\n    dependencies:\n      - git\n"
  },
  {
    "path": ".lefthook.yml",
    "content": "assert_lefthook_installed: true\nskip_lfs: true\n\noutput:\n  - meta\n  - summary\n  - jobs\n\npre-commit:\n  parallel: true\n  setup:\n    - run: command -v typos || brew install typos-cli\n    - run: command -v lychee || brew install lychee\n  jobs:\n    - name: lint & test\n      glob: \"*.go\"\n      group:\n        jobs:\n          - run: make lint\n            tags: lint\n            stage_fixed: true\n\n          - run: make test\n            tags: test\n\n    - name: check links\n      tags: docs\n      run: lychee --max-concurrency 3 -- {staged_files}\n      glob: '*.md'\n      exclude:\n        - CHANGELOG.md\n\n    - name: fix typos\n      tags: lint\n      run: typos --write-changes {staged_files}\n      exclude:\n        - \"*.svg\"\n        - \"*.png\"\n      stage_fixed: true\n\n    - name: update JSON schema\n      tags: docs\n      run: |\n        go generate gen/jsonschema.go > internal/config/jsonschema.json\n        go generate gen/jsonschema.go > schema.json\n        git add internal/config/jsonschema.json\n        git add schema.json\n      glob:\n        - 'gen/jsonschema.go'\n        - 'internal/config/command.go'\n        - 'internal/config/config.go'\n        - 'internal/config/hook.go'\n        - 'internal/config/job.go'\n        - 'internal/config/remote.go'\n        - 'internal/config/script.go'\n"
  },
  {
    "path": ".tool-versions",
    "content": "golangci-lint 2.10.1\n"
  },
  {
    "path": ".typos.toml",
    "content": "[default.extend-identifiers]\n\"PnP\" = \"PnP\"\n[default.extend-words]\nslq = \"slq\""
  },
  {
    "path": "AGENTS.md",
    "content": "# AGENTS.md\n\nLefthook is a CLI-first Git hooks manager. Contributions must be predictable,\nbackwards-compatible, and dependency-light.\n\n## Requirements\n\n- Go 1.26+ (respect `go.mod` toolchain)\n- Git, Make\n```\nmake build            # compile\nmake test             # unit tests\nmake test-integration # integration tests\nmake lint             # golangci-lint\nmake jsonschema       # regenerate schema.json after config changes\n```\n\n## Codebase map\n\n| Path | Purpose |\n|---|---|\n| `cmd/` | CLI commands |\n| `internal/config/` | Config parsing, validation, JSON schema |\n| `internal/run/` | Hook runner, parallelism |\n| `internal/command/` | Top-level orchestrator |\n| `internal/git/` | Git utilities |\n| `docs/` | documentation source → lefthook.dev |\n| `tests/` | Integration/fixture tests |\n\n## Rules\n\n**Errors** — always wrap with context; never silently ignore; no panic in production paths.\n\n**Concurrency** — no goroutine leaks; use `context.Context`; deterministic output when order matters.\n\n**CLI** — preserve exit codes, flag names, and output format. Update docs and tests for any behavior change.\n\n**Config** — edit structs in `internal/config/`, then run `make jsonschema`. Both `schema.json` and `internal/config/jsonschema.json` must be committed.\n\n**Security** — treat user input as untrusted; no unsafe shell concatenation; sanitize paths.\n\n## Testing\n\nPrefer table-driven unit tests. Integration tests should validate CLI behavior and real git interaction — not internal implementation details.\n\n## PR checklist\n\n- [ ] `make lint` passes\n- [ ] `make test` passes\n- [ ] Docs updated if behavior changed or new config option added\n\nWhen in doubt, follow existing patterns. Consistency over cleverness.\n"
  },
  {
    "path": "CHANGELOG.md",
    "content": "# Change log\n\n## 2.1.4 (2026-03-12)\n\n- pkg: fix scripts ([#1348](https://github.com/evilmartians/lefthook/pull/1348)) by [@mrexox](https://github.com/mrexox)\n- fix: bring back {lefthook_job_name} template ([#1347](https://github.com/evilmartians/lefthook/pull/1347)) by [@mrexox](https://github.com/mrexox)\n- pkg: refactor packaging (2) ([#1346](https://github.com/evilmartians/lefthook/pull/1346)) by [@mrexox](https://github.com/mrexox)\n- fix: separate more commands' non-option args with -- ([#1339](https://github.com/evilmartians/lefthook/pull/1339)) by [@scop](https://github.com/scop)\n- 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)\n- pkg: make it easier to read ([#1340](https://github.com/evilmartians/lefthook/pull/1340)) by [@mrexox](https://github.com/mrexox)\n- pkg: refactor packaging scripts ([#1308](https://github.com/evilmartians/lefthook/pull/1308)) by [@mrexox](https://github.com/mrexox)\n\n## 2.1.3 (2026-03-07)\n\n- chore: switch artifact attestations gen to actions/attest v4 ([#1338](https://github.com/evilmartians/lefthook/pull/1338)) by [@scop](https://github.com/scop)\n- chore: describe ENV variables usage in CLI help output ([#1337](https://github.com/evilmartians/lefthook/pull/1337)) by [@mrexox](https://github.com/mrexox)\n- fix: support git debug versions ([#1334](https://github.com/evilmartians/lefthook/pull/1334)) by [@mrexox](https://github.com/mrexox)\n- deps: March 2026 ([#1330](https://github.com/evilmartians/lefthook/pull/1330)) by [@mrexox](https://github.com/mrexox)\n- feat: update minimum go version ([#1331](https://github.com/evilmartians/lefthook/pull/1331)) by [@mrexox](https://github.com/mrexox)\n\n## 2.1.2 (2026-03-01)\n\n- feat: introduce setup hook option ([#1326](https://github.com/evilmartians/lefthook/pull/1326)) by [@mrexox](https://github.com/mrexox)\n- refactor: recovering logic for changesets ([#1324](https://github.com/evilmartians/lefthook/pull/1324)) by [@mrexox](https://github.com/mrexox)\n- fix: rollback auto-staged changes if unwanted changes detected ([#1251](https://github.com/evilmartians/lefthook/pull/1251)) by [@tuchfarber](https://github.com/tuchfarber)\n- docs: improve docs ui ([#1323](https://github.com/evilmartians/lefthook/pull/1323)) by [@mrexox](https://github.com/mrexox)\n- docs: additional skip example and note about reinstallation ([#1319](https://github.com/evilmartians/lefthook/pull/1319)) by [@iloveitaly](https://github.com/iloveitaly)\n- docs: fix incorrect --verbose usage ([#1318](https://github.com/evilmartians/lefthook/pull/1318)) by [@iloveitaly](https://github.com/iloveitaly)\n- pkg: fix python packages publishing by [@mrexox](https://github.com/mrexox)\n\n## 2.1.1 (2026-02-12)\n\n- ci: fix publishing to PyPi by [@mrexox](https://github.com/mrexox)\n- fix: reset colors on config read ([#1309](https://github.com/evilmartians/lefthook/pull/1309)) by [@mrexox](https://github.com/mrexox)\n- 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)\n- docs: add missing /v2 suffix for go get -tool ([#1304](https://github.com/evilmartians/lefthook/pull/1304)) by [@alexandregv](https://github.com/alexandregv)\n\n## 2.1.0 (2026-02-03)\n\n- ci: skip Python publishing by [@mrexox][]\n- chore: fancy wording and indentation for hits by [@mrexox][]\n- feat: check core.hooksPath when lefthook install ([#1292](https://github.com/evilmartians/lefthook/pull/1292)) by [@joevin-slq-docto][]\n- feat: allow installing non-git hooks ([#1301](https://github.com/evilmartians/lefthook/pull/1301)) by [@mrexox][]\n\n## 2.0.16 (2026-01-27)\n\n- chore: timeout cleanup ([#1297](https://github.com/evilmartians/lefthook/pull/1297)) by [@mrexox](https://github.com/mrexox)\n- feat: add timeout argument ([#1263](https://github.com/evilmartians/lefthook/pull/1263)) by [@franzramadhan](https://github.com/franzramadhan)\n- deps: January 2026 ([#1285](https://github.com/evilmartians/lefthook/pull/1285)) by [@mrexox](https://github.com/mrexox)\n- pkg: pack one binary per platform into python wheels ([#1181](https://github.com/evilmartians/lefthook/pull/1181)) by [@danfimov](https://github.com/danfimov)\n- fix: accept string in file_types ([#1288](https://github.com/evilmartians/lefthook/pull/1288)) by [@scop](https://github.com/scop)\n- docs: elaborate on when to refetch and failure mode ([#1287](https://github.com/evilmartians/lefthook/pull/1287)) by [@scop](https://github.com/scop)\n- fix: try reading direct file instead of all remotes ([#1243](https://github.com/evilmartians/lefthook/pull/1243)) by [@mrexox](https://github.com/mrexox)\n- perf: [**breaking**] skip ghost hook when hooks are already configured ([#1255](https://github.com/evilmartians/lefthook/pull/1255)) by [@WooWan](https://github.com/WooWan)\n- chore: upgrade to 2.8.0 ([#1278](https://github.com/evilmartians/lefthook/pull/1278)) by [@scop](https://github.com/scop)\n\n## 2.0.15 (2026-01-13)\n\n- docs: clarify remote settings ([#1260](https://github.com/evilmartians/lefthook/pull/1260)) by [@mrexox](https://github.com/mrexox)\n- feat: skip scripts if args given with empty file template ([#1277](https://github.com/evilmartians/lefthook/pull/1277)) by [@mrexox](https://github.com/mrexox)\n\n## 2.0.14 (2026-01-12)\n\n- fix: skip if any files template is empty ([#1275](https://github.com/evilmartians/lefthook/pull/1275)) by [@mrexox](https://github.com/mrexox)\n- feat: add jsonc support ([#1274](https://github.com/evilmartians/lefthook/pull/1274)) by [@mrexox](https://github.com/mrexox)\n- 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)\n- fix: don't install custom hooks to hooks dir ([#1246](https://github.com/evilmartians/lefthook/pull/1246)) by [@scop](https://github.com/scop)\n- deps: December 2025 ([#1209](https://github.com/evilmartians/lefthook/pull/1209)) by [@mrexox](https://github.com/mrexox)\n\n## 2.0.13 (2025-12-26)\n\n- fix: set extends to empty slice after loading remotes ([#1259](https://github.com/evilmartians/lefthook/pull/1259)) by [@mrexox]()\n- fix: allow custom hooks in JSON schema by updating generator ([#1250](https://github.com/evilmartians/lefthook/pull/1250)) by [@jeonghoon11]()\n- docs: remove duplicate config: false description ([#1245](https://github.com/evilmartians/lefthook/pull/1245)) by [@scop]()\n- chore: add more tests ([#1244](https://github.com/evilmartians/lefthook/pull/1244)) by [@mrexox]()\n\n## 2.0.12 (2025-12-15)\n\n- chore: small changes on diff printing ([#1242](https://github.com/evilmartians/lefthook/pull/1242)) by [@mrexox](https://github.com/mrexox)\n- feat: ability to show diff when failing on changes ([#1227](https://github.com/evilmartians/lefthook/pull/1227)) by [@scop](https://github.com/scop)\n- fix: make short status parser more robust ([#1236](https://github.com/evilmartians/lefthook/pull/1236)) by [@scop](https://github.com/scop)\n- docs: fix readme ([#1235](https://github.com/evilmartians/lefthook/pull/1235)) by [@matdibu](https://github.com/matdibu)\n\n## 2.0.11 (2025-12-12)\n\n- feat: refetch and cleanup on ref change ([#1210](https://github.com/evilmartians/lefthook/pull/1210)) by [@mrexox](https://github.com/mrexox)\n- ci: npm trusted publishing ([#1234](https://github.com/evilmartians/lefthook/pull/1234)) by [@mrexox](https://github.com/mrexox)\n- feat: more rudimentary shell completions ([#1230](https://github.com/evilmartians/lefthook/pull/1230)) by [@scop](https://github.com/scop)\n\n## 2.0.10 (2025-12-12)\n\n- feat: add no_auto_install to lefthook.yml ([#1231](https://github.com/evilmartians/lefthook/pull/1231)) by [@pavelzw](https://github.com/pavelzw)\n- fix: skip if empty files template ([#1233](https://github.com/evilmartians/lefthook/pull/1233)) by [@mrexox](https://github.com/mrexox)\n\n## 2.0.9 (2025-12-08)\n\n- fix: skip pre commit hook if no staged files ([#1229](https://github.com/evilmartians/lefthook/pull/1229)) by [@mrexox](https://github.com/mrexox)\n- fix: do not try to hash-object directories ([#1220](https://github.com/evilmartians/lefthook/pull/1220)) by [@scop](https://github.com/scop)\n- fix: check and report Scanner errors ([#1222](https://github.com/evilmartians/lefthook/pull/1222)) by [@scop](https://github.com/scop)\n- refactor: command executor tweaks ([#1224](https://github.com/evilmartians/lefthook/pull/1224)) by [@scop](https://github.com/scop)\n- refactor: remove some redundant code ([#1221](https://github.com/evilmartians/lefthook/pull/1221)) by [@scop](https://github.com/scop)\n- 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)\n- chore: upgrade golangci-lint to 2.7.1, add godoclint ([#1223](https://github.com/evilmartians/lefthook/pull/1223)) by [@scop](https://github.com/scop)\n- chore: remove unnecessary .svg executable permissions ([#1219](https://github.com/evilmartians/lefthook/pull/1219)) by [@scop](https://github.com/scop)\n\n## 2.0.8 (2025-12-05)\n\n- fix: do not escape custom templates in command replacement ([#1213](https://github.com/evilmartians/lefthook/pull/1213)) by [@joevin-sql-docto]()\n\n## 2.0.7 (2025-12-04)\n\n- fix: prefer using lefthook from the $PATH ([#1211](https://github.com/evilmartians/lefthook/pull/1211)) by [@joevin-sql-docto]()\n\n## 2.0.6 (2025-12-03)\n\n- feat: save original executable location in hooks ([#1208](https://github.com/evilmartians/lefthook/pull/1208)) by [@mrexox]()\n- docs: encourage python install using pipx ([#1207](https://github.com/evilmartians/lefthook/pull/1207)) by [@franzramadhan]()\n\n## 2.0.5 (2025-12-02)\n\n- feat: add optional args to scripts ([#1206](https://github.com/evilmartians/lefthook/pull/1206)) by [@mrexox]()\n- deps: November 2025 ([#1200](https://github.com/evilmartians/lefthook/pull/1200)) by [@mrexox]()\n- chore: upgrade golangci-lint to 2.6.1, add modernize ([#1190](https://github.com/evilmartians/lefthook/pull/1190)) by [@scop]()\n- chore: publish artifact attestations ([#1189](https://github.com/evilmartians/lefthook/pull/1189)) by [@scop]()\n\n## 2.0.4 (2025-11-13)\n\n- fix: glob_matcher jsonschema values\n- feat: add optional standard glob matcher (doublestar) ([#1188](https://github.com/evilmartians/lefthook/pull/1188)) by [@jasonwbarnett]()\n\n## 2.0.3 (2025-11-10)\n\n- feat: fail_on_changes non-ci option ([#1186](https://github.com/evilmartians/lefthook/pull/1186)) by [@scop](https://github.com/scop)\n- deps: update mimetypes ([#1185](https://github.com/evilmartians/lefthook/pull/1185)) by [@mrexox](https://github.com/mrexox)\n\n## 2.0.2 (2025-10-29)\n\n- fix: add mutex lock before all git commands ([#1178](https://github.com/evilmartians/lefthook/pull/1178)) by [@mrexox]()\n\n## 2.0.0 (2025-10-20)\n\n**Breaking changes**\n\n- `exclude` option no longer accepts regexp, only globs.\n- `skip_output` option is dropped, use `output` instead.\n- Some CLI arguments have changed their names to make it more consistent. See `lefthook run -h` for details.\n- for `only` and `skip` options with `- run: '...'` values the command executer was changed to Bourne Shell.\n\n**Commits**\n\n- fix: accept --fail-on-changes=false as override value ([#1168](https://github.com/evilmartians/lefthook/pull/1168)) by [@mrexox]()\n- feat: [**breaking**] use sh as command executor on Windows ([#1166](https://github.com/evilmartians/lefthook/pull/1166)) by [@mrexox]()\n- refactor: [**breaking**] drop support for exclude regexp ([#1162](https://github.com/evilmartians/lefthook/pull/1162)) by [@mrexox]()\n- refactor: [**breaking**] drop deprecated skip_output option ([#1159](https://github.com/evilmartians/lefthook/pull/1159)) by [@mrexox]()\n- refactor: [**breaking**] use another cli framework ([#1155](https://github.com/evilmartians/lefthook/pull/1155)) by [@mrexox]()\n\n## 1.13.6 (2025-09-30)\n\n- fix: embed jsonschema into binary ([#1158](https://github.com/evilmartians/lefthook/pull/1158)) by [@mrexox]()\n\n## 1.13.5 (2025-09-29)\n\n- chore: a small cleanup by [@mrexox]()\n- refactor: use semver to check versions ([#1152](https://github.com/evilmartians/lefthook/pull/1152)) by [@mrexox]()\n- fix: add comprehensive tests for spinner name formatting ([#1145](https://github.com/evilmartians/lefthook/pull/1145)) [@technicalpickles]()\n- docs: add LEFTHOOK_BIN environment variable to documentation ([#1151](https://github.com/evilmartians/lefthook/pull/1151)) [@technicalpickles]()\n- chore: tests improvements ([#1148](https://github.com/evilmartians/lefthook/pull/1148)) by [@mrexox]()\n- chore: fix naming for integration tests ([#1146](https://github.com/evilmartians/lefthook/pull/1146)) by [@mrexox]()\n- docs: use codecov coverage badge by [@mrexox]()\n- ci: codecov ([#1147](https://github.com/evilmartians/lefthook/pull/1147)) by [@mrexox]()\n- docs: use actual latest version ([#1143](https://github.com/evilmartians/lefthook/pull/1143)) by [@mrexox]()\n- docs: add exclude to hook-level settings by [@mrexox]()\n\n## 1.13.4 (2025-09-23)\n\n- fix: add exclude option to hook level ([#1141](https://github.com/evilmartians/lefthook/pull/1141)) by [@mrexox]()\n- fix: allow skipping groups ([#1140](https://github.com/evilmartians/lefthook/pull/1140)) by [@mrexox]()\n\n## 1.13.3 (2025-09-23)\n\n- deps: September 2025 ([#1139](https://github.com/evilmartians/lefthook/pull/1139)) by [@mrexox]()\n- fix: concurrent map access issue ([#1138](https://github.com/evilmartians/lefthook/pull/1138)) by [@mrexox]()\n\n## 1.13.2 (2025-09-22)\n\n- feat: inherit file_types from parent jobs ([#1135](https://github.com/evilmartians/lefthook/pull/1135)) by [@mrexox]()\n- fix: move gen at root ([#1133](https://github.com/evilmartians/lefthook/pull/1133)) by [@mrexox]()\n- refactor: better scope subpackages ([#1132](https://github.com/evilmartians/lefthook/pull/1132)) by [@mrexox]()\n\n## 1.13.1 (2025-09-17)\n\n- feat: add no stage fixed argument ([#1130](https://github.com/evilmartians/lefthook/pull/1130)) by [@mrexox]()\n- refactor: reduce the amount of code in a single file ([#1131](https://github.com/evilmartians/lefthook/pull/1131)) by [@mrexox]()\n- fix: re-evaluate status for changeset ([#1129](https://github.com/evilmartians/lefthook/pull/1129)) by [@mrexox]()\n- refactor: reduce the amount of code in a single file ([#1118](https://github.com/evilmartians/lefthook/pull/1118)) by [@mrexox]()\n- chore: update issue templates by [@mrexox](https://github.com/mrexox)\n- docs: add fail_on_changes to configuration/README.md ([#1119](https://github.com/evilmartians/lefthook/pull/1119)) by [@7crabs](https://github.com/7crabs)\n- docs: update go installation note ([#1117](https://github.com/evilmartians/lefthook/pull/1117)) by [@leakedmemory](https://github.com/leakedmemory)\n\n\n## 1.13.0 (2025-09-11)\n\n- fix: use batched cmd for calculating git hashes ([#1116](https://github.com/evilmartians/lefthook/pull/1116)) by [@mrexox]()\n- fix: add mutex to prevent concurrent git adds ([#1115](https://github.com/evilmartians/lefthook/pull/1115)) by [@mrexox]()\n- refactor: improve structuring ([#1103](https://github.com/evilmartians/lefthook/pull/1103)) by [@mrexox]()\n- feat: fail on change ([#1095](https://github.com/evilmartians/lefthook/pull/1095)) by [@olivier-lacroix]()\n- fix: set --force for git add command ([#1104](https://github.com/evilmartians/lefthook/pull/1104)) by [@michaelm]()\n- feat: recursively log successful results in summary ([#1108](https://github.com/evilmartians/lefthook/pull/1108)) by [@siler]()\n- fix: groups with successes and skips are successful ([#1107](https://github.com/evilmartians/lefthook/pull/1107)) by [@siler]()\n\n## 1.12.4 (2025-09-05)\n\n- deps: September 2025 ([#1102](https://github.com/evilmartians/lefthook/pull/1102)) by [@mrexox]()\n- feat: add tags argument ([#1101](https://github.com/evilmartians/lefthook/pull/1101)) by [@mrexox]()\n- chore: bump github.com/go-viper/mapstructure/v2 ([#1094](https://github.com/evilmartians/lefthook/pull/1094))\n\n## 1.12.3 (2025-08-12)\n\n- feat: add MIME types to file_types filters ([#1092](https://github.com/evilmartians/lefthook/pull/1092))\n- fix: respect LEFTHOOK_CONFIG in lefthook install ([#1090](https://github.com/evilmartians/lefthook/pull/1090)) by [@TECHNOFAB11](https://github.com/TECHNOFAB11)\n- docs: update pnpm installation note ([#1089](https://github.com/evilmartians/lefthook/pull/1089)) by [@skoch13](https://github.com/skoch13)\n- 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)\n- docs: 404 for local-config ([#1082](https://github.com/evilmartians/lefthook/pull/1082)) by [@rammanoj](https://github.com/rammanoj)\n- docs: fix typo ([#1079](https://github.com/evilmartians/lefthook/pull/1079)) by [@eai04191](https://github.com/eai04191)\n\n## 1.12.2 (2025-07-11)\n\n- feat: add implicit template lefthook_job_name ([#1074](https://github.com/evilmartians/lefthook/pull/1074))\n- docs: restructure documentation ([#1075](https://github.com/evilmartians/lefthook/pull/1075)) by [@mrexox](https://github.com/mrexox)\n- feat: allow overriding config path using LEFTHOOK_CONFIG env ([#1072](https://github.com/evilmartians/lefthook/pull/1072)) by [@TECHNOFAB11](https://github.com/TECHNOFAB11)\n\n## 1.12.1 (2025-07-09)\n\n- feat: add check-install command ([#1064](https://github.com/evilmartians/lefthook/pull/1064)) by [@mrexox](https://github.com/mrexox)\n- chore: only check if local configs exist by [@mrexox](https://github.com/mrexox)\n- feat: allow using local config only ([#1071](https://github.com/evilmartians/lefthook/pull/1071)) by [@sj26](https://github.com/sj26)\n\n## 1.12.0 (2025-07-08)\n\n- feat: allow installing only specific hooks ([#1069](https://github.com/evilmartians/lefthook/pull/1069))\n- refactor: [**breaking**] restructure files and folders, remove deprecated options ([#1067](https://github.com/evilmartians/lefthook/pull/1067))\n\n## 1.11.16 (2025-07-03)\n\n- fix: race condition on repo state ([#1066](https://github.com/evilmartians/lefthook/pull/1066))\n\n## 1.11.15 (2025-07-03)\n\n- feat: add exclude arg ([#1063](https://github.com/evilmartians/lefthook/pull/1063))\n- feat: inherit group envs ([#1061](https://github.com/evilmartians/lefthook/pull/1061))\n- fix: apply implicit staged files filter to all files when all files arg given ([#1062](https://github.com/evilmartians/lefthook/pull/1062))\n- deps: bump github.com/kaptinlin/jsonschema to 0.4.5\n- deps: bump github.com/knadh/koanf/parsers/yaml to 1.1.0\n- deps: bump github.com/knadh/koanf/v2 to 2.2.1 ([#1043](https://github.com/evilmartians/lefthook/pull/1043))\n- fix: friendlier updater error message\n- fix: bump goreleaser\n\n## 1.11.14 (2025-06-16)\n\n- feat: show time for jobs ([#1044](https://github.com/evilmartians/lefthook/pull/1044)) by [@adeebshihadeh](https://github.com/adeebshihadeh)\n- ci: update GoReleaser configurations ([#1040](https://github.com/evilmartians/lefthook/pull/1040)) by [@emmanuel-ferdman](https://github.com/emmanuel-ferdman)\n- feat: support devbox ([#1031](https://github.com/evilmartians/lefthook/pull/1031)) by [@misogihagi](https://github.com/misogihagi)\n- chore: regexp use improvements ([#1034](https://github.com/evilmartians/lefthook/pull/1034)) by [@scop](https://github.com/scop)\n- chore: upgrade golangci-lint to v2, address findings ([#1027](https://github.com/evilmartians/lefthook/pull/1027)) by [@scop](https://github.com/scop)\n\n## 1.11.13 (2025-05-16)\n\n- deps: May 2025 ([#1024](https://github.com/evilmartians/lefthook/pull/1024)) by [@mrexox](https://github.com/mrexox)\n- fix: load scripts from .config too ([#1018](https://github.com/evilmartians/lefthook/pull/1018)) by [@mrexox](https://github.com/mrexox)\n- chore: change \"existed\" to \"existing\" ([#1022](https://github.com/evilmartians/lefthook/pull/1022)) by [@assyrus-favolo](https://github.com/assyrus-favolo)\n- docs: fix grammatical error in `Local config` section ([#1019](https://github.com/evilmartians/lefthook/pull/1019)) by [@dev-kas](https://github.com/dev-kas)\n\n## 1.11.12 (2025-04-28)\n\n- feat: load from .config dir ([#1017](https://github.com/evilmartians/lefthook/pull/1017)) by [@mrexox](https://github.com/mrexox)\n- feat: complete all job names, recursively ([#1015](https://github.com/evilmartians/lefthook/pull/1015)) by [@scop](https://github.com/scop)\n- docs: update links to mise by [@mrexox](https://github.com/mrexox)\n\n## 1.11.11 (2025-04-21)\n\n- deps: koanf and jsonschema ([#1013](https://github.com/evilmartians/lefthook/pull/1013)) by [@mrexox](https://github.com/mrexox)\n- feat: add support for mise ([#1007](https://github.com/evilmartians/lefthook/pull/1007)) by [@shahar-py](https://github.com/shahar-py)\n\n## 1.11.10 (2025-04-14)\n\n- 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)\n- feat: add support for uv ([#1004](https://github.com/evilmartians/lefthook/pull/1004)) by [@toshok](https://github.com/toshok)\n\n## 1.11.9 (2025-04-11)\n\n- fix: better logging ([#1003](https://github.com/evilmartians/lefthook/pull/1003)) by [@mrexox](https://github.com/mrexox)\n- feat: allow installing hooks in CI ([#1001](https://github.com/evilmartians/lefthook/pull/1001)) by [@caugner](https://github.com/caugner)\n- deps: Dependencies upgrade [@mrexox](https://github.com/mrexox)\n\n## 1.11.8 (2025-04-08)\n\n- fix: sh lookup on Windows ([#997](https://github.com/evilmartians/lefthook/pull/997)) by [@mrexox](https://github.com/mrexox)\n- fix: fix command execution error on Windows #989 ([#992](https://github.com/evilmartians/lefthook/pull/992)) by [@atsushifx](https://github.com/atsushifx)\n\n## 1.11.7 (2025-04-07)\n\n- fix: avoid error logging when determining pre push files ([#995](https://github.com/evilmartians/lefthook/pull/995)) by [@mrexox](https://github.com/mrexox)\n- docs: allow duplicate files in SUMMARY ([#988](https://github.com/evilmartians/lefthook/pull/988)) by [@mrexox](https://github.com/mrexox)\n- fix: unquote paths to valid UTF-8 ([#987](https://github.com/evilmartians/lefthook/pull/987)) by [@mrexox](https://github.com/mrexox)\n- packaging: aur fixes ([#985](https://github.com/evilmartians/lefthook/pull/985)) by [@mrexox](https://github.com/mrexox)\n\n## 1.11.6 (2025-03-31)\n\n- fix: print git errors  ([#984](https://github.com/evilmartians/lefthook/pull/984)) by [@mrexox](https://github.com/mrexox)\n- packaging: maintain lefthook-bin AUR package ([#982](https://github.com/evilmartians/lefthook/pull/982)) by [@mrexox](https://github.com/mrexox)\n- chore: fancier logging ([#983](https://github.com/evilmartians/lefthook/pull/983)) by [@mrexox](https://github.com/mrexox)\n- docs: remove a note about the difference for unix-like and windows by [@mrexox](https://github.com/mrexox)\n\n## 1.11.5 (2025-03-25)\n\n- fix: windows scripts issues ([#979](https://github.com/evilmartians/lefthook/pull/979)) by [@mrexox](https://github.com/mrexox)\n\n## 1.11.4 (2025-03-24)\n\n- feat: support lefthook as go tool ([#976](https://github.com/evilmartians/lefthook/pull/976)) by [@nmoniz](https://github.com/nmoniz)\n- fix: use dedicated build path for swift plugin ([#978](https://github.com/evilmartians/lefthook/pull/978)) by [@csjones](https://github.com/csjones)\n- deps: March 2025 ([#977](https://github.com/evilmartians/lefthook/pull/977)) by [@mrexox](https://github.com/mrexox)\n- docs: update pnpm install command in the installation guide ([#974](https://github.com/evilmartians/lefthook/pull/974)) by [@hoosierhuy](https://github.com/hoosierhuy)\n\n## 1.11.3 (2025-03-07)\n\n- fix: remote cloning issues ([#969](https://github.com/evilmartians/lefthook/pull/969)) by [@mrexox](https://github.com/mrexox)\n\n## 1.11.2 (2025-02-26)\n\n- fix: do not inherit envs in remote Git commands ([#963](https://github.com/evilmartians/lefthook/pull/963)) by [@mrexox](https://github.com/mrexox)\n\n## 1.11.1 (2025-02-25)\n\n- fix: remote issue with worktrees ([#960](https://github.com/evilmartians/lefthook/pull/960)) by [@mrexox](https://github.com/mrexox)\n\n## 1.11.0 (2025-02-23)\n\n- perf: speed up git commands ([#956](https://github.com/evilmartians/lefthook/pull/956)) by [@judofyr](https://github.com/judofyr)\n\n## 1.10.11 (2025-02-21)\n\n- 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)\n- fix: add $schema property ([#942](https://github.com/evilmartians/lefthook/pull/942)) by [@mst-mkt](https://github.com/mst-mkt)\n- 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)\n\n## 1.10.10 (2025-01-21)\n\n- feat: allow providing a list of globs ([#937](https://github.com/evilmartians/lefthook/pull/937)) by [@mrexox](https://github.com/mrexox)\n- fix: properly inherit exclude options when not overwritten ([#936](https://github.com/evilmartians/lefthook/pull/936)) by [@mrexox](https://github.com/mrexox)\n\n## 1.10.9 (2025-01-20)\n\n- fix: make uninstall --remove-configs description more accurate ([#934](https://github.com/evilmartians/lefthook/pull/934)) by [@scop](https://github.com/scop)\n\n## 1.10.8 (2025-01-17)\n\n- feat: add custom plain templates ([#930](https://github.com/evilmartians/lefthook/pull/930)) by [@mrexox](https://github.com/mrexox)\n- fix: unique names for nested operations ([#931](https://github.com/evilmartians/lefthook/pull/931)) by [@mrexox](https://github.com/mrexox)\n\n## 1.10.7 (2025-01-15)\n\n- fix: use lefthook option in ghost hook too ([#929](https://github.com/evilmartians/lefthook/pull/929)) by [@mrexox](https://github.com/mrexox)\n- feat: add schema.json to npm packages ([#928](https://github.com/evilmartians/lefthook/pull/928)) by [@mrexox](https://github.com/mrexox)\n- fix: increase timeout for self-update to 2 mins by [@mrexox](https://github.com/mrexox)\n\n## 1.10.5 (2025-01-14)\n\n- feat: add lefthook option for custom path or command ([#927](https://github.com/evilmartians/lefthook/pull/927)) by [@mrexox](https://github.com/mrexox)\n- chore: update config template with new jobs by [@mrexox](https://github.com/mrexox)\n\n## 1.10.4 (2025-01-13)\n\n- fix: avoid skipping pre commit when deleted files staged ([#925](https://github.com/evilmartians/lefthook/pull/925)) by [@mrexox](https://github.com/mrexox)\n- fix: use roots from jobs for possible npm package location ([#924](https://github.com/evilmartians/lefthook/pull/924)) by [@mrexox](https://github.com/mrexox)\n- deps: January 2025 ([#926](https://github.com/evilmartians/lefthook/pull/926)) by [@mrexox](https://github.com/mrexox)\n\n## 1.10.3 (2025-01-10)\n\n- fix: replace cmd in jobs ([#918](https://github.com/evilmartians/lefthook/pull/918)) by [@mrexox](https://github.com/mrexox)\n\n## 1.10.2 (2025-01-10)\n\n- feat: add validate command ([#915](https://github.com/evilmartians/lefthook/pull/915)) by [@mrexox](https://github.com/mrexox)\n- feat: inherit exclude option in groups ([#916](https://github.com/evilmartians/lefthook/pull/916)) by [@mrexox](https://github.com/mrexox)\n- chore: auto generate json schema ([#914](https://github.com/evilmartians/lefthook/pull/914)) by [@mrexox](https://github.com/mrexox)\n- feat: run --jobs completion ([#913](https://github.com/evilmartians/lefthook/pull/913)) by [@scop](https://github.com/scop)\n- ci: add gzipped linux aarch64 binary to release artifacts ([#908](https://github.com/evilmartians/lefthook/pull/908)) by [@mrexox](https://github.com/mrexox)\n-\n## 1.10.1 (2024-12-26)\n\n- feat: add ability to specify job names for command run ([#904](https://github.com/evilmartians/lefthook/pull/904)) by [@mrexox](https://github.com/mrexox)\n- ci: add linux aarch64 binary to release ([#903](https://github.com/evilmartians/lefthook/pull/903)) by [@mrexox](https://github.com/mrexox)\n- ci: fix aur build ([#905](https://github.com/evilmartians/lefthook/pull/905)) by [@mrexox](https://github.com/mrexox)\n\n## 1.10.0 (2024-12-19)\n\n- feat: add jobs option ([#861](https://github.com/evilmartians/lefthook/pull/861)) by [@mrexox](https://github.com/mrexox)\n- ci: automate aur package update ([#899](https://github.com/evilmartians/lefthook/pull/899)) by [@mrexox](https://github.com/mrexox)\n\n## 1.9.3 (2024-12-18)\n\n- fix: correctly parse config options ([#895](https://github.com/evilmartians/lefthook/pull/895)) by [@mrexox](https://github.com/mrexox)\n- chore: add mdbook ([#894](https://github.com/evilmartians/lefthook/pull/894)) by [@mrexox](https://github.com/mrexox)\n\n## 1.9.2 (2024-12-12)\n\n- fix: use correct remote scripts folder ([#891](https://github.com/evilmartians/lefthook/pull/891)) by [@mrexox](https://github.com/mrexox)\n\n## 1.9.1 (2024-12-12)\n\n- fix: skip_lfs config option ([#889](https://github.com/evilmartians/lefthook/pull/889)) by [@zachahn](https://github.com/zachahn)\n\n## 1.9.0 (2024-12-06)\n\n- chore: add minimum git version support warning ([#886](https://github.com/evilmartians/lefthook/pull/886)) by [@mrexox](https://github.com/mrexox)\n- fix: reorder available hooks list ([#884](https://github.com/evilmartians/lefthook/pull/884)) by [@scop](https://github.com/scop)\n- docs: correct typo in 'Scoop for Windows' section ([#883](https://github.com/evilmartians/lefthook/pull/883)) by [@Daniil-Oberlev](https://github.com/Daniil-Oberlev)\n- refactor: [**breaking**] replace viper with koanf ([#813](https://github.com/evilmartians/lefthook/pull/813)) by [@mrexox](https://github.com/mrexox)\n- ci: fix packages release ([#881](https://github.com/evilmartians/lefthook/pull/881)) by [@mrexox](https://github.com/mrexox)\n\n## 1.8.5 (2024-12-02)\n\n- ci: automate publishing to cloudsmith ([#875](https://github.com/evilmartians/lefthook/pull/875)) by [@mrexox](https://github.com/mrexox)\n- feat: add option to skip running LFS hooks ([#879](https://github.com/evilmartians/lefthook/pull/879)) by [@zachah](https://github.com/zachah)\n\n## 1.8.4 (2024-11-18)\n\n- ci: fix goreleaser update changes ([#874](https://github.com/evilmartians/lefthook/pull/874)) by [@mrexox](https://github.com/mrexox)\n- deps: November 2024 ([#867](https://github.com/evilmartians/lefthook/pull/867)) by [@mrexox](https://github.com/mrexox)\n- docs: add docs for fnm configuration ([#869](https://github.com/evilmartians/lefthook/pull/869)) by [@vasylnahuliak](https://github.com/vasylnahuliak)\n- docs: add `output` to list of config options ([#868](https://github.com/evilmartians/lefthook/pull/868)) by [@cr7pt0gr4ph7](https://github.com/cr7pt0gr4ph7)\n\n## 1.8.3 (2024-11-18)\n\n- fix: use absolute paths when cloning remotes ([#873](https://github.com/evilmartians/lefthook/pull/873)) by [@mrexox](https://github.com/mrexox)\n\n## 1.8.2 (2024-10-29)\n\n- chore: fix linter and tests by [@mrexox](https://github.com/mrexox)\n- feat: add refetch_frequency parameter to settings ([#857](https://github.com/evilmartians/lefthook/pull/857)) by [@gabriel-ss](https://github.com/gabriel-ss)\n- docs: call commitizen properly ([#858](https://github.com/evilmartians/lefthook/pull/858)) by [@politician](https://github.com/politician)\n\n## 1.8.1 (2024-10-23)\n\n- chore: bump Go to 1.23 ([#856](https://github.com/evilmartians/lefthook/pull/856)) by Valentin Kiselev\n- fix: skip git lfs hook when calling manually ([#855](https://github.com/evilmartians/lefthook/pull/855)) by Valentin Kiselev\n\n## 1.8.0 (2024-10-22)\n\n- 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)\n- fix: [**breaking**] execute files command within configured root ([#607](https://github.com/evilmartians/lefthook/pull/607)) by [@mrexox](https://github.com/mrexox)\n- fix: calculate hashsum of the full config ([#854](https://github.com/evilmartians/lefthook/pull/854)) by [@mrexox](https://github.com/mrexox)\n- feat: support globs in extends ([#853](https://github.com/evilmartians/lefthook/pull/853)) by [@mrexox](https://github.com/mrexox)\n- docs: simplify configuration docs ([#851](https://github.com/evilmartians/lefthook/pull/851)) by [@mrexox](https://github.com/mrexox)\n\n## 1.7.22 (2024-10-18)\n\n- feat: add skip option merge-commit ([#850](https://github.com/evilmartians/lefthook/pull/850)) by [@mrexox](https://github.com/mrexox)\n- ci: parallelize publishing ([#847](https://github.com/evilmartians/lefthook/pull/847)) by [@mrexox](https://github.com/mrexox)\n- fix: increase self update download timeout ([#849](https://github.com/evilmartians/lefthook/pull/849)) by [@mrexox](https://github.com/mrexox)\n- docs: update docs with new packages ([#848](https://github.com/evilmartians/lefthook/pull/848)) by [@mrexox](https://github.com/mrexox)\n\n## 1.7.21 (2024-10-17)\n\n- feat: maintain Python package too ([#845](https://github.com/evilmartians/lefthook/pull/845)) by [@mrexox](https://github.com/mrexox)\n- ci: generate apk files ([#843](https://github.com/evilmartians/lefthook/pull/843)) by [@mrexox](https://github.com/mrexox)\n- docs: mention to uninstall npm package ([#842](https://github.com/evilmartians/lefthook/pull/842)) by [@mrexox](https://github.com/mrexox)\n- chore: hide remaining wiki links ([#841](https://github.com/evilmartians/lefthook/pull/841)) by [@midskyey](https://github.com/midskyey)\n- docs: update info about merge order ([#838](https://github.com/evilmartians/lefthook/pull/838)) by [@mrexox](https://github.com/mrexox)\n- docs: actualize ([#831](https://github.com/evilmartians/lefthook/pull/831)) by [@mrexox](https://github.com/mrexox)\n\n## 1.7.19 and 1.7.20 – failed to build\n\n## 1.7.18 (2024-09-30)\n\n- fix: force remote name origin when using remotes ([#830](https://github.com/evilmartians/lefthook/pull/830)) by [@mrexox](https://github.com/mrexox)\n- deps: September 2024 ([#829](https://github.com/evilmartians/lefthook/pull/829)) by [@mrexox](https://github.com/mrexox)\n\n## 1.7.17 (2024-09-26)\n\n- feat: skip LFS hooks when pre-push hook is skipped ([#818](https://github.com/evilmartians/lefthook/pull/818)) by [@zachahn](https://github.com/zachahn)\n\n## 1.7.16 (2024-09-23)\n\n- chore: enhance some code parts ([#824](https://github.com/evilmartians/lefthook/pull/824)) by [@mrexox](https://github.com/mrexox)\n- fix: quote script path ([#823](https://github.com/evilmartians/lefthook/pull/823)) by [@mrexox](https://github.com/mrexox)\n- docs: fix typo for command names in configuration.md ([#814](https://github.com/evilmartians/lefthook/pull/814)) by [@nack43](https://github.com/nack43)\n\n## 1.7.15 (2024-09-02)\n\n- fix: add better colors control ([#812](https://github.com/evilmartians/lefthook/pull/812)) by [@mrexox](https://github.com/mrexox)\n- deps: August 2024 ([#802](https://github.com/evilmartians/lefthook/pull/802)) by [@mrexox](https://github.com/mrexox)\n\n## 1.7.14 (2024-08-17)\n\nFix lefthook NPM package to include OpenBSD package as optional dependency.\n\n## 1.7.13 (2024-08-16)\n\n- feat: support openbsd ([#808](https://github.com/evilmartians/lefthook/pull/808)) by [@mrexox](https://github.com/mrexox)\n\n## 1.7.12 (2024-08-09)\n\n- fix: log stderr in debug logs only ([#804](https://github.com/evilmartians/lefthook/pull/804)) by [@mrexox](https://github.com/mrexox)\n\n## 1.7.11 (2024-07-29)\n\n- fix: revert packaging change ([#796](https://github.com/evilmartians/lefthook/pull/796)) by [@mrexox](https://github.com/mrexox)\n\n## 1.7.10 (2024-07-29)\n\n- deps: July 2024 ([#795](https://github.com/evilmartians/lefthook/pull/795)) by [@mrexox](https://github.com/mrexox)\n- packaging(npm): try direct reference for lefthook executable ([#794](https://github.com/evilmartians/lefthook/pull/794)) by [@mrexox](https://github.com/mrexox)\n\n## 1.7.9 (2024-07-26)\n\n- fix: typo CGO_ENABLED instead of GCO_ENABLED ([#791](https://github.com/evilmartians/lefthook/pull/791)) by [@mrexox](https://github.com/mrexox)\n\n## 1.7.8 (2024-07-26)\n\n- fix: npm fix packages ([#789](https://github.com/evilmartians/lefthook/pull/789)) by [@mrexox](https://github.com/mrexox)\n- fix: explicitly pass static flag to linker ([#788](https://github.com/evilmartians/lefthook/pull/788)) by [@mrexox](https://github.com/mrexox)\n- ci: update workflow files ([#787](https://github.com/evilmartians/lefthook/pull/787)) by [@mrexox](https://github.com/mrexox)\n- ci: use latest goreleaser ([#784](https://github.com/evilmartians/lefthook/pull/784)) by [@mrexox](https://github.com/mrexox)\n\n## 1.7.7 (2024-07-24)\n\n- fix: multiple excludes ([#782](https://github.com/evilmartians/lefthook/pull/782)) by [@mrexox](https://github.com/mrexox)\n\n## 1.7.6 (2024-07-24)\n\n- feat: add self-update command ([#778](https://github.com/evilmartians/lefthook/pull/778)) by [@mrexox](https://github.com/mrexox)\n\n## 1.7.5 (2024-07-22)\n\n- feat: use glob in exclude array ([#777](https://github.com/evilmartians/lefthook/pull/777)) by [@mrexox](https://github.com/mrexox)\n\n## 1.7.4 (2024-07-19)\n\n- fix: rollback packaging changes ([#776](https://github.com/evilmartians/lefthook/pull/776)) by [@mrexox](https://github.com/mrexox)\n\n## 1.7.3 (2024-07-18)\n\n- feat: allow list of files in exclude option ([#772](https://github.com/evilmartians/lefthook/pull/772)) by [@mrexox](https://github.com/mrexox)\n- docs: add docs for LEFTHOOK_OUTPUT var ([#771](https://github.com/evilmartians/lefthook/pull/771)) by [@manbearwiz](https://github.com/manbearwiz)\n- fix: use direct lefthook package ([#774](https://github.com/evilmartians/lefthook/pull/774)) by [@mrexox](https://github.com/mrexox)\n\n## 1.7.2 (2024-07-11)\n\n- fix: add missing sub directory in hook template ([#768](https://github.com/evilmartians/lefthook/pull/768)) by [@nikeee](https://github.com/nikeee)\n\n## 1.7.1 (2024-07-08)\n\n- fix: use correct extension in hook.tmpl ([#767](https://github.com/evilmartians/lefthook/pull/767)) by [@apfohl](https://github.com/apfohl)\n\n## 1.7.0 (2024-07-08)\n\n- fix: publishing ([#765](https://github.com/evilmartians/lefthook/pull/765)) by [@mrexox](https://github.com/mrexox)\n- perf: startup time reduce ([#705](https://github.com/evilmartians/lefthook/pull/705)) by [@dalisoft](https://github.com/dalisoft)\n- docs: add a note about pnpm package installation ([#761](https://github.com/evilmartians/lefthook/pull/761)) by [@mrexox](https://github.com/mrexox)\n- ci: retriable integrity tests ([#758](https://github.com/evilmartians/lefthook/pull/758)) by [@mrexox](https://github.com/mrexox)\n- ci: universal publisher with Ruby script ([#756](https://github.com/evilmartians/lefthook/pull/756)) by [@mrexox](https://github.com/mrexox)\n\n## 1.6.18 (2024-06-21)\n\n- fix: allow multiple levels of extends ([#755](https://github.com/evilmartians/lefthook/pull/755)) by [@mrexox](https://github.com/mrexox)\n\n## 1.6.17 (2024-06-20)\n\n- fix: apply local extends only if they are present ([#754](https://github.com/evilmartians/lefthook/pull/754)) by [@mrexox](https://github.com/mrexox)\n- chore: setting proper error message for missing lefthook file ([#748](https://github.com/evilmartians/lefthook/pull/748)) by [@Cadienvan](https://github.com/Cadienvan)\n\n## 1.6.16 (2024-06-13)\n\n- fix: skip overwriting hooks when fetching data from remotes ([#745](https://github.com/evilmartians/lefthook/pull/745)) by [@mrexox](https://github.com/mrexox)\n- fix: fetch remotes only for non ghost hooks ([#744](https://github.com/evilmartians/lefthook/pull/744)) by [@mrexox](https://github.com/mrexox)\n\n## 1.6.15 (2024-06-03)\n\n- feat: add refetch option to remotes config ([#739](https://github.com/evilmartians/lefthook/pull/739)) by [@mrexox](https://github.com/mrexox)\n- 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)\n- chore: enable copyloopvar, intrange, and prealloc ([#740](https://github.com/evilmartians/lefthook/pull/740)) by [@scop](https://github.com/scop)\n- 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)\n- chore: refactor commands interfaces ([#735](https://github.com/evilmartians/lefthook/pull/735)) by [@mrexox](https://github.com/mrexox)\n- chore: upgrade to 1.59.0 ([#738](https://github.com/evilmartians/lefthook/pull/738)) by [@scop](https://github.com/scop)\n\n## 1.6.14 (2024-05-30)\n\n- 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)\n\n## 1.6.13 (2024-05-27)\n\n- feat: expand Swift integration with Mint support ([#724](https://github.com/evilmartians/lefthook/pull/724)) by [@levibostian](https://github.com/levibostian)\n- deps: May 22 dependencies update ([#706](https://github.com/evilmartians/lefthook/pull/706)) by [@mrexox](https://github.com/mrexox)\n- chore: remove go patch version in go.mod ([#726](https://github.com/evilmartians/lefthook/pull/726)) by [@mrexox](https://github.com/mrexox)\n\n# 1.6.12 (2024-05-17)\n\n- fix: more verbose error on versions mismatch ([#721](https://github.com/evilmartians/lefthook/pull/721)) by [@mrexox](https://github.com/mrexox)\n- fix: enable interactive scripts ([#720](https://github.com/evilmartians/lefthook/pull/720)) by [@mrexox](https://github.com/mrexox)\n\n## 1.6.11 (2024-05-13)\n\n- feat: add run --no-auto-install flag ([#716](https://github.com/evilmartians/lefthook/pull/716)) by [@mrexox](https://github.com/mrexox)\n- fix: add `--porcelain` to `git status --short` ([#711](https://github.com/evilmartians/lefthook/pull/711)) by [@110y](https://github.com/110y)\n- chore: bump go to 1.22 ([#701](https://github.com/evilmartians/lefthook/pull/701)) by [@mrexox](https://github.com/mrexox)\n\n## 1.6.10 (2024-04-10)\n\n- feat: add file type filters ([#698](https://github.com/evilmartians/lefthook/pull/698)) by [@mrexox](https://github.com/mrexox)\n- ci: update github actions versions ([#699](https://github.com/evilmartians/lefthook/pull/699)) by [@mrexox](https://github.com/mrexox)\n\n## 1.6.9 (2024-04-09)\n\n- fix: enable interactive inputs for windows ([#696](https://github.com/evilmartians/lefthook/pull/696)) by [@mrexox](https://github.com/mrexox)\n- fix: add batching to implicit commands ([#695](https://github.com/evilmartians/lefthook/pull/695)) by [@mrexox](https://github.com/mrexox)\n- fix: command argument count validations ([#694](https://github.com/evilmartians/lefthook/pull/694)) by [@scop](https://github.com/scop)\n- fix: re-download remotes when called install with -f ([#692](https://github.com/evilmartians/lefthook/pull/692)) by [@mrexox](https://github.com/mrexox)\n- chore: remove redundant parallelisation ([#690](https://github.com/evilmartians/lefthook/pull/690)) by [@mrexox](https://github.com/mrexox)\n- chore: refactor Result handling ([#689](https://github.com/evilmartians/lefthook/pull/689)) by [@mrexox](https://github.com/mrexox)\n\n## 1.6.8 (2024-04-02)\n\n- fix: fallback to empty tree sha when no upstream set ([#687](https://github.com/evilmartians/lefthook/pull/687)) by [@mrexox](https://github.com/mrexox)\n- feat: add priorities to scripts ([#684](https://github.com/evilmartians/lefthook/pull/684)) by [@mrexox](https://github.com/mrexox)\n- deps: By April, 1 ([#678](https://github.com/evilmartians/lefthook/pull/678)) by [@mrexox](https://github.com/mrexox)\n\n## 1.6.7 (2024-03-15)\n\n- 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)\n- docs: allow only comma divided tags ([#675](https://github.com/evilmartians/lefthook/pull/675)) by [@mrexox](https://github.com/mrexox)\n\n## 1.6.6 (2024-03-14)\n\n- chore: add more tests on skip settings by [@mrexox](https://github.com/mrexox)\n- chore: add more linters, address findings ([#670](https://github.com/evilmartians/lefthook/pull/670)) by [@scop](https://github.com/scop)\n- chore: skip printing deprecation warning ([#674](https://github.com/evilmartians/lefthook/pull/674)) by [@mrexox](https://github.com/mrexox)\n- feat: handle `run` command in skip/only settings ([#634](https://github.com/evilmartians/lefthook/pull/634)) by [@prog-supdex](https://github.com/prog-supdex)\n- deps: Dependencies March 2024 ([#673](https://github.com/evilmartians/lefthook/pull/673)) by [@mrexox](https://github.com/mrexox)\n- fix: fix printing when using `output` log setting ([#672](https://github.com/evilmartians/lefthook/pull/672)) by [@mrexox](https://github.com/mrexox)\n- feat: Add output setting ([#637](https://github.com/evilmartians/lefthook/pull/637)) by [@prog-supdex](https://github.com/prog-supdex)\n- fix: use swift package before npx ([#668](https://github.com/evilmartians/lefthook/pull/668)) by [@mrexox](https://github.com/mrexox)\n- feat: use configurable path to lefthook (LEFTHOOK_BIN) ([#653](https://github.com/evilmartians/lefthook/pull/653)) by [@technicalpickles](https://github.com/technicalpickles)\n\n## 1.6.5 (2024-03-04)\n\n- fix: decrease max cmd length for windows ([#666](https://github.com/evilmartians/lefthook/pull/666)) by [@mrexox](https://github.com/mrexox)\n- deps: Dependencies 04.03.2024 ([#664](https://github.com/evilmartians/lefthook/pull/664)) by [@mrexox](https://github.com/mrexox)\n- chore: fix Makefile by [@mrexox](https://github.com/mrexox)\n- docs: fix redundant option by [@mrexox](https://github.com/mrexox)\n\n## 1.6.4 (2024-02-28)\n\n- deps: update uniseg ([#650](https://github.com/evilmartians/lefthook/pull/650)) by [@technicalpickles](https://github.com/technicalpickles)\n\n## 1.6.3 (2024-02-27)\n\n- deps: Dependencies (27.02.2024) ([#648](https://github.com/evilmartians/lefthook/pull/648)) by [@mrexox](https://github.com/mrexox)\n- chore: remove adaptive colors ([#647](https://github.com/evilmartians/lefthook/pull/647)) by [@mrexox](https://github.com/mrexox)\n- docs: update request help url ([#641](https://github.com/evilmartians/lefthook/pull/641)) by [@sbsrnt](https://github.com/sbsrnt)\n\n## 1.6.2 (2024-02-26)\n\n- fix: respect roots in commands for npm packages ([#616](https://github.com/evilmartians/lefthook/pull/616)) by [@mrexox](https://github.com/mrexox)\n- 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)\n- fix: handle LEFTHOOK_QUIET when there is no skip_output in config by [@prog-supdex](https://github.com/prog-supdex)\n- docs: add stage_fixed to the examples by [@mrexxo](https://github.com/mrexxo)\n- docs: clarify the difference between piped and parallel options by [@mrexox](https://github.com/mrexox)\n\n## 1.6.1 (2024-01-24)\n\n- fix: files from stdin only null separated ([#615](https://github.com/evilmartians/lefthook/pull/615)) by [@mrexox](https://github.com/mrexox)\n- docs: add a new article link by [@mrexox](https://github.com/mrexox)\n\n## 1.6.0 (2024-01-22)\n\n- feat: add remotes and configs options ([#609](https://github.com/evilmartians/lefthook/pull/609)) by [@NikitaCOEUR](https://github.com/NikitaCOEUR)\n- 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)\n\n## 1.5.7 (2024-01-17)\n\n- fix: pre push hook handling ([#613](https://github.com/evilmartians/lefthook/pull/613)) by [@mrexox](https://github.com/mrexox)\n- chore: hide wiki links ([#608](https://github.com/evilmartians/lefthook/pull/608)) by [@mrexox](https://github.com/mrexox)\n\n## 1.5.6 (2024-01-12)\n\n- feat: shell completion improvements ([#577](https://github.com/evilmartians/lefthook/pull/577)) by [@scop](https://github.com/scop)\n- fix: safe execute git commands without sh wrapper ([#606](https://github.com/evilmartians/lefthook/pull/606)) by [@mrexox](https://github.com/mrexox)\n- fix: use lefthook package with npx ([#604](https://github.com/evilmartians/lefthook/pull/604)) by [@mrexox](https://github.com/mrexox)\n- feat: allow setting a bool value for skip_output ([#601](https://github.com/evilmartians/lefthook/pull/601)) by [@nsklyarov](https://github.com/nsklyarov)\n- docs: update exception case about interactive option by [@mrexox](https://github.com/mrexox)\n\n## 1.5.5 (2023-11-30)\n\n- fix: use empty stdin by default ([#590](https://github.com/evilmartians/lefthook/pull/590)) by [@mrexox](https://github.com/mrexox)\n- feat: add priorities to commands ([#589](https://github.com/evilmartians/lefthook/pull/589)) by [@mrexox](https://github.com/mrexox)\n\n## 1.5.4 (2023-11-27)\n\n- chore: add typos fixer by [@mrexox](https://github.com/mrexox)\n- fix: drop new argument for git diff compatibility ([#586](https://github.com/evilmartians/lefthook/pull/586)) by [@mrexox](https://github.com/mrexox)\n\n## 1.5.3 (2023-11-22)\n\n- 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)\n- chore: skip summary separator if nothing is printed ([#575](https://github.com/evilmartians/lefthook/pull/575)) by [@mrexox](https://github.com/mrexox)\n- docs: update info about root option by [@mrexox](https://github.com/mrexox)\n\n## 1.5.2 (2023-10-9)\n\n- fix: correctly sort alphanumeric commands ([#562](https://github.com/evilmartians/lefthook/pull/562)) by [@mrexox](https://github.com/mrexox)\n\n## 1.5.1 (2023-10-6)\n\n- feat: add force flag to run command ([#561](https://github.com/evilmartians/lefthook/pull/561)) by [@mrexox](https://github.com/mrexox)\n- fix: do not enable export when sourcing rc file ([#553](https://github.com/evilmartians/lefthook/pull/553)) by [@hyperupcall](https://github.com/hyperupcall)\n- chore: wrap shell args in quotes for consistency by [@mrexox](https://github.com/mrexox)\n- docs: add a note that files template supports directories by [@mrexox](https://github.com/mrexox)\n- feat: initial support for Swift Plugins ([#556](https://github.com/evilmartians/lefthook/pull/556)) by [@csjones](https://github.com/csjones)\n\n## 1.5.0 (2023-09-21)\n\n- chore: output enhancements ([#549](https://github.com/evilmartians/lefthook/pull/549)) by [@mrexox](https://github.com/mrexox)\n- feat: add interrupt (Ctrl-C) handling ([#550](https://github.com/evilmartians/lefthook/pull/550)) by [@mrcljx](https://github.com/mrcljx)\n\n## 1.4.11 (2023-09-13)\n\n- docs: update docs and readme with tl;dr instructions ([#548](https://github.com/evilmartians/lefthook/pull/548)) by [@mrexox](https://github.com/mrexox)\n- fix: add use_stdin option for just reading from stdin ([#547](https://github.com/evilmartians/lefthook/pull/547)) by [@mrexox](https://github.com/mrexox)\n- chore: refactor commands passing ([#546](https://github.com/evilmartians/lefthook/pull/546)) by [@mrexox](https://github.com/mrexox)\n- fix: fail on non existing hook name ([#545](https://github.com/evilmartians/lefthook/pull/545)) by [@mrexox](https://github.com/mrexox)\n\n## 1.4.10 (2023-09-04)\n\n- fix: split command with file templates into chunks ([#541](https://github.com/evilmartians/lefthook/pull/541)) by [@mrexox](https://github.com/mrexox)\n- chore: add git-cliff config for easier changelog generation by [@mrexox](https://github.com/mrexox)\n- fix: allow empty staged files diffs ([#543](https://github.com/evilmartians/lefthook/pull/543)) by [@mrexox](https://github.com/mrexox)\n\n## 1.4.9 (2023-08-15)\n\n- chore: fix linter issues ([#537](https://github.com/evilmartians/lefthook/pull/537)) by [@mrexox](https://github.com/mrexox)\n- feat: add files, all-files, and commands flags ([#534](https://github.com/evilmartians/lefthook/pull/534)) by [@nihalgonsalves](https://github.com/nihalgonsalves)\n- chore: bump go to 1.21 ([#536](https://github.com/evilmartians/lefthook/pull/536)) by [@nihalgonsalves](https://github.com/nihalgonsalves)\n\n## 1.4.8 (2023-07-31)\n\n- feat: add assert_lefthook_installed option ([#533](https://github.com/evilmartians/lefthook/pull/533)) by [@mrexox](https://github.com/mrexox)\n- chore: add *Add docs* to PR template ([#532](https://github.com/evilmartians/lefthook/pull/532)) by [@technicalpickles](https://github.com/technicalpickles)\n- feat: add support for skipping empty summaries ([#531](https://github.com/evilmartians/lefthook/pull/531)) by [@technicalpickles](https://github.com/technicalpickles)\n\n## 1.4.7 (2023-07-24)\n\n- docs: add scoop installation method ([#527](https://github.com/evilmartians/lefthook/pull/527)) by [@sitiom](https://github.com/sitiom)\n- fix: correct merging of extends from remote config ([#529](https://github.com/evilmartians/lefthook/pull/529)) by [@mrexox](https://github.com/mrexox)\n- ci: add Winget Releaser action ([#526](https://github.com/evilmartians/lefthook/pull/526)) by [@sitiom](https://github.com/sitiom)\n- chore: improve correctness of load_test.go ([#525](https://github.com/evilmartians/lefthook/pull/525)) by [@hyperupcall](https://github.com/hyperupcall)\n\n## 1.4.6 (2023-07-18)\n\n- 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)\n- fix: uninstall all possible formats ([#523](https://github.com/evilmartians/lefthook/pull/523)) by [@mrexox](https://github.com/mrexox)\n- fix: LEFTHOOK_VERBOSE properly overrides --verbose flag ([#521](https://github.com/evilmartians/lefthook/pull/521)) by [@hyperupcall](https://github.com/hyperupcall)\n- feat: support .lefthook.yml and .lefthook-local.yml ([#520](https://github.com/evilmartians/lefthook/pull/520)) by [@hyperupcall](https://github.com/hyperupcall)\n\n## 1.4.5 (2023-07-12)\n\n- docs: improve documentation and examples ([#517](https://github.com/evilmartians/lefthook/pull/517)) by [@hyperupcall](https://github.com/hyperupcall)\n- fix: improve hook template ([#516](https://github.com/evilmartians/lefthook/pull/516)) by [@hyperupcall](https://github.com/hyperupcall)\n\n## 1.4.4 (2023-07-10)\n\n- 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)\n- deps: July 2023 ([#514](https://github.com/evilmartians/lefthook/pull/514)) by [@mrexox](https://github.com/mrexox)\n\n## 1.4.3 (2023-06-19)\n\n- fix: auto stage non-standard files ([#506](https://github.com/evilmartians/lefthook/pull/506)) by [@mrexox](https://github.com/mrexox)\n\n## 1.4.2 (2023-06-13)\n\n- deps: June 2023 ([#499](https://github.com/evilmartians/lefthook/pull/499))\n- feat: support toml dumpint ([#490](https://github.com/evilmartians/lefthook/pull/490)) by [@mrexox](https://github.com/mrexox)\n- feat: support json configs ([#489](https://github.com/evilmartians/lefthook/pull/489)) by [@mrexox](https://github.com/mrexox)\n\n## 1.4.1 (2023-05-22)\n\n- fix: add win32 binary to artifacts (by [@mrexox](https://github.com/mrexox))\n- feat: allow dumping with JSON ([#485](https://github.com/evilmartians/lefthook/pull/485) by [@mrexox](https://github.com/mrexox)\n- feat: add skip execution_info option ([#484](https://github.com/evilmartians/lefthook/pull/484)) by [@mrexox](https://github.com/mrexox)\n- deps: from 05.2023 ([#487](https://github.com/evilmartians/lefthook/pull/487)) by [@mrexox](https://github.com/mrexox)\n\n## 1.4.0 (2023-05-18)\n\n- feat: add adaptive colors ([#482](https://github.com/evilmartians/lefthook/pull/482)) by [@mrexox](https://github.com/mrexox)\n- fix: skip output for interactive commands if configured ([#483](https://github.com/evilmartians/lefthook/pull/483)) by [@mrexox](https://github.com/mrexox)\n- feat: add dump command ([#481](https://github.com/evilmartians/lefthook/pull/481)) by [@mrexox](https://github.com/mrexox)\n\n## 1.3.13 (2023-05-11)\n\n- feat: add only option ([#478](https://github.com/evilmartians/lefthook/pull/478)) by [@mrexox](https://github.com/mrexox)\n\n## 1.3.12 (2023-04-28)\n\n- fix: allow skipping execution_out with interactive mode ([#476](https://github.com/evilmartians/lefthook/pull/476)) by [@mrexox](https://github.com/mrexox)\n\n## 1.3.11 (2023-04-27)\n\n- feat: add execution_out to skip output settings ([#475](https://github.com/evilmartians/lefthook/pull/475)) by [@mrexox](https://github.com/mrexox)\n- chore: add debug logs when hook is skipped ([#474](https://github.com/evilmartians/lefthook/pull/474)) by [@mrexox](https://github.com/mrexox)\n\n## 1.3.10\n\n- 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)\n\n## 1.3.9 (2023-04-04)\n\n- feat: allow extra hooks in local config ([#462](https://github.com/evilmartians/lefthook/pull/462)) by [@fabn](https://github.com/fabn)\n- feat: pass numeric placeholders to files command ([#461](https://github.com/evilmartians/lefthook/pull/461)) by [@fabn](https://github.com/fabn)\n\n## 1.3.8 (2023-03-23)\n\n- fix: make hook template compatible with shells without source command ([#458](https://github.com/evilmartians/lefthook/pull/458)) by [@mdesantis](https://github.com/mdesantis)\n\n## 1.3.7 (2023-03-20)\n\n- fix: allow globs in skip option ([#457](https://github.com/evilmartians/lefthook/pull/457)) by [@mrexox](https://github.com/mrexox)\n- deps: dependencies update (March 2023) ([#455](https://github.com/evilmartians/lefthook/pull/455)) by [@mrexox](https://github.com/mrexox)\n- fix: don't fail on missing config file ([#450](https://github.com/evilmartians/lefthook/pull/450)) by [@mrexox](https://github.com/mrexox)\n\n## 1.3.6 (2023-03-16)\n\n- fix: stage fixed when root specified ([#449](https://github.com/evilmartians/lefthook/pull/449)) by [@mrexox](https://github.com/mrexox)\n- 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)\n\n## 1.3.5 (2023-03-15)\n\n- feat: add stage_fixed option ([#445](https://github.com/evilmartians/lefthook/pull/445)) by [@mrexox](https://github.com/mrexox)\n\n## 1.3.4 (2023-03-13)\n\n- 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)\n\n## 1.3.3 (2023-03-01)\n\n- fix: restore release assets name ([#437](https://github.com/evilmartians/lefthook/pull/437)) by [@watarukura](https://github.com/watarukura)\n\n## 1.3.2 (2023-02-27)\n\n- fix: Allow sh syntax in files ([#435](https://github.com/evilmartians/lefthook/pull/435)) by [@mrexox](https://github.com/mrexox)\n\n## 1.3.1 (2023-02-27)\n\n- fix: Force creation of git hooks folder ([#434](https://github.com/evilmartians/lefthook/pull/434)) by [@mrexox](https://github.com/mrexox)\n\n## 1.3.0 (2023-02-22)\n\n- fix: Use correct branch for {push_files} template ([#429](https://github.com/evilmartians/lefthook/pull/429)) by [@mrexox](https://github.com/mrexox)\n- feature: Skip unstaged changes for pre-commit hook ([#402](https://github.com/evilmartians/lefthook/pull/402)) by [@mrexox](https://github.com/mrexox)\n\n## 1.2.9 (2023-02-13)\n\n- fix: memory leak dependency ([#426](https://github.com/evilmartians/lefthook/pull/426)) by [@strpc](https://github.com/strpc)\n\n## 1.2.8 (2023-01-23)\n\n- fix: Don't join info path with root ([#418](https://github.com/evilmartians/lefthook/pull/418)) by [@mrexox](https://github.com/mrexox)\n\n## 1.2.7 (2023-01-10)\n\n- fix: Make info dir when it is absent ([#414](https://github.com/evilmartians/lefthook/pull/414)) by [@sato11](https://github.com/sato11)\n- 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)\n- 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)\n- fix: Double quote eval statements with $dir ([#404](https://github.com/evilmartians/lefthook/pull/404)) by [@jrfoell](https://github.com/jrfoell)\n- chore: Add PR template ([#401](https://github.com/evilmartians/lefthook/pull/401)) by [@mrexox](https://github.com/mrexox)\n- chore: Fix yml syntax missing colon ([#399](https://github.com/evilmartians/lefthook/pull/399)) by [@aaronkelton](https://github.com/aaronkelton)\n\n## 1.2.6 (2022-12-14)\n\n- feature: Allow following output ([#397](https://github.com/evilmartians/lefthook/pull/397)) by [@mrexox](https://github.com/mrexox)\n- fix: Remove quotes for rc in template ([#398](https://github.com/evilmartians/lefthook/pull/398)) by [@mrexox](https://github.com/mrexox)\n\n## 1.2.5 (2022-12-13)\n\n- feature: Add an option to disable spinner ([#396](https://github.com/evilmartians/lefthook/pull/396)) by [@mrexox](https://github.com/mrexox)\n- feature: Use pnpm before npx ([#393](https://github.com/evilmartians/lefthook/pull/393)) by [@mrexox](https://github.com/mrexox)\n- chore: Use lipgloss for output ([#395](https://github.com/evilmartians/lefthook/pull/395)) by [@mrexox](https://github.com/mrexox)\n\n## 1.2.4 (2022-12-05)\n\n- feature: Allow providing rc file ([PR #392](https://github.com/evilmartians/lefthook/pull/392) by [@mrexox](https://github.com/mrexox))\n\n## 1.2.3 (2022-11-30)\n\n- feature: Expand env variables ([PR #391](https://github.com/evilmartians/lefthook/pull/391) by [@mrexox](https://github.com/mrexox))\n- deps: Update important dependencies ([PR #389](https://github.com/evilmartians/lefthook/pull/389) by [@mrexox](https://github.com/mrexox))\n\n## 1.2.2 (2022-11-23)\n\n- chore: Add FreeBSD OS to packages ([PR #377](https://github.com/evilmartians/lefthook/pull/377) by [@mrexox](https://github.com/mrexox))\n- 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))\n- fix: Omit LFS output unless it is required ([PR #373](https://github.com/evilmartians/lefthook/pull/373) by [@mrexox](https://github.com/mrexox))\n\n## 1.2.1 (2022-11-17)\n\n- 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))\n- fix: remove lefthook.checksum on uninstall ([PR #370](https://github.com/evilmartians/lefthook/pull/370) by [@JuliusHenke](https://github.com/JuliusHenke))\n- 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))\n- fix: Allow changing refs for remote ([PR #363](https://github.com/evilmartians/lefthook/pull/363) by [@mrexox](https://github.com/mrexox))\n\n## 1.2.0 (2022-11-7)\n\n- fix: Full support for interactive commands and scripts ([PR #352](https://github.com/evilmartians/lefthook/pull/352) by [@mrexox](https://github.com/mrexox))\n- chore: Remove deprecated config options ([PR #351](https://github.com/evilmartians/lefthook/pull/351) by [@mrexox](https://github.com/mrexox))\n- 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))\n\n## 1.1.4 (2022-11-1)\n\n- feature: Add `LEFTHOOK_VERBOSE` env ([PR #346](https://github.com/evilmartians/lefthook/pull/346) by [@mrexox](https://github.com/mrexox))\n\n## 1.1.3 (2022-10-15)\n\n- ci: Fix snapcraft trying to create dirs in parallel by [@mrexox](https://github.com/mrexox)\n- feature: Allow setting env vars ([PR #337](https://github.com/evilmartians/lefthook/pull/337) by [@mrexox](https://github.com/mrexox))\n- feature: Show current running command and script name(s) ([PR #338](https://github.com/evilmartians/lefthook/pull/338) by [@mrexox](https://github.com/mrexox))\n- feature: Exclude by command names too ([PR #335](https://github.com/evilmartians/lefthook/pull/335) by [@mrexox](https://github.com/mrexox))\n- 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))\n- fix: Fixing typo in gemspec ([PR #333](https://github.com/evilmartians/lefthook/pull/333) by [@kerrizor](https://github.com/kerrizor))\n\n## 1.1.2 (2022-10-10)\n\n- Fix regression from #314 (chmod missed fix) ([PR #330](https://github.com/evilmartians/lefthook/pull/330) by [@ariccio](https://github.com/ariccio))\n- Pass stdin by default ([PR #324](https://github.com/evilmartians/lefthook/pull/324) by [@mrexox](https://github.com/mrexox))\n\n## 1.1.1 (2022-08-22)\n\n- Quote path to script ([PR #321](https://github.com/evilmartians/lefthook/pull/321) by [@mrexox](https://github.com/mrexox))\n\n## 1.1.0 (2022-08-13)\n\n- Add goreleaser action ([PR #307](https://github.com/evilmartians/lefthook/pull/307) by [@mrexox](https://github.com/mrexox))\n- Windows escaping issues ([PR #314](https://github.com/evilmartians/lefthook/pull/314) by [@mrexox](https://github.com/mrexox))\n- Check for lefthook.bat in hook template ([PR #316](https://github.com/evilmartians/lefthook/pull/316) by [@mrexox](https://github.com/mrexox))\n- Update node.md docs ([PR #312](https://github.com/evilmartians/lefthook/pull/312) by [@fantua](https://github.com/fantua))\n- Move postinstall script to main lefthook NPM package ([PR #311](https://github.com/evilmartians/lefthook/pull/311) by [@mrexox](https://github.com/mrexox))\n- Allow suppressing execution output ([PR #309](https://github.com/evilmartians/lefthook/pull/309) by [@mrexox](https://github.com/mrexox))\n- Update dependencies ([PR #308](https://github.com/evilmartians/lefthook/pull/308) by [@mrexox](https://github.com/mrexox))\n- Add support for Git LFS ([PR #306](https://github.com/evilmartians/lefthook/pull/306) by [@mrexox](https://github.com/mrexox))\n- Bump Go version to 1.19 ([PR #305](https://github.com/evilmartians/lefthook/pull/305) by [@mrexox](https://github.com/mrexox))\n- Add tests on runner ([PR #304](https://github.com/evilmartians/lefthook/pull/304) by [@mrexox](https://github.com/mrexox))\n- Add fail text option ([PR #301](https://github.com/evilmartians/lefthook/pull/301) by [@mrexox](https://github.com/mrexox))\n- Store lefthook checksum in non-hook file ([PR #280](https://github.com/evilmartians/lefthook/pull/280) by [@mrexox](https://github.com/mrexox))\n\n## 1.0.5 (2022-07-19)\n\n- Submodules issue ([PR #300](https://github.com/evilmartians/lefthook/pull/300) by [@mrexox](https://github.com/mrexox))\n- Remove rspec tests ([PR #299](https://github.com/evilmartians/lefthook/pull/299) by [@mrexox](https://github.com/mrexox))\n- Add `when \"mingw\" then \"windows\"` case ([PR #297](https://github.com/evilmartians/lefthook/pull/297) by [@ariccio](https://github.com/ariccio))\n- Define security policy and contact method ([PR #293](https://github.com/evilmartians/lefthook/pull/293) by [@Envek](https://github.com/Envek))\n\n# 1.0.4 (2022-06-27)\n\n- Support skipping on rebase ([PR #289](https://github.com/evilmartians/lefthook/pull/289) by [@mrexox](https://github.com/mrexox))\n\n# 1.0.3 (2022-06-25)\n\n- Fix NPM package\n- Update email information\n\n# 1.0.2 (2022-06-24)\n\n- Bring auto install back ([PR #286](https://github.com/evilmartians/lefthook/pull/286) by [@mrexox](https://github.com/mrexox))\n- Move main.go to root ([PR #285](https://github.com/evilmartians/lefthook/pull/285) by [@mrexox](https://github.com/mrexox))\n- Panic on commands structure misuse ([PR #284](https://github.com/evilmartians/lefthook/pull/284) by [@mrexox](https://github.com/mrexox))\n- Split npm package by os and cpu ([PR #281](https://github.com/evilmartians/lefthook/pull/281) by [@mrexox](https://github.com/mrexox))\n\n# 1.0.1 (2022-06-20) Ruby gem and NPM package fix\n\n- Fix folders structure for `[@evilmartians](https://github.com/evilmartians)/lefthook` and `[@evilmartians](https://github.com/evilmartians)/lefthook-installer` packages\n- Fix folders structure for `lefthook` gem\n\n# 1.0.0 (2022-06-19)\n\n- 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))\n- Replace deprecated `File.exists?` with `exist?` for Ruby script ([PR #263](https://github.com/evilmartians/lefthook/pull/263) by [@pocke](https://github.com/pocke))\n\n# 0.8.0 (2022-06-07)\n\n- 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))\n- 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))\n- 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))\n- Make filename globs case-insensitive ([PR #196](https://github.com/evilmartians/lefthook/pull/196) by [@skryukov](https://github.com/skryukov))\n- Fix lefthook binary extension on Windows ([PR #188](https://github.com/evilmartians/lefthook/pull/188) by [@aminya](https://github.com/aminya))\n- Stop building 32-bit binaries for releases due to low usage ([@Envek](https://github.com/Envek))\n- 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))\n- Fix unreachable conditional in hook template ([PR #242](https://github.com/evilmartians/lefthook/pull/242) by [@dannobytes](https://github.com/dannobytes))\n- 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))\n\n# 0.7.7 (2021-10-02)\n\n- Fix incorrect npx command in git hook script template ([PR #236](https://github.com/evilmartians/lefthook/pull/236)) [@PikachuEXE](https://github.com/PikachuEXE)\n- Update project URLs in NPM package.json ([PR #235](https://github.com/evilmartians/lefthook/pull/235)) [@PikachuEXE](https://github.com/PikachuEXE)\n- Pass all arguments to downstream hooks ([PR #231](https://github.com/evilmartians/lefthook/pull/231)) [@pablobirukov](https://github.com/pablobirukov)\n- 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)\n- Do not initialize git config on `help` and `version` commands ([PR #209](https://github.com/evilmartians/lefthook/pull/209)) [@pwinckles](https://github.com/pwinckles)\n- node: fix postinstall: process.cwd is a function and should be called [@Envek](https://github.com/Envek)\n\n# 0.7.6 (2021-06-02)\n\n- Fix lefthook binary extension on Windows. [@aminya](https://github.com/aminya)\n- [PR #193](https://github.com/evilmartians/lefthook/pull/193) Fix path for searching npm-installed binary when in worktree. [@Envek](https://github.com/Envek)\n- NPM, RPM, and DEB packaging fixes. [@Envek](https://github.com/Envek)\n\n# 0.7.5 (2021-05-14)\n\n- [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)\n- [PR #177](https://github.com/evilmartians/lefthook/pull/177) Support non-default git hooks path. [@charlie-wasp](https://github.com/charlie-wasp)\n- [PR #182](https://github.com/evilmartians/lefthook/pull/182) Support git workspaces and submodules. [@skryukov](https://github.com/skryukov)\n- [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)\n\n# 0.7.4 (2021-04-30)\n\n- [PR](https://github.com/evilmartians/lefthook/pull/171) Improve check for installed git [@DmitryTsepelev](https://github.com/DmitryTsepelev)\n- [PR](https://github.com/evilmartians/lefthook/pull/169) Create .git/hooks directory when it does not exist [@DmitryTsepelev](https://github.com/DmitryTsepelev)\n\n# 0.7.3 (2021-04-23)\n\n- [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)\n- [PR](https://github.com/evilmartians/lefthook/pull/167) Fix golang 15+ build [@skryukov](https://github.com/skryukov)\n\n# 0.7.2 (2020-02-02)\n\n- [PR](https://github.com/evilmartians/lefthook/pull/126) Feature multiple extends. Thanks [@Evilweed](https://github.com/Evilweed)\n\n- [PR](https://github.com/evilmartians/lefthook/pull/124) Fix `npx` when only `yarn` exists. Thanks [@dotterian](https://github.com/dotterian)\n\n- [PR](https://github.com/evilmartians/lefthook/pull/116) Fix use '-h' for robust lefthook. Thanks [@fahrinh](https://github.com/fahrinh)\n\n# 0.7.1 (2020-02-02)\n\n- [PR](https://github.com/evilmartians/lefthook/pull/108) Fix `sh` dependency on windows when executing `git`. Thanks [@lionskape](https://github.com/lionskape)\n\n- [PR](https://github.com/evilmartians/lefthook/pull/103) Add possibility for using `yaml` and `yml` extension for config. Thanks [@rbUUbr](https://github.com/rbUUbr)\n\n# 0.7.0 (2019-12-14)\n\n- [PR](https://github.com/evilmartians/lefthook/pull/98) Support relative roots for monorepos. Thanks [@jsmestad](https://github.com/jsmestad)\n\n# 0.6.7 (2019-12-14)\n\n- [Commit](https://github.com/evilmartians/lefthook/commit/e898b5c8ba56c4d6f29a4d1f433baa1779a0845b)\nSkip before executing command\n\n- [PR](https://github.com/evilmartians/lefthook/pull/94) Add option --keep-config. Thanks [@justinasposiunas](https://github.com/justinasposiunas)\n\n- [Commit](https://github.com/evilmartians/lefthook/commit/d79a3a46e7d1ee709b97e97f823bfd27e9466eff)\nCheck if shell is non interactive\n\n# 0.6.6 (2019-12-03)\n\n- [PR](https://github.com/evilmartians/lefthook/pull/94) Use eval instead of exec; Enable tty for the shell. Thanks [@ssnickolay](https://github.com/ssnickolay)\n\n# 0.6.5 (2019-11-15)\n\n- [PR](https://github.com/evilmartians/lefthook/pull/89) Add support for git-worktree. Thanks [@f440](https://github.com/f440)\n\n- [Commit](https://github.com/evilmartians/lefthook/commit/48702a0806d2b2eab13636ba56b0e0b99f346f1c)\nCommands and Scripts now can catch Stdin\n\n- [Commit](https://github.com/evilmartians/lefthook/commit/9a226842292ff1dda0f2273b66a0799988aa5289)\nAdd partial support for monorepos and command execution not from project root\n\n# 0.6.4 (2019-11-08)\n\n- [PR](https://github.com/evilmartians/lefthook/pull/84) Fix return value from shell exit. Thanks [@HaiD84](https://github.com/HaiD84)\n\n- [PR](https://github.com/evilmartians/lefthook/pull/86) Support postinstall script for npm installation for monorepos. Thanks [@sHooKDT](https://github.com/sHooKDT)\n\n- [PR](https://github.com/evilmartians/lefthook/pull/82) Now relative path to scripts supported. Thanks [@AlexeyMatskevich](https://github.com/AlexeyMatskevich)\n\n- [Commit](https://github.com/evilmartians/lefthook/pull/80/commits/1a4b0ee155eb66ae6f3c365164012bee9332605a)\nOption `extends` for top level config added. Now you can merge some settings from different places:\n```yml\nextends: $HOME/work/lefthook-extend.yml\n```\n\n- [Commit](https://github.com/evilmartians/lefthook/commit/83cf818106dbf222ea33ba86aafce8f30d7cb5a9)\nAdd examples to generated lefthook.yml\n\n## 0.6.3 (2019-07-15)\n\n- [Commit](https://github.com/evilmartians/lefthook/commit/0426936f48f248221126f15619932b0dc8c54d7a) Add `-a` means `aggressive` strategy for `install` command\n```bash\nlefthook install -a # clear .git/hooks dir and reinstall lefthook hooks\n```\n\n- [Commit](https://github.com/evilmartians/lefthook/commit/5efb0677a4a9ec1728d3cf1a083075e23315a796) Add Lefthook version indicator for commands and script execution\n\n- [Commit](https://github.com/evilmartians/lefthook/commit/8b55d91eed46643a1674bd4ad96fa211a177e159) Remove `npx` as dependency from node wrapper\n\nNow we will call directly binary from `./node_modules`\n\n- [Commit](https://github.com/evilmartians/lefthook/commit/76ffed4c698bc074984e91f5610c0b98784bd10b) Add `-f` means `force` strategy for `install` command\n\n```bash\nlefthook install -f # reinstall lefthook hooks without sync info check\n```\n\n- PR [#27](https://github.com/evilmartians/lefthook/pull/27) Move LEFTHOOK env check in hooks files\n\nNow if LEFTHOOK=0 we will not call the binary file\n\n- PR [#26](https://github.com/evilmartians/lefthook/pull/26) + [commit](https://github.com/evilmartians/lefthook/commit/afd67f94631a10975209ed4c5fabc763f44280eb) Add `{push_files}` shortcut\n\nAdd shortcut `{push_files}`\n\n```\npre-commit:\n  commands:\n    rubocop:\n      run: rubocop {push_files}\n```\nIt same as:\n```\npre-commit:\n  commands:\n    rubocop:\n      files: git diff --name-only HEAD @{push} || git diff --name-only HEAD master\n      run: rubocop {push_files}\n```\n\n- [Commit](https://github.com/evilmartians/lefthook/commit/af087b032a14952aa1dd235a3d0b5a51bc760a10) Add `min_version` option\n\nYou can mark your config for minimum Lefthook version:\n```\nmin_version: 0.6.1\n```\n\n## 0.6.0 (2019-07-10)\n\n- PR [#24](https://github.com/palkan/logidze/pull/110) Wrap `run` command in shell context.\n\nNow in `run` option available `sh` syntax.\n\n```\npre-commit:\n  commands:\n    bashed:\n      run: rubocop -a && git add\n```\nWill be executed in this way:\n```\nsh -c \"rubocop -a && git add\"\n```\n\n- PR [#23](https://github.com/evilmartians/lefthook/pull/24) Search Lefthook in Gemfile.\n\nNow it's possible to use Lefthook from Gemfile.\n\n```ruby\n# Gemfile\n\ngem 'lefthook'\n```\n\n[@mrexox]: https://github.com/mrexox\n[@olivier-lacroix]: https://github.com/olivier-lacroix\n[@michael-pplx]: https://github.com/michael-pplx\n[@siler]: https://github.com/siler\n[@technicalpickles]: https://github.com/technicalpickles\n[@jasonwbarnett]: https://github.com/jasonwbarnett\n[@scop]: https://github.com/scop\n[@franzramadhan]: https://github.com/franzramadhan\n[@joevin-sql-docto]: https://github.com/joevin-slq-docto\n[@jeonghoon11]: https://github.com/jeonghoon11\n"
  },
  {
    "path": "CONTRIBUTING.md",
    "content": "# Contributing\n\nFirst off, thanks for taking the time to contribute! Feel free to make Pull Request with your changes.\n\n# Requirements\n\nGo >= 1.26.0\n\n# Process\n\n1. Fork repo\n2. git clone <forked_repo>\n3. Make changes\n4. Push your changes in <forked_repo>\n"
  },
  {
    "path": "LICENSE",
    "content": "\nThe MIT License (MIT)\n\nCopyright (c) 2019 Arkweid\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n"
  },
  {
    "path": "Makefile",
    "content": "COMMIT_HASH = $(shell git rev-parse HEAD)\n\n.PHONY: build\nbuild:\n\tgo build -ldflags \"-s -w -X github.com/evilmartians/lefthook/v2/internal/version.commit=$(COMMIT_HASH)\" -o lefthook\n\n.PHONY: build-with-coverage\nbuild-with-coverage:\n\tgo build -cover -ldflags \"-s -w -X github.com/evilmartians/lefthook/v2/internal/version.commit=$(COMMIT_HASH)\" -o lefthook\n\n.PHONY: jsonschema\njsonschema:\n\tgo generate gen/jsonschema.go > schema.json\n\tgo generate gen/jsonschema.go > internal/config/jsonschema.json\n\ninstall: build\nifeq ($(shell go env GOOS),windows)\n\tcopy lefthook $(shell go env GOPATH)\\bin\\lefthook.exe\nelse\n\tcp lefthook $$(go env GOPATH)/bin\nendif\n\n.PHONY: test\ntest:\n\tgo test -cpu 24 -race -count=1 -timeout=30s ./...\n\n.PHONY: test-integration\ntest-integration: install\n\tgo test -cpu 24 -race -count=1 -timeout=30s -tags=integration integration_test.go\n\n.PHONY: bench\nbench:\n\tgo test -cpu 24 -race -run=Bench -bench=. ./...\n\n.PHONY: lint\nlint: bin/golangci-lint\n\tbin/golangci-lint run --fix\n\nbin/golangci-lint:\n\tcurl -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)\n\n.ONESHELL:\nversion:\n\t@read -p \"New version: \" version\n\tsed -i \"s/const version = .*/const version = \\\"$$version\\\"/\" internal/version/version.go\n\tsed -i \"s/VERSION = .*/VERSION = \\\"$$version\\\";/\" packaging/scripts/lib/Constants.rakumod\n\tsed -i \"s/lefthook-plugin.git\\\", exact: \\\".*\\\"/lefthook-plugin.git\\\", exact: \\\"$$version\\\"/\" docs/installation/swift.md\n\tsed -i \"s/go install github.com\\/evilmartians\\/lefthook\\/v2.*/go install github.com\\/evilmartians\\/lefthook\\/v2@v$$version/\" docs/installation/go.md\n\tsed -i \"s/go install github.com\\/evilmartians\\/lefthook\\/v2.*/go install github.com\\/evilmartians\\/lefthook\\/v2@v$$version/\" README.md\n\tsed -i \"s/go get -tool github.com\\/evilmartians\\/lefthook\\/v2.*/go get -tool github.com\\/evilmartians\\/lefthook\\/v2@v$$version/\" README.md\n\traku packaging/scripts/set-version.raku\n\tgit add internal/version/version.go packaging/* docs/ README.md\n"
  },
  {
    "path": "README.md",
    "content": "![Build Status](https://github.com/evilmartians/lefthook/actions/workflows/test.yml/badge.svg?branch=master)\n[![codecov](https://codecov.io/gh/evilmartians/lefthook/graph/badge.svg?token=d93ya8MfmB)](https://codecov.io/gh/evilmartians/lefthook)\n\n# Lefthook\n\n<img align=\"right\" width=\"147\" height=\"100\" title=\"Lefthook logo\"\n     src=\"./logo_sign.svg\">\n\nA Git hooks manager for Node.js, Ruby, Python and many other types of projects.\n\n* **Fast.** It is written in Go. Can run commands in parallel.\n* **Powerful.** It allows to control execution and files you pass to your commands.\n* **Simple.** It is single dependency-free binary which can work in any environment.\n\n📖 [Introduction post](https://evilmartians.com/chronicles/lefthook-knock-your-teams-code-back-into-shape?utm_source=lefthook)\n\n<a href=\"https://evilmartians.com/?utm_source=lefthook\">\n<img src=\"https://evilmartians.com/badges/sponsored-by-evil-martians.svg\" alt=\"Sponsored by Evil Martians\" width=\"100%\" height=\"54\"></a>\n\n## Install\n\nWith **Go** (>= 1.26):\n\n```bash\ngo install github.com/evilmartians/lefthook/v2@v2.1.4\n```\n\n* or as a go tool\n\n```bash\ngo get -tool github.com/evilmartians/lefthook/v2@v2.1.4\n```\n\nWith **NPM**:\n\n```bash\nnpm install lefthook --save-dev\n```\n\nFor **Ruby**:\n\n```bash\ngem install lefthook\n```\n\nFor **Python**:\n\n```bash\npipx install lefthook\n```\n\n**[Installation guide][installation]** with more ways to install lefthook: [apt][install-apt], [brew][install-brew], [winget][install-winget], and others.\n\n## Usage\n\nConfigure your hooks, install them once and forget about it: rely on the magic underneath.\n\n#### TL;DR\n\n```bash\n# Configure your hooks\nvim lefthook.yml\n\n# Install them to the git project\nlefthook install\n\n# Enjoy your work with git\ngit add -A && git commit -m '...'\n```\n\n#### More details\n\n- [**Configuration**][configuration] for `lefthook.yml` config options.\n- [**Usage**][usage] for **lefthook** CLI options, and features.\n- [**Discussions**][discussion] for questions, ideas, suggestions.\n<!-- - [**Wiki**](https://github.com/evilmartians/lefthook/wiki) for guides, examples, and benchmarks. -->\n\n## Why Lefthook\n\n* ### **Parallel execution**\nGives you more speed. [docs][config-parallel]\n\n```yml\npre-push:\n  parallel: true\n```\n\n* ### **Flexible list of files**\nIf you want your own list. [Custom][config-files] and [prebuilt][config-run] examples.\n\n```yml\npre-commit:\n  jobs:\n    - name: lint frontend\n      run: yarn eslint {staged_files}\n\n    - name: lint backend\n      run: bundle exec rubocop --force-exclusion -- {all_files}\n\n    - name: stylelint frontend\n      files: git diff --name-only HEAD @{push}\n      run: yarn stylelint {files}\n```\n\n* ### **Glob and regexp filters**\nIf you want to filter list of files. You could find more glob pattern examples [here](https://github.com/gobwas/glob#example).\n\n```yml\npre-commit:\n  jobs:\n    - name: lint backend\n      glob: \"*.rb\" # glob filter\n      exclude:\n        - \"*/application.rb\"\n        - \"*/routes.rb\"\n      run: bundle exec rubocop --force-exclusion -- {all_files}\n```\n\n* ### **Execute in sub-directory**\nIf you want to execute the commands in a relative path\n\n```yml\npre-commit:\n  jobs:\n    - name: lint backend\n      root: \"api/\" # Careful to have only trailing slash\n      glob: \"*.rb\" # glob filter\n      run: bundle exec rubocop -- {all_files}\n```\n\n* ### **Run scripts**\n\nIf oneline commands are not enough, you can execute files. [docs][config-scripts]\n\n```yml\ncommit-msg:\n  jobs:\n    - script: \"template_checker\"\n      runner: bash\n```\n\n* ### **Tags**\nIf you want to control a group of commands. [docs][config-tags]\n\n```yml\npre-push:\n  jobs:\n    - name: audit packages\n      tags:\n        - frontend\n        - linters\n      run: yarn lint\n\n    - name: audit gems\n      tags:\n        - backend\n        - security\n      run: bundle audit\n```\n\n* ### **Support Docker**\n\nIf you are in the Docker environment. [docs][config-run]\n\n```yml\npre-commit:\n  jobs:\n    - script: \"good_job.js\"\n      runner: docker run -it --rm <container_id_or_name> {cmd}\n```\n\n* ### **Local config**\n\nIf you are a frontend/backend developer and want to skip unnecessary commands or override something in Docker. [docs][usage-local-config]\n\n```yml\n# lefthook-local.yml\npre-push:\n  exclude_tags:\n    - frontend\n  jobs:\n    - name: audit packages\n      skip: true\n```\n\n* ### **Direct control**\n\nIf you want to run hooks group directly.\n\n```bash\n$ lefthook run pre-commit\n```\n\n* ### **Your own tasks**\n\nIf you want to run specific group of commands directly.\n\n```yml\nfixer:\n  jobs:\n    - run: bundle exec rubocop --force-exclusion --safe-auto-correct -- {staged_files}\n    - run: yarn eslint --fix {staged_files}\n```\n```bash\n$ lefthook run fixer\n```\n\n* ### **Control output**\n\nYou can control what lefthook prints with [output][config-output] option.\n\n```yml\noutput:\n  - execution\n  - failure\n```\n\n----\n\n### Guides\n\n* [Install with Node.js][install-node]\n* [Install with Ruby][install-ruby]\n* [Install with Homebrew][install-brew]\n* [Install with Winget][install-winget]\n* [Install for Debian-based Linux][install-apt]\n* [Install for RPM-based Linux][install-rpm]\n* [Install for Arch Linux][install-arch]\n* [Install for Alpine Linux][install-alpine]\n* [Usage][usage]\n* [Configuration][configuration]\n<!-- * [Troubleshooting](https://github.com/evilmartians/lefthook/wiki/Troubleshooting) -->\n\n<!-- ### Migrate from -->\n<!-- * [Husky](https://github.com/evilmartians/lefthook/wiki/Migration-from-husky) -->\n<!-- * [Husky and lint-staged](https://github.com/evilmartians/lefthook/wiki/Migration-from-husky-with-lint-staged) -->\n<!-- * [Overcommit](https://github.com/evilmartians/lefthook/wiki/Migration-from-overcommit) -->\n\n### Examples\n\nCheck [examples][examples]\n\n<!-- ### Benchmarks -->\n<!-- * [vs Overcommit](https://github.com/evilmartians/lefthook/wiki/Benchmark-lefthook-vs-overcommit) -->\n<!-- * [vs pre-commit](https://github.com/evilmartians/lefthook/wiki/Benchmark-lefthook-vs-pre-commit) -->\n\n<!-- ### Comparison list -->\n<!-- * [vs Overcommit, Husky, pre-commit](https://github.com/evilmartians/lefthook/wiki/Comparison-with-other-solutions) -->\n\n### Articles\n* [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)\n* [Lefthook: Knock your team’s code back into shape](https://evilmartians.com/chronicles/lefthook-knock-your-teams-code-back-into-shape?utm_source=lefthook)\n* [Lefthook + Crystalball](https://evilmartians.com/chronicles/lefthook-crystalball-and-git-magic?utm_source=lefthook)\n* [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)\n* [Automatically linting docker containers](https://dev.to/nitzano/linting-docker-containers-2lo6?utm_source=lefthook)\n* [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)\n* [Lefthook for React/React Native apps](https://blog.logrocket.com/deep-dive-into-lefthook-react-native?utm_source=lefthook)\n\n\n[documentation]: https://lefthook.dev/\n[configuration]: https://lefthook.dev/configuration/index\n[examples]: https://lefthook.dev/examples/lefthook-local\n[installation]: https://lefthook.dev/install/\n[usage]: https://lefthook.dev/usage/\n[discussion]: https://github.com/evilmartians/lefthook/discussions\n[install-apt]: https://lefthook.dev/installation/deb\n[install-ruby]: https://lefthook.dev/installation/ruby\n[install-node]: https://lefthook.dev/installation/node\n[install-brew]: https://lefthook.dev/installation/homebrew\n[install-winget]: https://lefthook.dev/installation/winget\n[install-rpm]: https://lefthook.dev/installation/rpm\n[install-arch]: https://lefthook.dev/installation/arch\n[install-alpine]: https://lefthook.dev/installation/alpine\n[config-parallel]: https://lefthook.dev/configuration/parallel\n[config-files]: https://lefthook.dev/configuration/files\n[config-glob]: https://lefthook.dev/configuration/glob\n[config-run]: https://lefthook.dev/configuration/run\n[config-scripts]: https://lefthook.dev/configuration/Scripts\n[config-tags]: https://lefthook.dev/configuration/tags\n[config-output]: https://lefthook.dev/configuration/output\n[usage-local-config]: https://lefthook.dev/examples/lefthook-local\n"
  },
  {
    "path": "SECURITY.md",
    "content": "# Security Policy\n\n## Supported Versions\n\nLatest major version of Lefthook is being supported with security updates.\n\n| Version | Supported          |\n| ------- | ------------------ |\n| 1.x     | :white_check_mark: |\n| 0.x     | :x:                |\n\n## Reporting a Vulnerability\n\nIf 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!\n\nPlease 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).\n"
  },
  {
    "path": "assets/css/lefthook.css",
    "content": ":root {\n  --link-color: #ff1e1e;\n  --font-family-mono: \"Martian Mono\", SFMono-Regular, Consolas, \"Liberation Mono\", Menlo, Courier, monospace;\n}\n\n.main-content-wrapper {\n  min-height: 100vh;\n}\n\nbody[data-theme=\"dark\"] {\n  --link-color: #ff1e1e;\n  --font-family-mono: \"Martian Mono\", SFMono-Regular, Consolas, \"Liberation Mono\", Menlo, Courier, monospace;\n}\n"
  },
  {
    "path": "book.toml",
    "content": "[book]\nauthors = [\"Evil Martians\"]\nlanguage = \"en\"\nmultilingual = false\nsrc = \"docs/mdbook\"\ntitle = \"Lefthook Documentation\"\n\n[output.html]\nno-section-label = true\ngit-repository-url = \"https://github.com/evilmartians/lefthook\"\n\n[output.html.fold]\nenable = true\n"
  },
  {
    "path": "cliff.toml",
    "content": "# https://git-cliff.org/docs/configuration\n\n[changelog]\nheader = \"# Change log\\n\\n\"\nbody = \"\"\"\n{% if version %}\\\n    ## {{ version | trim_start_matches(pat=\"v\") }} ({{ timestamp | date(format=\"%Y-%m-%d\") }})\n{% else %}\\\n    ## (unreleased)\n{% endif %}\n{% for commit in commits %}\\\n    - {{ 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 %}\n{% endfor %}\\n\n\"\"\"\ntrim = true\n\n[git]\n# parse the commits based on https://www.conventionalcommits.org\nconventional_commits = true\n# filter out the commits that are not conventional\nfilter_unconventional = true\n# process each line of a commit as an individual commit\nsplit_commits = false\n# regex for preprocessing the commit messages\ncommit_preprocessors = [\n  { pattern = '\\((\\w+\\s)?#([0-9]+)\\)', replace = \"([#${2}](https://github.com/evilmartians/lefthook/pull/${2}))\"}, # replace issue numbers\n]\n# regex for parsing and grouping commits\ncommit_parsers = [\n  { message = \"^feat\", group = \"feat\" },\n  { message = \"^fix\", group = \"fix\" },\n  { message = \"^docs\", group = \"docs\" },\n  { message = \"^perf\", group = \"perf\" },\n  { message = \"^refactor\", group = \"refactor\" },\n  { message = \"^ci\", group = \"ci\" },\n  { message = \"^test\", group = \"test\" },\n  { message = \"^chore\\\\(release\\\\): prepare for\", skip = true },\n  { message = \"^chore\", group = \"chore\" },\n  { body = \".*security\", group = \"security\" },\n]\n# protect breaking changes from being skipped due to matching a skipping commit_parser\nprotect_breaking_commits = false\n# filter out the commits that are not matched by commit parsers\nfilter_commits = false\n# glob pattern for matching git tags\ntag_pattern = \"v[0-9]*\"\n# regex for ignoring tags\nignore_tags = \"\"\n# sort the tags topologically\ntopo_order = false\n# sort the commits inside sections by oldest/newest order\nsort_commits = \"newest\"\n# limit the number of commits included in the changelog.\n# limit_commits = 42\n"
  },
  {
    "path": "cmd/add-usage.txt",
    "content": "lefthook add pre-commit\n\nThis command will try to build the following structure in repository:\n├───.git\n│   └───hooks\n│       └───pre-commit // this executable will be added. Existing file with\n│                      // same name will be renamed to pre-commit.old\n(lefthook adds these dirs if you run the command with the -d option)\n│\n├───.lefthook          // directory for project level hooks\n│   └───pre-commit     // directory with hook executables\n└───.lefthook-local    // directory for personal hooks; add it in .gitignore\n    └───pre-commit\n"
  },
  {
    "path": "cmd/add.go",
    "content": "package cmd\n\nimport (\n\t\"context\"\n\t_ \"embed\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/command\"\n)\n\n//go:embed add-usage.txt\nvar addUsageText string\n\nfunc add() *cli.Command {\n\tvar args command.AddArgs\n\tvar verbose bool\n\n\treturn &cli.Command{\n\t\tName:      \"add\",\n\t\tUsage:     \"add scripts directory and install the hook\",\n\t\tUsageText: addUsageText,\n\t\tFlags: []cli.Flag{\n\t\t\t&cli.BoolFlag{\n\t\t\t\tName:        \"force\",\n\t\t\t\tAliases:     []string{\"f\"},\n\t\t\t\tDestination: &args.Force,\n\t\t\t},\n\t\t\t&cli.BoolFlag{\n\t\t\t\tName:        \"create-dirs\",\n\t\t\t\tAliases:     []string{\"dirs\"},\n\t\t\t\tUsage:       \"create directories for scripts\",\n\t\t\t\tDestination: &args.CreateDirs,\n\t\t\t},\n\t\t\t&cli.BoolFlag{\n\t\t\t\tName:        \"verbose\",\n\t\t\t\tAliases:     []string{\"v\"},\n\t\t\t\tDestination: &verbose,\n\t\t\t},\n\t\t},\n\t\tAction: func(ctx context.Context, cmd *cli.Command) error {\n\t\t\tl, err := command.NewLefthook(verbose, \"auto\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\targs.Hook = cmd.Args().Get(0)\n\t\t\treturn l.Add(ctx, args)\n\t\t},\n\t\tShellComplete: func(ctx context.Context, cmd *cli.Command) {\n\t\t\tcommand.ShellCompleteFlags(cmd)\n\t\t\tcommand.ShellCompleteHookNames()\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "cmd/check_install.go",
    "content": "package cmd\n\nimport (\n\t\"context\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/command\"\n)\n\nfunc checkInstall() *cli.Command {\n\tvar verbose bool\n\treturn &cli.Command{\n\t\tName:  \"check-install\",\n\t\tUsage: \"check if hooks are installed\",\n\t\tUsageText: `lefthook check-install – Check if lefthook is installed. Exit codes:\n0 – hooks are installed\n1 – hooks are not installed or stale`,\n\t\tFlags: []cli.Flag{\n\t\t\t&cli.BoolFlag{\n\t\t\t\tName:        \"verbose\",\n\t\t\t\tAliases:     []string{\"v\"},\n\t\t\t\tDestination: &verbose,\n\t\t\t},\n\t\t},\n\t\tAction: func(ctx context.Context, cmd *cli.Command) error {\n\t\t\tl, err := command.NewLefthook(verbose, \"auto\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\treturn l.CheckInstall(ctx)\n\t\t},\n\t\tShellComplete: func(ctx context.Context, cmd *cli.Command) {\n\t\t\tcommand.ShellCompleteFlags(cmd)\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "cmd/commands.go",
    "content": "//go:build !no_self_update && !jsonschema\n\npackage cmd\n\nimport \"github.com/urfave/cli/v3\"\n\nvar commands = []*cli.Command{\n\trun(),\n\tinstall(),\n\tuninstall(),\n\tcheckInstall(),\n\tdump(),\n\tadd(),\n\tvalidate(),\n\tversion(),\n\tselfUpdate(),\n}\n"
  },
  {
    "path": "cmd/commands_without_self_update.go",
    "content": "//go:build no_self_update && !jsonschema\n\npackage cmd\n\nimport \"github.com/urfave/cli/v3\"\n\nvar commands = []*cli.Command{\n\trun(),\n\tinstall(),\n\tuninstall(),\n\tcheckInstall(),\n\tdump(),\n\tadd(),\n\tvalidate(),\n\tversion(),\n\t// selfUpdate(),\n}\n"
  },
  {
    "path": "cmd/dump.go",
    "content": "package cmd\n\nimport (\n\t\"context\"\n\t\"errors\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/command\"\n)\n\nvar errInvalidFormat = errors.New(\"invalid 'format' value, supported: 'toml', 'yaml', 'json'\")\n\nfunc dump() *cli.Command {\n\targs := command.DumpArgs{\n\t\tFormat: \"yaml\",\n\t}\n\n\treturn &cli.Command{\n\t\tName:  \"dump\",\n\t\tUsage: \"print config merged from all extensions\",\n\t\tFlags: []cli.Flag{\n\t\t\t&cli.StringFlag{\n\t\t\t\tName:        \"format\",\n\t\t\t\tUsage:       \"'yaml', 'toml', or 'json' (default: 'yaml')\",\n\t\t\t\tAliases:     []string{\"f\"},\n\t\t\t\tDestination: &args.Format,\n\t\t\t\tValidator: func(format string) error {\n\t\t\t\t\tswitch format {\n\t\t\t\t\tcase \"\":\n\t\t\t\t\tcase \"yaml\":\n\t\t\t\t\tcase \"toml\":\n\t\t\t\t\tcase \"json\":\n\t\t\t\t\tdefault:\n\t\t\t\t\t\treturn errInvalidFormat\n\t\t\t\t\t}\n\t\t\t\t\treturn nil\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\tAction: func(ctx context.Context, cmd *cli.Command) error {\n\t\t\tl, err := command.NewLefthook(false, \"no\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\treturn l.Dump(ctx, args)\n\t\t},\n\t\tShellComplete: func(ctx context.Context, cmd *cli.Command) {\n\t\t\tcommand.ShellCompleteFlags(cmd)\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "cmd/install.go",
    "content": "package cmd\n\nimport (\n\t\"context\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/command\"\n)\n\nfunc install() *cli.Command {\n\tvar args command.InstallArgs\n\tvar verbose bool\n\n\treturn &cli.Command{\n\t\tName:      \"install\",\n\t\tUsage:     \"install Git hook from the config or create a blank lefthook.yml\",\n\t\tUsageText: \"lefthook install [hook-names...] [options]\",\n\t\tFlags: []cli.Flag{\n\t\t\t&cli.BoolFlag{\n\t\t\t\tName:        \"force\",\n\t\t\t\tUsage:       \"overwrite .old files and proceed even if core.hooksPath is set\",\n\t\t\t\tAliases:     []string{\"f\"},\n\t\t\t\tDestination: &args.Force,\n\t\t\t},\n\t\t\t&cli.BoolFlag{\n\t\t\t\tName:        \"reset-hooks-path\",\n\t\t\t\tUsage:       \"automatically unset core.hooksPath configuration\",\n\t\t\t\tAliases:     []string{\"r\"},\n\t\t\t\tDestination: &args.ResetHooksPath,\n\t\t\t},\n\t\t\t&cli.BoolFlag{\n\t\t\t\tName:        \"verbose\",\n\t\t\t\tAliases:     []string{\"v\"},\n\t\t\t\tDestination: &verbose,\n\t\t\t},\n\t\t},\n\t\tAction: func(ctx context.Context, cmd *cli.Command) error {\n\t\t\tl, err := command.NewLefthook(verbose, \"auto\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\treturn l.Install(ctx, args, cmd.Args().Slice())\n\t\t},\n\t\tShellComplete: func(ctx context.Context, cmd *cli.Command) {\n\t\t\tcommand.ShellCompleteFlags(cmd)\n\t\t\tcommand.ShellCompleteHookNames()\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "cmd/lefthook.go",
    "content": "package cmd\n\nimport (\n\t\"github.com/urfave/cli/v3\"\n\n\tver \"github.com/evilmartians/lefthook/v2/internal/version\"\n)\n\nfunc Lefthook() *cli.Command {\n\treturn &cli.Command{\n\t\tName:     \"lefthook\",\n\t\tUsage:    \"Git hooks manager\",\n\t\tVersion:  ver.Version(true),\n\t\tCommands: commands,\n\t\tDescription: `... of supported ENV variables:\n\nLEFTHOOK          set to '0' or 'false' to disable lefthook execution\nLEFTHOOK_CONFIG   override main config path\nLEFTHOOK_OUTPUT   control printed sections (see config option 'output')\nLEFTHOOK_VERBOSE  enable debug logs`,\n\t\tEnableShellCompletion: true,\n\t\tSuggest:               true,\n\t}\n}\n"
  },
  {
    "path": "cmd/run.go",
    "content": "package cmd\n\nimport (\n\t\"context\"\n\t\"errors\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/command\"\n)\n\nfunc run() *cli.Command {\n\tvar args command.RunArgs\n\tvar colors string\n\tfailOnChanges := &cli.BoolWithInverseFlag{\n\t\tName:  \"fail-on-changes\",\n\t\tUsage: \"exit with 1 if some of the files were changed\",\n\t}\n\tfailOnChangesDiff := &cli.BoolWithInverseFlag{\n\t\tName:  \"fail-on-changes-diff\",\n\t\tUsage: \"output a diff when failing on changes\",\n\t}\n\n\treturn &cli.Command{\n\t\tName:      \"run\",\n\t\tUsage:     \"execute a group of hooks\",\n\t\tUsageText: \"lefthook run <hook-name> [args...] [options]\",\n\t\tFlags: []cli.Flag{\n\t\t\t&cli.BoolFlag{\n\t\t\t\tName:        \"verbose\",\n\t\t\t\tAliases:     []string{\"v\"},\n\t\t\t\tUsage:       \"enable debug logs\",\n\t\t\t\tDestination: &args.Verbose,\n\t\t\t},\n\t\t\t&cli.StringFlag{\n\t\t\t\tName:        \"colors\",\n\t\t\t\tUsage:       \"on, off, or auto (default: auto)\",\n\t\t\t\tDestination: &colors,\n\t\t\t\tValue:       \"auto\",\n\t\t\t},\n\t\t\t&cli.StringSliceFlag{\n\t\t\t\tName:        \"job\",\n\t\t\t\tUsage:       \"run only jobs with names\",\n\t\t\t\tDestination: &args.RunOnlyJobs,\n\t\t\t},\n\t\t\t&cli.StringSliceFlag{\n\t\t\t\tName:        \"tag\",\n\t\t\t\tUsage:       \"run only jobs with tag names\",\n\t\t\t\tDestination: &args.RunOnlyTags,\n\t\t\t},\n\t\t\t&cli.StringSliceFlag{\n\t\t\t\tName:        \"command\",\n\t\t\t\tUsage:       \"run only commands\",\n\t\t\t\tDestination: &args.RunOnlyCommands,\n\t\t\t},\n\t\t\t&cli.StringSliceFlag{\n\t\t\t\tName:        \"exclude\",\n\t\t\t\tUsage:       \"exclude files from all templates\",\n\t\t\t\tDestination: &args.Exclude,\n\t\t\t},\n\t\t\t&cli.StringSliceFlag{\n\t\t\t\tName:        \"file\",\n\t\t\t\tUsage:       \"overwrite file templates with files\",\n\t\t\t\tDestination: &args.Files,\n\t\t\t},\n\t\t\t&cli.BoolFlag{\n\t\t\t\tName:        \"force\",\n\t\t\t\tAliases:     []string{\"f\"},\n\t\t\t\tUsage:       \"do not skip if no files changed\",\n\t\t\t\tDestination: &args.Force,\n\t\t\t},\n\t\t\t&cli.BoolFlag{\n\t\t\t\tName:        \"all-files\",\n\t\t\t\tUsage:       \"replace files templates with {all_files}\",\n\t\t\t\tDestination: &args.AllFiles,\n\t\t\t},\n\t\t\t&cli.BoolFlag{\n\t\t\t\tName:        \"no-auto-install\",\n\t\t\t\tUsage:       \"do not implicitly install hooks\",\n\t\t\t\tDestination: &args.NoAutoInstall,\n\t\t\t},\n\t\t\t&cli.BoolFlag{\n\t\t\t\tName:        \"no-stage-fixed\",\n\t\t\t\tUsage:       \"ignore 'stage_fixed: true' setting\",\n\t\t\t\tDestination: &args.NoStageFixed,\n\t\t\t},\n\t\t\t&cli.BoolFlag{\n\t\t\t\tName:        \"no-tty\",\n\t\t\t\tUsage:       \"act as if no TTY is connected\",\n\t\t\t\tDestination: &args.NoTTY,\n\t\t\t},\n\t\t\t&cli.BoolFlag{\n\t\t\t\tName:        \"skip-lfs\",\n\t\t\t\tUsage:       \"do not run LFS hooks\",\n\t\t\t\tDestination: &args.SkipLFS,\n\t\t\t},\n\t\t\tfailOnChanges,\n\t\t\t&cli.BoolFlag{\n\t\t\t\tName:        \"files-from-stdin\",\n\t\t\t\tUsage:       \"parse filelist from STDIN\",\n\t\t\t\tDestination: &args.FilesFromStdin,\n\t\t\t},\n\t\t},\n\t\tAction: func(ctx context.Context, cmd *cli.Command) error {\n\t\t\tl, err := command.NewLefthook(args.Verbose, colors)\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif failOnChanges.IsSet() {\n\t\t\t\tvalue := cmd.Bool(\"fail-on-changes\")\n\t\t\t\targs.FailOnChanges = &value\n\t\t\t}\n\t\t\tif failOnChangesDiff.IsSet() {\n\t\t\t\tvalue := cmd.Bool(\"fail-on-changes-diff\")\n\t\t\t\targs.FailOnChangesDiff = &value\n\t\t\t}\n\n\t\t\tif cmd.Args().Len() < 1 {\n\t\t\t\treturn errors.New(\"hook name missing\")\n\t\t\t}\n\n\t\t\targs.Hook = cmd.Args().Get(0)\n\t\t\targs.GitArgs = cmd.Args().Slice()[1:]\n\t\t\treturn l.Run(ctx, args)\n\t\t},\n\t\tShellComplete: func(ctx context.Context, cmd *cli.Command) {\n\t\t\tcommand.ShellCompleteFlags(cmd)\n\t\t\tcommand.ShellCompleteHookNames()\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "cmd/self_update.go",
    "content": "package cmd\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\t\"os/signal\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/command\"\n\t\"github.com/evilmartians/lefthook/v2/internal/log\"\n\t\"github.com/evilmartians/lefthook/v2/internal/updater\"\n)\n\nfunc selfUpdate() *cli.Command {\n\tvar yes, force, verbose bool\n\n\treturn &cli.Command{\n\t\tName:  \"self-update\",\n\t\tUsage: \"update lefthook executable\",\n\t\tFlags: []cli.Flag{\n\t\t\t&cli.BoolFlag{\n\t\t\t\tName:        \"yes\",\n\t\t\t\tAliases:     []string{\"y\"},\n\t\t\t\tUsage:       \"do not prompt y/n\",\n\t\t\t\tDestination: &yes,\n\t\t\t},\n\t\t\t&cli.BoolFlag{\n\t\t\t\tName:        \"force\",\n\t\t\t\tAliases:     []string{\"f\"},\n\t\t\t\tUsage:       \"force reinstall\",\n\t\t\t\tDestination: &force,\n\t\t\t},\n\t\t\t&cli.BoolFlag{\n\t\t\t\tName:        \"verbose\",\n\t\t\t\tAliases:     []string{\"v\"},\n\t\t\t\tDestination: &verbose,\n\t\t\t},\n\t\t},\n\t\tAction: func(ctx context.Context, cmd *cli.Command) error {\n\t\t\tif os.Getenv(command.EnvVerbose) == \"1\" || os.Getenv(command.EnvVerbose) == \"true\" {\n\t\t\t\tverbose = true\n\t\t\t}\n\t\t\tif verbose {\n\t\t\t\tlog.SetLevel(log.DebugLevel)\n\t\t\t\tlog.Debug(\"Verbose mode enabled\")\n\t\t\t}\n\n\t\t\texePath, err := os.Executable()\n\t\t\tif err != nil {\n\t\t\t\treturn fmt.Errorf(\"failed to determine the binary path: %w\", err)\n\t\t\t}\n\n\t\t\tctxCancel, stop := signal.NotifyContext(ctx, os.Interrupt)\n\t\t\tdefer stop()\n\n\t\t\treturn updater.New().SelfUpdate(ctxCancel, updater.Options{\n\t\t\t\tYes:     yes,\n\t\t\t\tForce:   force,\n\t\t\t\tExePath: exePath,\n\t\t\t})\n\t\t},\n\t\tShellComplete: func(ctx context.Context, cmd *cli.Command) {\n\t\t\tcommand.ShellCompleteFlags(cmd)\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "cmd/uninstall.go",
    "content": "package cmd\n\nimport (\n\t\"context\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/command\"\n)\n\nfunc uninstall() *cli.Command {\n\tvar args command.UninstallArgs\n\tvar verbose bool\n\n\treturn &cli.Command{\n\t\tName:  \"uninstall\",\n\t\tUsage: \"delete installed hooks\",\n\t\tFlags: []cli.Flag{\n\t\t\t&cli.BoolFlag{\n\t\t\t\tName:        \"verbose\",\n\t\t\t\tAliases:     []string{\"v\"},\n\t\t\t\tDestination: &verbose,\n\t\t\t},\n\t\t\t&cli.BoolFlag{\n\t\t\t\tName:        \"force\",\n\t\t\t\tAliases:     []string{\"f\"},\n\t\t\t\tUsage:       \"remove all Git hooks\",\n\t\t\t\tDestination: &args.Force,\n\t\t\t},\n\t\t\t&cli.BoolFlag{\n\t\t\t\tName:        \"remove-configs\",\n\t\t\t\tUsage:       \"remove lefthook configs\",\n\t\t\t\tDestination: &args.RemoveConfig,\n\t\t\t},\n\t\t},\n\t\tAction: func(ctx context.Context, cmd *cli.Command) error {\n\t\t\tl, err := command.NewLefthook(verbose, \"auto\")\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\treturn l.Uninstall(ctx, args)\n\t\t},\n\t\tShellComplete: func(ctx context.Context, cmd *cli.Command) {\n\t\t\tcommand.ShellCompleteFlags(cmd)\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "cmd/validate.go",
    "content": "package cmd\n\nimport (\n\t\"context\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/command\"\n)\n\nfunc validate() *cli.Command {\n\tvar args command.ValidateArgs\n\tvar verbose bool\n\n\treturn &cli.Command{\n\t\tName:  \"validate\",\n\t\tUsage: \"validate lefthook config\",\n\t\tFlags: []cli.Flag{\n\t\t\t&cli.BoolFlag{\n\t\t\t\tName:        \"verbose\",\n\t\t\t\tAliases:     []string{\"v\"},\n\t\t\t\tDestination: &verbose,\n\t\t\t},\n\t\t},\n\t\tAction: func(ctx context.Context, cmd *cli.Command) error {\n\t\t\tl, err := command.NewLefthook(verbose, \"auto\")\n\t\t\tif err != nil {\n\t\t\t\treturn nil\n\t\t\t}\n\n\t\t\treturn l.Validate(ctx, args)\n\t\t},\n\t\tShellComplete: func(ctx context.Context, cmd *cli.Command) {\n\t\t\tcommand.ShellCompleteFlags(cmd)\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "cmd/version.go",
    "content": "package cmd\n\nimport (\n\t\"context\"\n\n\t\"github.com/urfave/cli/v3\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/command\"\n\t\"github.com/evilmartians/lefthook/v2/internal/log\"\n\tver \"github.com/evilmartians/lefthook/v2/internal/version\"\n)\n\nfunc version() *cli.Command {\n\tvar verbose bool\n\n\treturn &cli.Command{\n\t\tName:  \"version\",\n\t\tUsage: \"print version\",\n\t\tFlags: []cli.Flag{\n\t\t\t&cli.BoolFlag{\n\t\t\t\tName:        \"verbose\",\n\t\t\t\tAliases:     []string{\"v\"},\n\t\t\t\tDestination: &verbose,\n\t\t\t},\n\t\t\t&cli.BoolFlag{\n\t\t\t\tName:        \"full\",\n\t\t\t\tAliases:     []string{\"f\"},\n\t\t\t\tDestination: &verbose,\n\t\t\t},\n\t\t},\n\t\tAction: func(_ctx context.Context, cmd *cli.Command) error {\n\t\t\tlog.Println(ver.Version(verbose))\n\t\t\treturn nil\n\t\t},\n\t\tShellComplete: func(ctx context.Context, cmd *cli.Command) {\n\t\t\tcommand.ShellCompleteFlags(cmd)\n\t\t},\n\t}\n}\n"
  },
  {
    "path": "codecov.yml",
    "content": "comment: false\n"
  },
  {
    "path": "docmd.config.js",
    "content": "module.exports = {\n  siteTitle: \"Lefthook\",\n  siteUrl: \"https://lefthook.dev\",\n  logo: {\n    light: \"/assets/lefthook.png\",\n    dark: \"/assets/lefthook.png\",\n    alt: \"Logo\",\n    href: \"/\"\n  },\n  favicon: \"/assets/favicon.svg\",\n  srcDir: \"docs\",\n  outputDir: \"site\",\n  layout: {\n    spa: true,\n    header: {\n      enabled: true\n    },\n    sidebar: {\n      collapsible: true,\n      defaultCollapsed: false\n    },\n    optionsMenu: {\n      position: \"header\",\n      components: {\n        search: true,\n        themeSwitch: true,\n        sponsor: null\n      }\n    },\n    footer: {\n      style: \"minimal\",\n      content: \"© 2026 Lefthook. Evil Martians.\"\n    }\n  },\n  theme: {\n    name: \"default\",\n    defaultMode: \"system\",\n    codeHighlight: true,\n    customCss: [\n      \"assets/css/lefthook.css\"\n    ]\n  },\n  minify: true,\n  autoTitleFromH1: true,\n  copyCode: true,\n  pageNavigation: false,\n  editLink: {\n    enabled: false,\n    baseUrl: \"https://github.com/evilmartians/lefthook/edit/main/docs\",\n    text: \"Edit this page\"\n  },\n  plugins: {\n    seo: {\n      defaultDescription: \"Lefthook documentation.\",\n      openGraph: {\n        defaultImage: \"assets/lefthook.png\"\n      }\n    },\n    analytics: {},\n    sitemap: {\n      defaultChangefreq: \"weekly\",\n      defaultPriority: 0.8\n    },\n    search: {},\n    mermaid: {},\n    llms: {}\n  },\n  navigation: [\n    {\n      title: \"Installation\",\n      icon: \"rocket\",\n      path: \"/install\",\n      collapsible: true,\n      children: [\n        {\n          title: \"Ruby gem\",\n          path: \"/installation/ruby\"\n        },\n        {\n          title: \"NPM\",\n          path: \"/installation/node\"\n        },\n        {\n          title: \"Go\",\n          path: \"/installation/go\"\n        },\n        {\n          title: \"Python\",\n          path: \"/installation/python\"\n        },\n        {\n          title: \"Swift\",\n          path: \"/installation/swift\"\n        },\n        {\n          title: \"Homebrew\",\n          path: \"/installation/homebrew\"\n        },\n        {\n          title: \"Winget\",\n          path: \"/installation/winget\"\n        },\n        {\n          title: \"Scoop\",\n          path: \"/installation/scoop\"\n        },\n        {\n          title: \"Debian-based distro\",\n          path: \"/installation/deb\"\n        },\n        {\n          title: \"RPM-based distro\",\n          path: \"/installation/rpm\"\n        },\n        {\n          title: \"Alpine\",\n          path: \"/installation/alpine\"\n        },\n        {\n          title: \"Arch Linux\",\n          path: \"/installation/arch\"\n        },\n        {\n          title: \"Snap\",\n          path: \"/installation/snap\"\n        },\n        {\n          title: \"Devbox\",\n          path: \"/installation/devbox\"\n        },\n        {\n          title: \"Mise\",\n          path: \"/installation/mise\"\n        },\n        {\n          title: \"Manual\",\n          path: \"/installation/manual\"\n        }\n      ]\n    },\n    {\n      title: \"Configuration\",\n      path: \"/configuration\",\n      collapsible: true,\n      icon: \"settings\",\n      children: [\n        {\n          title: \"assert_lefthook_installed\",\n          path: \"/configuration/assert_lefthook_installed\"\n        },\n        {\n          title: \"colors\",\n          path: \"/configuration/colors\"\n        },\n        {\n          title: \"extends\",\n          path: \"/configuration/extends\"\n        },\n        {\n          title: \"install_non_git_hooks\",\n          path: \"/configuration/install_non_git_hooks\"\n        },\n        {\n          title: \"lefthook\",\n          path: \"/configuration/lefthook\"\n        },\n        {\n          title: \"min_version\",\n          path: \"/configuration/min_version\"\n        },\n        {\n          title: \"no_auto_install\",\n          path: \"/configuration/no_auto_install\"\n        },\n        {\n          title: \"no_tty\",\n          path: \"/configuration/no_tty\"\n        },\n        {\n          title: \"output\",\n          path: \"/configuration/output\"\n        },\n        {\n          title: \"rc\",\n          path: \"/configuration/rc\"\n        },\n        {\n          title: \"remotes\",\n          path: \"/configuration/remotes\",\n          children: [\n            {\n              title: \"git_url\",\n              path: \"/configuration/git_url\"\n            },\n            {\n              title: \"ref\",\n              path: \"/configuration/ref\"\n            },\n            {\n              title: \"refetch\",\n              path: \"/configuration/refetch\"\n            },\n            {\n              title: \"refetch_frequency\",\n              path: \"/configuration/refetch_frequency\"\n            },\n            {\n              title: \"configs\",\n              path: \"/configuration/configs\"\n            }\n          ]\n        },\n        {\n          title: \"source_dir\",\n          path: \"/configuration/source_dir\"\n        },\n        {\n          title: \"source_dir_local\",\n          path: \"/configuration/source_dir_local\"\n        },\n        {\n          title: \"skip_lfs\",\n          path: \"/configuration/skip_lfs\"\n        },\n        {\n          title: \"glob_matcher\",\n          path: \"/configuration/glob_matcher\"\n        },\n        {\n          title: \"templates\",\n          path: \"/configuration/templates\"\n        },\n        {\n          title: \"Hook\",\n          path: \"/configuration/Hook\",\n          children: [\n            {\n              title: \"parallel\",\n              path: \"/configuration/parallel\"\n            },\n            {\n              title: \"piped\",\n              path: \"/configuration/piped\"\n            },\n            {\n              title: \"follow\",\n              path: \"/configuration/follow\"\n            },\n            {\n              title: \"files\",\n              path: \"/configuration/files-global\"\n            },\n            {\n              title: \"fail_on_changes\",\n              path: \"/configuration/fail_on_changes\"\n            },\n            {\n              title: \"fail_on_changes_diff\",\n              path: \"/configuration/fail_on_changes_diff\"\n            },\n            {\n              title: \"exclude_tags\",\n              path: \"/configuration/exclude_tags\"\n            },\n            {\n              title: \"exclude\",\n              path: \"/configuration/exclude\"\n            },\n            {\n              title: \"only\",\n              path: \"/configuration/only\"\n            },\n            {\n              title: \"skip\",\n              path: \"/configuration/skip\"\n            },\n            {\n              title: \"setup\",\n              path: \"/configuration/setup\"\n            },\n            {\n              title: \"jobs\",\n              path: \"/configuration/jobs\",\n              children: [\n                {\n                  title: \"name\",\n                  path: \"/configuration/name\"\n                },\n                {\n                  title: \"run\",\n                  path: \"/configuration/run\"\n                },\n                {\n                  title: \"script\",\n                  path: \"/configuration/script\"\n                },\n                {\n                  title: \"runner\",\n                  path: \"/configuration/runner\"\n                },\n                {\n                  title: \"args\",\n                  path: \"/configuration/args\"\n                },\n                {\n                  title: \"group\",\n                  collapsible: true,\n                  path: \"/configuration/group\",\n                  children: [\n                    {\n                      title: \"parallel\",\n                      path: \"/configuration/parallel\"\n                    },\n                    {\n                      title: \"piped\",\n                      path: \"/configuration/piped\"\n                    },\n                    {\n                      title: \"jobs\",\n                      path: \"/configuration/jobs\"\n                    }\n                  ]\n                },\n                {\n                  title: \"skip\",\n                  path: \"/configuration/skip\"\n                },\n                {\n                  title: \"only\",\n                  path: \"/configuration/only\"\n                },\n                {\n                  title: \"tags\",\n                  path: \"/configuration/tags\"\n                },\n                {\n                  title: \"glob\",\n                  path: \"/configuration/glob\"\n                },\n                {\n                  title: \"files\",\n                  path: \"/configuration/files\"\n                },\n                {\n                  title: \"file_types\",\n                  path: \"/configuration/file_types\"\n                },\n                {\n                  title: \"env\",\n                  path: \"/configuration/env\"\n                },\n                {\n                  title: \"root\",\n                  path: \"/configuration/root\"\n                },\n                {\n                  title: \"exclude\",\n                  path: \"/configuration/exclude\"\n                },\n                {\n                  title: \"fail_text\",\n                  path: \"/configuration/fail_text\"\n                },\n                {\n                  title: \"stage_fixed\",\n                  path: \"/configuration/stage_fixed\"\n                },\n                {\n                  title: \"interactive\",\n                  path: \"/configuration/interactive\"\n                },\n                {\n                  title: \"use_stdin\",\n                  path: \"/configuration/use_stdin\"\n                }\n              ]\n            },\n            {\n              title: \"commands\",\n              path: \"/configuration/Commands\",\n              children: [\n                {\n                  title: \"run\",\n                  path: \"/configuration/run\"\n                },\n                {\n                  title: \"skip\",\n                  path: \"/configuration/skip\"\n                },\n                {\n                  title: \"only\",\n                  path: \"/configuration/only\"\n                },\n                {\n                  title: \"tags\",\n                  path: \"/configuration/tags\"\n                },\n                {\n                  title: \"glob\",\n                  path: \"/configuration/glob\"\n                },\n                {\n                  title: \"files\",\n                  path: \"/configuration/files\"\n                },\n                {\n                  title: \"file_types\",\n                  path: \"/configuration/file_types\"\n                },\n                {\n                  title: \"env\",\n                  path: \"/configuration/env\"\n                },\n                {\n                  title: \"root\",\n                  path: \"/configuration/root\"\n                },\n                {\n                  title: \"exclude\",\n                  path: \"/configuration/exclude\"\n                },\n                {\n                  title: \"fail_text\",\n                  path: \"/configuration/fail_text\"\n                },\n                {\n                  title: \"stage_fixed\",\n                  path: \"/configuration/stage_fixed\"\n                },\n                {\n                  title: \"interactive\",\n                  path: \"/configuration/interactive\"\n                },\n                {\n                  title: \"use_stdin\",\n                  path: \"/configuration/use_stdin\"\n                },\n                {\n                  title: \"priority\",\n                  path: \"/configuration/priority\"\n                }\n              ]\n            },\n            {\n              title: \"scripts\",\n              path: \"/configuration/Scripts\",\n              children: [\n                {\n                  title: \"runner\",\n                  path: \"/configuration/runner\"\n                },\n                {\n                  title: \"args\",\n                  path: \"/configuration/args\"\n                },\n                {\n                  title: \"skip\",\n                  path: \"/configuration/skip\"\n                },\n                {\n                  title: \"only\",\n                  path: \"/configuration/only\"\n                },\n                {\n                  title: \"tags\",\n                  path: \"/configuration/tags\"\n                },\n                {\n                  title: \"env\",\n                  path: \"/configuration/env\"\n                },\n                {\n                  title: \"fail_text\",\n                  path: \"/configuration/fail_text\"\n                },\n                {\n                  title: \"stage_fixed\",\n                  path: \"/configuration/stage_fixed\"\n                },\n                {\n                  title: \"interactive\",\n                  path: \"/configuration/interactive\"\n                },\n                {\n                  title: \"use_stdin\",\n                  path: \"/configuration/use_stdin\"\n                },\n                {\n                  title: \"priority\",\n                  path: \"/configuration/priority\"\n                }\n              ]\n            }\n          ]\n        }\n      ]\n    },\n    {\n      title: \"CLI\",\n      collapsible: true,\n      icon: \"terminal\",\n      children: [\n        {\n          title: \"lefthook install\",\n          icon: \"chevron-right\",\n          path: \"/usage/commands/install\"\n        },\n        {\n          title: \"lefthook uninstall\",\n          icon: \"chevron-right\",\n          path: \"/usage/commands/uninstall\"\n        },\n        {\n          title: \"lefthook run\",\n          icon: \"chevron-right\",\n          path: \"/usage/commands/run\"\n        },\n        {\n          title: \"lefthook add\",\n          icon: \"chevron-right\",\n          path: \"/usage/commands/add\"\n        },\n        {\n          title: \"lefthook validate\",\n          icon: \"chevron-right\",\n          path: \"/usage/commands/validate\"\n        },\n        {\n          title: \"lefthook dump\",\n          icon: \"chevron-right\",\n          path: \"/usage/commands/dump\"\n        },\n        {\n          title: \"lefthook check-install\",\n          icon: \"chevron-right\",\n          path: \"/usage/commands/check-install\"\n        },\n        {\n          title: \"lefthook self-update\",\n          icon: \"chevron-right\",\n          path: \"/usage/commands/self-update\"\n        },\n        {\n          title: \"ENV variables\",\n          collapsible: true,\n          icon: \"dollar-sign\",\n          children: [\n            {\n              title: \"LEFTHOOK\",\n              path: \"/usage/envs/LEFTHOOK\"\n            },\n            {\n              title: \"LEFTHOOK_VERBOSE\",\n              path: \"/usage/envs/LEFTHOOK_VERBOSE\"\n            },\n            {\n              title: \"LEFTHOOK_OUTPUT\",\n              path: \"/usage/envs/LEFTHOOK_OUTPUT\"\n            },\n            {\n              title: \"LEFTHOOK_CONFIG\",\n              path: \"/usage/envs/LEFTHOOK_CONFIG\"\n            },\n            {\n              title: \"LEFTHOOK_EXCLUDE\",\n              path: \"/usage/envs/LEFTHOOK_EXCLUDE\"\n            },\n            {\n              title: \"CLICOLOR_FORCE\",\n              path: \"/usage/envs/CLICOLOR_FORCE\"\n            },\n            {\n              title: \"NO_COLOR\",\n              path: \"/usage/envs/NO_COLOR\"\n            },\n            {\n              title: \"CI\",\n              path: \"/usage/envs/CI\"\n            }\n          ]\n        }\n      ]\n    },\n    {\n      title: \"Examples\",\n      collapsible: true,\n      icon: \"file-code\",\n      children: [\n        {\n          title: \"Using local only config\",\n          path: \"/examples/lefthook-local\"\n        },\n        {\n          title: \"Wrap commands locally\",\n          path: \"/examples/wrap-commands\"\n        },\n        {\n          title: \"Auto add linter fixes to commit\",\n          path: \"/examples/stage_fixed\"\n        },\n        {\n          title: \"Filter files\",\n          path: \"/examples/filters\"\n        },\n        {\n          title: \"Skip or run on condition\",\n          path: \"/examples/skip\"\n        },\n        {\n          title: \"Remote configs\",\n          path: \"/examples/remotes\"\n        },\n        {\n          title: \"With commitlint\",\n          path: \"/examples/commitlint\"\n        }\n      ]\n    },\n    {\n      title: \"Contributors\",\n      path: \"/misc/contributors\",\n      icon: \"users-round\"\n    },\n    {\n      title: \"GitHub\",\n      path: \"https://github.com/evilmartians/lefthook\",\n      icon: \"github\",\n      external: true\n    }\n  ]\n};\n"
  },
  {
    "path": "docs/configuration/Commands.md",
    "content": "---\ntitle: \"commands\"\n---\n\n# `commands`\n\nCommands to be executed for the hook. Each command has a name and associated run [options](#command).\n\n#### Example\n\n```yml\n# lefthook.yml\n\npre-commit:\n  commands:\n    lint:\n      ... # command options\n```\n\n### Command options\n\n- [`run`](./run.md)\n- [`skip`](./skip.md)\n- [`only`](./only.md)\n- [`tags`](./tags.md)\n- [`glob`](./glob.md)\n- [`files`](./files.md)\n- [`file_types`](./file_types.md)\n- [`env`](./env.md)\n- [`root`](./root.md)\n- [`exclude`](./exclude.md)\n- [`fail_text`](./fail_text.md)\n- [`stage_fixed`](./stage_fixed.md)\n- [`interactive`](./interactive.md)\n- [`use_stdin`](./use_stdin.md)\n- [`priority`](./priority.md)\n"
  },
  {
    "path": "docs/configuration/Hook.md",
    "content": "---\ntitle: \"Hook\"\n---\n\n# Git hook\n\nContains settings for the git hook (commands, scripts, skip rules, etc.). You can specify any Git hook or your own custom, e.g. `test`\n\n\n```yml\n# lefthook.yml\n\n# Git hook\npre-commit:\n  jobs:\n    - run: yarn lint {staged_files} --fix\n      stage_fixed: true\n\n# Custom hook\ncheck-docs:\n  jobs:\n    - run: yarn check-docs\n    - run: typos\n```\n"
  },
  {
    "path": "docs/configuration/README.md",
    "content": "## Config file name\n\nLefthook supports the following file names for the main config:\n\n| Format | File name |\n|-------|-----------|\n| YAML  | `lefthook.yml` |\n| YAML  | `.lefthook.yml` |\n| YAML  | `.config/lefthook.yml` |\n|       |              |\n| YAML  | `lefthook.yaml` |\n| YAML  | `.lefthook.yaml` |\n| YAML  | `.config/lefthook.yaml` |\n|       |              |\n| TOML  | `lefthook.toml` |\n| TOML  | `.lefthook.toml` |\n| TOML  | `.config/lefthook.toml` |\n|       |              |\n| JSON  | `lefthook.json` |\n| JSON  | `.lefthook.json` |\n| JSON  | `.config/lefthook.json` |\n|       |              |\n| JSONC | `lefthook.jsonc` |\n| JSONC | `.lefthook.jsonc` |\n| JSONC | `.config/lefthook.jsonc` |\n\nIf 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.\n\nFilenames without the leading dot will also be looked up from the [`.config` subdirectory](https://github.com/pi0/config-dir).\n\nLefthook 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`.\n\nThe `-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`.\n\n## Options\n\n- [`assert_lefthook_installed`](./assert_lefthook_installed.md)\n- [`colors`](./colors.md)\n- [`extends`](./extends.md)\n- [`lefthook`](./lefthook.md)\n- [`min_version`](./min_version.md)\n- [`no_tty`](./no_tty.md)\n- [`output`](./output.md)\n- [`rc`](./rc.md)\n- [`remotes`](./remotes.md)\n  - [`git_url`](./git_url.md)\n  - [`ref`](./ref.md)\n  - [`refetch`](./refetch.md)\n  - [`refetch_frequency`](./refetch_frequency.md)\n  - [`configs`](./configs.md)\n- [`source_dir`](./source_dir.md)\n- [`source_dir_local`](./source_dir_local.md)\n- [`skip_lfs`](./skip_lfs.md)\n- [`templates`](./templates.md)\n- [{Git hook name}](./Hook.md) (e.g. `pre-commit`)\n  - [`files` (global)](./files-global.md)\n  - [`parallel`](./parallel.md)\n  - [`piped`](./piped.md)\n  - [`follow`](./follow.md)\n  - [`fail_on_changes`](./fail_on_changes.md)\n  - [`fail_on_changes_diff`](./fail_on_changes_diff.md)\n  - [`exclude_tags`](./exclude_tags.md)\n  - [`exclude`](./exclude.md)\n  - [`skip`](./skip.md)\n  - [`only`](./only.md)\n  - [`jobs`](./jobs.md)\n    - [`name`](./name.md)\n    - [`run`](./run.md)\n    - [`script`](./script.md)\n    - [`runner`](./runner.md)\n    - [`args`](./args.md)\n    - [`group`](./group.md)\n      - [`parallel`](./parallel.md)\n      - [`piped`](./piped.md)\n      - [`jobs`](./jobs.md)\n    - [`skip`](./skip.md)\n    - [`only`](./only.md)\n    - [`tags`](./tags.md)\n    - [`glob`](./glob.md)\n    - [`files`](./files.md)\n    - [`file_types`](./file_types.md)\n    - [`env`](./env.md)\n    - [`root`](./root.md)\n    - [`exclude`](./exclude.md)\n    - [`fail_text`](./fail_text.md)\n    - [`stage_fixed`](./stage_fixed.md)\n    - [`interactive`](./interactive.md)\n    - [`use_stdin`](./use_stdin.md)\n  - [`commands`](./Commands.md)\n    - [`run`](./run.md)\n    - [`skip`](./skip.md)\n    - [`only`](./only.md)\n    - [`tags`](./tags.md)\n    - [`glob`](./glob.md)\n    - [`files`](./files.md)\n    - [`file_types`](./file_types.md)\n    - [`env`](./env.md)\n    - [`root`](./root.md)\n    - [`exclude`](./exclude.md)\n    - [`fail_text`](./fail_text.md)\n    - [`stage_fixed`](./stage_fixed.md)\n    - [`interactive`](./interactive.md)\n    - [`use_stdin`](./use_stdin.md)\n    - [`priority`](./priority.md)\n  - [`scripts`](./Scripts.md)\n    - [`runner`](./runner.md)\n    - [`args`](./args.md)\n    - [`skip`](./skip.md)\n    - [`only`](./only.md)\n    - [`tags`](./tags.md)\n    - [`env`](./env.md)\n    - [`fail_text`](./fail_text.md)\n    - [`stage_fixed`](./stage_fixed.md)\n    - [`interactive`](./interactive.md)\n    - [`use_stdin`](./use_stdin.md)\n    - [`priority`](./priority.md)\n"
  },
  {
    "path": "docs/configuration/Scripts.md",
    "content": "---\ntitle: \"Scripts\"\n---\n\n# Scripts\n\nScripts are stored under `<source_dir>/<hook-name>/` folder. These scripts are your own executables which are being run in the project root.\n\nTo add a script for a `pre-commit` hook:\n\n1. Run `lefthook add -d pre-commit`\n1. Edit `.lefthook/pre-commit/my-script.sh`\n1. Add an entry to `lefthook.yml`\n   ```yml\n   # lefthook.yml\n\n   pre-commit:\n     scripts:\n       \"my-script.sh\":\n         runner: bash\n   ```\n\n### Example\n\nLet's create a bash script to check commit templates `.lefthook/commit-msg/template_checker`:\n\n```bash\nINPUT_FILE=$1\nSTART_LINE=`head -n1 $INPUT_FILE`\nPATTERN=\"^(TICKET)-[[:digit:]]+: \"\nif ! [[ \"$START_LINE\" =~ $PATTERN ]]; then\n  echo \"Bad commit message, see example: TICKET-123: some text\"\n  exit 1\nfi\n```\n\nNow we can ask lefthook to run our bash script by adding this code to\n`lefthook.yml` file:\n\n```yml\n# lefthook.yml\n\ncommit-msg:\n  scripts:\n    \"template_checker\":\n      runner: bash\n```\n\nWhen 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.\n"
  },
  {
    "path": "docs/configuration/args.md",
    "content": "---\ntitle: \"args\"\n---\n\n# `args`\n\n::: callout tip New feature\nAdded in lefthook `2.0.5`\n:::\n\nSometimes 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).\n\nArguments passed by Git will be omitted if you specify `args` in the config. Providing no `args` or providing `args: \"{0}\"` works the same way.\n\nSee [`run`](./run.md) for supported templates.\n\n#### Example\n\n```yml\n# lefthook.yml\n\npre-commit:\n  jobs:\n    - script: check-python-files.sh\n      runner: bash\n      args: \"{staged_files}\"\n      glob: \"*.py\"\n\n    - run: yarn lint\n      args: \"{staged_files}\"\n      glob:\n        - \"*.ts\"\n        - \"*.js\"\n```\n"
  },
  {
    "path": "docs/configuration/assert_lefthook_installed.md",
    "content": "---\ntitle: \"assert_lefthook_installed\"\n---\n\n# `assert_lefthook_installed`\n\n**Default: `false`**\n\nWhen 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.\n"
  },
  {
    "path": "docs/configuration/colors.md",
    "content": "---\ntitle: \"colors\"\n---\n\n# `colors`\n\n**Default: `auto`**\n\nWhether enable or disable colorful output of Lefthook. This option can be overwritten with `--colors` option. You can also provide your own color codes.\n\n#### Example\n\nDisable colors.\n\n```yml\n# lefthook.yml\n\ncolors: false\n```\n\nCustom color codes. Can be hex or ANSI codes.\n\n```yml\n# lefthook.yml\n\ncolors:\n  cyan: 14\n  gray: 244\n  green: '#32CD32'\n  red: '#FF1493'\n  yellow: '#F0E68C'\n```\n\nControl via ENV variable.\n\n- Set `NO_COLOR=true` to disable colored output in lefthook and all subcommands that lefthook calls.\n- Set `CLICOLOR_FORCE=true` to force colored output in lefthook and all subcommands.\n"
  },
  {
    "path": "docs/configuration/configs.md",
    "content": "---\ntitle: \"configs\"\n---\n\n# `configs`\n\n**Default:** `[lefthook.yml]`\n\nAn optional array of config paths from remote's root.\n\n#### Example\n\n```yml\n# lefthook.yml\n\nremotes:\n  - git_url: git@github.com:evilmartians/lefthook\n    ref: v1.0.0\n    configs:\n      - examples/ruby-linter.yml\n      - examples/test.yml\n```\n\nExample with multiple remotes merging multiple configurations.\n\n```yml\n# lefthook.yml\n\nremotes:\n  - git_url: git@github.com:org/lefthook-configs\n    ref: v1.0.0\n    configs:\n      - examples/ruby-linter.yml\n      - examples/test.yml\n  - git_url: https://github.com/org2/lefthook-configs\n    configs:\n      - lefthooks/pre_commit.yml\n      - lefthooks/post_merge.yml\n  - git_url: https://github.com/org3/lefthook-configs\n    ref: feature/new\n    configs:\n      - configs/pre-push.yml\n\n```\n"
  },
  {
    "path": "docs/configuration/env.md",
    "content": "---\ntitle: \"env\"\n---\n\n# `env`\n\nYou can specify some ENV variables for the command or script.\n\n#### Example\n\n```yml\n# lefthook.yml\n\npre-commit:\n  commands:\n    test:\n      env:\n        RAILS_ENV: test\n      run: bundle exec rspec\n```\n\n#### Extending PATH\n\nIf your hook is run by GUI program, and you use some PATH tweaks in your ~/.<shell>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.\n\n```yml\n# lefthook.yml\n\npre-commit:\n  commands:\n    test:\n      run: yarn test\n```\n\n```yml\n# lefthook-local.yml\n\npre-commit:\n  commands:\n    test:\n      env:\n        PATH: $PATH:/home/me/path/to/yarn\n```\n\n**Notes**\n\nThis option is useful when using lefthook on different OSes or shells where ENV variables are set in different ways.\n"
  },
  {
    "path": "docs/configuration/exclude.md",
    "content": "---\ntitle: \"exclude\"\n---\n\n# `exclude`\n\nThis option allows to setup a list of globs for files to be excluded in files template.\n\n::: callout info Note\nThe 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.\n:::\n\n#### Example\n\nRun Rubocop on staged files with `.rb` extension except for `application.rb`, `routes.rb`, `rails_helper.rb`, and all Ruby files in `config/initializers/`.\n\n```yml\n# lefthook.yml\n\npre-commit:\n  jobs:\n    - name: lint\n      glob: \"*.rb\"\n      exclude:\n        - config/routes.rb\n        - config/application.rb\n        - config/initializers/*.rb\n        - spec/rails_helper.rb\n      run: bundle exec rubocop --force-exclusion -- {staged_files}\n```\n\nIf 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.\n\n```yml\n# lefthook.yml\n\npre-commit:\n  exclude:\n    - \"*/application.rb\"\n  jobs:\n    - name: lint\n      run: bundle exec rubocop # will skip if only application.rb was staged\n```\n"
  },
  {
    "path": "docs/configuration/exclude_tags.md",
    "content": "---\ntitle: \"exclude_tags\"\n---\n\n# `exclude_tags`\n\n[Tags](./tags.md) or command names that you want to exclude. This option can be overwritten with `LEFTHOOK_EXCLUDE` env variable.\n\n#### Example\n\n```yml\n# lefthook.yml\n\npre-commit:\n  exclude_tags: frontend\n  commands:\n    lint:\n      tags: frontend\n      ...\n    test:\n      tags: frontend\n      ...\n    check-syntax:\n      tags: documentation\n```\n\n```bash\nlefthook run pre-commit # will only run check-syntax command\n```\n\n**Notes**\n\nThis option is good to specify in `lefthook-local.yml` when you want to skip some execution locally.\n\n```yml\n# lefthook.yml\n\npre-push:\n  commands:\n    packages-audit:\n      tags:\n        - frontend\n        - security\n      run: yarn audit\n    gems-audit:\n      tags:\n        - backend\n        - security\n      run: bundle audit\n```\n\nYou can skip commands by tags:\n\n```yml\n# lefthook-local.yml\n\npre-push:\n  exclude_tags:\n    - frontend\n```\n"
  },
  {
    "path": "docs/configuration/extends.md",
    "content": "---\ntitle: \"extends\"\n---\n\n# `extends`\n\nYou 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.\n\nYou can use asterisk to make a glob.\n\n#### Example\n\n```yml\n# lefthook.yml\n\nextends:\n  - /home/user/work/lefthook-extend.yml\n  - /home/user/work/lefthook-extend-2.yml\n  - lefthook-extends/file.yml\n  - ../extend.yml\n  - projects/*/specific-lefthook-config.yml\n```\n\n> The extends will be merged to the main configuration in your file. Here is the order of settings applied:\n>\n> - `lefthook.yml` – main config file\n> - `extends` – configs specified in [extends](./extends.md) option\n> - `remotes` – configs specified in [remotes](./remotes.md) option\n> - `lefthook-local.yml` – local config file\n>\n> So, `extends` override settings from `lefthook.yml`, `remotes` override `extends`, and `lefthook-local.yml` can override everything.\n"
  },
  {
    "path": "docs/configuration/fail_on_changes.md",
    "content": "---\ntitle: \"fail_on_changes\"\n---\n\n# `fail_on_changes`\n\nThe behaviour of lefthook when files (tracked by git) are modified can set by modifying the `fail_on_changes` configuration parameter. The possible values are:\n\n- `never`: never exit with a non-zero status if files were modified (default).\n- `always`: always exit with a non-zero status if files were modified.\n- `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.\n- `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).\n\nSee also [`fail_on_changes_diff`](./fail_on_changes_diff.md).\n\n```yml\n# lefthook.yml\npre-commit:\n  parallel: true\n  fail_on_changes: \"always\"\n  commands:\n    lint:\n      run: yarn lint\n    test:\n      run: yarn test\n```\n"
  },
  {
    "path": "docs/configuration/fail_on_changes_diff.md",
    "content": "---\ntitle: \"fail_on_changes_diff\"\n---\n\n# `fail_on_changes_diff`\n\nWhen Lefthook exits with a non-zero status as a result of [`fail_on_changes`](./fail_on_changes.md) triggering,\nit can optionally output a diff of the detected changes.\n\nThe default behavior is to output the diff when run in a CI pipeline.\nThe `fail_on_changes_diff` boolean configuration parameter can be used to override this.\n\n```yml\n# lefthook.yml\npre-commit:\n  parallel: true\n  fail_on_changes: \"always\"\n  fail_on_changes_diff: true\n  commands:\n    lint:\n      run: yarn lint\n    test:\n      run: yarn test\n```\n"
  },
  {
    "path": "docs/configuration/fail_text.md",
    "content": "---\ntitle: \"fail_text\"\n---\n\n# `fail_text`\n\nYou can specify a text to show when the command or script fails.\n\n#### Example\n\n```yml\n# lefthook.yml\n\npre-commit:\n  commands:\n    lint:\n      run: yarn lint\n      fail_text: Add node executable to $PATH\n```\n\n```bash\n$ git commit -m 'fix: Some bug'\n\nLefthook v1.1.3\nRUNNING HOOK: pre-commit\n\n  EXECUTE > lint\n\nSUMMARY: (done in 0.01 seconds)\n🥊  lint: Add node executable to $PATH env\n```\n"
  },
  {
    "path": "docs/configuration/file_types.md",
    "content": "---\ntitle: \"file_types\"\n---\n\n# `file_types`\n\nFilter files in a [`run`](./run.md) templates by their type. Special file types and MIME types are supported[^1]:\n\n|File type| Explanation|\n|---------|-----------|\n|`text`   | Any file that contains text. Symlinks are not followed. |\n|`binary` | Any file that contains non-text bytes. Symlinks are not followed. |\n|`executable` | Any file that has executable bits set. Symlinks are not followed. |\n|`not executable` | Any file without executable bits in file mode. Symlinks included. |\n|`symlink` | A symlink file. |\n|`not symlink` | Any non-symlink file. |\n|`text/html` | An HTML file. |\n|`text/xml`  | An XML file. |\n|`text/javascript` | A Javascript file. |\n|`text/x-php` | A PHP file. |\n|`text/x-lua` | A Lua file. |\n|`text/x-perl` | A Perl file. |\n|`text/x-python` | A Python file. |\n|`text/x-shellscript` | Shell script file. |\n|`text/x-sh` | Also shell script file. |\n|`application/json` | JSON file. |\n\n> **Important**\n> The following types are applied using AND logic:\n> - text\n> - binary\n> - executable\n> - not executable\n> - symlink\n> - not symlink\n>\n> 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`.\n\n**Examples**\n\nApply some different linters on text and binary files.\n\n```yml\n# lefthook.yml\n\npre-commit:\n  commands:\n    lint-code:\n      run: yarn lint {staged_files}\n      file_types: text\n    check-hex-codes:\n      run: yarn check-hex {staged_files}\n      file_types: binary\n```\n\nSkip symlinks.\n\n```yml\n# lefthook.yml\n\npre-commit:\n  commands:\n    lint:\n      run: yarn lint --fix {staged_files}\n      file_types:\n        - not symlink\n```\n\nLint executable scripts.\n\n```yml\n# lefthook.yml\n\npre-commit:\n  commands:\n    lint:\n      run: yarn lint --fix {staged_files}\n      file_types:\n        - executable\n        - text\n```\n\nCheck typos in scripts.\n\n```yml\n# lefthook.yml\n\npre-commit:\n  jobs:\n    - run: typos -w -- {staged_files}\n      file_types:\n        - text/x-perl\n        - text/x-python\n        - text/x-php\n        - text/x-lua\n        - text/x-sh\n```\n\n[^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)\n"
  },
  {
    "path": "docs/configuration/files-global.md",
    "content": "---\ntitle: \"files (hook-level)\"\n---\n\n# `files`\n\nA 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).\n\nIf the result of this command is empty, the execution of commands will be skipped.\n\n#### Example\n\n```yml\n# lefthook.yml\n\npre-commit:\n  files: git diff --name-only master # custom list of files\n  commands:\n    ...\n```\n"
  },
  {
    "path": "docs/configuration/files.md",
    "content": "---\ntitle: \"files (job-level)\"\n---\n\n# `files`\n\nA custom command executed by the `sh` shell that returns the files or directories to be referenced in `{files}` template for [`run`](./run.md) setting.\n\nIf the result of this command is empty, the execution of commands will be skipped.\n\nThis option overwrites the [hook-level `files`](./files-global.md) option.\n\n#### Example\n\nProvide a git command to list files.\n\n```yml\n# lefthook.yml\n\npre-push:\n  commands:\n    stylelint:\n      tags:\n        - frontend\n        - style\n      files: git diff --name-only master\n      glob: \"*.js\"\n      run: yarn stylelint {files}\n```\n\nCall a custom script for listing files.\n\n```yml\n# lefthook.yml\n\npre-push:\n  commands:\n    rubocop:\n      tags: backend\n      glob: \"**/*.rb\"\n      files: node ./lefthook-scripts/ls-files.js # you can call your own scripts\n      run: bundle exec rubocop --force-exclusion --parallel -- {files}\n```\n"
  },
  {
    "path": "docs/configuration/follow.md",
    "content": "---\ntitle: \"follow\"\n---\n\n# `follow`\n\n**Default: `false`**\n\nFollow the STDOUT of the running commands and scripts.\n\n#### Example\n\n```yml\n# lefthook.yml\n\npre-push:\n  follow: true\n  commands:\n    backend-tests:\n      run: bundle exec rspec\n    frontend-tests:\n      run: yarn test\n```\n\n::: callout info Note\nIf used with [`parallel`](#parallel) the output can be a mess, so please avoid setting both options to `true`\n:::\n"
  },
  {
    "path": "docs/configuration/git_url.md",
    "content": "---\ntitle: \"git_url\"\n---\n\n# `git_url`\n\nA URL to Git repository. It will be accessed with privileges of the machine lefthook runs on.\n\n#### Example\n\n```yml\n# lefthook.yml\n\nremotes:\n  - git_url: git@github.com:evilmartians/lefthook\n```\n\nOr\n\n```yml\n# lefthook.yml\n\nremotes:\n  - git_url: https://github.com/evilmartians/lefthook\n```\n"
  },
  {
    "path": "docs/configuration/glob.md",
    "content": "---\ntitle: \"glob\"\n---\n\n# `glob`\n\nYou 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.\n\n#### Example\n\n```yml\n# lefthook.yml\n\npre-commit:\n  jobs:\n    - name: lint\n      run: yarn eslint {staged_files}\n      glob: \"*.{js,ts,jsx,tsx}\"\n```\n\n::: callout info Note\nFrom lefthook version `1.10.10` you can also provide a list of globs:\n\n```yml\n# lefthook.yml\n\npre-commit:\n  jobs:\n    - run: yarn lint {staged_files}\n      glob:\n        - \"*.ts\"\n        - \"*.js\"\n```\n:::\n\nFor 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.\n\n**When using `root:`**\n\nGlobs are still calculated from the actual root of the git repo, `root` is ignored.\n\n**Behaviour of `**`**\n\nNote 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.\nIf you want to match *both* files at the top level and nested, then rather than:\n\n```yaml\nglob: \"src/**/*.js\"\n```\n\nYou'll need:\n\n```yaml\nglob: \"src/*.js\"\n```\n\nAlternatively, 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.\n\n**Using `glob` without a files template in`run`**\n\nIf 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.\n\n```yml\n# lefthook.yml\n\npre-commit:\n  jobs:\n    - name: lint\n      run: npm run lint # skipped if no .js files staged\n      glob: \"*.js\"\n```\n"
  },
  {
    "path": "docs/configuration/glob_matcher.md",
    "content": "---\ntitle: \"glob_matcher\"\n---\n\n# `glob_matcher`\n\nYou 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.\n\n**Values:**\n- `gobwas` (default): The current glob implementation\n- `doublestar`: Standard glob behavior where `**` matches 0 or more directories\n\n**Example:**\n\n```yml\n# lefthook.yml\n\nglob_matcher: doublestar\n\npre-commit:\n  jobs:\n    - name: lint\n      run: yarn eslint {staged_files}\n      glob: \"**/*.{js,ts}\"\n```\n\n### Key Differences\n\nThe main difference between the two matchers is how they handle `**`:\n\n#### Default behavior (`gobwas`)\n\nThe `**` pattern matches **1 or more** directories:\n- `**/*.js` matches `folder/file.js`, `a/b/c/file.js`\n- `**/*.js` does **NOT** match `file.js` at the root level\n\n#### Standard behavior (`doublestar`)\n\nThe `**` pattern matches **0 or more** directories:\n- `**/*.js` matches `file.js`, `folder/file.js`, `a/b/c/file.js`\n- This is consistent with most glob implementations\n\n### When to Use\n\n**Use `glob_matcher: doublestar` when:**\n- You want standard glob behavior consistent with other tools\n- You need `**` to match files at any level including the root\n- You're migrating from other tools that use standard glob patterns\n\n**Keep the default (`gobwas`) when:**\n- You want to maintain existing behavior\n- You specifically need `**` to require at least one directory level\n- You have existing patterns that depend on the current behavior\n\n### Example Comparison\n\n```yml\n# With default (gobwas)\nglob_matcher: gobwas  # or omit this line\n\npre-commit:\n  jobs:\n    - run: eslint -- {staged_files}\n      glob: \"**/*.js\"\n      # Matches: src/app.js, lib/util.js\n      # Does NOT match: app.js\n\n    - run: eslint -- {staged_files}\n      glob: \"*.js\"\n      # Matches: app.js\n      # Does NOT match: src/app.js\n```\n\n```yml\n# With doublestar\nglob_matcher: doublestar\n\npre-commit:\n  jobs:\n    - run: eslint -- {staged_files}\n      glob: \"**/*.js\"\n      # Matches: app.js, src/app.js, lib/util.js\n```\n\n### Notes\n\n- The `glob_matcher` setting is global and applies to all `glob` and `exclude` patterns in your configuration\n- This setting does not affect `root` or other path-related options\n- The setting is fully backward compatible - existing configurations continue to work without modification\n"
  },
  {
    "path": "docs/configuration/group.md",
    "content": "---\ntitle: \"group\"\n---\n\n# `group`\n\nYou can define a group of jobs and configure how they should execute using the following options:\n\n- [`parallel`](./parallel.md): Executes all jobs in the group simultaneously.\n- [`piped`](./piped.md): Executes jobs sequentially, passing output between them.\n- [`jobs`](./jobs.md): Specifies the jobs within the group.\n\n### Example\n\n```yml\n# lefthook.yml\n\npre-commit:\n  jobs:\n    - group:\n        parallel: true\n        jobs:\n          - run: echo 1\n          - run: echo 2\n          - run: echo 3\n```\n\nIf you specify `env`, `root`, `glob`, or `exclude` on a group, they will be inherited to the underlying jobs.\n\n```yml\n# lefthook.yml\n\npre-commit:\n  jobs:\n    - env:\n        E1: hello\n      glob:\n        - \"*.md\"\n      exclude:\n        - \"README.md\"\n      root: \"subdir/\"\n      group:\n        parallel: true\n        jobs:\n          - run: echo $E1\n          - run: echo $E1\n            env:\n              E1: bonjour\n```\n\n::: callout info Note\nTo make a group mergeable with settings defined in local config or extends you have to specify the name of the job group belongs to:\n```yml\npre-commit:\n  jobs:\n    - name: a name of a group\n      group:\n        jobs:\n          - name: lint\n            run: yarn lint\n          - name: test\n            run: yarn test\n```\n:::\n"
  },
  {
    "path": "docs/configuration/install_non_git_hooks.md",
    "content": "---\ntitle: \"install_non_git_hooks\"\n---\n\n# `install_non_git_hooks`\n\n> Since lefthook 2.0.17\n\nInstall non-Git hooks into `.git/hooks`. May be useful for using with tools like https://git-flow.sh/.\n"
  },
  {
    "path": "docs/configuration/interactive.md",
    "content": "---\ntitle: \"interactive\"\n---\n\n# `interactive`\n\n**Default: `false`**\n\n::: callout info Note\nIf 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.\n:::\n\n\nWhether to use interactive mode. This applies the certain behavior:\n- All `interactive` commands/scripts are executed after non-interactive. Exception: [`piped`](./piped.md) option is set to `true`.\n- When executing, lefthook tries to open /dev/tty (Linux/Unix only) and use it as stdin.\n- When [`no_tty`](./no_tty.md) option is set, `interactive` is ignored.\n"
  },
  {
    "path": "docs/configuration/jobs.md",
    "content": "---\ntitle: \"jobs\"\n---\n\n# `jobs`\n\n::: callout tip New feature\nAdded in lefthook `1.10.0`\n:::\n\nJobs provide a flexible way to define tasks, supporting both commands and scripts. Jobs can be grouped for advanced flow control.\n\n### Basic example\n\nDefine jobs in your `lefthook.yml` file under a specific hook like `pre-commit`:\n\n```yml\n# lefthook.yml\n\npre-commit:\n  jobs:\n    - run: yarn lint\n    - run: yarn test\n```\n\n### Differences from Commands and Scripts\n\n**Optional Job Names**\n\n- Named jobs are merged across [`extends`](./extends.md) and local config.\n- Unnamed jobs are appended in the order of their definition.\n\n**Job Groups**\n\n- Groups can include other jobs.\n- Flow within groups can be parallel or piped. Options `glob`, `root`, and `exclude` apply to all jobs in the group, including nested ones.\n\n### Example\n\n::: callout info Note\nCurrently, 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.\n:::\n\nA configuration demonstrating a piped group running in parallel with other jobs:\n\n```yml\n# lefthook.yml\n\npre-commit:\n  parallel: true\n  jobs:\n    - name: migrate\n      root: backend/\n      glob: \"db/migrations/*\"\n      group:\n        piped: true\n        jobs:\n          - run: bundle install\n          - run: rails db:migrate\n    - run: yarn lint --fix {staged_files}\n      root: frontend/\n      stage_fixed: true\n    - run: bundle exec rubocop\n      root: backend/\n    - run: golangci-lint\n      root: proxy/\n    - script: verify.sh\n      runner: bash\n```\n\nThis configuration runs migrate jobs in a piped flow while other jobs execute in parallel.\n"
  },
  {
    "path": "docs/configuration/lefthook.md",
    "content": "---\ntitle: \"lefthook\"\n---\n\n# `lefthook`\n\n**Default:** `null`\n\n::: callout tip New feature\nAdded in lefthook `1.10.5`\n:::\n\nProvide a full path to lefthook executable or a command to run lefthook. Bourne shell (`sh`) syntax is supported.\n\n> **Important:** This option does not merge from `remotes` or `extends` for security reasons. But it gets merged from lefthook local config if specified.\n\nThere are three reasons you may want to specify `lefthook`:\n\n1. You want to force using specific lefthook version from your dependencies (e.g. npm package)\n1. You use PnP loader for your JS/TS project, and your `package.json` with lefthook dependency locates in a subfolder\n1. You want to make sure you use concrete lefthook executable path and want to defined it in `lefthook-local.yml`\n\n### Examples\n\n#### Specify lefthook executable\n\n```yml\n# lefthook.yml\n\nlefthook: /usr/bin/lefthook\n\npre-commit:\n  jobs:\n    - run: yarn lint\n```\n\n#### Specify a command to run lefthook\n\n```yml\n# lefthook.yml\n\nlefthook: |\n  cd project-with-lefthook\n  pnpm lefthook\n\npre-commit:\n  jobs:\n    - run: yarn lint\n      root: project-with-lefthook\n```\n\n#### Force using a version from Rubygems\n\n```yml\n# lefthook.yml\n\nlefthook: bundle exec lefthook\n\npre-commit:\n  jobs:\n    - run: bundle exec rubocop -- {staged_files}\n```\n\n#### Enable debug logs\n\n```yml\n# lefthook-local.yml\n\nlefthook: LEFTHOOK_VERBOSE=1 lefthook\n```\n"
  },
  {
    "path": "docs/configuration/min_version.md",
    "content": "---\ntitle: \"min_version\"\n---\n\n# `min_version`\n\nIf 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.\n\n#### Example\n\n```yml\n# lefthook.yml\n\nmin_version: 1.1.3\n```\n"
  },
  {
    "path": "docs/configuration/name.md",
    "content": "---\ntitle: \"name\"\n---\n\n# `name`\n\nName 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).\n\n### Example\n\n```yml\n# lefthook.yml\n\npre-commit:\n  jobs:\n    - name: lint and fix\n      run: yarn run eslint --fix {staged_files}\n```\n"
  },
  {
    "path": "docs/configuration/no_auto_install.md",
    "content": "---\ntitle: \"no_auto_install\"\n---\n\n# `no_auto_install`\n\n**Default: `false`**\n\nDisable 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.\n\nThis can also be controlled with the `--no-auto-install` option for the `lefthook run` command.\n\n#### Example\n\n```yml\n# lefthook.yml\n\nno_auto_install: true\n\npre-commit:\n  commands:\n    lint:\n      run: npm run lint\n```\n"
  },
  {
    "path": "docs/configuration/no_tty.md",
    "content": "---\ntitle: \"no_tty\"\n---\n\n# `no_tty`\n\n**Default: `false`**\n\nWhether hide spinner and other interactive things. This can be also controlled with `--no-tty` option for `lefthook run` command.\n\n#### Example\n\n```yml\n# lefthook.yml\n\nno_tty: true\n```\n"
  },
  {
    "path": "docs/configuration/only.md",
    "content": "---\ntitle: \"only\"\n---\n\n# `only`\n\nYou 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.\n\n::: callout info Note\n`skip` option takes precedence over `only` option, so if you have conflicting conditions the execution will be skipped.\n:::\n\n#### Example\n\nExecute a hook only for `dev/*` branches.\n\n```yml\n# lefthook.yml\n\npre-commit:\n  only:\n    - ref: dev/*\n  commands:\n    lint:\n      run: yarn lint\n    test:\n      run: yarn test\n```\n\nWhen rebasing execute quick linter but skip usual linter and tests.\n\n```yml\n# lefthook.yml\n\npre-commit:\n  commands:\n    lint:\n      skip: rebase\n      run: yarn lint\n    test:\n      skip: rebase\n      run: yarn test\n    lint-on-rebase:\n      only: rebase\n      run: yarn lint-quickly\n```\n"
  },
  {
    "path": "docs/configuration/output.md",
    "content": "---\ntitle: \"output\"\n---\n\n# `output`\n\nYou 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\n\nPossible values are `meta,summary,success,failure,execution,execution_out,execution_info,skips`.\nBy default, all output values are enabled\n\nYou can also disable all output with setting `output: false`. In this case only errors will be printed.\n\n#### Example\n\n```yml\n# lefthook.yml\n\noutput:\n  - meta           # Print lefthook version\n  - summary        # Print summary block (successful and failed steps)\n  - empty_summary  # Print summary heading when there are no steps to run\n  - success        # Print successful steps\n  - failure        # Print failed steps printing\n  - execution      # Print any execution logs\n  - execution_out  # Print execution output\n  - execution_info # Print `EXECUTE > ...` logging\n  - skips          # Print \"skip\" (i.e. no files matched)\n```\n\nYou can also *extend* this list with an environment variable `LEFTHOOK_OUTPUT`:\n\n```bash\nLEFTHOOK_OUTPUT=\"meta,success,summary\" lefthook run pre-commit\n```\n"
  },
  {
    "path": "docs/configuration/parallel.md",
    "content": "---\ntitle: \"parallel\"\n---\n\n# `parallel`\n\n**Default: `false`**\n\n::: callout info Note\nLefthook runs commands and scripts **sequentially** by default\n:::\n\nRun commands and scripts concurrently.\n"
  },
  {
    "path": "docs/configuration/piped.md",
    "content": "---\ntitle: \"piped\"\n---\n\n# `piped`\n\n**Default: `false`**\n\n::: callout info Note\nLefthook will return an error if both `piped: true` and `parallel: true` are set\n:::\n\nStop running commands and scripts if one of them fail.\n\n#### Example\n\n```yml\n# lefthook.yml\n\ndatabase:\n  piped: true # Stop if one of the steps fail\n  commands:\n    1_create:\n      run: rake db:create\n    2_migrate:\n      run: rake db:migrate\n    3_seed:\n      run: rake db:seed\n```\n"
  },
  {
    "path": "docs/configuration/priority.md",
    "content": "---\ntitle: \"priority\"\n---\n\n# `priority`\n\n**Default: `0`**\n\n::: callout info Note\nThis option makes sense only when `parallel: false` or `piped: true` is set.\n\nValue `0` is considered an `+Infinity`, so commands or scripts with `priority: 0` or without this setting will be run at the very end.\n:::\n\nSet priority from 1 to +Infinity. This option can be used to configure the order of the sequential steps.\n\n#### Example\n\n```yml\n# lefthook.yml\n\npost-checkout:\n  piped: true\n  commands:\n    db-create:\n      priority: 1\n      run: rails db:create\n    db-migrate:\n      priority: 2\n      run: rails db:migrate\n    db-seed:\n      priority: 3\n      run: rails db:seed\n\n  scripts:\n    \"check-spelling.sh\":\n      runner: bash\n      priority: 1\n    \"check-grammar.rb\":\n      runner: ruby\n      priority: 2\n```\n"
  },
  {
    "path": "docs/configuration/rc.md",
    "content": "---\ntitle: \"rc\"\n---\n\n# `rc`\n\nProvide 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.\n\n#### Example\n\nUse cases:\n\n- You have a GUI program that runs git hooks (e.g., VSCode)\n- You reference executables that are accessible only from a tweaked $PATH environment variable (e.g., when using rbenv or nvm, fnm)\n- Or even if your GUI program cannot locate the `lefthook` executable :scream:\n- Or if you want to use ENV variables that control the executables behavior in `lefthook.yml`\n\n```bash\n# An npm executable which is managed by nvm\n$ which npm\n/home/user/.nvm/versions/node/v15.14.0/bin/npm\n```\n\n```yml\n# lefthook.yml\n\npre-commit:\n  commands:\n    lint:\n      run: npm run eslint {staged_files}\n```\n\nProvide a tweak to access `npm` executable the same way you do it in your ~/<shell>rc.\n\n```yml\n# lefthook-local.yml\n\n# You can choose whatever name you want.\n# You can share it between projects where you use lefthook.\n# Make sure the path is absolute.\nrc: ~/.lefthookrc\n```\n\nOr\n\n```yml\n# lefthook-local.yml\n\n# If the path contains spaces, you need to quote it.\nrc: '\"${XDG_CONFIG_HOME:-$HOME/.config}/lefthookrc\"'\n```\n\nIn the rc file, export any new environment variables or modify existing ones.\n\n```bash\n# ~/.lefthookrc\n\n# An nvm way\nexport NVM_DIR=\"$HOME/.nvm\"\n[ -s \"$NVM_DIR/nvm.sh\" ] && \\. \"$NVM_DIR/nvm.sh\"\n\n# An fnm way\nexport FNM_DIR=\"$HOME/.fnm\"\n[ -s \"$FNM_DIR/fnm.sh\" ] && \\. \"$FNM_DIR/fnm.sh\"\n\n# Or maybe just\nPATH=$PATH:$HOME/.nvm/versions/node/v15.14.0/bin\n```\n\n```bash\n# Make sure you updated git hooks. This is important.\n$ lefthook install -f\n```\n\nNow any program that runs your hooks will have a tweaked PATH environment variable and will be able to get `nvm` :wink:\n"
  },
  {
    "path": "docs/configuration/ref.md",
    "content": "---\ntitle: \"ref\"\n---\n\n# `ref`\n\nAn optional *branch* or *tag* name.\n\n::: callout info Note\nIf 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.\n:::\n\nSee also [`refetch_frequency`](./refetch_frequency.md).\n\n#### Example\n\n```yml\n# lefthook.yml\n\nremotes:\n  - git_url: git@github.com:evilmartians/lefthook\n    ref: v1.0.0\n```\n"
  },
  {
    "path": "docs/configuration/refetch.md",
    "content": "---\ntitle: \"refetch\"\n---\n\n# `refetch`\n\n**Default:** `false`\n\nForce remote config refetching on every run. Lefthook will be refetching the specified remote every time it is called.\n\nSee [`refetch_frequency`](./refetch_frequency.md) for more flexible refetching options and additional considerations.\n\n#### Example\n\n```yml\n# lefthook.yml\n\nremotes:\n  - git_url: https://github.com/evilmartians/lefthook\n    refetch: true\n```\n"
  },
  {
    "path": "docs/configuration/refetch_frequency.md",
    "content": "---\ntitle: \"refetch_frequency\"\n---\n\n# `refetch_frequency`\n\n**Default:** Not set\n\nSpecifies how frequently Lefthook should refetch the remote configuration. This can be set to `always`, `never` or a time duration like `24h`, `30m`, etc.\n\n- When set to `always`, Lefthook will always refetch the remote configuration on each run.\n- 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.\n- When set to `never` or not set, Lefthook will not fetch from remote.\n\nIt is recommended to configure remotes that point to mutable references\n(including ones without a `ref`) to be refetched with some frequency appropriate for the project.\n\nFailure to fetch does not cause an error, but just a warning message.\nIf a successfully fetched previous configuration exists, it will be used.\nOtherwise, the remote will be ignored.\n\n#### Example\n\n```yml\n# lefthook.yml\n\nremotes:\n  - git_url: https://github.com/evilmartians/lefthook\n    refetch_frequency: 24h # Refetches once every 24 hours\n```\n\n> WARNING\n> If `refetch` is set to `true`, it overrides any setting in `refetch_frequency`.\n"
  },
  {
    "path": "docs/configuration/remotes.md",
    "content": "---\ntitle: \"remotes\"\n---\n\n# `remotes`\n\nYou 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`.\n\nYou can use [`extends`](./extends.md) but the paths must be relative to the remote repository root.\n\nIf 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**.\n\n**Note**\n\nThe configuration from `remotes` will be merged to the local config using the following priority:\n\n1. Local main config (`lefthook.yml`)\n1. Remote configs (`remotes`)\n1. Local overrides (`lefthook-local.yml`)\n\nThis priority may be changed in the future. For simplicity, try to keep jobs in remote settings independent from any other steps.\n"
  },
  {
    "path": "docs/configuration/root.md",
    "content": "---\ntitle: \"root\"\n---\n\n# `root`\n\nYou can change the CWD for the command you execute using `root` option.\n\nThis is useful when you execute some `npm` or `yarn` command but the `package.json` is in another directory.\n\nFor `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.\n\n#### Example\n\nFormat and stage files from a `client/` folder.\n\n```bash\n# Folders structure\n\n$ tree .\n.\n├── client/\n│   ├── package.json\n│   ├── node_modules/\n|   ├── ...\n├── server/\n|   ...\n```\n\n```yml\n# lefthook.yml\n\npre-commit:\n  commands:\n    lint:\n      root: \"client/\"\n      glob: \"*.{js,ts}\"\n      run: yarn eslint --fix {staged_files} && git add {staged_files}\n```\n\n**When using `root:`**\n\nGlobs are still calculated from the actual root of the git repo, `root` is ignored.\n"
  },
  {
    "path": "docs/configuration/run.md",
    "content": "---\ntitle: \"run\"\n---\n\n# `run`\n\nThis is a mandatory option for a command, which specifies the actual command to be run using the `sh` shell.\n\nYou can use files templates that will be substituted with the appropriate files on execution:\n\n- `{files}` - custom [`files`](./files.md) command result.\n- `{staged_files}` - staged files which you try to commit.\n- `{push_files}` - files that are committed but not pushed.\n- `{all_files}` - all files tracked by git.\n- `{cmd}` - shorthand for the command from `lefthook.yml`.\n- `{0}` - shorthand for the single space-joint string of git hook arguments.\n- `{1}` - shorthand for the 1-st git hook argument (and so on for `{2}`, `{3}`, etc.)\n- `{lefthook_job_name}` - current job/command/script name\n\n::: callout info Note\nCommand 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.\n:::\n\n#### Example\n\nRun `yarn lint` on `pre-commit` hook.\n\n```yml\n# lefthook.yml\n\npre-commit:\n  commands:\n    lint:\n      run: yarn lint\n```\n\n#### `{files}` template\n\nRun `go vet` only on files listed with `git ls-files -m` command with `.go` extension.\n\n```yml\n# lefthook.yml\n\npre-commit:\n  commands:\n    govet:\n      files: git ls-files -m\n      glob: \"*.go\"\n      run: go vet -- {files}\n```\n\n#### `{staged_files}`\n\nRun `yarn eslint` only on staged files with `.js`, `.ts`, `.jsx`, and `.tsx` extensions.\n\n```yml\n# lefthook.yml\n\npre-commit:\n  commands:\n    eslint:\n      glob: \"*.{js,ts,jsx,tsx}\"\n      run: yarn eslint {staged_files}\n```\n\n#### `{push_files}`\n\nIf you want to lint files only before pushing them.\n\n```yml\n# lefthook.yml\n\npre-push:\n  commands:\n    eslint:\n      glob: \"*.{js,ts,jsx,tsx}\"\n      run: yarn eslint {push_files}\n```\n\n#### `{all_files}`\n\nSimply run `bundle exec rubocop` on all files with `.rb` extension excluding `application.rb` and `routes.rb` files.\n\n::: callout info Note\n`--force-exclusion` will apply `Exclude` configuration setting of Rubocop\n:::\n\n```yml\n# lefthook.yml\n\npre-commit:\n  commands:\n    rubocop:\n      tags:\n        - backend\n        - style\n      glob: \"*.rb\"\n      exclude:\n        - config/application.rb\n        - config/routes.rb\n      run: bundle exec rubocop --force-exclusion -- {all_files}\n```\n\n#### `{cmd}`\n\n```yml\n# lefthook.yml\n\npre-commit:\n  commands:\n    lint:\n      run: yarn lint\n  scripts:\n    \"good_job.js\":\n      runner: node\n```\n\nYou can wrap it in docker runner locally:\n\n```yml\n# lefthook-local.yml\n\npre-commit:\n  commands:\n    lint:\n      run: docker run -it --rm <container_id_or_name> {cmd}\n  scripts:\n    \"good_job.js\":\n      runner: docker run -it --rm <container_id_or_name> {cmd}\n```\n\n#### Git arguments\n\nMake sure commits are signed.\n\n```yml\n# lefthook.yml\n\n# Note: commit-msg hook takes a single parameter,\n#       the name of the file that holds the proposed commit log message.\n# Source: https://git-scm.com/docs/githooks#_commit_msg\ncommit-msg:\n  commands:\n    multiple-sign-off:\n      run: 'test $(grep -c \"^Signed-off-by: \" {1}) -lt 2'\n```\n\n#### Rubocop\n\nIf using `{all_files}` with RuboCop, it will ignore RuboCop's `Exclude` configuration setting. To avoid this, pass `--force-exclusion`.\n\n#### Quotes\n\nIf you want to have all your files quoted with double quotes `\"` or single quotes `'`, quote the appropriate shorthand:\n\n```yml\n# lefthook.yml\n\npre-commit:\n  commands:\n    lint:\n      glob: \"*.js\"\n      # Quoting with double quotes `\"` might be helpful for Windows users\n      run: yarn eslint \"{staged_files}\" # will run `yarn eslint \"file1.js\" \"file2.js\" \"[strange name].js\"`\n    test:\n      glob: \"*.{spec.js}\"\n      run: yarn test '{staged_files}' # will run `yarn eslint 'file1.spec.js' 'file2.spec.js' '[strange name].spec.js'`\n    format:\n      glob: \"*.js\"\n      # Will quote where needed with single quotes\n      run: yarn test {staged_files} # will run `yarn eslint file1.js file2.js '[strange name].spec.js'`\n```\n\n#### Scripts\n\n```yml\n# lefthook.yml\n\npre-commit:\n  jobs:\n    - name: a whole script in a run\n      run: |\n        for file in $(ls .); do\n          yarn lint $file\n        done\n```\n"
  },
  {
    "path": "docs/configuration/runner.md",
    "content": "---\ntitle: \"runner\"\n---\n\n# `runner`\n\nYou should specify a runner for the script. This is a command that should execute a script file. It will be called the following way: `<runner> <path-to-script>` (e.g. `ruby .lefthook/pre-commit/lint.rb`).\n\n#### Example\n\n```yml\n# lefthook.yml\n\npre-commit:\n  scripts:\n    \"lint.js\":\n      runner: node\n    \"check.go\":\n      runner: go run\n```\n"
  },
  {
    "path": "docs/configuration/script.md",
    "content": "---\ntitle: \"script\"\n---\n\n# `script`\n\nName of a script to execute. The rules are the same as for [`scripts`](./Scripts.md)\n\n### Example\n\n```yml\n# lefthook.yml\n\npre-commit:\n  jobs:\n    - script: linter.sh\n      runner: bash\n```\n\n```bash\n# .lefthook/pre-commit/linter.sh\n\necho \"Everything is OK\"\n```\n"
  },
  {
    "path": "docs/configuration/setup.md",
    "content": "---\ntitle: 'setup'\n---\n\n# `setup`\n\n::: callout tip New feature\nAdded in lefthook `2.1.2`\n:::\n\nA list of instructions to run before any job. Supports templates and Git args like in [`run`](./run.md).\n\n::: callout info Note\nWhen 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.\n:::\n\n#### Example\n\n```yml\n# lefthook.yml\n\npre-commit:\n  setup:\n    - run: |\n        if ! command -v golangci-lint >/dev/null 2>&1; then\n          go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.10.1\n        fi\n  jobs:\n    - run: golangci-lint -- {staged_files}\n      glob: \"*.go\"\n```\n"
  },
  {
    "path": "docs/configuration/skip.md",
    "content": "---\ntitle: \"skip\"\n---\n\n# `skip`\n\nYou 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.\n\nPossible skip values:\n- `rebase` - when in rebase git state\n- `merge` - when in merge git state\n- `merge-commit` - when current HEAD commit is the merge commit\n- `ref: main` - when on a `main` branch\n- `run: test ${SKIP_ME} -eq 1` - when `test ${SKIP_ME} -eq 1` is successful (return code is 0)\n\n#### Example\n\nAlways skipping a command:\n\n```yml\n# lefthook.yml\n\npre-commit:\n  commands:\n    lint:\n      skip: true\n      run: yarn lint\n```\n\nSkipping on merging and rebasing:\n\n```yml\n# lefthook.yml\n\npre-commit:\n  commands:\n    lint:\n      skip:\n        - merge\n        - rebase\n      run: yarn lint\n```\n\nOr\n\n```yml\n# lefthook.yml\n\npre-commit:\n  commands:\n    lint:\n      skip: merge\n      run: yarn lint\n```\n\nSkipping when your are on a merge commit:\n\n```yml\n# lefthook.yml\n\npre-push:\n  commands:\n    lint:\n      skip: merge-commit\n      run: yarn lint\n```\n\nSkipping the whole hook on `main` branch:\n\n```yml\n# lefthook.yml\n\npre-commit:\n  skip:\n    - ref: main\n  commands:\n    lint:\n      run: yarn lint\n    test:\n      run: yarn test\n```\n\nSkipping hook for all `dev/*` branches:\n\n```yml\n# lefthook.yml\n\npre-commit:\n  skip:\n    - ref: dev/*\n  commands:\n    lint:\n      run: yarn lint\n    test:\n      run: yarn test\n```\n\nSkipping hook by running a command:\n\n```yml\n# lefthook.yml\n\npre-commit:\n  skip:\n    - run: test \"${NO_HOOK}\" -eq 1\n  commands:\n    lint:\n      run: yarn lint\n    test:\n      run: yarn test\n```\n\nSkipping a command conditionally based on the existence of a CLI tool:\n\n```yml\nprepare-commit-msg:\n  skip:\n    - merge\n    - rebase\n  commands:\n    aiautocommit:\n      interactive: true\n      run: aiautocommit commit --output-file \"{1}\"\n      env:\n        LOG_LEVEL: info\n      skip:\n        # only run this if the tool exists\n        - run: \"! which aiautocommit\"\n```\n\n> TIP\n>\n> 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`.\n>\n> ```yml\n> # lefthook.yml\n>\n> pre-commit:\n>   commands:\n>     lint:\n>       run: yarn lint\n> ```\n>\n> ```yml\n> # lefthook-local.yml\n>\n> pre-commit:\n>   commands:\n>     lint:\n>       skip: true\n> ```\n"
  },
  {
    "path": "docs/configuration/skip_lfs.md",
    "content": "---\ntitle: \"skip_lfs\"\n---\n\n# `skip_lfs`\n\n**Default:** `false`\n\nSkip running LFS hooks even if it exists on your system.\n\n### Example\n\n```yml\n# lefthook.yml\n\nskip_lfs: true\n\npre-push:\n  commands:\n    test:\n      run: yarn test\n```\n"
  },
  {
    "path": "docs/configuration/source_dir.md",
    "content": "---\ntitle: \"source_dir\"\n---\n\n# `source_dir`\n\n**Default: `.lefthook/`**\n\nChange a directory for script files. Directory for script files contains folders with git hook names which contain script files.\n\nExample of directory tree:\n\n```\n.lefthook/\n├── pre-commit/\n│   ├── lint.sh\n│   └── test.py\n└── pre-push/\n    └── check-files.rb\n```\n\n"
  },
  {
    "path": "docs/configuration/source_dir_local.md",
    "content": "---\ntitle: \"source_dir_local\"\n---\n\n# `source_dir_local`\n\n**Default: `.lefthook-local/`**\n\nChange a directory for *local* script files (not stored in VCS).\n\nThis option is useful if you have a `lefthook-local.yml` config file and want to reference different scripts there.\n\n"
  },
  {
    "path": "docs/configuration/stage_fixed.md",
    "content": "---\ntitle: \"stage_fixed\"\n---\n\n# `stage_fixed`\n\n**Default: `false`**\n\n> Works **only for `pre-commit`** hook\n\nWhen 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.\n\n#### Example\n\n```yml\n# lefthook.yml\n\npre-commit:\n  commands:\n    lint:\n      run: npm run lint --fix {staged_files}\n      stage_fixed: true\n```\n"
  },
  {
    "path": "docs/configuration/tags.md",
    "content": "---\ntitle: \"tags\"\n---\n\n# `tags`\n\nYou can specify tags for commands and scripts. This is useful for [excluding](./exclude_tags.md). You can specify more than one tag using comma.\n\n#### Example\n\n```yml\n# lefthook.yml\n\npre-commit:\n  commands:\n    lint:\n      tags:\n        - frontend\n        - js\n      run: yarn lint\n    test:\n      tags:\n        - backend\n        - ruby\n      run: bundle exec rspec\n```\n"
  },
  {
    "path": "docs/configuration/templates.md",
    "content": "---\ntitle: \"templates\"\n---\n\n# `templates`\n\n::: callout tip New feature\nAdded in lefthook `1.10.8`\n:::\n\nProvide custom replacement for templates in `run` values.\n\nWith `templates` you can specify what can be overridden via `lefthook-local.yml` without a need to overwrite every jobs in your configuration.\n\n## Example\n\n### Override with lefthook-local.yml\n\n```yml\n# lefthook.yml\n\ntemplates:\n  dip: # empty\n\npre-commit:\n  jobs:\n    # Will run: `bundle exec rubocop -- file1 file2 file3 ...`\n    - run: {dip} bundle exec rubocop -- {staged_files}\n```\n\n```yml\n# lefthook-local.yml\n\ntemplates:\n  dip: dip # Will run: `dip bundle exec rubocop -- file1 file2 file3 ...`\n```\n\n### Reduce redundancy\n\n```yml\n# lefthook.yml\n\ntemplates:\n  wrapper: docker-compose run --rm -v $(pwd):/app service\n\npre-commit:\n  jobs:\n    - run: {wrapper} yarn format\n    - run: {wrapper} yarn lint\n    - run: {wrapper} yarn test\n```\n"
  },
  {
    "path": "docs/configuration/use_stdin.md",
    "content": "---\ntitle: \"use_stdin\"\n---\n\n# `use_stdin`\n\n::: callout info Note\nWith 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).\n:::\n\nPass the stdin from the OS to the command/script.\n\n#### Example\n\nUse 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.\n\n```bash\n# .lefthook/pre-push/do-the-magic.sh\n\nremote=\"$1\"\nurl=\"$2\"\n\nwhile read local_ref local_oid remote_ref remote_oid; do\n  # ...\ndone\n```\n\n```yml\n# lefthook.yml\npre-push:\n  scripts:\n    \"do-the-magic.sh\":\n      runner: bash\n      use_stdin: true\n```\n"
  },
  {
    "path": "docs/configuration.md",
    "content": "---\ntitle: \"Configuration\"\n---\n\n# Config file name\n\nLefthook supports the following file names for the main config:\n\n| Format | Acceptable config names |\n|-------|-----------|\n| YAML  |`lefthook.yml`<br />`lefthook.yaml`<br />`.lefthook.yml`<br />`.lefthook.yaml`<br />`.config/lefthook.yml`<br />`.config/lefthook.yaml` |\n| TOML  | `lefthook.toml` <br />`.lefthook.toml` <br />`.config/lefthook.toml` |\n| JSON  | `lefthook.json` <br />`.lefthook.json` <br />`.config/lefthook.json` |\n| JSONC | `lefthook.jsonc` <br />`.lefthook.jsonc` <br />`.config/lefthook.jsonc` |\n\nIf 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.\n\nFilenames without the leading dot will also be looked up from the [`.config` subdirectory](https://github.com/pi0/config-dir).\n\nLefthook 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`.\n\nThe `-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`.\n\n"
  },
  {
    "path": "docs/examples/commitlint.md",
    "content": "# Commitlint and commitzen\n\nUse lefthook to generate commit messages using commitzen and validate them with commitlint.\n\n## Install dependencies\n\n```bash\nyarn add -D @commitlint/cli @commitlint/config-conventional\n\n# For commitzen\nyarn add -D commitizen cz-conventional-changelog\n```\n\n## Configure\n\nSetup `commitlint.config.js`. Conventional configuration:\n\n```js\n// commitlint.config.js\n\nmodule.exports = {extends: ['@commitlint/config-conventional']};\n```\n\nIf you are using commitzen, make sure to add this in `package.json`:\n\n```json\n\"config\": {\n  \"commitizen\": {\n    \"path\": \"./node_modules/cz-conventional-changelog\"\n  }\n}\n```\n\nConfigure lefthook:\n\n```yml\n# lefthook.yml\n\n# Build commit messages\nprepare-commit-msg:\n  commands:\n    commitzen:\n      interactive: true\n      run: yarn run cz --hook # Or npx cz --hook\n      env:\n        LEFTHOOK: 0\n\n# Validate commit messages\ncommit-msg:\n  commands:\n    \"lint commit message\":\n      run: yarn run commitlint --edit {1}\n```\n\n\n## Test it\n\n```bash\n# You can type it without message, if you are using commitzen\ngit commit\n\n# Or provide a commit message is using only commitlint\ngit commit -am 'fix: typo'\n```\n"
  },
  {
    "path": "docs/examples/filters.md",
    "content": "# Filters\n\nFiles passed to your hooks can be filtered with the following options\n\n- [`glob`](../configuration/glob.md)\n- [`exclude`](../configuration/exclude.md)\n- [`file_types`](../configuration/file_types.md)\n- [`root`](../configuration/root.md)\n\nIn this example all **staged files** will pass through these filters.\n\n```yml\n# lefthook.yml\n\npre-commit:\n  commands:\n    lint:\n      run: yarn lint {staged_files} --fix\n      glob: \"*.{js,ts}\"\n      root: frontend\n      exclude:\n        - *.config.js\n        - *.config.ts\n      file_types:\n        - not executable\n```\n\nImagine you've staged the following files\n\n```bash\nbackend/asset.js\nfrontend/src/index.ts\nfrontend/bin/cli.js # <- executable\nfrontend/eslint.config.js\nfrontend/README.md\n```\n\nAfter all filters applied the `lint` command will execute the following:\n\n```bash\nyarn lint frontend/src/index.ts --fix\n```\n"
  },
  {
    "path": "docs/examples/lefthook-local.md",
    "content": "# lefthook-local.yml\n\n::: callout tip Tip\nYou can put `lefthook-local.yml` into your `~/.gitignore`, so in every project you can have your local-only overrides.\n:::\n\n`lefthook-local.yml` overrides and extends the configuration of your main `lefthook.yml`.\n\n```yml\n# lefthook.yml\n\npre-commit:\n  commands:\n    lint:\n      run: bundle exec rubocop -- {staged_files}\n      glob: \"*.rb\"\n    check-links:\n      run: lychee -- {staged_files}\n```\n\n```yml\n# lefthook-local.yml\n\npre-commit:\n  parallel: true # run all commands concurrently\n  commands:\n    lint:\n      run: docker-compose run backend {cmd} # wrap the original command with docker-compose\n    check-links:\n      skip: true # skip checking links\n\n# Add another hook\npost-merge:\n  files: \"git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD\"\n  commands:\n    dependencies:\n      glob: \"Gemfile*\"\n      run: docker-compose run backend bundle install\n```\n\n---\n\n### The merged config lefthook will use\n\n```yml\n\npre-commit:\n  parallel: true\n  commands:\n    lint:\n      run: docker-compose run backend bundle exec rubocop -- {staged_files}\n      glob: \"*.rb\"\n    check-links:\n      run: lychee -- {staged_files}\n      skip: true\n\npost-merge:\n  files: \"git diff-tree -r --name-only --no-commit-id ORIG_HEAD HEAD\"\n  commands:\n    dependencies:\n      glob: \"Gemfile*\"\n      run: docker-compose run backend bundle install\n```\n"
  },
  {
    "path": "docs/examples/remotes.md",
    "content": "# Remotes\n\nUse configurations from other Git repositories via `remotes` feature.\n\nLefthook will automatically download the remote config files and merge them into existing configuration.\n\n```yml\nremotes:\n  - git_url: https://github.com/evilmartians/lefthook\n    configs:\n      - examples/remote/ping.yml\n```\n"
  },
  {
    "path": "docs/examples/skip.md",
    "content": "# Skip or run on condition\n\nHere are two hooks.\n\n`pre-commit` hook will only be executed when you're committing something on a branch starting with `dev/` prefix.\n\nIn `pre-push` hook:\n- `test` command will be skipped if `NO_TEST` env variable is set to `1`\n- `lint` command will only be executed if you're pushing the `main` branch\n\n```yml\n# lefthook.yml\n\npre-commit:\n  only:\n    - ref: dev/*\n  commands:\n    lint:\n      run: yarn lint {staged_files} --fix\n      glob: \"*.{ts,js}\"\n    test:\n      run: yarn test\n\npre-push:\n  commands:\n    test:\n      run: yarn test\n      skip:\n        - run: test \"$NO_TEST\" -eq 1\n    lint:\n      run: yarn lint\n      only:\n        - ref: main\n```\n"
  },
  {
    "path": "docs/examples/stage_fixed.md",
    "content": "# Stage fixed files\n\n> Works only for `pre-commit` Git hook\n\nSometimes 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.\n\n```yml\n# lefthook.yml\n\npre-commit:\n  commands:\n    lint:\n      run: yarn lint {staged_files} --fix\n      stage_fixed: true\n```\n"
  },
  {
    "path": "docs/examples/wrap-commands.md",
    "content": "# Wrap commands in local config\n\nWrapping some commands defined in a main config with `dip`[^1].\n\n```yml\n# lefthook.yml\n\npre-commit:\n  jobs:\n    - name: rubocop\n      run: bundle exec rubocop -A -- {staged_files}\n```\n\n```yml\n# lefthook-local.yml\n\npre-commit:\n  jobs:\n    - name: rubocop\n      run: dip {cmd}\n```\n\n[^1]: [dip](https://github.com/bibendi/dip) – dockerized dev experience with, similar to `docker-compose run`\n"
  },
  {
    "path": "docs/index.md",
    "content": "---\ntitle: \"What is Lefthook?\"\ndescription: \"Welcome to Lefthook documentation\"\n---\n\n**Lefthook** is a Git hooks manager. It is\n\n- Fast\n- Powerful\n- Simple\n\n## How lefthook works?\n\nYou\n\n- Configure [`lefthook.yml`](./configuration.md)\n- Run `lefthook install`\n\nLefthook installs the configured hooks into `.git/hooks/`. Hook is a simple script that calls `lefthook run {hook-name}` when executed.\n\n## How to install lefthook?\n\nThe most common way is to use the package manager of your project, e.g. [gem](./installation/ruby.md) or [npm package](./installation/node.md).\n\nYou 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)\n\n## Example configuration\n\nRun linters on `pre-commit` hook.\n\n```yml\n# lefthook.yml\n\npre-commit:\n  parallel: true\n  jobs:\n    - run: yarn run stylelint --fix {staged_files}\n      glob: \"*.css\"\n      stage_fixed: true\n\n    - run: yarn run eslint --fix \"{staged_files}\"\n      glob:\n        - \"*.ts\"\n        - \"*.js\"\n        - \"*.tsx\"\n        - \"*.jsx\"\n      stage_fixed: true\n```\n\n---\n\n<a href=\"https://evilmartians.com/?utm_source=lefthook\">\n<img src=\"https://evilmartians.com/badges/sponsored-by-evil-martians.svg\" alt=\"Sponsored by Evil Martians\" width=\"100%\" height=\"54\"></a>\n"
  },
  {
    "path": "docs/install.md",
    "content": "---\ntitle: \"Install Lefthook\"\n---\n\nLefthook 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).\n\nYou 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\n\n```\nlefthook self-update\n```\n"
  },
  {
    "path": "docs/installation/alpine.md",
    "content": "---\ntitle: \"Alpine\"\n---\n\n# APK packages for Alpine\n\n```sh\nsudo apk add --no-cache bash curl\ncurl -1sLf 'https://dl.cloudsmith.io/public/evilmartians/lefthook/setup.alpine.sh' | sudo -E bash\nsudo apk add lefthook\n```\n\nSee all instructions: https://cloudsmith.io/~evilmartians/repos/lefthook/setup/#formats-alpine\n\n[![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\")\n"
  },
  {
    "path": "docs/installation/arch.md",
    "content": "---\ntitle: \"Arch Linux\"\n---\n\n# AUR for Arch\n\n- Official [AUR package](https://aur.archlinux.org/packages/lefthook) (compiles from sources)\n- Community [AUR package](https://aur.archlinux.org/packages/lefthook-bin) (delivers pre-compiled binaries)\n\n```sh\n# To compile from sources\nyay -S lefthook\n\n# To install only executable\nyay -S lefthook-bin\n```\n"
  },
  {
    "path": "docs/installation/deb.md",
    "content": "---\ntitle: \"Debian-based\"\n---\n\n# APT packages for Debian/Ubuntu Linux\n\n```sh\ncurl -1sLf 'https://dl.cloudsmith.io/public/evilmartians/lefthook/setup.deb.sh' | sudo -E bash\nsudo apt install lefthook\n```\nSee all instructions: https://cloudsmith.io/~evilmartians/repos/lefthook/setup/#formats-deb\n\n[![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\")\n\n"
  },
  {
    "path": "docs/installation/devbox.md",
    "content": "# Devbox\n\nAdd lefthook in the devbox environment.\nlefthook 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)\n\n```bash\ndevbox add lefthook@latest\n```\n\n::: callout info Note\nThe 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.\n:::\n"
  },
  {
    "path": "docs/installation/go.md",
    "content": "# Go\n\nThe minimum Go version required is 1.26 and you can install\n\n- as global package\n\n```bash\ngo install github.com/evilmartians/lefthook/v2@v2.1.4\n```\n\n- or as a go tool in your project\n\n```bash\ngo get -tool github.com/evilmartians/lefthook/v2\n```\n"
  },
  {
    "path": "docs/installation/homebrew.md",
    "content": "---\ntitle: \"Homebrew\"\n---\n\n# Homebrew for MacOS and Linux\n\n```bash\nbrew install lefthook\n```\n"
  },
  {
    "path": "docs/installation/manual.md",
    "content": "---\ntitle: \"Manual\"\n---\n\n# Manual installation with prebuilt executable\n\nDownload binaries from [latest release](https://github.com/evilmartians/lefthook/releases/latest) and install manually.\n"
  },
  {
    "path": "docs/installation/mise.md",
    "content": "# Mise\n\n> See [https://github.com/jdx/mise](https://github.com/jdx/mise)\n\n```bash\nmise use lefthook@latest\n```\n\n::: callout info Note\nThe 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.\n:::\n"
  },
  {
    "path": "docs/installation/node.md",
    "content": "---\ntitle: \"NPM\"\n---\n\n# NPM package\n\n```bash\nnpm install --save-dev lefthook\n```\n\n```bash\nyarn add --dev lefthook\n```\n\n```bash\npnpm add -D lefthook\n```\n\n::: callout info Note\nIf 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.\n:::\n\n## Choose right package\n\nLefthook supports three NPM packages with different ways to deliver the executables\n\n 1. [lefthook](https://www.npmjs.com/package/lefthook) installs one executable for your system\n\n    ```bash\n    npm install --save-dev lefthook\n    ```\n\n 1. **legacy**[^1] [@evilmartians/lefthook](https://www.npmjs.com/package/@evilmartians/lefthook)  installs executables for all OS\n\n    ```bash\n    npm install --save-dev @evilmartians/lefthook\n    ```\n\n 1. **legacy**[^1] [@evilmartians/lefthook-installer](https://www.npmjs.com/package/@evilmartians/lefthook-installer) fetches the right executable on installation\n\n    ```bash\n    npm install --save-dev @evilmartians/lefthook-installer\n    ```\n[^1]: Legacy distributions are still maintained but they will be shut down in the future.\n"
  },
  {
    "path": "docs/installation/python.md",
    "content": "# Python\n\n```sh\npython -m pip install --user lefthook\n```\n\n```sh\nuv add --dev lefthook\n```\n\n```sh\npipx install lefthook\n```\n"
  },
  {
    "path": "docs/installation/rpm.md",
    "content": "---\ntitle: \"RPM-based\"\n---\n\n# RPM packages for CentOS/Fedora Linux\n\n```sh\ncurl -1sLf 'https://dl.cloudsmith.io/public/evilmartians/lefthook/setup.rpm.sh' | sudo -E bash\nsudo yum install lefthook\n```\n\nSee all instructions: https://cloudsmith.io/~evilmartians/repos/lefthook/setup/#repository-setup-yum\n\n[![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\")\n"
  },
  {
    "path": "docs/installation/ruby.md",
    "content": "# Ruby\n\n```ruby\n# Gemfile\n\ngroup :development do\n  gem \"lefthook\", require: false\nend\n```\n\nOr globally\n\n```bash\ngem install lefthook\n```\n\n**Troubleshooting**\n\nIf you see the error `lefthook: command not found` you need to check your $PATH. Also try to restart your terminal.\n"
  },
  {
    "path": "docs/installation/scoop.md",
    "content": "---\ntitle: \"Scoop\"\n---\n\n# Scoop for Windows\n\n```sh\nscoop install lefthook\n```\n"
  },
  {
    "path": "docs/installation/snap.md",
    "content": "---\ntitle: \"Snap\"\n---\n\n# Snap for Linux\n\n```sh\nsnap install --classic lefthook\n```\n"
  },
  {
    "path": "docs/installation/swift.md",
    "content": "# Swift\n\nYou can find the Swift wrapper plugin [here](https://github.com/csjones/lefthook-plugin).\n\nUtilize lefthook in your Swift project using Swift Package Manager:\n\n```swift\n.package(url: \"https://github.com/csjones/lefthook-plugin.git\", exact: \"2.1.4\"),\n```\n\nOr, with [mint](https://github.com/yonaskolb/Mint):\n\n```bash\nmint run csjones/lefthook-plugin\n```\n"
  },
  {
    "path": "docs/installation/winget.md",
    "content": "---\ntitle: \"Winget\"\n---\n\n# Winget for Windows\n\n```sh\nwinget install evilmartians.lefthook\n```\n"
  },
  {
    "path": "docs/misc/contributors.md",
    "content": "# Contributors\n\n- [Arkweid](https://github.com/Arkweid)\n- [Envek](https://github.com/Envek)\n- [mrexox](https://github.com/mrexox)\n- [skryukov](https://github.com/skryukov)\n- [scop](https://github.com/scop)\n- [hyperupcall](https://github.com/hyperupcall)\n- [MartijnCuppens](https://github.com/MartijnCuppens)\n- [palkan](https://github.com/palkan)\n- [markovichecha](https://github.com/markovichecha)\n- [technicalpickles](https://github.com/technicalpickles)\n- [aminya](https://github.com/aminya)\n- [prog-supdex](https://github.com/prog-supdex)\n- [HellSquirrel](https://github.com/HellSquirrel)\n- [Evilweed](https://github.com/Evilweed)\n- [PikachuEXE](https://github.com/PikachuEXE)\n- [jsmestad](https://github.com/jsmestad)\n- [DmitryTsepelev](https://github.com/DmitryTsepelev)\n- [pmirecki](https://github.com/pmirecki)\n- [0legovich](https://github.com/0legovich)\n- [zachahn](https://github.com/zachahn)\n- [sitiom](https://github.com/sitiom)\n- [spearmootz](https://github.com/spearmootz)\n- [pwinckles](https://github.com/pwinckles)\n- [pablobirukov](https://github.com/pablobirukov)\n- [nihalgonsalves](https://github.com/nihalgonsalves)\n- [nesk](https://github.com/nesk)\n- [jaydorsey](https://github.com/jaydorsey)\n- [fantua](https://github.com/fantua)\n- [orsinium](https://github.com/orsinium)\n- [fabn](https://github.com/fabn)\n\nIf you feel you’re missing from this list, feel free to add yourself in a PR.\n\n<!---\ncurl https://api.github.com/repos/evilmartians/lefthook/contributors | jq '.[] | \"[\" + .login + \"]\" + \"(\" + .url + \")\"'\n-->\n"
  },
  {
    "path": "docs/usage/commands/add.md",
    "content": "---\ntitle: \"lefthook add\"\n---\n\n## `lefthook add`\n\nInstalls the given hook to Git hook.\n\nWith argument `--dirs` creates a directory `.git/hooks/<hook name>/` if it doesn't exist. Use it before adding a script to configuration.\n\n#### Example\n\n```bash\n$ lefthook add pre-push  --dirs\n```\n\nDescribe pre-push commands in `lefthook.yml`:\n\n```yml\npre-push:\n  jobs:\n    - script: \"audit.sh\"\n      runner: bash\n```\n\nEdit the script:\n\n```bash\n$ vim .lefthook/pre-push/audit.sh\n...\n```\n\nRun `git push` and lefthook will run `bash audit.sh` as a pre-push hook.\n"
  },
  {
    "path": "docs/usage/commands/check-install.md",
    "content": "---\ntitle: \"lefthook check-install\"\n---\n\n## `lefthook check-install`\n\nChecks if Git hooks are installed and synchronized.\n\nReturns:\n- `0` if hooks installed and synchronized\n- `1` if hooks not installed or need a sync\n"
  },
  {
    "path": "docs/usage/commands/dump.md",
    "content": "---\ntitle: \"lefthook dump\"\n---\n\n## `lefthook dump`\n\nPrints the whole configuration after merging all secondary configs.\n\nThis is the actual config lefthook uses, it can be build from the main config (`lefthook.yml`), remotes, extends, and `lefthook-local.yml` overrides.\n\n"
  },
  {
    "path": "docs/usage/commands/install.md",
    "content": "---\ntitle: \"lefthook install\"\n---\n\n## `lefthook install`\n\nCreates an empty `lefthook.yml` if a configuration file does not exist.\n\nInstalls configured hooks to Git hooks.\n\n::: callout info Note\nReinstall is not required when you modify `lefthook.yml`, the configuration file is read every time a git hook is run.\n:::\n\n::: callout info Note\nNPM package `lefthook` installs the hooks in a postinstall script automatically. For projects not using NPM package run `lefthook install` after cloning the repo.\n:::\n\n### Installing specific hooks\n\nYou can install only specific hooks by running `lefthook install <hook-1> <hook-2> ...`.\n"
  },
  {
    "path": "docs/usage/commands/run.md",
    "content": "---\ntitle: \"lefthook run\"\n---\n\n## `lefthook run`\n\nExecutes the commands and scripts configured for a given hook. Installed Git hooks call `lefthook run` implicitly.\n\n#### Example\n\n```yml\n# lefthook.yml\n\npre-commit:\n  jobs:\n    - name: lint\n      run: yarn lint --fix {staged_files}\n\ntest:\n  jobs:\n    - name: test\n      run: yarn test\n```\n\nInstall the hook.\n\n```bash\n$ lefthook install\n```\n\n```bash\n$ lefthook run test # will run 'yarn test'\n$ git commit # will run pre-commit hook ('yarn lint --fix')\n$ lefthook run pre-commit # will run pre-commit hook (`yarn lint --fix`)\n```\n\n### Run specific jobs\n\nYou can specify which jobs to run (also `--tag` supported).\n\n```bash\n$ lefthook run pre-commit --job lints --job pretty --tag checks\n```\n\n### Specify files\n\nYou can force replacing files templates (like `{staged_files}`) with either all files (will acts as `{all_files}` template) or a list of files.\n\n```bash\n$ lefthook run pre-commit --all-files\n$ lefthook run pre-commit --file file1.js --file file2.js\n```\n\n(if both are specified, `--all-files` is ignored)\n"
  },
  {
    "path": "docs/usage/commands/self-update.md",
    "content": "---\ntitle: \"lefthook self-update\"\n---\n\n## `lefthook self-update`\n\nUpdates the binary with the latest lefthook release on Github.\n\nThis 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.\n"
  },
  {
    "path": "docs/usage/commands/uninstall.md",
    "content": "---\ntitle: \"lefthook uninstall\"\n---\n\n## `lefthook uninstall`\n\nClears Git hooks installed by lefthook.\n\n"
  },
  {
    "path": "docs/usage/commands/validate.md",
    "content": "---\ntitle: \"lefthook validate\"\n---\n\n## `lefthook validate`\n\nValidates your lefthook configuration. Use `lefthook dump` to see it.\n\nIt uses JSON schema from the lefthook Github repo.\n"
  },
  {
    "path": "docs/usage/commands/version.md",
    "content": "---\ntitle: \"lefthook version\"\n---\n\n## `lefthook version`\n\n`lefthook version` prints the current binary version. Print the commit hash with `lefthook version --full`\n\n#### Example\n\n```bash\n$ lefthook version --full\n\n1.1.3 bb099d13c24114d2859815d9d23671a32932ffe2\n```\n"
  },
  {
    "path": "docs/usage/envs/CI.md",
    "content": "---\ntitle: \"CI\"\n---\n\n## `CI`\n\nWhen 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:\n\n```bash\nCI=true npm install\nCI=true yarn install\nCI=true pnpm install\n```\n\n::: callout info Note\nSet `LEFTHOOK=1` or `LEFTHOOK=true` to override this behavior and install hooks in the postinstall script (despite `CI=true`).\n:::\n\n"
  },
  {
    "path": "docs/usage/envs/CLICOLOR_FORCE.md",
    "content": "---\ntitle: \"CLICOLOR_FORCE\"\n---\n\n## `CLICOLOR_FORCE`\n\nSet `CLICOLOR_FORCE=true` to force colored output in lefthook and all subcommands.\n"
  },
  {
    "path": "docs/usage/envs/LEFTHOOK.md",
    "content": "---\ntitle: \"LEFTHOOK\"\n---\n\n## `LEFTHOOK`\n\nUse `LEFTHOOK=0 git ...` or `LEFTHOOK=false git ...` to disable lefthook when running git commands.\n\n#### Example\n\n```bash\nLEFTHOOK=0 git commit -am \"Lefthook skipped\"\n```\n\nWhen 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:\n\n#### Example\n\n```bash\nLEFTHOOK=1 npm install\nLEFTHOOK=1 yarn install\nLEFTHOOK=1 pnpm install\n```\n"
  },
  {
    "path": "docs/usage/envs/LEFTHOOK_BIN.md",
    "content": "---\ntitle: \"LEFTHOOK_BIN\"\n---\n\n## `LEFTHOOK_BIN`\n\nSet `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.\n\nUseful for cases when:\n\n- 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)\n- debugging and/or developing lefthook\n"
  },
  {
    "path": "docs/usage/envs/LEFTHOOK_CONFIG.md",
    "content": "---\ntitle: \"LEFTHOOK_CONFIG\"\n---\n\n## `LEFTHOOK_CONFIG`\n\nOverride the main lefthook config with `LEFTHOOK_CONFIG=~/global_lefthook.yml`. Note: local config, specified extends, and remotes will still be loaded.\n"
  },
  {
    "path": "docs/usage/envs/LEFTHOOK_EXCLUDE.md",
    "content": "---\ntitle: \"LEFTHOOK_EXCLUDE\"\n---\n\n## `LEFTHOOK_EXCLUDE`\n\nUse `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.\n\n#### Example\n\n```bash\nLEFTHOOK_EXCLUDE=ruby,security,lint git commit -am \"Skip some tag checks\"\n```\n"
  },
  {
    "path": "docs/usage/envs/LEFTHOOK_OUTPUT.md",
    "content": "---\ntitle: \"LEFTHOOK_OUTPUT\"\n---\n\n## `LEFTHOOK_OUTPUT`\n\nUse `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.\n\n#### Example\n\n```bash\n$ LEFTHOOK_OUTPUT=summary lefthook run pre-commit\nsummary: (done in 0.52 seconds)\n✔️  lint\n```\n"
  },
  {
    "path": "docs/usage/envs/LEFTHOOK_VERBOSE.md",
    "content": "---\ntitle: \"LEFTHOOK_VERBOSE\"\n---\n\n## `LEFTHOOK_VERBOSE`\n\nSet `LEFTHOOK_VERBOSE=1` or `LEFTHOOK_VERBOSE=true` to enable verbose printing.\n\n#### Example\n\n```bash\nLEFTHOOK_VERBOSE=1 lefthook run pre-commit\n```\n"
  },
  {
    "path": "docs/usage/envs/NO_COLOR.md",
    "content": "---\ntitle: \"NO_COLOR\"\n---\n\n## `NO_COLOR`\n\nSet `NO_COLOR=true` to disable colored output in lefthook and all subcommands that lefthook calls.\n\n"
  },
  {
    "path": "docs/usage/features/git-args.md",
    "content": "## Capture ARGS from git in the script\n\nLefthook passes Git arguments to your commands and scripts.\n\n```\n├── .lefthook\n│   └── prepare-commit-msg\n│       └── message.sh\n└── lefthook.yml\n```\n\n```yml\n# lefthook.yml\n\nprepare-commit-msg:\n  jobs:\n    - script: \"message.sh\"\n      runner: bash\n    - run: echo \"Git args: {1} {2} {3}\"\n```\n\n```bash\n# .lefthook/prepare-commit-msg/message.sh\n\n# Arguments get passed from Git\n\nCOMMIT_MSG_FILE=$1\nCOMMIT_SOURCE=$2\nSHA1=$3\n\n# ...\n```\n\n"
  },
  {
    "path": "docs/usage/features/git-lfs.md",
    "content": "## Git LFS support\n\n::: callout info Note\nIf 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.\n\nGit LFS hooks may be slow. Disable them with the global `skip_lfs: true` setting.\n:::\n\nLefthook runs LFS hooks internally for the following hooks:\n\n- post-checkout\n- post-commit\n- post-merge\n- pre-push\n\nErrors 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.\n\nTo avoid calling LFS hooks set [`skip_lfs: true`](../../configuration/skip_lfs.md) in lefthook.yml or lefthook-local.yml\n"
  },
  {
    "path": "docs/usage/features/interactive.md",
    "content": "## Using an interactive command or script\n\nWhen 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.\n\n"
  },
  {
    "path": "docs/usage/features/local.md",
    "content": "## Local config\n\nYou can extend and override options of your main configuration with `lefthook-local.yml`. Don't forget to add the file to `.gitignore`.\n\nYou 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.\n\n```yml\n# lefthook.yml (committed into your repo)\n\npre-commit:\n  jobs:\n    - name: linter\n      run: yarn lint\n    - name: tests\n      run: yarn test\n```\n\n```yml\n# lefthook-local.yml (ignored by git)\n\npre-commit:\n  jobs:\n    - name: tests\n      skip: true # don't want to run tests on every commit\n    - name: linter\n      run: yarn lint {staged_files} # lint only staged files\n```\n"
  },
  {
    "path": "docs/usage/features/pass-stdin.md",
    "content": "## Pass stdin to a command or script\n\nWhen 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).\n\n\n"
  },
  {
    "path": "docs/usage.md",
    "content": "# Usage\n\nHere are the most common usage cases. You can find more info in the docs.\n\n## Basic CLI commands\n\n```bash\n# Create/update Git hooks based on lefthook.yml, or create an empty lefthook.yml\nlefthook install\n\n# Run pre-commit hook commands and scripts (requires lefthook.yml)\nlefthook run pre-commit\n\n# Validate the configuration\nlefthook validate\n\n# Dump the configuration (useful when you have remotes, extends that overwrite the configuration)\nlefthook dump\n```\n\n## Skip running lefthook when committing changes\n\n```bash\nLEFTHOOK=0 git commit\n```\n"
  },
  {
    "path": "examples/commitlint/README.md",
    "content": "# Use commitlint and/or commitzen\n\n## Install dependencies\n\n```bash\nyarn add -D @commitlint/cli @commitlint/config-conventional\n# If using commitzen\nyarn add -D commitizen cz-conventional-changelog\n```\n\n## Configure\n\nSetup `commitlint.config.js`. Conventional configuration:\n\n```bash\necho \"module.exports = {extends: ['@commitlint/config-conventional']};\" > commitlint.config.js\n```\n\nIf you are using commitzen, make sure to add this in `package.json`:\n\n```json\n\"config\": {\n  \"commitizen\": {\n    \"path\": \"./node_modules/cz-conventional-changelog\"\n  }\n}\n```\n\n## Test it\n\n```bash\n# You can type it without message, if you are using commitzen\ngit commit\n\n# Or provide a commit message is using only commitlint\ngit commit -am 'fix: typo'\n```\n"
  },
  {
    "path": "examples/commitlint/commitlint.config.js",
    "content": "module.exports = {extends: ['@commitlint/config-conventional']};\n"
  },
  {
    "path": "examples/commitlint/lefthook.yml",
    "content": "# Use this to build commit messages\nprepare-commit-msg:\n  commands:\n    commitzen:\n      interactive: true\n      run: yarn run cz --hook # Or npx cz --hook\n      env:\n        LEFTHOOK: 0\n\n# Use this to validate commit messages\ncommit-msg:\n  commands:\n    \"lint commit message\":\n      run: yarn run commitlint --edit {1}\n"
  },
  {
    "path": "examples/complete/lefthook.yml",
    "content": "commit-msg:\n  scripts:\n    \"template_checker\":\n      runner: bash\n\npre-commit:\n  commands:\n    stylelint:\n      tags: frontend style\n      glob: \"*.js\"\n      run: yarn stylelint {staged_files}\n      stage_fixed: true\n    rubocop:\n      tags: backend style\n      glob: \"*.rb\"\n      exclude:\n        - \"*/application.rb\"\n        - \"*/routes.rb\"\n      run: bundle exec rubocop --force-exclusion -- {all_files}\n      stage_fixed: true\n  scripts:\n    \"good_job.js\":\n      runner: node\n\npre-push:\n  parallel: true\n  commands:\n    stylelint:\n      tags: frontend style\n      files: git diff --name-only master\n      glob: \"*.js\"\n      run: yarn stylelint {files}\n    rubocop:\n      tags: backend style\n      files: git diff --name-only master\n      glob: \"*.rb\"\n      run: bundle exec rubocop --force-exclusion -- {files}\n  scripts:\n    \"verify\":\n      runner: sh\n\n"
  },
  {
    "path": "examples/remote/ping.yml",
    "content": "# Test `remotes` config of lefthook.\n#\n# # lefthook.yml\n#\n# remotes:\n#   - git_url: git@github.com:evilmartians/lefthook\n#     configs:\n#       - examples/remote/ping.yml\n#\n# $ lefthook run pre-commit\n\npre-commit:\n  commands:\n    ping:\n      run: echo pong\n"
  },
  {
    "path": "examples/verbose/lefthook.yml",
    "content": "---\n# lefthook.yml\n\n# This hook executes on `git commit`\npre-commit:\n  parallel: true  # All commands will be executed concurrently\n  commands:       # Commands section\n    # `js-lint` will call `npx eslit --fix` only on staged files.\n    # It will filter staged files by glob.\n    # If there are no files left after filtering, this command will be skipped\n    js-lint:\n      glob: \"*.{js,ts}\"\n      run: npx eslint --fix -- {staged_files} && git add -- {staged_files}\n\n    # `ruby-test` will skip execution only when in a merging or rebasing state.\n    ruby-test:\n      skip:\n        - merge\n        - rebase\n      run: bundle exec rspec\n      fail_text: Run bundle install\n\n    # `ruby-lint` has `files` option which is a git command for replacing\n    # the {files} template. Then lefthook applies glob pattern to the result.\n    # If the final list is empty, the command will be skipped.\n    # Otherwise the {files} templace will be replaces with list.\n    #\n    # Note: if a template has surrounding quotes, they will be used to wrap\n    # each file in the list.\n    # Double quotes `\"` and single quotes `'` are supported.\n    ruby-lint:\n      glob: \"*.rb\"\n      files: git diff-tree -r --name-only --diff-filter=CDMR HEAD origin/master\n      run: bundle exec rubocop --force-exclusion --parallel -- '{files}'\n\n# You can provide more hooks.\npre-push:\n  commands:\n    spelling:\n      files: git diff --name-only HEAD @{push}\n      glob: \"*.md\"\n      run: npx yaspeller -- {files}\n"
  },
  {
    "path": "examples/with_scripts/lefthook.yml",
    "content": "pre-commit:\n  scripts:\n    \"good_job.js\":\n      runner: node\n"
  },
  {
    "path": "gen/jsonschema.go",
    "content": "package main\n\nimport (\n\t\"encoding/json\"\n\t\"fmt\"\n\t\"os\"\n\t\"reflect\"\n\t\"time\"\n\n\t\"github.com/invopop/jsonschema\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/config\"\n)\n\n//go:generate go run jsonschema.go\nfunc main() {\n\tr := new(jsonschema.Reflector)\n\tr.ExpandedStruct = true\n\tr.AdditionalFields = func(t reflect.Type) []reflect.StructField {\n\t\tif t == reflect.TypeFor[config.Config]() {\n\t\t\treturn reflect.VisibleFields(reflect.TypeFor[struct {\n\t\t\t\tSchema               string       `json:\"$schema,omitempty\"`\n\t\t\t\tPreCommit            *config.Hook `json:\"pre-commit,omitempty\"`\n\t\t\t\tApplypatchMsg        *config.Hook `json:\"applypatch-msg,omitempty\"`\n\t\t\t\tPreApplypatch        *config.Hook `json:\"pre-applypatch,omitempty\"`\n\t\t\t\tPostApplypatch       *config.Hook `json:\"post-applypatch,omitempty\"`\n\t\t\t\tPreMergeCommit       *config.Hook `json:\"pre-merge-commit,omitempty\"`\n\t\t\t\tPrepareCommitMsg     *config.Hook `json:\"prepare-commit-msg,omitempty\"`\n\t\t\t\tCommitMsg            *config.Hook `json:\"commit-msg,omitempty\"`\n\t\t\t\tPostCommit           *config.Hook `json:\"post-commit,omitempty\"`\n\t\t\t\tPreRebase            *config.Hook `json:\"pre-rebase,omitempty\"`\n\t\t\t\tPostCheckout         *config.Hook `json:\"post-checkout,omitempty\"`\n\t\t\t\tPostMerge            *config.Hook `json:\"post-merge,omitempty\"`\n\t\t\t\tPrePush              *config.Hook `json:\"pre-push,omitempty\"`\n\t\t\t\tPreReceive           *config.Hook `json:\"pre-receive,omitempty\"`\n\t\t\t\tUpdate               *config.Hook `json:\"update,omitempty\"`\n\t\t\t\tProcReceive          *config.Hook `json:\"proc-receive,omitempty\"`\n\t\t\t\tPostReceive          *config.Hook `json:\"post-receive,omitempty\"`\n\t\t\t\tPostUpdate           *config.Hook `json:\"post-update,omitempty\"`\n\t\t\t\tReferenceTransaction *config.Hook `json:\"reference-transaction,omitempty\"`\n\t\t\t\tPushToCheckout       *config.Hook `json:\"push-to-checkout,omitempty\"`\n\t\t\t\tPreAutoGc            *config.Hook `json:\"pre-auto-gc,omitempty\"`\n\t\t\t\tPostRewrite          *config.Hook `json:\"post-rewrite,omitempty\"`\n\t\t\t\tSendemailValidate    *config.Hook `json:\"sendemail-validate,omitempty\"`\n\t\t\t\tFsmonitorWatchman    *config.Hook `json:\"fsmonitor-watchman,omitempty\"`\n\t\t\t\tP4Changelist         *config.Hook `json:\"p4-changelist,omitempty\"`\n\t\t\t\tP4PrepareChangelist  *config.Hook `json:\"p4-prepare-changelist,omitempty\"`\n\t\t\t\tP4PostChangelist     *config.Hook `json:\"p4-post-changelist,omitempty\"`\n\t\t\t\tP4PreSubmit          *config.Hook `json:\"p4-pre-submit,omitempty\"`\n\t\t\t\tPostIndexChange      *config.Hook `json:\"post-index-change,omitempty\"`\n\t\t\t}]())\n\t\t}\n\n\t\treturn []reflect.StructField{}\n\t}\n\tschema := r.Reflect(&config.Config{})\n\tif hookDef, ok := schema.Definitions[\"Hook\"]; ok {\n\t\tschema.AdditionalProperties = hookDef\n\t}\n\tschema.ID = \"https://json.schemastore.org/lefthook.json\"\n\tschema.Comments = \"Last updated on \" + time.Now().Format(\"2006.01.02\") + \".\"\n\tdumped, err := json.MarshalIndent(schema, \"\", \"  \")\n\tif err != nil {\n\t\t_, _ = fmt.Fprintf(os.Stderr, \"failed to generate json: %s\", err)\n\t\tos.Exit(1)\n\t}\n\n\t_, _ = os.Stdout.Write(dumped)\n}\n"
  },
  {
    "path": "go.mod",
    "content": "module github.com/evilmartians/lefthook/v2\n\ngo 1.26\n\ntoolchain go1.26.0\n\nrequire (\n\tgithub.com/bmatcuk/doublestar/v4 v4.10.0\n\tgithub.com/briandowns/spinner v1.23.2\n\tgithub.com/charmbracelet/lipgloss v1.1.0\n\tgithub.com/creack/pty v1.1.24\n\tgithub.com/gabriel-vasile/mimetype v1.4.13\n\tgithub.com/gobwas/glob v0.2.3\n\tgithub.com/goccy/go-yaml v1.19.2\n\tgithub.com/invopop/jsonschema v0.13.0\n\tgithub.com/kaptinlin/jsonschema v0.7.5\n\tgithub.com/knadh/koanf/maps v0.1.2\n\tgithub.com/knadh/koanf/parsers/json v1.0.0\n\tgithub.com/knadh/koanf/parsers/toml/v2 v2.2.0\n\tgithub.com/knadh/koanf/parsers/yaml v1.1.0\n\tgithub.com/knadh/koanf/providers/fs v1.0.0\n\tgithub.com/knadh/koanf/providers/rawbytes v1.0.0\n\tgithub.com/knadh/koanf/v2 v2.3.2\n\tgithub.com/mattn/go-tty v0.0.7\n\tgithub.com/mitchellh/mapstructure v1.5.0\n\tgithub.com/rogpeppe/go-internal v1.14.1\n\tgithub.com/schollz/progressbar/v3 v3.19.0\n\tgithub.com/spf13/afero v1.15.0\n\tgithub.com/stretchr/testify v1.11.1\n\tgithub.com/tidwall/jsonc v0.3.2\n\tgithub.com/urfave/cli/v3 v3.7.0\n\tgolang.org/x/mod v0.33.0\n)\n\nrequire (\n\tgithub.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect\n\tgithub.com/bahlo/generic-list-go v0.2.0 // indirect\n\tgithub.com/buger/jsonparser v1.1.1 // indirect\n\tgithub.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect\n\tgithub.com/charmbracelet/x/ansi v0.8.0 // indirect\n\tgithub.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect\n\tgithub.com/charmbracelet/x/term v0.2.1 // indirect\n\tgithub.com/clipperhouse/uax29/v2 v2.2.0 // indirect\n\tgithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect\n\tgithub.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect\n\tgithub.com/go-viper/mapstructure/v2 v2.4.0 // indirect\n\tgithub.com/kaptinlin/go-i18n v0.2.12 // indirect\n\tgithub.com/kaptinlin/jsonpointer v0.4.17 // indirect\n\tgithub.com/kaptinlin/messageformat-go v0.4.18 // indirect\n\tgithub.com/kr/pretty v0.3.1 // indirect\n\tgithub.com/mailru/easyjson v0.7.7 // indirect\n\tgithub.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect\n\tgithub.com/mitchellh/copystructure v1.2.0 // indirect\n\tgithub.com/mitchellh/reflectwalk v1.0.2 // indirect\n\tgithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect\n\tgithub.com/wk8/go-ordered-map/v2 v2.1.8 // indirect\n\tgithub.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect\n\tgolang.org/x/tools v0.41.0 // indirect\n\tgopkg.in/yaml.v3 v3.0.1 // indirect\n)\n\nrequire (\n\tgithub.com/alessio/shellescape v1.4.1\n\tgithub.com/fatih/color v1.18.0 // indirect\n\tgithub.com/lucasb-eyer/go-colorful v1.2.0 // indirect\n\tgithub.com/mattn/go-colorable v0.1.13 // indirect\n\tgithub.com/mattn/go-isatty v0.0.20\n\tgithub.com/mattn/go-runewidth v0.0.20\n\tgithub.com/muesli/termenv v0.16.0 // indirect\n\tgithub.com/pelletier/go-toml/v2 v2.2.4\n\tgithub.com/rivo/uniseg v0.4.7 // indirect\n\tgo.yaml.in/yaml/v3 v3.0.4\n\tgolang.org/x/sys v0.41.0 // indirect\n\tgolang.org/x/term v0.40.0\n\tgolang.org/x/text v0.34.0 // indirect\n)\n"
  },
  {
    "path": "go.sum",
    "content": "github.com/alessio/shellescape v1.4.1 h1:V7yhSDDn8LP4lc4jS8pFkt0zCnzVJlG5JXy9BVKJUX0=\ngithub.com/alessio/shellescape v1.4.1/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30=\ngithub.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=\ngithub.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=\ngithub.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk=\ngithub.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg=\ngithub.com/bmatcuk/doublestar/v4 v4.10.0 h1:zU9WiOla1YA122oLM6i4EXvGW62DvKZVxIe6TYWexEs=\ngithub.com/bmatcuk/doublestar/v4 v4.10.0/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc=\ngithub.com/briandowns/spinner v1.23.2 h1:Zc6ecUnI+YzLmJniCfDNaMbW0Wid1d5+qcTq4L2FW8w=\ngithub.com/briandowns/spinner v1.23.2/go.mod h1:LaZeM4wm2Ywy6vO571mvhQNRcWfRUnXOs0RcKV0wYKM=\ngithub.com/buger/jsonparser v1.1.1 h1:2PnMjfWD7wBILjqQbt530v576A/cAbQvEW9gGIpYMUs=\ngithub.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=\ngithub.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=\ngithub.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=\ngithub.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=\ngithub.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=\ngithub.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=\ngithub.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=\ngithub.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=\ngithub.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=\ngithub.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=\ngithub.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=\ngithub.com/chengxilo/virtualterm v1.0.4 h1:Z6IpERbRVlfB8WkOmtbHiDbBANU7cimRIof7mk9/PwM=\ngithub.com/chengxilo/virtualterm v1.0.4/go.mod h1:DyxxBZz/x1iqJjFxTFcr6/x+jSpqN0iwWCOK1q10rlY=\ngithub.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=\ngithub.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=\ngithub.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=\ngithub.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=\ngithub.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=\ngithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=\ngithub.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=\ngithub.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=\ngithub.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=\ngithub.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM=\ngithub.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=\ngithub.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao=\ngithub.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=\ngithub.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9LvH92wZUgs=\ngithub.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=\ngithub.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=\ngithub.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=\ngithub.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=\ngithub.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=\ngithub.com/invopop/jsonschema v0.13.0 h1:KvpoAJWEjR3uD9Kbm2HWJmqsEaHt8lBUpd0qHcIi21E=\ngithub.com/invopop/jsonschema v0.13.0/go.mod h1:ffZ5Km5SWWRAIN6wbDXItl95euhFz2uON45H2qjYt+0=\ngithub.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=\ngithub.com/kaptinlin/go-i18n v0.2.12 h1:ywDsvb4KDFddMC2dpI/rrIzGU2mWUSvHmWUm9BMsdl4=\ngithub.com/kaptinlin/go-i18n v0.2.12/go.mod h1:pVcu9qsW5pOIOoZFJXesRYmLos1vMQrby70JPAoWmJU=\ngithub.com/kaptinlin/jsonpointer v0.4.17 h1:mY9k8ciWncxbsECyaxKnR0MdmxamNdp2tLQkAKVrtSk=\ngithub.com/kaptinlin/jsonpointer v0.4.17/go.mod h1:SsfsjqnHG5zuKo1DTBzk1VknaHlL4osHw+X9kZKukpU=\ngithub.com/kaptinlin/jsonschema v0.7.5 h1:jkK4a3NyzNoGlvu12CsL3IcqNMVa5sL51HPVa0nWcPY=\ngithub.com/kaptinlin/jsonschema v0.7.5/go.mod h1:3gIWnptl+SWMyfMR2r4TXXd0xsQZ1m50AKrwmcUONSg=\ngithub.com/kaptinlin/messageformat-go v0.4.18 h1:RBlHVWgZyoxTcUgGWBsl2AcyScq/urqbLZvzgryTmSI=\ngithub.com/kaptinlin/messageformat-go v0.4.18/go.mod h1:ntI3154RnqJgr7GaC+vZBnIExl2V3sv9selvRNNEM24=\ngithub.com/knadh/koanf/maps v0.1.2 h1:RBfmAW5CnZT+PJ1CVc1QSJKf4Xu9kxfQgYVQSu8hpbo=\ngithub.com/knadh/koanf/maps v0.1.2/go.mod h1:npD/QZY3V6ghQDdcQzl1W4ICNVTkohC8E73eI2xW4yI=\ngithub.com/knadh/koanf/parsers/json v1.0.0 h1:1pVR1JhMwbqSg5ICzU+surJmeBbdT4bQm7jjgnA+f8o=\ngithub.com/knadh/koanf/parsers/json v1.0.0/go.mod h1:zb5WtibRdpxSoSJfXysqGbVxvbszdlroWDHGdDkkEYU=\ngithub.com/knadh/koanf/parsers/toml/v2 v2.2.0 h1:2nV7tHYJ5OZy2BynQ4mOJ6k5bDqbbCzRERLUKBytz3A=\ngithub.com/knadh/koanf/parsers/toml/v2 v2.2.0/go.mod h1:JpjTeK1Ge1hVX0wbof5DMCuDBriR8bWgeQP98eeOZpI=\ngithub.com/knadh/koanf/parsers/yaml v1.1.0 h1:3ltfm9ljprAHt4jxgeYLlFPmUaunuCgu1yILuTXRdM4=\ngithub.com/knadh/koanf/parsers/yaml v1.1.0/go.mod h1:HHmcHXUrp9cOPcuC+2wrr44GTUB0EC+PyfN3HZD9tFg=\ngithub.com/knadh/koanf/providers/fs v1.0.0 h1:tvn4MrduLgdOSUqqEHULUuIcELXf6xDOpH8GUErpYaY=\ngithub.com/knadh/koanf/providers/fs v1.0.0/go.mod h1:FksHET+xXFNDozvj8ZCdom54OnZ6eGKJtC5FhZJKx/8=\ngithub.com/knadh/koanf/providers/rawbytes v1.0.0 h1:MrKDh/HksJlKJmaZjgs4r8aVBb/zsJyc/8qaSnzcdNI=\ngithub.com/knadh/koanf/providers/rawbytes v1.0.0/go.mod h1:KxwYJf1uezTKy6PBtfE+m725NGp4GPVA7XoNTJ/PtLo=\ngithub.com/knadh/koanf/v2 v2.3.2 h1:Ee6tuzQYFwcZXQpc2MiVeC6qHMandf5SMUJJNoFp/c4=\ngithub.com/knadh/koanf/v2 v2.3.2/go.mod h1:gRb40VRAbd4iJMYYD5IxZ6hfuopFcXBpc9bbQpZwo28=\ngithub.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=\ngithub.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=\ngithub.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=\ngithub.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=\ngithub.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=\ngithub.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=\ngithub.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=\ngithub.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=\ngithub.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=\ngithub.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=\ngithub.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=\ngithub.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=\ngithub.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=\ngithub.com/mattn/go-runewidth v0.0.20 h1:WcT52H91ZUAwy8+HUkdM3THM6gXqXuLJi9O3rjcQQaQ=\ngithub.com/mattn/go-runewidth v0.0.20/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=\ngithub.com/mattn/go-tty v0.0.7 h1:KJ486B6qI8+wBO7kQxYgmmEFDaFEE96JMBQ7h400N8Q=\ngithub.com/mattn/go-tty v0.0.7/go.mod h1:f2i5ZOvXBU/tCABmLmOfzLz9azMo5wdAaElRNnJKr+k=\ngithub.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ=\ngithub.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw=\ngithub.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=\ngithub.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=\ngithub.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=\ngithub.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=\ngithub.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=\ngithub.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw=\ngithub.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=\ngithub.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=\ngithub.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=\ngithub.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=\ngithub.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsKMEsNbx1VGcRFpLqf3715MtcvvzbA=\ngithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=\ngithub.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=\ngithub.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=\ngithub.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=\ngithub.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=\ngithub.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=\ngithub.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=\ngithub.com/schollz/progressbar/v3 v3.19.0 h1:Ea18xuIRQXLAUidVDox3AbwfUhD0/1IvohyTutOIFoc=\ngithub.com/schollz/progressbar/v3 v3.19.0/go.mod h1:IsO3lpbaGuzh8zIMzgY3+J8l4C8GjO0Y9S69eFvNsec=\ngithub.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=\ngithub.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg=\ngithub.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=\ngithub.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=\ngithub.com/tidwall/jsonc v0.3.2 h1:ZTKrmejRlAJYdn0kcaFqRAKlxxFIC21pYq8vLa4p2Wc=\ngithub.com/tidwall/jsonc v0.3.2/go.mod h1:dw+3CIxqHi+t8eFSpzzMlcVYxKp08UP5CD8/uSFCyJE=\ngithub.com/urfave/cli/v3 v3.7.0 h1:AGSnbUyjtLiM+WJUb4dzXKldl/gL+F8OwmRDtVr6g2U=\ngithub.com/urfave/cli/v3 v3.7.0/go.mod h1:ysVLtOEmg2tOy6PknnYVhDoouyC/6N42TMeoMzskhso=\ngithub.com/wk8/go-ordered-map/v2 v2.1.8 h1:5h/BUHu93oj4gIdvHHHGsScSTMijfx5PeYkE/fJgbpc=\ngithub.com/wk8/go-ordered-map/v2 v2.1.8/go.mod h1:5nJHM5DyteebpVlHnWMV0rPz6Zp7+xBAnxjb1X5vnTw=\ngithub.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=\ngithub.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=\ngo.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc=\ngo.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=\ngolang.org/x/exp v0.0.0-20220909182711-5c715a9e8561 h1:MDc5xs78ZrZr3HMQugiXOAkSZtfTpbJLDr/lwfgO53E=\ngolang.org/x/exp v0.0.0-20220909182711-5c715a9e8561/go.mod h1:cyybsKvd6eL0RnXn6p/Grxp8F5bW7iYuBgsNCOHpMYE=\ngolang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8=\ngolang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w=\ngolang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=\ngolang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=\ngolang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=\ngolang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=\ngolang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=\ngolang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=\ngolang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=\ngolang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=\ngolang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=\ngopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=\ngopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=\ngopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=\ngopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=\n"
  },
  {
    "path": "integration_test.go",
    "content": "//go:build integration\n\npackage main_test\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/rogpeppe/go-internal/testscript\"\n)\n\nfunc TestLefthook(t *testing.T) {\n\ttestscript.Run(t, testscript.Params{\n\t\tDir: filepath.Join(\"tests\", \"integration\"),\n\t\tSetup: func(env *testscript.Env) error {\n\t\t\tenv.Vars = append(env.Vars, fmt.Sprintf(\"GOCOVERDIR=%s\", os.Getenv(\"GOCOVERDIR\")))\n\t\t\treturn nil\n\t\t},\n\t})\n}\n"
  },
  {
    "path": "internal/command/add.go",
    "content": "package command\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"path/filepath\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/config\"\n\t\"github.com/evilmartians/lefthook/v2/internal/templates\"\n)\n\nconst defaultDirMode = 0o755\n\ntype AddArgs struct {\n\tHook string\n\n\tCreateDirs, Force bool\n}\n\n// Add creates a hook, given in args. The hook is a Lefthook hook.\nfunc (l *Lefthook) Add(_ctx context.Context, args AddArgs) error {\n\tif !config.KnownHook(args.Hook) {\n\t\treturn fmt.Errorf(\"skip adding, hook is unavailable: %s\", args.Hook)\n\t}\n\n\terr := l.cleanHook(args.Hook, args.Force)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif err = l.ensureHooksDirExists(); err != nil {\n\t\treturn err\n\t}\n\n\terr = l.addHook(args.Hook, templates.Args{})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif args.CreateDirs {\n\t\tglobal, local := l.getSourceDirs()\n\n\t\tsourceDir := filepath.Join(l.repo.RootPath, global, args.Hook)\n\t\tsourceDirLocal := filepath.Join(l.repo.RootPath, local, args.Hook)\n\n\t\tif err = l.fs.MkdirAll(sourceDir, defaultDirMode); err != nil {\n\t\t\treturn err\n\t\t}\n\t\tif err = l.fs.MkdirAll(sourceDirLocal, defaultDirMode); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (l *Lefthook) getSourceDirs() (global, local string) {\n\tglobal = config.DefaultSourceDir\n\tlocal = config.DefaultSourceDirLocal\n\n\tcfg, err := l.LoadConfig()\n\tif err == nil {\n\t\tif len(cfg.SourceDir) > 0 {\n\t\t\tglobal = cfg.SourceDir\n\t\t}\n\t\tif len(cfg.SourceDirLocal) > 0 {\n\t\t\tlocal = cfg.SourceDirLocal\n\t\t}\n\t}\n\n\treturn global, local\n}\n"
  },
  {
    "path": "internal/command/add_test.go",
    "content": "package command\n\nimport (\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/spf13/afero\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/git\"\n)\n\nfunc TestLefthookAdd(t *testing.T) {\n\troot, err := filepath.Abs(\"src\")\n\tif err != nil {\n\t\tt.Errorf(\"unexpected error: %s\", err)\n\t}\n\n\tconfigPath := filepath.Join(root, \"lefthook.yml\")\n\thooksPath := filepath.Join(root, \".git\", \"hooks\")\n\n\thookPath := func(hook string) string {\n\t\treturn filepath.Join(root, \".git\", \"hooks\", hook)\n\t}\n\n\tfor n, tt := range [...]struct {\n\t\tname                    string\n\t\targs                    AddArgs\n\t\texistingHooks           map[string]string\n\t\tconfig                  string\n\t\twantExist, wantNotExist []string\n\t\twantError               bool\n\t}{\n\t\t{\n\t\t\tname: \"default empty repository\",\n\t\t\targs: AddArgs{Hook: \"pre-commit\"},\n\t\t\twantExist: []string{\n\t\t\t\thookPath(\"pre-commit\"),\n\t\t\t},\n\t\t\twantNotExist: []string{\n\t\t\t\tfilepath.Join(root, \".lefthook\"),\n\t\t\t\tfilepath.Join(root, \".lefthook-local\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:      \"unavailable hook\",\n\t\t\targs:      AddArgs{Hook: \"super-star\"},\n\t\t\twantError: true,\n\t\t\twantNotExist: []string{\n\t\t\t\thookPath(\"super-star\"),\n\t\t\t\tfilepath.Join(root, \".lefthook\"),\n\t\t\t\tfilepath.Join(root, \".lefthook-local\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"with create dirs arg\",\n\t\t\targs: AddArgs{Hook: \"post-commit\", CreateDirs: true},\n\t\t\twantExist: []string{\n\t\t\t\thookPath(\"post-commit\"),\n\t\t\t\tfilepath.Join(root, \".lefthook\"),\n\t\t\t\tfilepath.Join(root, \".lefthook-local\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"with configured source dirs\",\n\t\t\targs: AddArgs{Hook: \"post-commit\", CreateDirs: true},\n\t\t\tconfig: `\nsource_dir: .source_dir\nsource_dir_local: .source_dir_local\n`,\n\t\t\twantExist: []string{\n\t\t\t\thookPath(\"post-commit\"),\n\t\t\t\tfilepath.Join(root, \".source_dir\", \"post-commit\"),\n\t\t\t\tfilepath.Join(root, \".source_dir_local\", \"post-commit\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"with existing hook\",\n\t\t\targs: AddArgs{Hook: \"post-commit\"},\n\t\t\texistingHooks: map[string]string{\n\t\t\t\t\"post-commit\": \"custom script\",\n\t\t\t},\n\t\t\twantExist: []string{\n\t\t\t\thookPath(\"post-commit\"),\n\t\t\t\thookPath(\"post-commit.old\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"with existing lefthook hook\",\n\t\t\targs: AddArgs{Hook: \"post-commit\"},\n\t\t\texistingHooks: map[string]string{\n\t\t\t\t\"post-commit\": \"LEFTHOOK file\",\n\t\t\t},\n\t\t\twantExist: []string{\n\t\t\t\thookPath(\"post-commit\"),\n\t\t\t},\n\t\t\twantNotExist: []string{\n\t\t\t\thookPath(\"post-commit.old\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"with existing .old hook\",\n\t\t\targs: AddArgs{Hook: \"post-commit\"},\n\t\t\texistingHooks: map[string]string{\n\t\t\t\t\"post-commit\":     \"custom hook\",\n\t\t\t\t\"post-commit.old\": \"custom old hook\",\n\t\t\t},\n\t\t\twantError: true,\n\t\t\twantExist: []string{\n\t\t\t\thookPath(\"post-commit\"),\n\t\t\t\thookPath(\"post-commit.old\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"with existing .old hook, forced\",\n\t\t\targs: AddArgs{Hook: \"post-commit\", Force: true},\n\t\t\texistingHooks: map[string]string{\n\t\t\t\t\"post-commit\":     \"custom hook\",\n\t\t\t\t\"post-commit.old\": \"custom old hook\",\n\t\t\t},\n\t\t\twantExist: []string{\n\t\t\t\thookPath(\"post-commit\"),\n\t\t\t\thookPath(\"post-commit.old\"),\n\t\t\t},\n\t\t},\n\t} {\n\t\tt.Run(fmt.Sprintf(\"%d: %s\", n, tt.name), func(t *testing.T) {\n\t\t\tfs := afero.NewMemMapFs()\n\t\t\tlefthook := &Lefthook{\n\t\t\t\tfs: fs,\n\t\t\t\trepo: &git.Repository{\n\t\t\t\t\tFs:        fs,\n\t\t\t\t\tHooksPath: hooksPath,\n\t\t\t\t\tRootPath:  root,\n\t\t\t\t},\n\t\t\t}\n\n\t\t\tif len(tt.config) > 0 {\n\t\t\t\terr := afero.WriteFile(fs, configPath, []byte(tt.config), 0o644)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tfor hook, content := range tt.existingHooks {\n\t\t\t\tpath := hookPath(hook)\n\t\t\t\tif err := fs.MkdirAll(filepath.Dir(path), 0o755); err != nil {\n\t\t\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t\t\t}\n\t\t\t\tif err := afero.WriteFile(fs, path, []byte(content), 0o644); err != nil {\n\t\t\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\terr := lefthook.Add(t.Context(), tt.args)\n\t\t\tif tt.wantError && err == nil {\n\t\t\t\tt.Errorf(\"expected an error\")\n\t\t\t} else if !tt.wantError && err != nil {\n\t\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t\t}\n\n\t\t\tfor _, file := range tt.wantExist {\n\t\t\t\tok, err := afero.Exists(fs, file)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t\t\t}\n\t\t\t\tif !ok {\n\t\t\t\t\tt.Errorf(\"expected %s to exist\", file)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Test files that should not exist\n\t\t\tfor _, file := range tt.wantNotExist {\n\t\t\t\tok, err := afero.Exists(fs, file)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t\t\t}\n\t\t\t\tif ok {\n\t\t\t\t\tt.Errorf(\"expected %s to not exist\", file)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/command/check_install.go",
    "content": "package command\n\nimport (\n\t\"context\"\n\t\"os\"\n)\n\ntype installationStatus int\n\nconst (\n\tinstalled installationStatus = iota\n\tnotInstalled\n)\n\nfunc (l *Lefthook) CheckInstall(_ctx context.Context) error {\n\tcheck, err := l.checkInstall()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tswitch check {\n\tcase installed:\n\t\tos.Exit(0)\n\tcase notInstalled:\n\t\tos.Exit(1)\n\t}\n\n\treturn nil\n}\n\nfunc (l *Lefthook) checkInstall() (installationStatus, error) {\n\tif !l.configExists(l.repo.RootPath) {\n\t\treturn notInstalled, nil\n\t}\n\n\tcfg, err := l.LoadConfig()\n\tif err != nil {\n\t\treturn notInstalled, err\n\t}\n\n\tok, _ := l.checkHooksSynchronized(cfg)\n\tif !ok {\n\t\treturn notInstalled, nil\n\t}\n\n\treturn installed, nil\n}\n"
  },
  {
    "path": "internal/command/dump.go",
    "content": "package command\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"os\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/config\"\n)\n\ntype DumpArgs struct {\n\tFormat string\n}\n\nfunc (l *Lefthook) Dump(_ctx context.Context, args DumpArgs) error {\n\tcfg, err := l.LoadConfig()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"couldn't load config: %w\", err)\n\t}\n\n\tvar format config.DumpFormat\n\n\tswitch args.Format {\n\tcase \"yaml\":\n\t\tformat = config.YAMLFormat\n\tcase \"json\":\n\t\tformat = config.JSONFormat\n\tcase \"toml\":\n\t\tformat = config.TOMLFormat\n\tdefault:\n\t\tformat = config.YAMLFormat\n\t}\n\n\tif err := cfg.Dump(format, os.Stdout); err != nil {\n\t\treturn fmt.Errorf(\"couldn't dump config: %w\", err)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/command/install.go",
    "content": "package command\n\nimport (\n\t\"bufio\"\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"slices\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/gobwas/glob\"\n\t\"github.com/spf13/afero\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/config\"\n\t\"github.com/evilmartians/lefthook/v2/internal/git\"\n\t\"github.com/evilmartians/lefthook/v2/internal/log\"\n\t\"github.com/evilmartians/lefthook/v2/internal/templates\"\n)\n\nconst (\n\tconfigFileMode   = 0o666\n\tchecksumFileMode = 0o644\n\thooksDirMode     = 0o755\n\ttimestampBase    = 10\n\ttimestampBitsize = 64\n)\n\nvar (\n\tlefthookChecksumRegexp = regexp.MustCompile(`(\\w+)\\s+(\\d+)(?:\\s+([\\w,-]+))?`)\n\terrNoConfig            = errors.New(\"no lefthook config found\")\n)\n\ntype InstallArgs struct {\n\tForce          bool\n\tResetHooksPath bool\n}\n\nfunc (l *Lefthook) Install(ctx context.Context, args InstallArgs, hooks []string) error {\n\tif err := l.ensureHooksPathUnset(args.Force, args.ResetHooksPath); err != nil {\n\t\treturn err\n\t}\n\n\tcfg, err := l.readOrCreateConfig()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tvar remotesSynced bool\n\tfor _, remote := range cfg.Remotes {\n\t\tif remote.Configured() {\n\t\t\tif err = l.repo.SyncRemote(remote.GitURL, remote.Ref, args.Force); err != nil {\n\t\t\t\tlog.Warnf(\"Couldn't sync from %s. Will continue anyway: %s\", remote.GitURL, err)\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tremotesSynced = true\n\t\t}\n\t}\n\n\tif remotesSynced {\n\t\t// Reread the config file with synced remotes\n\t\tcfg, err = l.readOrCreateConfig()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn l.createHooksIfNeeded(cfg, hooks, args.Force)\n}\n\nfunc (l *Lefthook) readOrCreateConfig() (*config.Config, error) {\n\tlog.Debug(\"config dir: \", l.repo.RootPath)\n\n\tif !l.configExists(l.repo.RootPath) {\n\t\tlog.Info(\"Config not found, creating...\")\n\t\tif err := l.createConfig(l.repo.RootPath); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn l.LoadConfig()\n}\n\nfunc (l *Lefthook) configExists(path string) bool {\n\tconfigPath, _ := l.findMainConfig(path)\n\treturn configPath != \"\"\n}\n\nfunc (l *Lefthook) findMainConfig(path string) (string, error) {\n\tconfigOverride := os.Getenv(\"LEFTHOOK_CONFIG\")\n\tif len(configOverride) != 0 {\n\t\tif !filepath.IsAbs(configOverride) {\n\t\t\tconfigOverride = filepath.Join(path, configOverride)\n\t\t}\n\t\tif ok, _ := afero.Exists(l.fs, configOverride); !ok {\n\t\t\treturn \"\", fmt.Errorf(\"couldn't find config from LEFTHOOK_CONFIG: %s\", configOverride)\n\t\t}\n\t\treturn configOverride, nil\n\t}\n\n\tfor _, name := range config.MainConfigNames {\n\t\tfor _, extension := range config.Extensions {\n\t\t\tconfigPath := filepath.Join(path, name+extension)\n\t\t\tif ok, _ := afero.Exists(l.fs, configPath); ok {\n\t\t\t\treturn configPath, nil\n\t\t\t}\n\t\t}\n\t}\n\n\treturn \"\", errNoConfig\n}\n\nfunc (l *Lefthook) createConfig(path string) error {\n\tfile := filepath.Join(path, config.DefaultConfigName)\n\n\terr := afero.WriteFile(l.fs, file, templates.Config(), configFileMode)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tlog.Info(\"Added config:\", file)\n\n\treturn nil\n}\n\nfunc (l *Lefthook) syncHooks(cfg *config.Config, fetchRemotes bool) (*config.Config, error) {\n\tvar remotesSynced bool\n\n\t//nolint:nestif\n\tif fetchRemotes {\n\t\tfetchedRemotes := make(map[string]struct{})\n\t\tfor _, remote := range cfg.Remotes {\n\t\t\tif !remote.Configured() {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif l.shouldRefetch(remote) {\n\t\t\t\tif serr := l.repo.SyncRemote(remote.GitURL, remote.Ref, false); serr != nil {\n\t\t\t\t\tref, err := l.findAvailableRemoteRef(remote.GitURL)\n\t\t\t\t\tif err != nil {\n\t\t\t\t\t\tlog.Warnf(\"Couldn't sync from %s. Will continue without that remote.\", remote.GitURL)\n\t\t\t\t\t\tcontinue\n\t\t\t\t\t}\n\n\t\t\t\t\tif ref != \"\" {\n\t\t\t\t\t\tlog.Warnf(\"Couldn't sync %s %s. Will continue with fallback version: %s.\", remote.GitURL, remote.Ref, ref)\n\t\t\t\t\t} else {\n\t\t\t\t\t\tlog.Warnf(\"Couldn't sync %s %s. Will continue with old version.\", remote.GitURL, remote.Ref)\n\t\t\t\t\t}\n\n\t\t\t\t\tremote.Ref = ref\n\t\t\t\t}\n\n\t\t\t\tremotesSynced = true\n\t\t\t}\n\n\t\t\tfetchedRemotes[l.repo.RemoteFolder(remote.GitURL, remote.Ref)] = struct{}{}\n\t\t}\n\n\t\tif remotesSynced {\n\t\t\tvar err error\n\n\t\t\t// Reread the config file with synced remotes\n\t\t\tcfg, err = l.reloadConfig(cfg)\n\t\t\tif err != nil {\n\t\t\t\treturn nil, fmt.Errorf(\"failed to reread the config: %w\", err)\n\t\t\t}\n\t\t}\n\n\t\tif len(fetchedRemotes) > 0 {\n\t\t\t// Delete stale remotes\n\t\t\tentries, err := afero.ReadDir(l.fs, l.repo.RemotesFolder())\n\t\t\tif err != nil {\n\t\t\t\treturn nil, err\n\t\t\t}\n\t\t\tfor _, entry := range entries {\n\t\t\t\tremotePath := filepath.Join(l.repo.RemotesFolder(), entry.Name())\n\t\t\t\tif _, ok := fetchedRemotes[remotePath]; !ok {\n\t\t\t\t\tlog.Debug(\"Removing stale remote: \", remotePath)\n\n\t\t\t\t\tif err = l.fs.RemoveAll(remotePath); err != nil {\n\t\t\t\t\t\tlog.Error(\"failed to drop stale remote path: \", remotePath)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t}\n\n\tok, hooks := l.checkHooksSynchronized(cfg)\n\tif ok {\n\t\treturn cfg, nil\n\t}\n\n\t// Don't rely on config checksum if remotes were refetched\n\treturn cfg, l.createHooksIfNeeded(cfg, hooks, false)\n}\n\nfunc (l *Lefthook) shouldRefetch(remote *config.Remote) bool {\n\tif remote.Refetch || remote.RefetchFrequency == \"always\" {\n\t\treturn true\n\t}\n\tif remote.RefetchFrequency == \"never\" {\n\t\treturn false\n\t}\n\n\tvar lastFetchTime time.Time\n\tremotePath := l.repo.RemoteFolder(remote.GitURL, remote.Ref)\n\tinfo, err := l.fs.Stat(filepath.Join(remotePath, \".git\", \"FETCH_HEAD\"))\n\tif err != nil {\n\t\tif errors.Is(err, os.ErrNotExist) {\n\t\t\treturn true\n\t\t}\n\n\t\tlog.Warnf(\"Failed to detect last fetch time: %s\", err)\n\t\treturn false\n\t}\n\n\tif len(remote.RefetchFrequency) == 0 {\n\t\treturn false\n\t}\n\n\tlastFetchTime = info.ModTime()\n\ttimedelta, err := time.ParseDuration(remote.RefetchFrequency)\n\tif err != nil {\n\t\tlog.Warnf(\"Couldn't parse refetch frequency %s. Will continue anyway: %s\", remote.RefetchFrequency, err)\n\t\treturn false\n\t}\n\n\treturn time.Now().After(lastFetchTime.Add(timedelta))\n}\n\nfunc (l *Lefthook) findAvailableRemoteRef(url string) (string, error) {\n\tentries, err := afero.ReadDir(l.fs, l.repo.RemotesFolder())\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\trepoName := git.RemoteDirectoryName(url, \"\")\n\tg := glob.MustCompile(repoName + \"*\")\n\tfor _, info := range slices.Backward(entries) {\n\t\tif g.Match(info.Name()) {\n\t\t\tif info.Name() == repoName {\n\t\t\t\treturn \"\", nil\n\t\t\t}\n\n\t\t\toldRef := strings.Replace(info.Name(), repoName, \"\", 1)\n\t\t\treturn oldRef[1:], nil\n\t\t}\n\t}\n\treturn \"\", errors.New(\"not found\")\n}\n\nfunc (l *Lefthook) createHooksIfNeeded(cfg *config.Config, hooks []string, force bool) error {\n\tonlyHooks := make(map[string]struct{})\n\tfor _, hook := range hooks {\n\t\tonlyHooks[hook] = struct{}{}\n\t}\n\n\tvar success bool\n\tdefer func() {\n\t\tif !success {\n\t\t\tlog.Info(log.Cyan(\"sync hooks: ❌\"))\n\t\t}\n\t}()\n\n\tchecksum, err := cfg.Md5()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"could not calculate checksum: %w\", err)\n\t}\n\n\tif err = l.ensureHooksDirExists(); err != nil {\n\t\treturn fmt.Errorf(\"could not create hooks dir: %w\", err)\n\t}\n\n\trootsMap := make(map[string]struct{})\n\tfor _, hook := range cfg.Hooks {\n\t\tfor _, command := range hook.Commands {\n\t\t\tif len(command.Root) > 0 {\n\t\t\t\troot := strings.Trim(command.Root, \"/\")\n\t\t\t\tif _, ok := rootsMap[root]; !ok {\n\t\t\t\t\trootsMap[root] = struct{}{}\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tcollectAllJobRoots(rootsMap, hook.Jobs)\n\t}\n\troots := make([]string, 0, len(rootsMap))\n\tfor root := range rootsMap {\n\t\troots = append(roots, root)\n\t}\n\n\thookNames := make([]string, 0, len(cfg.Hooks)+1)\n\tfor hook := range cfg.Hooks {\n\t\tif _, ok := onlyHooks[hook]; len(onlyHooks) > 0 && !ok {\n\t\t\tlog.Debug(\"skip installing: \", hook)\n\t\t\tcontinue\n\t\t}\n\n\t\tif err = l.cleanHook(hook, force); err != nil {\n\t\t\treturn fmt.Errorf(\"could not replace the hook: %w\", err)\n\t\t}\n\n\t\tif _, ok := config.AvailableHooks[hook]; !ok && !cfg.InstallNonGitHooks {\n\t\t\tcontinue\n\t\t}\n\n\t\thookNames = append(hookNames, hook)\n\n\t\ttemplateArgs := templates.Args{\n\t\t\tRc:                      cfg.Rc,\n\t\t\tAssertLefthookInstalled: cfg.AssertLefthookInstalled,\n\t\t\tRoots:                   roots,\n\t\t\tLefthookPath:            cfg.Lefthook,\n\t\t}\n\t\tif err = l.addHook(hook, templateArgs); err != nil {\n\t\t\treturn fmt.Errorf(\"could not add the hook: %w\", err)\n\t\t}\n\t}\n\n\tif len(onlyHooks) == 0 && len(cfg.Hooks) == 0 {\n\t\ttemplateArgs := templates.Args{\n\t\t\tRc:                      cfg.Rc,\n\t\t\tAssertLefthookInstalled: cfg.AssertLefthookInstalled,\n\t\t\tRoots:                   roots,\n\t\t\tLefthookPath:            cfg.Lefthook,\n\t\t}\n\t\tif err = l.addHook(config.GhostHookName, templateArgs); err != nil {\n\t\t\treturn nil\n\t\t}\n\t}\n\n\tif err = l.addChecksumFile(checksum, hooks); err != nil {\n\t\treturn fmt.Errorf(\"could not create a checksum file: %w\", err)\n\t}\n\n\tsuccess = true\n\tif len(hookNames) > 0 {\n\t\tlog.Info(log.Cyan(\"sync hooks: ✔️\"), log.Gray(\"(\"+strings.Join(hookNames, \", \")+\")\"))\n\t} else {\n\t\tlog.Info(log.Cyan(\"sync hooks: ✔️ \"))\n\t}\n\n\treturn nil\n}\n\nfunc collectAllJobRoots(roots map[string]struct{}, jobs []*config.Job) {\n\tfor _, job := range jobs {\n\t\tif len(job.Root) > 0 {\n\t\t\troot := strings.Trim(job.Root, \"/\")\n\t\t\tif _, ok := roots[root]; !ok {\n\t\t\t\troots[root] = struct{}{}\n\t\t\t}\n\t\t}\n\n\t\tif job.Group != nil {\n\t\t\tcollectAllJobRoots(roots, job.Group.Jobs)\n\t\t}\n\t}\n}\n\n// checkHooksSynchronized checks is config hooks synchronized and returns the\n// list of hooks which are synchronized.\nfunc (l *Lefthook) checkHooksSynchronized(cfg *config.Config) (bool, []string) {\n\t// Check checksum in a checksum file\n\tfile, err := l.fs.Open(l.checksumFilePath())\n\tif err != nil {\n\t\treturn false, nil\n\t}\n\tdefer func() {\n\t\tif cErr := file.Close(); cErr != nil {\n\t\t\tlog.Warnf(\"Could not close %s: %s\", file.Name(), cErr)\n\t\t}\n\t}()\n\n\tscanner := bufio.NewScanner(file)\n\n\tvar storedChecksum string\n\tvar storedTimestamp int64\n\tvar storedHooks []string\n\n\t// Checksum format:\n\t// <md5sum> <timestamp> <hook1,hook2,hook3>\n\tfor scanner.Scan() {\n\t\tmatch := lefthookChecksumRegexp.FindStringSubmatch(scanner.Text())\n\t\tif match != nil {\n\t\t\tstoredChecksum = match[1]\n\t\t\tstoredTimestamp, err = strconv.ParseInt(match[2], timestampBase, timestampBitsize)\n\t\t\tif err != nil {\n\t\t\t\treturn false, nil\n\t\t\t}\n\t\t\tif len(match[3]) > 0 {\n\t\t\t\tstoredHooks = strings.Split(match[3], \",\")\n\t\t\t}\n\n\t\t\tbreak\n\t\t}\n\t}\n\tif err = scanner.Err(); err != nil {\n\t\tlog.Warnf(\"Could not read %s: %s\", file.Name(), err)\n\t\treturn false, nil\n\t}\n\n\tif len(storedChecksum) == 0 {\n\t\treturn false, storedHooks\n\t}\n\n\tconfigTimestamp, err := l.configLastUpdateTimestamp()\n\tif err != nil {\n\t\treturn false, storedHooks\n\t}\n\n\tif storedTimestamp == configTimestamp {\n\t\treturn true, storedHooks\n\t}\n\n\tconfigChecksum, err := cfg.Md5()\n\tif err != nil {\n\t\treturn false, storedHooks\n\t}\n\n\treturn storedChecksum == configChecksum, storedHooks\n}\n\nfunc (l *Lefthook) configLastUpdateTimestamp() (int64, error) {\n\tconfigPath, err := l.findMainConfig(l.repo.RootPath)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\tconfig, err := l.fs.Stat(configPath)\n\tif err != nil {\n\t\treturn 0, err\n\t}\n\n\treturn config.ModTime().Unix(), nil\n}\n\nfunc (l *Lefthook) addChecksumFile(checksum string, hooks []string) error {\n\ttimestamp, err := l.configLastUpdateTimestamp()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"unable to get config update timestamp: %w\", err)\n\t}\n\n\treturn afero.WriteFile(\n\t\tl.fs, l.checksumFilePath(), templates.Checksum(checksum, timestamp, hooks), checksumFileMode,\n\t)\n}\n\nfunc (l *Lefthook) checksumFilePath() string {\n\treturn filepath.Join(l.repo.InfoPath, config.ChecksumFileName)\n}\n\nfunc (l *Lefthook) ensureHooksDirExists() error {\n\texists, err := afero.Exists(l.fs, l.repo.HooksPath)\n\tif !exists || err != nil {\n\t\terr = l.fs.MkdirAll(l.repo.HooksPath, hooksDirMode)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// getHooksPathConfig checks if core.hooksPath is configured locally or globally.\nfunc (l *Lefthook) getHooksPathConfig() (local, global string) {\n\tlocal, _ = l.repo.Git.Cmd([]string{\"git\", \"config\", \"--local\", \"core.hooksPath\"})\n\tglobal, _ = l.repo.Git.Cmd([]string{\"git\", \"config\", \"--global\", \"core.hooksPath\"})\n\treturn\n}\n\n// ensureHooksPathUnset ensures core.hooksPath is not configured.\n//\n// In general using lefthook doesn't make sense with global hooks.\n// Local hooks make sense only in terms of migratio from other hook managers.\nfunc (l *Lefthook) ensureHooksPathUnset(force, resetHooksPath bool) error {\n\tlocal, global := l.getHooksPathConfig()\n\n\thasLocal := len(local) > 0\n\thasGlobal := len(global) > 0\n\n\tif !hasLocal && !hasGlobal {\n\t\treturn nil\n\t}\n\n\t// If neither force nor resetHooksPath, returns an error with instructions.\n\tif !force && !resetHooksPath {\n\t\treturn formatHooksPathError(local, global)\n\t}\n\n\tif hasLocal {\n\t\tlog.Warnf(\"core.hooksPath is set locally to '%s'\", local)\n\t}\n\tif hasGlobal {\n\t\tlog.Warnf(\"core.hooksPath is set globally to '%s'\", global)\n\t}\n\n\tif resetHooksPath {\n\t\treturn l.unsetHooksPathConfig(local, global)\n\t}\n\n\t// Local setting takes precedence.\n\tpath := local\n\tif !hasLocal && hasGlobal {\n\t\tpath = global\n\t}\n\tlog.Warnf(\"Installing hooks anyway in '%s'\", path)\n\n\treturn nil\n}\n\n// formatHooksPathError formats an error message for core.hooksPath conflicts.\nfunc formatHooksPathError(local, global string) error {\n\tvar errMsg strings.Builder\n\tvar hints []string\n\thasLocal := len(local) > 0\n\thasGlobal := len(global) > 0\n\n\tif hasLocal {\n\t\tfmt.Fprintf(&errMsg, \"core.hooksPath is set locally to '%s'\\n\", local)\n\t\thints = append(hints, \"hint:   git config --unset-all --local core.hooksPath\")\n\t}\n\tif hasGlobal {\n\t\tfmt.Fprintf(&errMsg, \"core.hooksPath is set globally to '%s'\\n\", global)\n\t\thints = append(hints, \"hint:   git config --unset-all --global core.hooksPath\")\n\t}\n\terrMsg.WriteString(\"\\n\")\n\terrMsg.WriteString(\"hint: Unset it:\\n\")\n\terrMsg.WriteString(strings.Join(hints, \"\\n\"))\n\terrMsg.WriteString(\"\\nhint:\\n\")\n\terrMsg.WriteString(\"hint: Run 'lefthook install --reset-hooks-path' to automatically unset it.\\n\")\n\n\t// Determine path: use global path if only global is defined, otherwise use local path\n\tpath := local\n\tif !hasLocal && hasGlobal {\n\t\tpath = global\n\t}\n\terrMsg.WriteString(\"hint:\\n\")\n\tfmt.Fprintf(&errMsg, \"hint: Run 'lefthook install --force' to install hooks anyway in '%s'.\", path)\n\n\treturn errors.New(errMsg.String())\n}\n\n// unsetHooksPathConfig removes core.hooksPath configuration.\nfunc (l *Lefthook) unsetHooksPathConfig(local, global string) error {\n\tif len(local) > 0 {\n\t\tif _, err := l.repo.Git.Cmd([]string{\"git\", \"config\", \"--local\", \"--unset-all\", \"core.hooksPath\"}); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to unset local core.hooksPath: %w\", err)\n\t\t}\n\t\tlog.Warn(\"local core.hooksPath has been unset.\")\n\t}\n\n\tif len(global) > 0 {\n\t\tif _, err := l.repo.Git.Cmd([]string{\"git\", \"config\", \"--global\", \"--unset-all\", \"core.hooksPath\"}); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to unset global core.hooksPath: %w\", err)\n\t\t}\n\t\tlog.Warn(\"global core.hooksPath has been unset.\")\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/command/install_test.go",
    "content": "package command\n\nimport (\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/spf13/afero\"\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/config\"\n\t\"github.com/evilmartians/lefthook/v2/tests/helpers/cmdtest\"\n\t\"github.com/evilmartians/lefthook/v2/tests/helpers/gittest\"\n)\n\nfunc TestLefthookInstall(t *testing.T) {\n\troot, err := filepath.Abs(\"src\")\n\tassert.NoError(t, err)\n\n\tconfigPath := filepath.Join(root, \"lefthook.yml\")\n\n\thookPath := func(hook string) string {\n\t\treturn filepath.Join(gittest.GitPath(root), \"hooks\", hook)\n\t}\n\n\tinfoPath := func(file string) string {\n\t\treturn filepath.Join(gittest.GitPath(root), \"info\", file)\n\t}\n\n\tfor n, tt := range [...]struct {\n\t\tname, config, checksum  string\n\t\tforce                   bool\n\t\thooks                   []string\n\t\tgit                     []cmdtest.Out\n\t\texistingFiles           map[string]string\n\t\twantExist, wantNotExist []string\n\t\twantError               bool\n\t}{\n\t\t{\n\t\t\tname:      \"without a config file\",\n\t\t\twantExist: []string{configPath},\n\t\t},\n\t\t{\n\t\t\tname: \"simple default config\",\n\t\t\tconfig: `\npre-commit:\n  commands:\n    tests:\n      run: yarn test\n\npost-commit:\n  commands:\n    notify:\n      run: echo 'Done!'\n`,\n\t\t\twantExist: []string{\n\t\t\t\tconfigPath,\n\t\t\t\thookPath(\"pre-commit\"),\n\t\t\t\thookPath(\"post-commit\"),\n\t\t\t\tinfoPath(config.ChecksumFileName),\n\t\t\t},\n\t\t\twantNotExist: []string{\n\t\t\t\thookPath(config.GhostHookName),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"with given hook\",\n\t\t\tconfig: `\npre-commit:\n  commands:\n    tests:\n      run: yarn test\n\npost-commit:\n  commands:\n    notify:\n      run: echo 'Done!'\n`,\n\t\t\thooks: []string{\"pre-commit\"},\n\t\t\twantExist: []string{\n\t\t\t\tconfigPath,\n\t\t\t\thookPath(\"pre-commit\"),\n\t\t\t\tinfoPath(config.ChecksumFileName),\n\t\t\t},\n\t\t\twantNotExist: []string{\n\t\t\t\thookPath(\"post-commit\"),\n\t\t\t\thookPath(config.GhostHookName),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"with non-git hook\",\n\t\t\tconfig: `\ntest:\n  jobs:\n    - run: echo test\n`,\n\t\t\twantNotExist: []string{\n\t\t\t\thookPath(\"test\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"with non-git hook\",\n\t\t\tconfig: `\ninstall_non_git_hooks: true\n\ntest:\n  jobs:\n    - run: echo test\n`,\n\t\t\twantExist: []string{\n\t\t\t\thookPath(\"test\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"with existing hooks\",\n\t\t\tconfig: `\npre-commit:\n  commands:\n    tests:\n      run: yarn test\n\npost-commit:\n  commands:\n    notify:\n      run: echo 'Done!'\n`,\n\t\t\texistingFiles: map[string]string{\n\t\t\t\thookPath(\"pre-commit\"): \"\",\n\t\t\t},\n\t\t\twantExist: []string{\n\t\t\t\tconfigPath,\n\t\t\t\thookPath(\"pre-commit\"),\n\t\t\t\thookPath(\"pre-commit.old\"),\n\t\t\t\thookPath(\"post-commit\"),\n\t\t\t\tinfoPath(config.ChecksumFileName),\n\t\t\t},\n\t\t\twantNotExist: []string{\n\t\t\t\thookPath(config.GhostHookName),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"with existing lefthook hooks\",\n\t\t\tconfig: `\npre-commit:\n  commands:\n    tests:\n      run: yarn test\n\npost-commit:\n  commands:\n    notify:\n      run: echo 'Done!'\n`,\n\t\t\texistingFiles: map[string]string{\n\t\t\t\thookPath(\"pre-commit\"): \"# LEFTHOOK file\",\n\t\t\t},\n\t\t\twantExist: []string{\n\t\t\t\tconfigPath,\n\t\t\t\thookPath(\"pre-commit\"),\n\t\t\t\thookPath(\"post-commit\"),\n\t\t\t\tinfoPath(config.ChecksumFileName),\n\t\t\t},\n\t\t\twantNotExist: []string{\n\t\t\t\thookPath(\"pre-commit.old\"),\n\t\t\t\thookPath(config.GhostHookName),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"with stale timestamp and checksum\",\n\t\t\tconfig: `\npre-commit:\n  commands:\n    tests:\n      run: yarn test\n\npost-commit:\n  commands:\n    notify:\n      run: echo 'Done!'\n`,\n\t\t\tchecksum: \"8b2c9fc6b3391b3cf020b97ab7037c62 1555894310\\n\",\n\t\t\twantExist: []string{\n\t\t\t\tconfigPath,\n\t\t\t\thookPath(\"pre-commit\"),\n\t\t\t\thookPath(\"post-commit\"),\n\t\t\t\tinfoPath(config.ChecksumFileName),\n\t\t\t},\n\t\t\twantNotExist: []string{\n\t\t\t\thookPath(config.GhostHookName),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"with existing hook and .old file\",\n\t\t\tconfig: `\npre-commit:\n  commands:\n    tests:\n      run: yarn test\n\npost-commit:\n  commands:\n    notify:\n      run: echo 'Done!'\n`,\n\t\t\texistingFiles: map[string]string{\n\t\t\t\thookPath(\"pre-commit\"):     \"\",\n\t\t\t\thookPath(\"pre-commit.old\"): \"\",\n\t\t\t},\n\t\t\twantError: true,\n\t\t\twantExist: []string{\n\t\t\t\tconfigPath,\n\t\t\t\thookPath(\"pre-commit\"),\n\t\t\t\thookPath(\"pre-commit.old\"),\n\t\t\t},\n\t\t\twantNotExist: []string{\n\t\t\t\tinfoPath(config.ChecksumFileName),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:  \"with existing hook and .old file, but forced\",\n\t\t\tforce: true,\n\t\t\tconfig: `\npre-commit:\n  commands:\n    tests:\n      run: yarn test\n\npost-commit:\n  commands:\n    notify:\n      run: echo 'Done!'\n`,\n\t\t\texistingFiles: map[string]string{\n\t\t\t\thookPath(\"pre-commit\"):     \"\",\n\t\t\t\thookPath(\"pre-commit.old\"): \"\",\n\t\t\t},\n\t\t\twantExist: []string{\n\t\t\t\tconfigPath,\n\t\t\t\thookPath(\"pre-commit\"),\n\t\t\t\thookPath(\"pre-commit.old\"),\n\t\t\t\thookPath(\"post-commit\"),\n\t\t\t\tinfoPath(config.ChecksumFileName),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"with custom hook\",\n\t\t\tconfig: `\nmy-custom-hook:\n  commands:\n    custom:\n      run: echo 'Hello from custom!'\n`,\n\t\t\twantNotExist: []string{\n\t\t\t\thookPath(\"my-custom-hook\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"with custom existing hook\",\n\t\t\tconfig: `\nmy-custom-hook:\n  commands:\n    custom:\n      run: echo 'Hello from custom!'\n`,\n\t\t\texistingFiles: map[string]string{\n\t\t\t\thookPath(\"my-custom-hook\"): \"\",\n\t\t\t},\n\t\t\twantExist: []string{\n\t\t\t\thookPath(\"my-custom-hook.old\"),\n\t\t\t},\n\t\t\twantNotExist: []string{\n\t\t\t\thookPath(\"my-custom-hook\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"with unfetched remote\",\n\t\t\tconfig: `\nremotes:\n  - git_url: https://github.com/evilmartians/lefthook\n    configs:\n      - lefthook.yml\n`,\n\t\t\tgit: []cmdtest.Out{\n\t\t\t\t{\n\t\t\t\t\tCommand: \"git -C \" + filepath.Join(root, \".git\", \"info\", \"lefthook-remotes\") + \" clone --quiet --origin origin --depth 1 https://github.com/evilmartians/lefthook lefthook\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"needs refetching\",\n\t\t\tconfig: `\nremotes:\n  - git_url: https://github.com/evilmartians/lefthook\n    ref: v2.0.0\n    configs:\n      - lefthook.yml\n`,\n\t\t\texistingFiles: map[string]string{\n\t\t\t\tfilepath.Join(root, \".git\", \"info\", \"lefthook-remotes\", \"lefthook-v2.0.1\", \".git\", \"FETCH_HEAD\"): \"\",\n\t\t\t},\n\t\t\tgit: []cmdtest.Out{\n\t\t\t\t{\n\t\t\t\t\tCommand: \"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\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tCommand: \"git -C \" + filepath.Join(root, \".git\", \"info\", \"lefthook-remotes\", \"lefthook-v2.0.0\") + \" fetch --quiet --depth 1 origin -- v2.0.0\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tCommand: \"git -C \" + filepath.Join(root, \".git\", \"info\", \"lefthook-remotes\", \"lefthook-v2.0.0\") + \" checkout FETCH_HEAD\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t} {\n\t\tfs := afero.NewMemMapFs()\n\n\t\tt.Run(fmt.Sprintf(\"%d: %s\", n, tt.name), func(t *testing.T) {\n\t\t\tassert := assert.New(t)\n\n\t\t\t// Prepend git config commands required by getHooksPathConfig() in install.go.\n\t\t\t// These commands are always called at the start of Install() to detect core.hooksPath conflicts.\n\t\t\tgitCmds := tt.git\n\t\t\tif len(gitCmds) == 0 || gitCmds[0].Command != \"git config --local core.hooksPath\" {\n\t\t\t\tgitCmds = append([]cmdtest.Out{\n\t\t\t\t\t{Command: \"git config --local core.hooksPath\"},\n\t\t\t\t\t{Command: \"git config --global core.hooksPath\"},\n\t\t\t\t}, gitCmds...)\n\t\t\t}\n\n\t\t\trepo := gittest.NewRepositoryBuilder().\n\t\t\t\tRoot(root).\n\t\t\t\tFs(fs).\n\t\t\t\tCmd(cmdtest.NewOrdered(t, gitCmds)).\n\t\t\t\tBuild()\n\t\t\tlefthook := &Lefthook{\n\t\t\t\tfs:   fs,\n\t\t\t\trepo: repo,\n\t\t\t}\n\n\t\t\t// Create configuration file\n\t\t\tif len(tt.config) > 0 {\n\t\t\t\tassert.NoError(afero.WriteFile(fs, configPath, []byte(tt.config), 0o644))\n\t\t\t\ttimestamp := time.Date(2022, time.June, 22, 10, 40, 10, 1, time.UTC)\n\t\t\t\tassert.NoError(fs.Chtimes(configPath, timestamp, timestamp))\n\t\t\t}\n\n\t\t\tif len(tt.checksum) > 0 {\n\t\t\t\tassert.NoError(afero.WriteFile(fs, lefthook.checksumFilePath(), []byte(tt.checksum), 0o644))\n\t\t\t}\n\n\t\t\t// Create files that should exist\n\t\t\tfor path, content := range tt.existingFiles {\n\t\t\t\tassert.NoError(fs.MkdirAll(filepath.Dir(path), 0o755))\n\t\t\t\tassert.NoError(afero.WriteFile(fs, path, []byte(content), 0o755))\n\t\t\t}\n\n\t\t\t// Do install\n\t\t\terr := lefthook.Install(t.Context(), InstallArgs{Force: tt.force}, tt.hooks)\n\t\t\tif tt.wantError {\n\t\t\t\tassert.Error(err)\n\t\t\t} else {\n\t\t\t\tassert.NoError(err)\n\t\t\t}\n\n\t\t\t// Test files that should exist\n\t\t\tfor _, file := range tt.wantExist {\n\t\t\t\tok, err := afero.Exists(fs, file)\n\t\t\t\tassert.NoError(err)\n\t\t\t\tassert.Equal(true, ok)\n\t\t\t}\n\n\t\t\t// Test files that should not exist\n\t\t\tfor _, file := range tt.wantNotExist {\n\t\t\t\tok, err := afero.Exists(fs, file)\n\t\t\t\tassert.NoError(err)\n\t\t\t\tassert.Equal(false, ok)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc Test_syncHooks(t *testing.T) {\n\troot, err := filepath.Abs(\"src\")\n\tassert.NoError(t, err)\n\n\tconfigPath := filepath.Join(root, \"lefthook.yml\")\n\n\thookPath := func(hook string) string {\n\t\treturn filepath.Join(root, \".git\", \"hooks\", hook)\n\t}\n\n\tinfoPath := func(file string) string {\n\t\treturn filepath.Join(root, \".git\", \"info\", file)\n\t}\n\n\tfor n, tt := range [...]struct {\n\t\tname, config, checksum  string\n\t\texistingFiles           map[string]string\n\t\tgit                     []cmdtest.Out\n\t\twantExist, wantNotExist []string\n\t\twantError               bool\n\t}{\n\t\t{\n\t\t\tname: \"with synchronized hooks\",\n\t\t\tconfig: `\npre-commit:\n  commands:\n    tests:\n      run: yarn test\n\npost-commit:\n  commands:\n    notify:\n      run: echo 'Done!'\n`,\n\t\t\tchecksum: \"8b2c9fc6b3391b3cf020b97ab7037c61 1655894410\\n\",\n\t\t\twantExist: []string{\n\t\t\t\tconfigPath,\n\t\t\t\tinfoPath(config.ChecksumFileName),\n\t\t\t},\n\t\t\twantNotExist: []string{\n\t\t\t\thookPath(\"pre-commit\"),\n\t\t\t\thookPath(\"post-commit\"),\n\t\t\t\thookPath(config.GhostHookName),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"with stale timestamp but synchronized\",\n\t\t\tconfig: `\npre-commit:\n  commands:\n    tests:\n      run: yarn test\n\npost-commit:\n  commands:\n    notify:\n      run: echo 'Done!'\n`,\n\t\t\tchecksum: \"939f59e3f706df65f379a9ff5ce0119b 1555894310\\n\",\n\t\t\twantExist: []string{\n\t\t\t\tconfigPath,\n\t\t\t\tinfoPath(config.ChecksumFileName),\n\t\t\t},\n\t\t\twantNotExist: []string{\n\t\t\t\thookPath(\"pre-commit\"),\n\t\t\t\thookPath(\"post-commit\"),\n\t\t\t\thookPath(config.GhostHookName),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"unsynchronized\",\n\t\t\tconfig: `\npre-commit:\n  commands:\n    tests:\n      run: yarn test\n\npost-commit:\n  commands:\n    notify:\n      run: echo 'Done!'\n\ncommit-msg:\n  jobs:\n    - run: echo 'commit-msg'\n`,\n\t\t\tchecksum: \"00000000f706df65f379a9ff5ce0119b 1555894311\\n\",\n\t\t\twantExist: []string{\n\t\t\t\tconfigPath,\n\t\t\t\thookPath(\"pre-commit\"),\n\t\t\t\thookPath(\"post-commit\"),\n\t\t\t\thookPath(\"commit-msg\"),\n\t\t\t\tinfoPath(config.ChecksumFileName),\n\t\t\t},\n\t\t\twantNotExist: []string{\n\t\t\t\thookPath(config.GhostHookName),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"unsynchronized with selected hooks\",\n\t\t\tconfig: `\npre-commit:\n  commands:\n    tests:\n      run: yarn test\n\npost-commit:\n  commands:\n    notify:\n      run: echo 'Done!'\n\ncommit-msg:\n  jobs:\n    - run: echo 'commit-msg'\n`,\n\t\t\tchecksum: \"00000000f706df65f379a9ff5ce0119b 1555894310 pre-commit,post-commit\\n\",\n\t\t\twantExist: []string{\n\t\t\t\tconfigPath,\n\t\t\t\thookPath(\"pre-commit\"),\n\t\t\t\thookPath(\"post-commit\"),\n\t\t\t\tinfoPath(config.ChecksumFileName),\n\t\t\t},\n\t\t\twantNotExist: []string{\n\t\t\t\thookPath(\"commit-msg\"),\n\t\t\t\thookPath(config.GhostHookName),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"with unfetched remote\",\n\t\t\tconfig: `\nremotes:\n  - git_url: https://github.com/evilmartians/lefthook\n    configs:\n      - lefthook.yml\n`,\n\t\t\tgit: []cmdtest.Out{\n\t\t\t\t{\n\t\t\t\t\tCommand: \"git -C \" + filepath.Join(root, \".git\", \"info\", \"lefthook-remotes\") + \" clone --quiet --origin origin --depth 1 https://github.com/evilmartians/lefthook lefthook\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"no need to refetch\",\n\t\t\tconfig: `\nremotes:\n  - git_url: https://github.com/evilmartians/lefthook\n    ref: v2.0.1\n    configs:\n      - lefthook.yml\n`,\n\t\t\texistingFiles: map[string]string{\n\t\t\t\tfilepath.Join(root, \".git\", \"info\", \"lefthook-remotes\", \"lefthook-v2.0.1\", \".git\", \"FETCH_HEAD\"): \"\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"needs refetching\",\n\t\t\tconfig: `\nremotes:\n  - git_url: https://github.com/evilmartians/lefthook\n    ref: v2.0.0\n    configs:\n      - lefthook.yml\n`,\n\t\t\texistingFiles: map[string]string{\n\t\t\t\tfilepath.Join(root, \".git\", \"info\", \"lefthook-remotes\", \"lefthook-v2.0.1\", \".git\", \"FETCH_HEAD\"): \"\",\n\t\t\t},\n\t\t\tgit: []cmdtest.Out{\n\t\t\t\t{\n\t\t\t\t\tCommand: \"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\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tCommand: \"git -C \" + filepath.Join(root, \".git\", \"info\", \"lefthook-remotes\", \"lefthook-v2.0.0\") + \" fetch --quiet --depth 1 origin -- v2.0.0\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tCommand: \"git -C \" + filepath.Join(root, \".git\", \"info\", \"lefthook-remotes\", \"lefthook-v2.0.0\") + \" checkout FETCH_HEAD\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twantNotExist: []string{\n\t\t\t\tfilepath.Join(root, \".git\", \"info\", \"lefthook-remotes\", \"lefthook-v2.0.1\"),\n\t\t\t},\n\t\t},\n\t} {\n\t\tfs := afero.NewMemMapFs()\n\n\t\tt.Run(fmt.Sprintf(\"%d: %s\", n, tt.name), func(t *testing.T) {\n\t\t\tassert := assert.New(t)\n\n\t\t\trepo := gittest.NewRepositoryBuilder().Root(root).Fs(fs).Cmd(cmdtest.NewOrdered(t, tt.git)).Build()\n\t\t\tlefthook := &Lefthook{\n\t\t\t\tfs:   fs,\n\t\t\t\trepo: repo,\n\t\t\t}\n\n\t\t\t// Create configuration file\n\t\t\tif len(tt.config) > 0 {\n\t\t\t\tassert.NoError(afero.WriteFile(fs, configPath, []byte(tt.config), 0o644))\n\t\t\t\ttimestamp := time.Date(2022, time.June, 22, 10, 40, 10, 1, time.UTC)\n\t\t\t\tassert.NoError(fs.Chtimes(configPath, timestamp, timestamp))\n\t\t\t}\n\n\t\t\tif len(tt.checksum) > 0 {\n\t\t\t\tassert.NoError(afero.WriteFile(fs, lefthook.checksumFilePath(), []byte(tt.checksum), 0o644))\n\t\t\t}\n\n\t\t\t// Create files that should exist\n\t\t\tfor path, content := range tt.existingFiles {\n\t\t\t\tassert.NoError(fs.MkdirAll(filepath.Dir(path), 0o755))\n\t\t\t\tassert.NoError(afero.WriteFile(fs, path, []byte(content), 0o755))\n\t\t\t}\n\n\t\t\tcfg, err := config.Load(lefthook.fs, repo)\n\t\t\tassert.NoError(err)\n\n\t\t\t// Create hooks\n\t\t\t_, err = lefthook.syncHooks(cfg, true)\n\t\t\tif tt.wantError {\n\t\t\t\tassert.Error(err)\n\t\t\t} else {\n\t\t\t\tassert.NoError(err)\n\t\t\t}\n\n\t\t\t// Test files that should exist\n\t\t\tfor _, file := range tt.wantExist {\n\t\t\t\tok, err := afero.Exists(fs, file)\n\t\t\t\tassert.NoError(err)\n\t\t\t\tassert.Equal(true, ok, file)\n\t\t\t}\n\n\t\t\t// Test files that should not exist\n\t\t\tfor _, file := range tt.wantNotExist {\n\t\t\t\tok, err := afero.Exists(fs, file)\n\t\t\t\tassert.NoError(err)\n\t\t\t\tassert.Equal(false, ok, file)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestShouldRefetch(t *testing.T) {\n\troot, err := filepath.Abs(\"src\")\n\tassert.NoError(t, err)\n\n\tconfigPath := filepath.Join(root, \"lefthook.yml\")\n\tfetchHeadPath := func(lefthook *Lefthook, remote *config.Remote) string {\n\t\tremotePath := lefthook.repo.RemoteFolder(remote.GitURL, remote.Ref)\n\t\treturn filepath.Join(remotePath, \".git\", \"FETCH_HEAD\")\n\t}\n\n\tfor n, tt := range [...]struct {\n\t\tname, config                                                    string\n\t\tshouldRefetchInitially, shouldRefetchAfter, shouldRefetchBefore bool\n\t}{\n\t\t{\n\t\t\tname: \"with refetch frequency configured to always\",\n\t\t\tconfig: `\nremotes:\n  - git_url: https://github.com/evilmartians/lefthook\n    refetch_frequency: always\n    configs:\n      - examples/remote/ping.yml\n`,\n\t\t\tshouldRefetchInitially: true,\n\t\t\tshouldRefetchAfter:     true,\n\t\t\tshouldRefetchBefore:    true,\n\t\t},\n\t\t{\n\t\t\tname: \"with refetch frequency configured to 1 minute\",\n\t\t\tconfig: `\nremotes:\n  - git_url: https://github.com/evilmartians/lefthook\n    refetch_frequency: 1m\n    configs:\n      - examples/remote/ping.yml\n`,\n\t\t\tshouldRefetchInitially: true,\n\t\t\tshouldRefetchAfter:     true,\n\t\t\tshouldRefetchBefore:    false,\n\t\t},\n\t\t{\n\t\t\tname: \"with refetch frequency configured to never\",\n\t\t\tconfig: `\nremotes:\n  - git_url: https://github.com/evilmartians/lefthook\n    refetch_frequency: never\n    configs:\n      - examples/remote/ping.yml\n`,\n\t\t\tshouldRefetchInitially: false,\n\t\t\tshouldRefetchAfter:     false,\n\t\t\tshouldRefetchBefore:    false,\n\t\t},\n\t\t{\n\t\t\tname: \"with refetch frequency not configured\",\n\t\t\tconfig: `\nremotes:\n  - git_url: https://github.com/evilmartians/lefthook\n    configs:\n      - examples/remote/ping.yml\n`,\n\t\t\tshouldRefetchInitially: true,\n\t\t\tshouldRefetchAfter:     false,\n\t\t\tshouldRefetchBefore:    false,\n\t\t},\n\t} {\n\t\tfs := afero.NewMemMapFs()\n\t\trepo := gittest.NewRepositoryBuilder().Root(root).Fs(fs).Build()\n\t\tlefthook := &Lefthook{\n\t\t\tfs:   fs,\n\t\t\trepo: repo,\n\t\t}\n\n\t\tt.Run(fmt.Sprintf(\"%d: %s\", n, tt.name), func(t *testing.T) {\n\t\t\tassert := assert.New(t)\n\n\t\t\t// Create configuration file\n\t\t\tif len(tt.config) > 0 {\n\t\t\t\tassert.NoError(afero.WriteFile(fs, configPath, []byte(tt.config), 0o644))\n\t\t\t\ttimestamp := time.Date(2022, time.June, 22, 10, 40, 10, 1, time.UTC)\n\t\t\t\tassert.NoError(fs.Chtimes(configPath, timestamp, timestamp))\n\t\t\t}\n\n\t\t\tcfg, err := config.Load(lefthook.fs, repo)\n\t\t\tassert.NoError(err)\n\n\t\t\tremote := cfg.Remotes[0]\n\n\t\t\tassert.Equal(lefthook.shouldRefetch(remote), tt.shouldRefetchInitially)\n\n\t\t\tassert.NoError(afero.WriteFile(fs, fetchHeadPath(lefthook, remote), []byte(\"\"), 0o644))\n\t\t\tfirstFetchTime := time.Now().Add(-2 * time.Minute)\n\n\t\t\tassert.NoError(fs.Chtimes(fetchHeadPath(lefthook, remote), firstFetchTime, firstFetchTime))\n\t\t\tassert.Equal(lefthook.shouldRefetch(remote), tt.shouldRefetchAfter)\n\n\t\t\tassert.NoError(fs.Chtimes(fetchHeadPath(lefthook, remote), firstFetchTime, time.Now()))\n\t\t\tassert.Equal(lefthook.shouldRefetch(remote), tt.shouldRefetchBefore)\n\t\t})\n\t}\n}\n\nfunc TestLefthookInstallWithCoreHooksPath(t *testing.T) {\n\troot, err := filepath.Abs(\"src\")\n\tassert.NoError(t, err)\n\n\tconfigPath := filepath.Join(root, \"lefthook.yml\")\n\n\thookPath := func(hook string) string {\n\t\treturn filepath.Join(gittest.GitPath(root), \"hooks\", hook)\n\t}\n\n\tinfoPath := func(file string) string {\n\t\treturn filepath.Join(gittest.GitPath(root), \"info\", file)\n\t}\n\n\tconfigContent := `\npre-commit:\n  commands:\n    tests:\n      run: yarn test\n`\n\n\tfor n, tt := range [...]struct {\n\t\tname           string\n\t\tforce          bool\n\t\tresetHooksPath bool\n\t\tgit            []cmdtest.Out\n\t\twantError      bool\n\t\twantErrorMsg   string\n\t\twantExist      []string\n\t}{\n\t\t{\n\t\t\tname:           \"with local and global core.hooksPath without flags\",\n\t\t\tforce:          false,\n\t\t\tresetHooksPath: false,\n\t\t\tgit: []cmdtest.Out{\n\t\t\t\t{\n\t\t\t\t\tCommand: \"git config --local core.hooksPath\",\n\t\t\t\t\tOutput:  \".custom-hooks\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tCommand: \"git config --global core.hooksPath\",\n\t\t\t\t\tOutput:  \"/usr/local/hooks\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twantError:    true,\n\t\t\twantErrorMsg: \"core.hooksPath\",\n\t\t},\n\t\t{\n\t\t\tname:           \"with local and global core.hooksPath with --force\",\n\t\t\tforce:          true,\n\t\t\tresetHooksPath: false,\n\t\t\tgit: []cmdtest.Out{\n\t\t\t\t{\n\t\t\t\t\tCommand: \"git config --local core.hooksPath\",\n\t\t\t\t\tOutput:  \".custom-hooks\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tCommand: \"git config --global core.hooksPath\",\n\t\t\t\t\tOutput:  \"/usr/local/hooks\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twantError: false,\n\t\t\twantExist: []string{\n\t\t\t\tconfigPath,\n\t\t\t\thookPath(\"pre-commit\"),\n\t\t\t\tinfoPath(config.ChecksumFileName),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:           \"with local and global core.hooksPath with --reset-hooks-path\",\n\t\t\tforce:          false,\n\t\t\tresetHooksPath: true,\n\t\t\tgit: []cmdtest.Out{\n\t\t\t\t{\n\t\t\t\t\tCommand: \"git config --local core.hooksPath\",\n\t\t\t\t\tOutput:  \".custom-hooks\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tCommand: \"git config --global core.hooksPath\",\n\t\t\t\t\tOutput:  \"/usr/local/hooks\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tCommand: \"git config --local --unset-all core.hooksPath\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tCommand: \"git config --global --unset-all core.hooksPath\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twantError: false,\n\t\t\twantExist: []string{\n\t\t\t\tconfigPath,\n\t\t\t\thookPath(\"pre-commit\"),\n\t\t\t\tinfoPath(config.ChecksumFileName),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:           \"with only global core.hooksPath with --force\",\n\t\t\tforce:          true,\n\t\t\tresetHooksPath: false,\n\t\t\tgit: []cmdtest.Out{\n\t\t\t\t{\n\t\t\t\t\tCommand: \"git config --local core.hooksPath\",\n\t\t\t\t\tOutput:  \"\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tCommand: \"git config --global core.hooksPath\",\n\t\t\t\t\tOutput:  \"/usr/local/hooks\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twantError: false,\n\t\t\twantExist: []string{\n\t\t\t\tconfigPath,\n\t\t\t\thookPath(\"pre-commit\"),\n\t\t\t\tinfoPath(config.ChecksumFileName),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname:           \"with only local core.hooksPath with --reset-hooks-path\",\n\t\t\tforce:          false,\n\t\t\tresetHooksPath: true,\n\t\t\tgit: []cmdtest.Out{\n\t\t\t\t{\n\t\t\t\t\tCommand: \"git config --local core.hooksPath\",\n\t\t\t\t\tOutput:  \".custom-hooks\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tCommand: \"git config --global core.hooksPath\",\n\t\t\t\t\tOutput:  \"\",\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tCommand: \"git config --local --unset-all core.hooksPath\",\n\t\t\t\t},\n\t\t\t},\n\t\t\twantError: false,\n\t\t\twantExist: []string{\n\t\t\t\tconfigPath,\n\t\t\t\thookPath(\"pre-commit\"),\n\t\t\t\tinfoPath(config.ChecksumFileName),\n\t\t\t},\n\t\t},\n\t} {\n\t\tfs := afero.NewMemMapFs()\n\n\t\tt.Run(fmt.Sprintf(\"%d: %s\", n, tt.name), func(t *testing.T) {\n\t\t\tassert := assert.New(t)\n\n\t\t\trepo := gittest.NewRepositoryBuilder().\n\t\t\t\tRoot(root).\n\t\t\t\tFs(fs).\n\t\t\t\tCmd(cmdtest.NewOrdered(t, tt.git)).\n\t\t\t\tBuild()\n\t\t\tlefthook := &Lefthook{\n\t\t\t\tfs:   fs,\n\t\t\t\trepo: repo,\n\t\t\t}\n\n\t\t\t// Create configuration file\n\t\t\tassert.NoError(afero.WriteFile(fs, configPath, []byte(configContent), 0o644))\n\t\t\ttimestamp := time.Date(2022, time.June, 22, 10, 40, 10, 1, time.UTC)\n\t\t\tassert.NoError(fs.Chtimes(configPath, timestamp, timestamp))\n\n\t\t\t// Do install\n\t\t\terr := lefthook.Install(t.Context(), InstallArgs{Force: tt.force, ResetHooksPath: tt.resetHooksPath}, nil)\n\t\t\tif tt.wantError {\n\t\t\t\tif assert.Error(err) && tt.wantErrorMsg != \"\" {\n\t\t\t\t\tassert.Contains(err.Error(), tt.wantErrorMsg)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tassert.NoError(err)\n\t\t\t\t// Test files that should exist\n\t\t\t\tfor _, file := range tt.wantExist {\n\t\t\t\t\tok, err := afero.Exists(fs, file)\n\t\t\t\t\tassert.NoError(err)\n\t\t\t\t\tassert.True(ok)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/command/lefthook.go",
    "content": "package command\n\nimport (\n\t\"bufio\"\n\t\"bytes\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"slices\"\n\t\"strings\"\n\n\t\"github.com/knadh/koanf/parsers/json\"\n\t\"github.com/knadh/koanf/providers/rawbytes\"\n\t\"github.com/knadh/koanf/v2\"\n\t\"github.com/spf13/afero\"\n\t\"github.com/urfave/cli/v3\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/config\"\n\t\"github.com/evilmartians/lefthook/v2/internal/git\"\n\t\"github.com/evilmartians/lefthook/v2/internal/log\"\n\t\"github.com/evilmartians/lefthook/v2/internal/system\"\n\t\"github.com/evilmartians/lefthook/v2/internal/templates\"\n)\n\nconst (\n\tEnvVerbose             = \"LEFTHOOK_VERBOSE\" // keep all output\n\tenvNoColor             = \"NO_COLOR\"\n\tenvClicolorForce       = \"CLICOLOR_FORCE\"\n\tenvClicolor            = \"CLICOLOR\"\n\thookFileMode           = 0o755\n\toldHookPostfix         = \".old\"\n\thookContentFingerprint = \"LEFTHOOK\"\n)\n\ntype Lefthook struct {\n\tfs     afero.Fs\n\trepo   *git.Repository\n\tcolors string\n}\n\n// NewLefthook returns an instance of Lefthook.\nfunc NewLefthook(verbose bool, colors string) (*Lefthook, error) {\n\tfs := afero.NewOsFs()\n\n\tif isEnvEnabled(EnvVerbose) {\n\t\tverbose = true\n\t}\n\n\tif verbose {\n\t\tlog.SetLevel(log.DebugLevel)\n\t}\n\n\tswitch colors {\n\tcase \"auto\", \"\":\n\t\tif isEnvEnabled(envClicolorForce) {\n\t\t\tcolors = \"on\"\n\t\t}\n\n\t\tif isEnvEnabled(envNoColor) {\n\t\t\tcolors = \"off\"\n\t\t}\n\tcase \"on\":\n\t\t// Try to overwrite the lipgloss ENV handling.\n\t\t_ = os.Unsetenv(envNoColor)\n\t\t_ = os.Unsetenv(envClicolor)\n\t}\n\n\tlog.SetColors(colors)\n\n\trepo, err := git.NewRepository(fs, git.NewExecutor(system.Cmd))\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn &Lefthook{fs: fs, repo: repo, colors: colors}, nil\n}\n\nfunc (l *Lefthook) LoadConfig() (*config.Config, error) {\n\tcfg, err := config.Load(l.fs, l.repo)\n\n\t// Reset colors\n\tlog.SetColors(l.colors)\n\n\treturn cfg, err\n}\n\nfunc (l *Lefthook) reloadConfig(cfg *config.Config) (*config.Config, error) {\n\tlog.Debug(\"Reloading config...\")\n\n\tbuffer := new(bytes.Buffer)\n\tif err := cfg.Dump(config.JSONCompactFormat, buffer); err != nil {\n\t\treturn nil, err\n\t}\n\n\tmain := koanf.New(\".\")\n\tif err := main.Load(rawbytes.Provider(buffer.Bytes()), json.Parser()); err != nil {\n\t\treturn nil, err\n\t}\n\n\tsecondary, err := config.LoadSecondary(main, l.fs, l.repo)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn config.Unmarshal(main, secondary)\n}\n\n// Tests a file whether it is a lefthook-created file.\nfunc (l *Lefthook) isLefthookFile(path string) bool {\n\tfile, err := l.fs.Open(path)\n\tif err != nil {\n\t\treturn false\n\t}\n\tdefer func() {\n\t\tif cErr := file.Close(); cErr != nil {\n\t\t\tlog.Warnf(\"Could not close %s: %s\", file.Name(), cErr)\n\t\t}\n\t}()\n\n\tscanner := bufio.NewScanner(file)\n\n\tfor scanner.Scan() {\n\t\tif strings.Contains(scanner.Text(), hookContentFingerprint) {\n\t\t\treturn true\n\t\t}\n\t}\n\tif err = scanner.Err(); err != nil {\n\t\tlog.Warnf(\"Could not read %s: %s\", file.Name(), err)\n\t}\n\n\treturn false\n}\n\n// Removes the hook from hooks path, saving non-lefthook hooks with .old suffix.\nfunc (l *Lefthook) cleanHook(hook string, force bool) error {\n\thookPath := filepath.Join(l.repo.HooksPath, hook)\n\texists, err := afero.Exists(l.fs, hookPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !exists {\n\t\treturn nil\n\t}\n\n\t// Just remove lefthook hook\n\tif l.isLefthookFile(hookPath) {\n\t\treturn l.fs.Remove(hookPath)\n\t}\n\n\t// Check if .old file already exists before renaming.\n\texists, err = afero.Exists(l.fs, hookPath+oldHookPostfix)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif exists {\n\t\tif force {\n\t\t\tlog.Infof(\"\\nFile %s.old already exists, overwriting\\n\", hook)\n\t\t} else {\n\t\t\treturn fmt.Errorf(\"can't rename %s to %s.old - file already exists\", hook, hook)\n\t\t}\n\t}\n\n\terr = l.fs.Rename(hookPath, hookPath+oldHookPostfix)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tlog.Infof(\"Renamed %s to %s.old\\n\", hookPath, hookPath)\n\treturn nil\n}\n\n// Creates a hook file using hook template.\nfunc (l *Lefthook) addHook(hook string, args templates.Args) error {\n\thookPath := filepath.Join(l.repo.HooksPath, hook)\n\treturn afero.WriteFile(\n\t\tl.fs, hookPath, templates.Hook(hook, args), hookFileMode,\n\t)\n}\n\nfunc isEnvEnabled(name string) bool {\n\tvalue := os.Getenv(name)\n\tif len(value) > 0 && value != \"0\" && value != \"false\" {\n\t\treturn true\n\t}\n\n\treturn false\n}\n\nfunc ShellCompleteHookNames() {\n\tl, err := NewLefthook(false, \"off\")\n\tif err != nil {\n\t\treturn\n\t}\n\n\tcfg, err := l.LoadConfig()\n\tif err != nil {\n\t\treturn\n\t}\n\n\tfor hook := range cfg.Hooks {\n\t\tfmt.Println(hook) //nolint:forbidigo // undecorated stdout is a must\n\t}\n}\n\nfunc ShellCompleteFlags(cmd *cli.Command) {\n\tgiven := cmd.FlagNames()\nflags:\n\tfor _, f := range cmd.VisibleFlags() {\n\t\ttoAdd := make([]string, 0, len(f.Names()))\n\t\tfor _, fn := range f.Names() {\n\t\t\t// Exclude all aliases of a flag if any of them is already given\n\t\t\tif slices.Contains(given, fn) {\n\t\t\t\tcontinue flags\n\t\t\t}\n\t\t\t// Do not bother with single letter flags.\n\t\t\t// If the user knows what they're for, they can just write them (hit the letter instead of tab),\n\t\t\t// no need to clutter the output with them.\n\t\t\tif len(fn) != 1 {\n\t\t\t\ttoAdd = append(toAdd, fn)\n\t\t\t}\n\t\t}\n\t\tfor _, fn := range toAdd {\n\t\t\tfmt.Println(\"--\" + fn) //nolint:forbidigo // undecorated stdout is a must\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/command/run.go",
    "content": "package command\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"os/signal\"\n\t\"path/filepath\"\n\t\"time\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/config\"\n\t\"github.com/evilmartians/lefthook/v2/internal/git\"\n\t\"github.com/evilmartians/lefthook/v2/internal/log\"\n\t\"github.com/evilmartians/lefthook/v2/internal/run\"\n\t\"github.com/evilmartians/lefthook/v2/internal/run/result\"\n\t\"github.com/evilmartians/lefthook/v2/internal/version\"\n)\n\nconst (\n\tenvEnabled = \"LEFTHOOK\"        // \"0\", \"false\"\n\tenvOutput  = \"LEFTHOOK_OUTPUT\" // \"meta,success,failure,summary,skips,execution,execution_out,execution_info\"\n)\n\nvar errPipedAndParallelSet = errors.New(\"conflicting options 'piped' and 'parallel' are set to 'true', remove one of this option from hook group\")\n\ntype RunArgs struct {\n\tNoTTY             bool\n\tAllFiles          bool\n\tFilesFromStdin    bool\n\tForce             bool\n\tNoAutoInstall     bool\n\tNoStageFixed      bool\n\tSkipLFS           bool\n\tVerbose           bool\n\tFailOnChanges     *bool\n\tFailOnChangesDiff *bool\n\tHook              string\n\tExclude           []string\n\tFiles             []string\n\tRunOnlyCommands   []string\n\tRunOnlyJobs       []string\n\tRunOnlyTags       []string\n\tGitArgs           []string\n}\n\nfunc (l *Lefthook) Run(ctx context.Context, args RunArgs) error {\n\tif os.Getenv(envEnabled) == \"0\" || os.Getenv(envEnabled) == \"false\" {\n\t\treturn nil\n\t}\n\n\twaitPrecompute := l.repo.Precompute()\n\tdefer waitPrecompute()\n\n\tif args.Verbose {\n\t\tlog.SetLevel(log.DebugLevel)\n\t}\n\n\t// Load config\n\tcfg, err := l.LoadConfig()\n\tif err != nil {\n\t\tvar errNotFound config.ConfigNotFoundError\n\t\tif ok := errors.As(err, &errNotFound); ok {\n\t\t\tlog.Warn(err.Error())\n\t\t\treturn nil\n\t\t}\n\t\treturn err\n\t}\n\n\tif err = checkVersion(cfg.MinVersion); err != nil {\n\t\treturn err\n\t}\n\n\t// Suppress prepare-commit-msg output if the hook doesn't exist in config.\n\t// prepare-commit-msg hook is used for seamless synchronization of hooks with config.\n\t// See: internal/lefthook/install.go\n\t_, ok := cfg.Hooks[args.Hook]\n\tisGhostHook := args.Hook == config.GhostHookName && !ok && !args.Verbose\n\tif isGhostHook {\n\t\tlog.SetLevel(log.WarnLevel)\n\t}\n\n\tenableLogTags := os.Getenv(envOutput)\n\n\tlog.InitSettings()\n\tlog.ApplySettings(enableLogTags, cfg.Output)\n\n\tif log.Settings.LogMeta() {\n\t\tlog.LogMeta(args.Hook)\n\t}\n\n\tif !args.NoAutoInstall && !cfg.NoAutoInstall {\n\t\t// This line controls updating the git hook if config has changed\n\t\tvar newCfg *config.Config\n\t\tnewCfg, err = l.syncHooks(cfg, !isGhostHook)\n\t\tif err != nil {\n\t\t\tlog.Warnf(\n\t\t\t\t\"⚠️  There was a problem with synchronizing git hooks. Run 'lefthook install' manually.\\n   Error: %s\", err,\n\t\t\t)\n\t\t} else {\n\t\t\tcfg = newCfg\n\t\t}\n\t}\n\n\thook, err := resolveHook(cfg, args.Hook)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif hook == nil {\n\t\treturn nil\n\t}\n\n\tfiles, err := getFiles(l.repo, args)\n\tif err != nil {\n\t\treturn err\n\t}\n\targs.Files = files\n\n\tsourceDirs := getSourceDirs(l.repo, cfg)\n\n\tfailOnChanges, err := shouldFailOnChanges(args.FailOnChanges, hook.FailOnChanges)\n\tif err != nil {\n\t\treturn err\n\t}\n\tfailOnChangesDiff := shouldFailOnChangesDiff(args.FailOnChangesDiff, hook.FailOnChangesDiff)\n\n\t// Convert Commands and Scripts into Jobs\n\thook.Jobs = append(hook.Jobs, config.CommandsToJobs(hook.Commands)...)\n\thook.Commands = nil\n\thook.Jobs = append(hook.Jobs, config.ScriptsToJobs(hook.Scripts)...)\n\thook.Scripts = nil\n\targs.RunOnlyJobs = append(args.RunOnlyJobs, args.RunOnlyCommands...)\n\n\treturn runHook(ctx, hook, l.repo, run.Options{\n\t\tDisableTTY:        cfg.NoTTY || args.NoTTY,\n\t\tSkipLFS:           cfg.SkipLFS || args.SkipLFS,\n\t\tTemplates:         cfg.Templates,\n\t\tGlobMatcher:       cfg.GlobMatcher,\n\t\tGitArgs:           args.GitArgs,\n\t\tExcludeFiles:      args.Exclude,\n\t\tFiles:             args.Files,\n\t\tForce:             args.Force,\n\t\tNoStageFixed:      args.NoStageFixed,\n\t\tRunOnlyJobs:       args.RunOnlyJobs,\n\t\tRunOnlyTags:       args.RunOnlyTags,\n\t\tSourceDirs:        sourceDirs,\n\t\tFailOnChanges:     failOnChanges,\n\t\tFailOnChangesDiff: failOnChangesDiff,\n\t})\n}\n\nfunc resolveHook(cfg *config.Config, hookName string) (*config.Hook, error) {\n\thook, ok := cfg.Hooks[hookName]\n\tif !ok {\n\t\tif config.KnownHook(hookName) {\n\t\t\tlog.Debugf(\"[lefthook] skip: Hook %s doesn't exist in the config\", hookName)\n\t\t\treturn nil, nil\n\t\t}\n\t\treturn nil, fmt.Errorf(\"hook %s doesn't exist in the config\", hookName)\n\t}\n\n\tif hook.Parallel && hook.Piped {\n\t\treturn nil, errPipedAndParallelSet\n\t}\n\n\treturn hook, nil\n}\n\nfunc getFiles(repo *git.Repository, args RunArgs) ([]string, error) {\n\tif args.FilesFromStdin {\n\t\tpaths, err := io.ReadAll(os.Stdin)\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to read the files from standard input: %w\", err)\n\t\t}\n\t\treturn append(args.Files, parseFilesFromString(string(paths))...), nil\n\t} else if args.AllFiles {\n\t\tfiles, err := repo.AllFiles()\n\t\tif err != nil {\n\t\t\treturn nil, fmt.Errorf(\"failed to get all files: %w\", err)\n\t\t}\n\t\treturn append(args.Files, files...), nil\n\t}\n\n\treturn args.Files, nil\n}\n\nfunc getSourceDirs(repo *git.Repository, cfg *config.Config) []string {\n\tsourceDirs := []string{\n\t\tfilepath.Join(repo.RootPath, cfg.SourceDir),\n\t\tfilepath.Join(repo.RootPath, cfg.SourceDirLocal),\n\n\t\t// Additional source dirs to support .config/\n\t\tfilepath.Join(repo.RootPath, \".config\", \"lefthook\"),\n\t\tfilepath.Join(repo.RootPath, \".config\", \"lefthook-local\"),\n\t}\n\n\tfor _, remote := range cfg.Remotes {\n\t\tif remote.Configured() {\n\t\t\t// Append only source_dir, because source_dir_local doesn't make sense\n\t\t\tsourceDirs = append(\n\t\t\t\tsourceDirs,\n\t\t\t\tfilepath.Join(\n\t\t\t\t\trepo.RemoteFolder(remote.GitURL, remote.Ref),\n\t\t\t\t\tcfg.SourceDir,\n\t\t\t\t),\n\t\t\t)\n\t\t}\n\t}\n\n\treturn sourceDirs\n}\n\nfunc shouldFailOnChanges(fromArg *bool, fromHook string) (bool, error) {\n\tif fromArg != nil {\n\t\treturn *fromArg, nil\n\t}\n\n\tswitch fromHook {\n\tcase \"never\", \"false\", \"0\", \"\":\n\t\treturn false, nil\n\tcase \"always\", \"true\", \"1\":\n\t\treturn true, nil\n\tcase \"ci\":\n\t\t_, ok := os.LookupEnv(\"CI\")\n\t\treturn ok, nil\n\tcase \"non-ci\":\n\t\t_, ok := os.LookupEnv(\"CI\")\n\t\treturn !ok, nil\n\tdefault:\n\t\treturn false, fmt.Errorf(\"invalid value for fail_on_changes: %s\", fromHook)\n\t}\n}\n\nfunc shouldFailOnChangesDiff(fromArg *bool, fromHook *bool) bool {\n\tif fromArg != nil {\n\t\treturn *fromArg\n\t}\n\tif fromHook != nil {\n\t\treturn *fromHook\n\t}\n\n\t_, ok := os.LookupEnv(\"CI\")\n\treturn ok\n}\n\nfunc runHook(ctx context.Context, hook *config.Hook, repo *git.Repository, opts run.Options) error {\n\tctx, stop := signal.NotifyContext(ctx, os.Interrupt)\n\tdefer stop()\n\n\tstartTime := time.Now()\n\tresults, err := run.Run(ctx, hook, repo, opts)\n\tif err != nil {\n\t\tvar failOnChangesErr *run.FailOnChangesError\n\t\tif errors.As(err, &failOnChangesErr) {\n\t\t\treturn err\n\t\t}\n\t\treturn fmt.Errorf(\"failed to run the hook: %w\", err)\n\t}\n\n\tif ctx.Err() != nil {\n\t\treturn errors.New(\"Interrupted\")\n\t}\n\n\tprintSummary(time.Since(startTime), results)\n\n\tfor _, result := range results {\n\t\tif result.Failure() {\n\t\t\treturn errors.New(\"\") // No error should be printed\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc printSummary(\n\tduration time.Duration,\n\tresults []result.Result,\n) {\n\tif log.Settings.LogSummary() {\n\t\tsummaryPrint := log.Separate\n\n\t\tif !log.Settings.LogExecution() {\n\t\t\tsummaryPrint = func(s string) { log.Info(s) }\n\t\t}\n\n\t\tif len(results) == 0 {\n\t\t\tif log.Settings.LogEmptySummary() {\n\t\t\t\tsummaryPrint(\n\t\t\t\t\tfmt.Sprintf(\n\t\t\t\t\t\t\"%s %s %s\",\n\t\t\t\t\t\tlog.Cyan(\"summary:\"),\n\t\t\t\t\t\tlog.Gray(\"(skip)\"),\n\t\t\t\t\t\tlog.Yellow(\"empty\"),\n\t\t\t\t\t),\n\t\t\t\t)\n\t\t\t}\n\t\t\treturn\n\t\t}\n\n\t\tsummaryPrint(\n\t\t\tlog.Cyan(\"summary: \") + log.Gray(fmt.Sprintf(\"(done in %.2f seconds)\", duration.Seconds())),\n\t\t)\n\t}\n\n\tlogResults(0, results)\n}\n\nfunc logResults(indent int, results []result.Result) {\n\tif log.Settings.LogSuccess() {\n\t\tfor _, result := range results {\n\t\t\tif !result.Success() {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tlog.Success(indent, result.Name, result.Duration)\n\n\t\t\tif len(result.Sub) > 0 {\n\t\t\t\tlogResults(indent+1, result.Sub)\n\t\t\t}\n\t\t}\n\t}\n\n\tif log.Settings.LogFailure() {\n\t\tfor _, result := range results {\n\t\t\tif !result.Failure() {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tlog.Failure(indent, result.Name, result.Text(), result.Duration)\n\n\t\t\tif len(result.Sub) > 0 {\n\t\t\t\tlogResults(indent+1, result.Sub)\n\t\t\t}\n\t\t}\n\t}\n}\n\n// parseFilesFromString parses both `\\0`-separated files.\nfunc parseFilesFromString(paths string) []string {\n\tvar result []string\n\tstart := 0\n\tfor i, c := range paths {\n\t\tif c == 0 {\n\t\t\tresult = append(result, paths[start:i])\n\t\t\tstart = i + 1\n\t\t}\n\t}\n\tresult = append(result, paths[start:])\n\treturn result\n}\n\nfunc checkVersion(minVersion string) error {\n\tif len(minVersion) == 0 {\n\t\treturn nil\n\t}\n\n\tif err := version.Check(minVersion, version.Version(false)); err != nil {\n\t\tif errors.Is(err, version.ErrInvalidVersion) {\n\t\t\treturn errors.New(\"format of 'min_version' setting is incorrect\")\n\t\t}\n\n\t\texecPath, oserr := os.Executable()\n\t\tif oserr != nil {\n\t\t\texecPath = \"<unknown>\"\n\t\t}\n\n\t\treturn fmt.Errorf(\"required lefthook version (%s) is higher than current (%s) at %s\", minVersion, version.Version(false), execPath)\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/command/run_test.go",
    "content": "package command\n\nimport (\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/spf13/afero\"\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"github.com/evilmartians/lefthook/v2/tests/helpers/cmdtest\"\n\t\"github.com/evilmartians/lefthook/v2/tests/helpers/gittest\"\n)\n\nfunc TestRun(t *testing.T) {\n\troot, err := filepath.Abs(\"src\")\n\tif err != nil {\n\t\tt.Errorf(\"unexpected error: %s\", err)\n\t}\n\n\tgitPath := gittest.GitPath(root)\n\tconfigPath := filepath.Join(root, \"lefthook.yml\")\n\n\tfor i, tt := range [...]struct {\n\t\tname, hook, config string\n\t\tgitArgs            []string\n\t\tenvs               map[string]string\n\t\texistingDirs       []string\n\t\terror              bool\n\t}{\n\t\t{\n\t\t\tname: \"Skip case\",\n\t\t\thook: \"pre-commit\",\n\t\t\tenvs: map[string]string{\n\t\t\t\t\"LEFTHOOK\": \"0\",\n\t\t\t},\n\t\t\terror: false,\n\t\t},\n\t\t{\n\t\t\tname: \"Skip case\",\n\t\t\thook: \"pre-commit\",\n\t\t\tenvs: map[string]string{\n\t\t\t\t\"LEFTHOOK\": \"false\",\n\t\t\t},\n\t\t\terror: false,\n\t\t},\n\t\t{\n\t\t\tname: \"Invalid version\",\n\t\t\thook: \"pre-commit\",\n\t\t\tconfig: `\nmin_version: 23.0.1\n`,\n\t\t\terror: true,\n\t\t},\n\t\t{\n\t\t\tname: \"Valid version, no hook\",\n\t\t\thook: \"pre-commit\",\n\t\t\tconfig: `\nmin_version: 0.7.9\n`,\n\t\t\terror: false,\n\t\t},\n\t\t{\n\t\t\tname: \"Invalid hook\",\n\t\t\thook: \"pre-commit\",\n\t\t\tconfig: `\npre-commit:\n  parallel: true\n  piped: true\n`,\n\t\t\terror: true,\n\t\t},\n\t\t{\n\t\t\tname: \"Valid hook\",\n\t\t\thook: \"pre-commit\",\n\t\t\tconfig: `\npre-commit:\n  parallel: false\n  piped: true\n`,\n\t\t\terror: false,\n\t\t},\n\t\t{\n\t\t\tname: \"When in git rebase-merge flow\",\n\t\t\thook: \"pre-commit\",\n\t\t\tconfig: `\npre-commit:\n  parallel: false\n  piped: true\n  commands:\n    echo:\n      skip:\n        - rebase\n        - merge\n      run: echo 'SHOULD NEVER RUN'\n`,\n\t\t\texistingDirs: []string{\n\t\t\t\tfilepath.Join(gitPath, \"rebase-merge\"),\n\t\t\t},\n\t\t\terror: false,\n\t\t},\n\t\t{\n\t\t\tname: \"When in git rebase-apply flow\",\n\t\t\thook: \"pre-commit\",\n\t\t\tconfig: `\npre-commit:\n  parallel: false\n  piped: true\n  commands:\n    echo:\n      skip:\n        - rebase\n        - merge\n      run: echo 'SHOULD NEVER RUN'\n`,\n\t\t\texistingDirs: []string{\n\t\t\t\tfilepath.Join(gitPath, \"rebase-apply\"),\n\t\t\t},\n\t\t\terror: false,\n\t\t},\n\t\t{\n\t\t\tname: \"When not in rebase flow\",\n\t\t\thook: \"post-commit\",\n\t\t\tconfig: `\npost-commit:\n  parallel: false\n  piped: true\n  commands:\n    echo:\n      skip:\n        - rebase\n        - merge\n      run: echo 'SHOULD RUN'\n`,\n\t\t\terror: true,\n\t\t},\n\t} {\n\t\tt.Run(fmt.Sprintf(\"%d: %s\", i, tt.name), func(t *testing.T) {\n\t\t\tassert := assert.New(t)\n\t\t\tfs := afero.NewMemMapFs()\n\t\t\tlefthook := &Lefthook{\n\t\t\t\tfs:   fs,\n\t\t\t\trepo: gittest.NewRepositoryBuilder().Cmd(cmdtest.NewDumb()).Fs(fs).Root(root).Build(),\n\t\t\t}\n\t\t\tlefthook.repo.Setup()\n\n\t\t\t// Create files that should exist\n\t\t\tfor _, path := range tt.existingDirs {\n\t\t\t\tassert.NoError(fs.MkdirAll(path, 0o755))\n\t\t\t}\n\n\t\t\tassert.NoError(afero.WriteFile(fs, configPath, []byte(tt.config), 0o644))\n\t\t\tfor env, value := range tt.envs {\n\t\t\t\tt.Setenv(env, value)\n\t\t\t}\n\n\t\t\terr = lefthook.Run(t.Context(), RunArgs{Hook: tt.hook, GitArgs: tt.gitArgs})\n\t\t\tif tt.error {\n\t\t\t\tassert.Error(err)\n\t\t\t} else {\n\t\t\t\tassert.NoError(err)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/command/uninstall.go",
    "content": "package command\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"os\"\n\t\"path/filepath\"\n\n\t\"github.com/spf13/afero\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/config\"\n\t\"github.com/evilmartians/lefthook/v2/internal/log\"\n)\n\ntype UninstallArgs struct {\n\tForce, RemoveConfig bool\n}\n\nfunc (l *Lefthook) Uninstall(_ctx context.Context, args UninstallArgs) error {\n\tif err := l.deleteHooks(args.Force); err != nil {\n\t\treturn err\n\t}\n\n\terr := l.fs.Remove(l.checksumFilePath())\n\tswitch {\n\tcase err == nil:\n\t\tlog.Debugf(\"%s removed\", l.checksumFilePath())\n\tcase errors.Is(err, os.ErrNotExist):\n\t\tlog.Debugf(\"%s not found, skipping\\n\", l.checksumFilePath())\n\tdefault:\n\t\tlog.Errorf(\"Failed removing %s: %s\\n\", l.checksumFilePath(), err)\n\t}\n\n\tif args.RemoveConfig {\n\t\tfor _, name := range append(config.MainConfigNames, config.LocalConfigNames...) {\n\t\t\tfor _, extension := range []string{\n\t\t\t\t\".yml\", \".yaml\", \".toml\", \".json\",\n\t\t\t} {\n\t\t\t\tl.removeFile(filepath.Join(l.repo.RootPath, name+extension))\n\t\t\t}\n\t\t}\n\t}\n\n\treturn l.fs.RemoveAll(l.repo.RemotesFolder())\n}\n\nfunc (l *Lefthook) deleteHooks(force bool) error {\n\thooks, err := afero.ReadDir(l.fs, l.repo.HooksPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tfor _, file := range hooks {\n\t\thookFile := filepath.Join(l.repo.HooksPath, file.Name())\n\n\t\t// Skip non-lefthook files if removal not forced\n\t\tif !l.isLefthookFile(hookFile) && !force {\n\t\t\tcontinue\n\t\t}\n\n\t\tif err := l.fs.Remove(hookFile); err == nil {\n\t\t\tlog.Debugf(\"%s removed\", hookFile)\n\t\t} else {\n\t\t\tlog.Errorf(\"Failed removing %s: %s\\n\", hookFile, err)\n\t\t}\n\n\t\t// Recover .old file if exists\n\t\toldHookFile := filepath.Join(l.repo.HooksPath, file.Name()+\".old\")\n\t\tif exists, _ := afero.Exists(l.fs, oldHookFile); !exists {\n\t\t\tcontinue\n\t\t}\n\n\t\tif err := l.fs.Rename(oldHookFile, hookFile); err == nil {\n\t\t\tlog.Debug(oldHookFile, \"renamed to\", file.Name())\n\t\t} else {\n\t\t\tlog.Errorf(\"Failed renaming %s: %s\\n\", oldHookFile, err)\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (l *Lefthook) removeFile(glob string) {\n\tpaths, err := afero.Glob(l.fs, glob)\n\tif err != nil {\n\t\tlog.Errorf(\"Failed removing configuration files: %s\\n\", err)\n\t\treturn\n\t}\n\n\tfor _, fileName := range paths {\n\t\tif err := l.fs.Remove(fileName); err == nil {\n\t\t\tlog.Debugf(\"%s removed\", fileName)\n\t\t} else {\n\t\t\tlog.Errorf(\"Failed removing file %s: %s\\n\", fileName, err)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/command/uninstall_test.go",
    "content": "package command\n\nimport (\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/spf13/afero\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/config\"\n\t\"github.com/evilmartians/lefthook/v2/tests/helpers/gittest\"\n)\n\nfunc TestLefthookUninstall(t *testing.T) {\n\troot, err := filepath.Abs(\"src\")\n\tif err != nil {\n\t\tt.Errorf(\"unexpected error: %s\", err)\n\t}\n\n\tconfigPath := filepath.Join(root, \"lefthook.yml\")\n\tchecksumPath := filepath.Join(gittest.GitPath(root), \"info\", config.ChecksumFileName)\n\n\thookPath := func(hook string) string {\n\t\treturn filepath.Join(gittest.GitPath(root), \"hooks\", hook)\n\t}\n\n\tfor n, tt := range [...]struct {\n\t\tname, config            string\n\t\targs                    UninstallArgs\n\t\texistingHooks           map[string]string\n\t\twantExist, wantNotExist []string\n\t}{\n\t\t{\n\t\t\tname: \"simple defaults\",\n\t\t\texistingHooks: map[string]string{\n\t\t\t\t\"pre-commit\":  \"not a lefthook hook\",\n\t\t\t\t\"post-commit\": `\"$LEFTHOOK\" file`,\n\t\t\t},\n\t\t\tconfig: \"# empty\",\n\t\t\twantExist: []string{\n\t\t\t\tconfigPath,\n\t\t\t\thookPath(\"pre-commit\"),\n\t\t\t},\n\t\t\twantNotExist: []string{\n\t\t\t\tchecksumPath,\n\t\t\t\thookPath(\"post-commit\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"with force\",\n\t\t\targs: UninstallArgs{Force: true},\n\t\t\texistingHooks: map[string]string{\n\t\t\t\t\"pre-commit\":  \"not a lefthook hook\",\n\t\t\t\t\"post-commit\": \"\\n# LEFTHOOK file\\n\",\n\t\t\t},\n\t\t\tconfig:    \"# empty\",\n\t\t\twantExist: []string{configPath},\n\t\t\twantNotExist: []string{\n\t\t\t\tchecksumPath,\n\t\t\t\thookPath(\"pre-commit\"),\n\t\t\t\thookPath(\"post-commit\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"with --remove-configs option\",\n\t\t\targs: UninstallArgs{RemoveConfig: true},\n\t\t\texistingHooks: map[string]string{\n\t\t\t\t\"pre-commit\":  \"not a lefthook hook\",\n\t\t\t\t\"post-commit\": \"# LEFTHOOK\",\n\t\t\t},\n\t\t\tconfig: \"# empty\",\n\t\t\twantExist: []string{\n\t\t\t\thookPath(\"pre-commit\"),\n\t\t\t},\n\t\t\twantNotExist: []string{\n\t\t\t\tchecksumPath,\n\t\t\t\tconfigPath,\n\t\t\t\thookPath(\"post-commit\"),\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"with .old files\",\n\t\t\texistingHooks: map[string]string{\n\t\t\t\t\"pre-commit\":      \"not a lefthook hook\",\n\t\t\t\t\"post-commit\":     \"LEFTHOOK file\",\n\t\t\t\t\"post-commit.old\": \"not a lefthook hook\",\n\t\t\t},\n\t\t\tconfig: \"# empty\",\n\t\t\twantExist: []string{\n\t\t\t\tconfigPath,\n\t\t\t\thookPath(\"pre-commit\"),\n\t\t\t\thookPath(\"post-commit\"),\n\t\t\t},\n\t\t\twantNotExist: []string{\n\t\t\t\tchecksumPath,\n\t\t\t\thookPath(\"post-commit.old\"),\n\t\t\t},\n\t\t},\n\t} {\n\t\tt.Run(fmt.Sprintf(\"%d: %s\", n, tt.name), func(t *testing.T) {\n\t\t\tfs := afero.NewMemMapFs()\n\t\t\tlefthook := &Lefthook{\n\t\t\t\tfs:   fs,\n\t\t\t\trepo: gittest.NewRepositoryBuilder().Fs(fs).Root(root).Build(),\n\t\t\t}\n\n\t\t\t// Create config and checksum file\n\t\t\terr := afero.WriteFile(fs, configPath, []byte(tt.config), 0o644)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t\t}\n\t\t\terr = afero.WriteFile(fs, checksumPath, []byte(\"CHECKSUM\"), 0o644)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t\t}\n\n\t\t\t// Prepare files that should exist\n\t\t\tfor hook, content := range tt.existingHooks {\n\t\t\t\tpath := hookPath(hook)\n\t\t\t\tif err = fs.MkdirAll(filepath.Dir(path), 0o755); err != nil {\n\t\t\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t\t\t}\n\t\t\t\tif err = afero.WriteFile(fs, path, []byte(content), 0o755); err != nil {\n\t\t\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Do uninstall\n\t\t\terr = lefthook.Uninstall(t.Context(), tt.args)\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t\t}\n\n\t\t\t// Test files that should exist\n\t\t\tfor _, file := range tt.wantExist {\n\t\t\t\tok, err := afero.Exists(fs, file)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t\t\t}\n\t\t\t\tif !ok {\n\t\t\t\t\tt.Errorf(\"expected %s to exist\", file)\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t// Test files that should not exist\n\t\t\tfor _, file := range tt.wantNotExist {\n\t\t\t\tok, err := afero.Exists(fs, file)\n\t\t\t\tif err != nil {\n\t\t\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t\t\t}\n\t\t\t\tif ok {\n\t\t\t\t\tt.Errorf(\"expected %s to not exist\", file)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/command/validate.go",
    "content": "package command\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"strings\"\n\n\t\"github.com/kaptinlin/jsonschema\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/config\"\n\t\"github.com/evilmartians/lefthook/v2/internal/log\"\n)\n\ntype ValidateArgs struct {\n\tSchemaPath string\n}\n\nfunc (l *Lefthook) Validate(_ctx context.Context, args ValidateArgs) error {\n\tmain, secondary, err := config.LoadKoanf(l.fs, l.repo)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tcompiler := jsonschema.NewCompiler()\n\tschema, err := compiler.Compile(config.JsonSchema)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tresult := schema.Validate(main.Raw())\n\tif !result.IsValid() {\n\t\tdetails := result.ToList()\n\t\tlogValidationErrors(0, *details)\n\n\t\treturn errors.New(\"validation failed for main config\")\n\t}\n\n\tresult = schema.Validate(secondary.Raw())\n\tif !result.IsValid() {\n\t\tdetails := result.ToList()\n\t\tlogValidationErrors(0, *details)\n\n\t\treturn errors.New(\"validation failed for secondary config\")\n\t}\n\n\tlog.Info(\"All good\")\n\treturn nil\n}\n\nfunc logValidationErrors(indent int, details jsonschema.List) {\n\tif details.Valid {\n\t\treturn\n\t}\n\n\tif len(details.InstanceLocation) > 0 {\n\t\tlogDetail(indent, details)\n\n\t\tindent += 2\n\t}\n\n\tfor _, d := range details.Details {\n\t\tlogValidationErrors(indent, d)\n\t}\n}\n\nfunc logDetail(indent int, details jsonschema.List) {\n\tvar errors []string\n\tif len(details.Errors) > 0 {\n\t\tfor _, err := range details.Errors {\n\t\t\terrors = append(errors, err)\n\t\t}\n\t}\n\n\toption := strings.Repeat(\" \", indent) + strings.TrimLeft(details.InstanceLocation, \"/\") + \":\"\n\n\tif len(errors) == 0 {\n\t\toption = log.Gray(option)\n\t} else {\n\t\toption = log.Yellow(option)\n\t}\n\n\tif len(details.Details) > 0 {\n\t\tlog.Info(option)\n\t} else {\n\t\tlog.Info(option, log.Red(strings.Join(errors, \",\")))\n\t}\n}\n"
  },
  {
    "path": "internal/config/available_hooks.go",
    "content": "package config\n\n// ChecksumFileName - the file, which is used just to store the current config checksum version.\nconst ChecksumFileName = \"lefthook.checksum\"\n\n// GhostHookName - the hook which logs are not shown and which is used for synchronizing hooks.\nconst GhostHookName = \"prepare-commit-msg\"\n\n// AvailableHooks - list of hooks taken from https://git-scm.com/docs/githooks.\n// Keep the order of the hooks same here for easy syncing.\nvar AvailableHooks = map[string]struct{}{\n\t\"applypatch-msg\":        {},\n\t\"pre-applypatch\":        {},\n\t\"post-applypatch\":       {},\n\t\"pre-commit\":            {},\n\t\"pre-merge-commit\":      {},\n\t\"prepare-commit-msg\":    {},\n\t\"commit-msg\":            {},\n\t\"post-commit\":           {},\n\t\"pre-rebase\":            {},\n\t\"post-checkout\":         {},\n\t\"post-merge\":            {},\n\t\"pre-push\":              {},\n\t\"pre-receive\":           {},\n\t\"update\":                {},\n\t\"proc-receive\":          {},\n\t\"post-receive\":          {},\n\t\"post-update\":           {},\n\t\"reference-transaction\": {},\n\t\"push-to-checkout\":      {},\n\t\"pre-auto-gc\":           {},\n\t\"post-rewrite\":          {},\n\t\"sendemail-validate\":    {},\n\t\"fsmonitor-watchman\":    {},\n\t\"p4-changelist\":         {},\n\t\"p4-prepare-changelist\": {},\n\t\"p4-post-changelist\":    {},\n\t\"p4-pre-submit\":         {},\n\t\"post-index-change\":     {},\n}\n\nfunc HookUsesStagedFiles(hook string) bool {\n\treturn hook == \"pre-commit\"\n}\n\nfunc HookUsesPushFiles(hook string) bool {\n\treturn hook == \"pre-push\"\n}\n\nfunc KnownHook(hook string) bool {\n\t_, ok := AvailableHooks[hook]\n\treturn ok\n}\n"
  },
  {
    "path": "internal/config/command.go",
    "content": "package config\n\nimport (\n\t\"cmp\"\n\t\"errors\"\n\t\"slices\"\n\t\"strings\"\n\t\"time\"\n)\n\nvar ErrFilesIncompatible = errors.New(\"one of your runners contains incompatible file types\")\n\ntype Command struct {\n\tRun      string        `json:\"run\"                 mapstructure:\"run\"                   toml:\"run\"               yaml:\"run\"`\n\tFiles    string        `json:\"files,omitempty\"     mapstructure:\"files\"                 toml:\"files,omitempty\"   yaml:\",omitempty\"`\n\tRoot     string        `json:\"root,omitempty\"      mapstructure:\"root\"                  toml:\"root,omitempty\"    yaml:\",omitempty\"`\n\tFailText string        `json:\"fail_text,omitempty\" koanf:\"fail_text\"                    mapstructure:\"fail_text\" toml:\"fail_text,omitempty\" yaml:\"fail_text,omitempty\"`\n\tTimeout  time.Duration `json:\"timeout,omitempty\"   jsonschema:\"type=string,example=15s\" mapstructure:\"timeout\"   toml:\"timeout,omitempty\"   yaml:\",omitempty\"`\n\n\tSkip any `json:\"skip,omitempty\" jsonschema:\"oneof_type=boolean;array\" mapstructure:\"skip\" toml:\"skip,omitempty,inline\" yaml:\",omitempty\"`\n\tOnly any `json:\"only,omitempty\" jsonschema:\"oneof_type=boolean;array\" mapstructure:\"only\" toml:\"only,omitempty,inline\" yaml:\",omitempty\"`\n\n\tTags      []string `json:\"tags,omitempty\"       jsonschema:\"oneof_type=string;array\" mapstructure:\"tags\"    toml:\"tags,omitempty\"     yaml:\",omitempty\"`\n\tFileTypes []string `json:\"file_types,omitempty\" jsonschema:\"oneof_type=string;array\" koanf:\"file_types\"     mapstructure:\"file_types\" toml:\"file_types,omitempty\" yaml:\"file_types,omitempty\"`\n\tGlob      []string `json:\"glob,omitempty\"       jsonschema:\"oneof_type=string;array\" mapstructure:\"glob\"    toml:\"glob,omitempty\"     yaml:\",omitempty\"`\n\tExclude   []string `json:\"exclude,omitempty\"    jsonschema:\"oneof_type=string;array\" mapstructure:\"exclude\" toml:\"exclude,omitempty\"  yaml:\",omitempty\"`\n\n\tEnv map[string]string `json:\"env,omitempty\" mapstructure:\"env\" toml:\"env,omitempty\" yaml:\",omitempty\"`\n\n\tPriority    int  `json:\"priority,omitempty\"    mapstructure:\"priority\"    toml:\"priority,omitempty\"    yaml:\",omitempty\"`\n\tInteractive bool `json:\"interactive,omitempty\" mapstructure:\"interactive\" toml:\"interactive,omitempty\" yaml:\",omitempty\"`\n\tUseStdin    bool `json:\"use_stdin,omitempty\"   koanf:\"use_stdin\"          mapstructure:\"use_stdin\"     toml:\"use_stdin,omitempty\"   yaml:\"use_stdin,omitempty\"`\n\tStageFixed  bool `json:\"stage_fixed,omitempty\" koanf:\"stage_fixed\"        mapstructure:\"stage_fixed\"   toml:\"stage_fixed,omitempty\" yaml:\"stage_fixed,omitempty\"`\n}\n\nfunc CommandsToJobs(commands map[string]*Command) []*Job {\n\tjobs := make([]*Job, 0, len(commands))\n\tfor name, command := range commands {\n\t\tjobs = append(jobs, &Job{\n\t\t\tName:        name,\n\t\t\tRun:         command.Run,\n\t\t\tGlob:        command.Glob,\n\t\t\tRoot:        command.Root,\n\t\t\tFiles:       command.Files,\n\t\t\tFailText:    command.FailText,\n\t\t\tTimeout:     command.Timeout,\n\t\t\tTags:        command.Tags,\n\t\t\tFileTypes:   command.FileTypes,\n\t\t\tEnv:         command.Env,\n\t\t\tInteractive: command.Interactive,\n\t\t\tUseStdin:    command.UseStdin,\n\t\t\tStageFixed:  command.StageFixed,\n\t\t\tExclude:     command.Exclude,\n\t\t\tSkip:        command.Skip,\n\t\t\tOnly:        command.Only,\n\t\t})\n\t}\n\n\t// ASC\n\tslices.SortFunc(jobs, func(i, j *Job) int {\n\t\ta := commands[i.Name]\n\t\tb := commands[j.Name]\n\n\t\tif a.Priority != 0 || b.Priority != 0 {\n\t\t\t// Script without a priority must be the last\n\t\t\tif a.Priority == 0 {\n\t\t\t\treturn 1\n\t\t\t}\n\t\t\tif b.Priority == 0 {\n\t\t\t\treturn -1\n\t\t\t}\n\n\t\t\treturn cmp.Compare(a.Priority, b.Priority)\n\t\t}\n\n\t\tiNum := parseNum(i.Name)\n\t\tjNum := parseNum(j.Name)\n\n\t\tif iNum == -1 && jNum == -1 {\n\t\t\treturn strings.Compare(i.Name, j.Name)\n\t\t}\n\n\t\tif iNum == -1 {\n\t\t\treturn 1\n\t\t}\n\n\t\tif jNum == -1 {\n\t\t\treturn -1\n\t\t}\n\n\t\treturn cmp.Compare(iNum, jNum)\n\t})\n\n\treturn jobs\n}\n"
  },
  {
    "path": "internal/config/command_executor.go",
    "content": "package config\n\nimport (\n\t\"bytes\"\n\t\"strings\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/log\"\n\t\"github.com/evilmartians/lefthook/v2/internal/system\"\n)\n\n// commandExecutor implements execution of a skip checks passed in a `run` option.\ntype commandExecutor struct {\n\tcmd system.Command\n}\n\n// cmd runs plain string command in a subshell returning the success of it.\nfunc (c *commandExecutor) execute(commandLine string) bool {\n\tif commandLine == \"\" {\n\t\treturn false\n\t}\n\n\tsh, err := system.Sh()\n\tif err != nil {\n\t\tlog.Errorf(\"`sh` executable not found: %s\\n\", err)\n\t\treturn false\n\t}\n\n\targs := []string{sh, \"-c\", commandLine}\n\n\tstdout := new(bytes.Buffer)\n\tstderr := new(bytes.Buffer)\n\n\terr = c.cmd.Run(args, \"\", system.NullReader, stdout, stderr)\n\n\tb := log.Builder(log.DebugLevel, \"[lefthook] \").\n\t\tAdd(\"run: \", strings.Join(args, \" \")).\n\t\tAdd(\"out: \", stdout.String()).\n\t\tAdd(\"err: \", stderr.String())\n\n\tif err != nil {\n\t\tb.Add(\"!:   \", err.Error())\n\t}\n\n\tb.Log()\n\n\treturn err == nil\n}\n"
  },
  {
    "path": "internal/config/command_test.go",
    "content": "package config\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestCommandsToJobs(t *testing.T) {\n\tcommands := map[string]*Command{\n\t\t\"check\": {\n\t\t\tRun:      \"echo\",\n\t\t\tPriority: 150,\n\t\t},\n\t\t\"10lint\": {\n\t\t\tRun:        \"echo\",\n\t\t\tStageFixed: true,\n\t\t},\n\t\t\"first\": {\n\t\t\tRun:      \"echo\",\n\t\t\tPriority: 1,\n\t\t},\n\t\t\"2lint\": {\n\t\t\tRun:        \"echo\",\n\t\t\tStageFixed: true,\n\t\t},\n\t\t\"last\": {\n\t\t\tRun: \"echo\",\n\t\t},\n\t}\n\n\tjobs := CommandsToJobs(commands)\n\n\tassert.Equal(t, jobs, []*Job{\n\t\t{Name: \"first\", Run: \"echo\"},\n\t\t{Name: \"check\", Run: \"echo\"},\n\t\t{Name: \"2lint\", Run: \"echo\", StageFixed: true},\n\t\t{Name: \"10lint\", Run: \"echo\", StageFixed: true},\n\t\t{Name: \"last\", Run: \"echo\"},\n\t})\n}\n\nfunc TestCommandsToJobsWithTimeout(t *testing.T) {\n\tcommands := map[string]*Command{\n\t\t\"lint\": {\n\t\t\tRun:      \"echo lint\",\n\t\t\tTimeout:  60 * time.Second,\n\t\t\tPriority: 1,\n\t\t},\n\t\t\"test\": {\n\t\t\tRun:     \"echo test\",\n\t\t\tTimeout: 5 * time.Minute,\n\t\t},\n\t}\n\n\tjobs := CommandsToJobs(commands)\n\n\tassert.Equal(t, jobs, []*Job{\n\t\t{Name: \"lint\", Run: \"echo lint\", Timeout: 60 * time.Second},\n\t\t{Name: \"test\", Run: \"echo test\", Timeout: 5 * time.Minute},\n\t})\n}\n"
  },
  {
    "path": "internal/config/config.go",
    "content": "package config\n\nimport (\n\t\"bytes\"\n\t\"crypto/md5\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\n\t\"github.com/mitchellh/mapstructure\"\n\t\"github.com/pelletier/go-toml/v2\"\n\t\"go.yaml.in/yaml/v3\"\n)\n\ntype DumpFormat int\n\nconst (\n\tYAMLFormat DumpFormat = iota\n\tTOMLFormat\n\tJSONFormat\n\tJSONCompactFormat\n\n\tyamlIndent = 2\n)\n\ntype Config struct {\n\tMinVersion string `json:\"min_version,omitempty\" jsonschema:\"description=Specify a minimum version for the lefthook binary\" koanf:\"min_version\" mapstructure:\"min_version,omitempty\"`\n\n\tLefthook string `json:\"lefthook,omitempty\" jsonschema:\"description=Lefthook executable path or command\" mapstructure:\"lefthook,omitempty\"`\n\n\tSourceDir 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\"`\n\n\tSourceDirLocal 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\"`\n\n\tRc string `json:\"rc,omitempty\" jsonschema:\"description=Provide an rc file - a simple sh script\" mapstructure:\"rc,omitempty\"`\n\n\tOutput any `json:\"output,omitempty\" jsonschema:\"oneof_type=boolean;array,description=Manage verbosity by skipping the printing of output of some steps\" mapstructure:\"output,omitempty\"`\n\n\tColors 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\"`\n\n\tExtends []string `json:\"extends,omitempty\" jsonschema:\"description=Specify files to extend config with\" mapstructure:\"extends,omitempty\"`\n\n\tNoTTY bool `json:\"no_tty,omitempty\" jsonschema:\"description=Whether hide spinner and other interactive things\" koanf:\"no_tty\" mapstructure:\"no_tty,omitempty\"`\n\n\tAssertLefthookInstalled bool `json:\"assert_lefthook_installed,omitempty\" koanf:\"assert_lefthook_installed\" mapstructure:\"assert_lefthook_installed,omitempty\"`\n\n\tSkipLFS bool `json:\"skip_lfs,omitempty\" jsonschema:\"description=Skip running Git LFS hooks (enabled by default)\" koanf:\"skip_lfs\" mapstructure:\"skip_lfs,omitempty\"`\n\n\tNoAutoInstall 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\"`\n\n\tInstallNonGitHooks 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\"`\n\n\tGlobMatcher 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\"`\n\n\tRemotes []*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\"`\n\n\tTemplates map[string]string `json:\"templates,omitempty\" jsonschema:\"description=Custom templates for replacements in run commands.\" mapstructure:\"templates,omitempty\"`\n\n\tHooks map[string]*Hook `jsonschema:\"-\" mapstructure:\"-\"`\n}\n\nfunc (c *Config) Md5() (checksum string, err error) {\n\tconfigBytes := new(bytes.Buffer)\n\n\terr = c.Dump(JSONCompactFormat, configBytes)\n\tif err != nil {\n\t\treturn checksum, err\n\t}\n\n\thash := md5.New()\n\t_, err = io.Copy(hash, configBytes)\n\tif err != nil {\n\t\treturn checksum, err\n\t}\n\n\tchecksum = hex.EncodeToString(hash.Sum(nil)[:16])\n\treturn checksum, err\n}\n\nfunc (c *Config) Dump(format DumpFormat, out io.Writer) error {\n\tres := make(map[string]any)\n\tif err := mapstructure.Decode(c, &res); err != nil {\n\t\treturn err\n\t}\n\n\tif c.SourceDir == DefaultSourceDir {\n\t\tdelete(res, \"source_dir\")\n\t}\n\tif c.SourceDirLocal == DefaultSourceDirLocal {\n\t\tdelete(res, \"source_dir_local\")\n\t}\n\n\tfor hookName, hook := range c.Hooks {\n\t\tres[hookName] = hook\n\t}\n\n\tvar dumper dumper\n\tswitch format {\n\tcase YAMLFormat:\n\t\tdumper = yamlDumper{}\n\tcase TOMLFormat:\n\t\tdumper = tomlDumper{}\n\tcase JSONFormat:\n\t\tdumper = jsonDumper{pretty: true}\n\tcase JSONCompactFormat:\n\t\tdumper = jsonDumper{pretty: false}\n\tdefault:\n\t\tdumper = yamlDumper{}\n\t}\n\n\treturn dumper.Dump(res, out)\n}\n\ntype dumper interface {\n\tDump(map[string]any, io.Writer) error\n}\n\ntype yamlDumper struct{}\n\nfunc (yamlDumper) Dump(input map[string]any, out io.Writer) error {\n\tencoder := yaml.NewEncoder(out)\n\tencoder.SetIndent(yamlIndent)\n\n\terr := errors.Join(encoder.Encode(input), encoder.Close())\n\treturn err\n}\n\ntype tomlDumper struct{}\n\nfunc (tomlDumper) Dump(input map[string]any, out io.Writer) error {\n\tencoder := toml.NewEncoder(out)\n\terr := encoder.Encode(input)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\ntype jsonDumper struct {\n\tpretty bool\n}\n\nfunc (j jsonDumper) Dump(input map[string]any, out io.Writer) error {\n\tvar res []byte\n\tvar err error\n\tif j.pretty {\n\t\tres, err = json.MarshalIndent(input, \"\", \"  \")\n\t} else {\n\t\tres, err = json.Marshal(input)\n\t}\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tn, err := out.Write(res)\n\tif n != len(res) {\n\t\treturn fmt.Errorf(\"file not written fully: %d/%d\", n, len(res))\n\t}\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif j.pretty {\n\t\t_, _ = out.Write([]byte(\"\\n\"))\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/config/files.go",
    "content": "package config\n\nimport \"strings\"\n\nconst (\n\tSubFiles       string = \"{files}\"\n\tSubAllFiles    string = \"{all_files}\"\n\tSubStagedFiles string = \"{staged_files}\"\n\tSubPushFiles   string = \"{push_files}\"\n)\n\nfunc IsRunFilesCompatible(run string) bool {\n\treturn !strings.Contains(run, SubStagedFiles) || !strings.Contains(run, SubPushFiles)\n}\n"
  },
  {
    "path": "internal/config/hook.go",
    "content": "package config\n\nconst CMD = \"{cmd}\"\n\ntype Hook struct {\n\tName              string   `json:\"-\"                              jsonschema:\"-\"                                                                             koanf:\"-\"                           mapstructure:\"-\"                      toml:\"-\"                              yaml:\"-\"`\n\tParallel          bool     `json:\"parallel,omitempty\"             mapstructure:\"parallel\"                                                                    toml:\"parallel,omitempty\"           yaml:\",omitempty\"`\n\tPiped             bool     `json:\"piped,omitempty\"                mapstructure:\"piped\"                                                                       toml:\"piped,omitempty\"              yaml:\",omitempty\"`\n\tFollow            bool     `json:\"follow,omitempty\"               mapstructure:\"follow\"                                                                      toml:\"follow,omitempty\"             yaml:\",omitempty\"`\n\tFailOnChanges     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\"`\n\tFailOnChangesDiff *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\"`\n\tFiles             string   `json:\"files,omitempty\"                mapstructure:\"files\"                                                                       toml:\"files,omitempty\"              yaml:\",omitempty\"`\n\tExcludeTags       []string `json:\"exclude_tags,omitempty\"         koanf:\"exclude_tags\"                                                                       mapstructure:\"exclude_tags\"         toml:\"exclude_tags,omitempty\"         yaml:\"exclude_tags,omitempty\"`\n\tExclude           []string `json:\"exclude,omitempty\"              koanf:\"exclude\"                                                                            mapstructure:\"exclude\"              toml:\"exclude,omitempty\"              yaml:\"exclude,omitempty\"`\n\tSkip              any      `json:\"skip,omitempty\"                 jsonschema:\"oneof_type=boolean;array\"                                                      mapstructure:\"skip\"                 toml:\"skip,omitempty,inline\"          yaml:\",omitempty\"`\n\tOnly              any      `json:\"only,omitempty\"                 jsonschema:\"oneof_type=boolean;array\"                                                      mapstructure:\"only\"                 toml:\"only,omitempty,inline\"          yaml:\",omitempty\"`\n\n\tSetup []*SetupInstruction `json:\"setup,omitempty\" mapstructure:\"setup\" toml:\"setup,omitempty\" yaml:\",omitempty\"`\n\tJobs  []*Job              `json:\"jobs,omitempty\"  mapstructure:\"jobs\"  toml:\"jobs,omitempty\"  yaml:\",omitempty\"`\n\n\tCommands map[string]*Command `json:\"commands,omitempty\" mapstructure:\"-\" toml:\"commands,omitempty\" yaml:\",omitempty\"`\n\tScripts  map[string]*Script  `json:\"scripts,omitempty\"  mapstructure:\"-\" toml:\"scripts,omitempty\"  yaml:\",omitempty\"`\n}\n\ntype SetupInstruction struct {\n\tRun string `json:\"run,omitempty\" jsonschema:\"oneof_required=Run a command\" mapstructure:\"run\" toml:\"run,omitempty\" yaml:\",omitempty\"`\n}\n"
  },
  {
    "path": "internal/config/job.go",
    "content": "package config\n\nimport \"time\"\n\ntype Job struct {\n\tName     string        `json:\"name,omitempty\"      mapstructure:\"name\"                       toml:\"name,omitempty\"    yaml:\",omitempty\"`\n\tRun      string        `json:\"run,omitempty\"       jsonschema:\"oneof_required=Run a command\" mapstructure:\"run\"       toml:\"run,omitempty\"       yaml:\",omitempty\"`\n\tScript   string        `json:\"script,omitempty\"    jsonschema:\"oneof_required=Run a script\"  mapstructure:\"script\"    toml:\"script,omitempty\"    yaml:\",omitempty\"`\n\tRunner   string        `json:\"runner,omitempty\"    mapstructure:\"runner\"                     toml:\"runner,omitempty\"  yaml:\",omitempty\"`\n\tArgs     string        `json:\"args,omitempty\"      mapstructure:\"args\"                       toml:\"args,omitempty\"    yaml:\",omitempty\"`\n\tRoot     string        `json:\"root,omitempty\"      mapstructure:\"root\"                       toml:\"root,omitempty\"    yaml:\",omitempty\"`\n\tFiles    string        `json:\"files,omitempty\"     mapstructure:\"files\"                      toml:\"files,omitempty\"   yaml:\",omitempty\"`\n\tFailText string        `json:\"fail_text,omitempty\" koanf:\"fail_text\"                         mapstructure:\"fail_text\" toml:\"fail_text,omitempty\" yaml:\"fail_text,omitempty\"`\n\tTimeout  time.Duration `json:\"timeout,omitempty\"   jsonschema:\"type=string,example=15s\"      mapstructure:\"timeout\"   toml:\"timeout,omitempty\"   yaml:\",omitempty\"`\n\n\tGlob      []string `json:\"glob,omitempty\"       jsonschema:\"oneof_type=string;array\" mapstructure:\"glob\"    toml:\"glob,omitempty\"     yaml:\",omitempty\"`\n\tExclude   []string `json:\"exclude,omitempty\"    jsonschema:\"oneof_type=string;array\" mapstructure:\"exclude\" toml:\"exclude,omitempty\"  yaml:\",omitempty\"`\n\tTags      []string `json:\"tags,omitempty\"       mapstructure:\"tags\"                  toml:\"tags,omitempty\"  yaml:\",omitempty\"`\n\tFileTypes []string `json:\"file_types,omitempty\" jsonschema:\"oneof_type=string;array\" koanf:\"file_types\"     mapstructure:\"file_types\" toml:\"file_types,omitempty\" yaml:\"file_types,omitempty\"`\n\n\tEnv map[string]string `json:\"env,omitempty\" mapstructure:\"env\" toml:\"env,omitempty\" yaml:\",omitempty\"`\n\n\tInteractive bool `json:\"interactive,omitempty\" mapstructure:\"interactive\" toml:\"interactive,omitempty\" yaml:\",omitempty\"`\n\tUseStdin    bool `json:\"use_stdin,omitempty\"   koanf:\"use_stdin\"          mapstructure:\"use_stdin\"     toml:\"use_stdin,omitempty\"   yaml:\"use_stdin,omitempty\"`\n\tStageFixed  bool `json:\"stage_fixed,omitempty\" koanf:\"stage_fixed\"        mapstructure:\"stage_fixed\"   toml:\"stage_fixed,omitempty\" yaml:\"stage_fixed,omitempty\"`\n\n\tSkip any `json:\"skip,omitempty\" jsonschema:\"oneof_type=boolean;array\" mapstructure:\"skip\" toml:\"skip,omitempty,inline\" yaml:\",omitempty\"`\n\tOnly any `json:\"only,omitempty\" jsonschema:\"oneof_type=boolean;array\" mapstructure:\"only\" toml:\"only,omitempty,inline\" yaml:\",omitempty\"`\n\n\tGroup *Group `json:\"group,omitempty\" jsonschema:\"oneof_required=Run a group\" mapstructure:\"group\" toml:\"group,omitempty\" yaml:\",omitempty\"`\n}\n\ntype Group struct {\n\tRoot     string `json:\"root,omitempty\"     mapstructure:\"root\"     toml:\"root,omitempty\"     yaml:\",omitempty\"`\n\tParallel bool   `json:\"parallel,omitempty\" mapstructure:\"parallel\" toml:\"parallel,omitempty\" yaml:\",omitempty\"`\n\tPiped    bool   `json:\"piped,omitempty\"    mapstructure:\"piped\"    toml:\"piped,omitempty\"    yaml:\",omitempty\"`\n\tJobs     []*Job `json:\"jobs\"               mapstructure:\"jobs\"     toml:\"jobs\"               yaml:\"jobs\"`\n}\n\nfunc (job *Job) PrintableName(id string) string {\n\tif len(job.Name) != 0 {\n\t\treturn job.Name\n\t}\n\tif len(job.Run) != 0 {\n\t\treturn job.Run\n\t}\n\tif len(job.Script) != 0 {\n\t\treturn job.Script\n\t}\n\n\treturn \"[\" + id + \"]\"\n}\n"
  },
  {
    "path": "internal/config/jsonc_parser.go",
    "content": "package config\n\nimport (\n\t\"encoding/json\"\n\n\t\"github.com/tidwall/jsonc\"\n)\n\ntype JSONC struct{}\n\nfunc jsoncParser() *JSONC {\n\treturn &JSONC{}\n}\n\nfunc (p *JSONC) Unmarshal(b []byte) (map[string]any, error) {\n\tvar out map[string]any\n\tif err := json.Unmarshal(jsonc.ToJSON(b), &out); err != nil {\n\t\treturn nil, err\n\t}\n\treturn out, nil\n}\n\n// Marshal marshals the given config map to JSON bytes.\nfunc (p *JSONC) Marshal(o map[string]any) ([]byte, error) {\n\treturn json.Marshal(o)\n}\n"
  },
  {
    "path": "internal/config/jsonschema.go",
    "content": "package config\n\nimport (\n\t_ \"embed\"\n)\n\n//go:embed jsonschema.json\nvar JsonSchema []byte\n"
  },
  {
    "path": "internal/config/jsonschema.json",
    "content": "{\n  \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n  \"$id\": \"https://json.schemastore.org/lefthook.json\",\n  \"$defs\": {\n    \"Command\": {\n      \"properties\": {\n        \"run\": {\n          \"type\": \"string\"\n        },\n        \"files\": {\n          \"type\": \"string\"\n        },\n        \"root\": {\n          \"type\": \"string\"\n        },\n        \"fail_text\": {\n          \"type\": \"string\"\n        },\n        \"timeout\": {\n          \"type\": \"string\",\n          \"examples\": [\n            \"15s\"\n          ]\n        },\n        \"skip\": {\n          \"oneOf\": [\n            {\n              \"type\": \"boolean\"\n            },\n            {\n              \"type\": \"array\"\n            }\n          ]\n        },\n        \"only\": {\n          \"oneOf\": [\n            {\n              \"type\": \"boolean\"\n            },\n            {\n              \"type\": \"array\"\n            }\n          ]\n        },\n        \"tags\": {\n          \"oneOf\": [\n            {\n              \"type\": \"string\"\n            },\n            {\n              \"type\": \"array\"\n            }\n          ],\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"file_types\": {\n          \"oneOf\": [\n            {\n              \"type\": \"string\"\n            },\n            {\n              \"type\": \"array\"\n            }\n          ],\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"glob\": {\n          \"oneOf\": [\n            {\n              \"type\": \"string\"\n            },\n            {\n              \"type\": \"array\"\n            }\n          ],\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"exclude\": {\n          \"oneOf\": [\n            {\n              \"type\": \"string\"\n            },\n            {\n              \"type\": \"array\"\n            }\n          ],\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"env\": {\n          \"additionalProperties\": {\n            \"type\": \"string\"\n          },\n          \"type\": \"object\"\n        },\n        \"priority\": {\n          \"type\": \"integer\"\n        },\n        \"interactive\": {\n          \"type\": \"boolean\"\n        },\n        \"use_stdin\": {\n          \"type\": \"boolean\"\n        },\n        \"stage_fixed\": {\n          \"type\": \"boolean\"\n        }\n      },\n      \"additionalProperties\": false,\n      \"type\": \"object\",\n      \"required\": [\n        \"run\"\n      ]\n    },\n    \"Group\": {\n      \"properties\": {\n        \"root\": {\n          \"type\": \"string\"\n        },\n        \"parallel\": {\n          \"type\": \"boolean\"\n        },\n        \"piped\": {\n          \"type\": \"boolean\"\n        },\n        \"jobs\": {\n          \"items\": {\n            \"$ref\": \"#/$defs/Job\"\n          },\n          \"type\": \"array\"\n        }\n      },\n      \"additionalProperties\": false,\n      \"type\": \"object\",\n      \"required\": [\n        \"jobs\"\n      ]\n    },\n    \"Hook\": {\n      \"properties\": {\n        \"parallel\": {\n          \"type\": \"boolean\"\n        },\n        \"piped\": {\n          \"type\": \"boolean\"\n        },\n        \"follow\": {\n          \"type\": \"boolean\"\n        },\n        \"fail_on_changes\": {\n          \"type\": \"string\",\n          \"enum\": [\n            \"true\",\n            \"1\",\n            \"0\",\n            \"false\",\n            \"never\",\n            \"always\",\n            \"ci\",\n            \"non-ci\"\n          ]\n        },\n        \"fail_on_changes_diff\": {\n          \"type\": \"boolean\"\n        },\n        \"files\": {\n          \"type\": \"string\"\n        },\n        \"exclude_tags\": {\n          \"items\": {\n            \"type\": \"string\"\n          },\n          \"type\": \"array\"\n        },\n        \"exclude\": {\n          \"items\": {\n            \"type\": \"string\"\n          },\n          \"type\": \"array\"\n        },\n        \"skip\": {\n          \"oneOf\": [\n            {\n              \"type\": \"boolean\"\n            },\n            {\n              \"type\": \"array\"\n            }\n          ]\n        },\n        \"only\": {\n          \"oneOf\": [\n            {\n              \"type\": \"boolean\"\n            },\n            {\n              \"type\": \"array\"\n            }\n          ]\n        },\n        \"setup\": {\n          \"items\": {\n            \"$ref\": \"#/$defs/SetupInstruction\"\n          },\n          \"type\": \"array\"\n        },\n        \"jobs\": {\n          \"items\": {\n            \"$ref\": \"#/$defs/Job\"\n          },\n          \"type\": \"array\"\n        },\n        \"commands\": {\n          \"additionalProperties\": {\n            \"$ref\": \"#/$defs/Command\"\n          },\n          \"type\": \"object\"\n        },\n        \"scripts\": {\n          \"additionalProperties\": {\n            \"$ref\": \"#/$defs/Script\"\n          },\n          \"type\": \"object\"\n        }\n      },\n      \"additionalProperties\": false,\n      \"type\": \"object\"\n    },\n    \"Job\": {\n      \"oneOf\": [\n        {\n          \"required\": [\n            \"run\"\n          ],\n          \"title\": \"Run a command\"\n        },\n        {\n          \"required\": [\n            \"script\"\n          ],\n          \"title\": \"Run a script\"\n        },\n        {\n          \"required\": [\n            \"group\"\n          ],\n          \"title\": \"Run a group\"\n        }\n      ],\n      \"properties\": {\n        \"name\": {\n          \"type\": \"string\"\n        },\n        \"run\": {\n          \"type\": \"string\"\n        },\n        \"script\": {\n          \"type\": \"string\"\n        },\n        \"runner\": {\n          \"type\": \"string\"\n        },\n        \"args\": {\n          \"type\": \"string\"\n        },\n        \"root\": {\n          \"type\": \"string\"\n        },\n        \"files\": {\n          \"type\": \"string\"\n        },\n        \"fail_text\": {\n          \"type\": \"string\"\n        },\n        \"timeout\": {\n          \"type\": \"string\",\n          \"examples\": [\n            \"15s\"\n          ]\n        },\n        \"glob\": {\n          \"oneOf\": [\n            {\n              \"type\": \"string\"\n            },\n            {\n              \"type\": \"array\"\n            }\n          ],\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"exclude\": {\n          \"oneOf\": [\n            {\n              \"type\": \"string\"\n            },\n            {\n              \"type\": \"array\"\n            }\n          ],\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"tags\": {\n          \"items\": {\n            \"type\": \"string\"\n          },\n          \"type\": \"array\"\n        },\n        \"file_types\": {\n          \"oneOf\": [\n            {\n              \"type\": \"string\"\n            },\n            {\n              \"type\": \"array\"\n            }\n          ],\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"env\": {\n          \"additionalProperties\": {\n            \"type\": \"string\"\n          },\n          \"type\": \"object\"\n        },\n        \"interactive\": {\n          \"type\": \"boolean\"\n        },\n        \"use_stdin\": {\n          \"type\": \"boolean\"\n        },\n        \"stage_fixed\": {\n          \"type\": \"boolean\"\n        },\n        \"skip\": {\n          \"oneOf\": [\n            {\n              \"type\": \"boolean\"\n            },\n            {\n              \"type\": \"array\"\n            }\n          ]\n        },\n        \"only\": {\n          \"oneOf\": [\n            {\n              \"type\": \"boolean\"\n            },\n            {\n              \"type\": \"array\"\n            }\n          ]\n        },\n        \"group\": {\n          \"$ref\": \"#/$defs/Group\"\n        }\n      },\n      \"additionalProperties\": false,\n      \"type\": \"object\"\n    },\n    \"Remote\": {\n      \"properties\": {\n        \"git_url\": {\n          \"type\": \"string\",\n          \"description\": \"A URL to Git repository. It will be accessed with privileges of the machine lefthook runs on.\"\n        },\n        \"ref\": {\n          \"type\": \"string\",\n          \"description\": \"An optional *branch* or *tag* name\"\n        },\n        \"configs\": {\n          \"items\": {\n            \"type\": \"string\"\n          },\n          \"type\": \"array\",\n          \"description\": \"An optional array of config paths from remote's root\",\n          \"default\": [\n            \"lefthook.yml\"\n          ]\n        },\n        \"refetch\": {\n          \"type\": \"boolean\",\n          \"description\": \"Set to true if you want to always refetch the remote\"\n        },\n        \"refetch_frequency\": {\n          \"type\": \"string\",\n          \"description\": \"Provide a frequency for the remotes refetches\",\n          \"examples\": [\n            \"24h\"\n          ]\n        }\n      },\n      \"additionalProperties\": false,\n      \"type\": \"object\"\n    },\n    \"Script\": {\n      \"properties\": {\n        \"runner\": {\n          \"type\": \"string\"\n        },\n        \"args\": {\n          \"type\": \"string\"\n        },\n        \"skip\": {\n          \"oneOf\": [\n            {\n              \"type\": \"boolean\"\n            },\n            {\n              \"type\": \"array\"\n            }\n          ]\n        },\n        \"only\": {\n          \"oneOf\": [\n            {\n              \"type\": \"boolean\"\n            },\n            {\n              \"type\": \"array\"\n            }\n          ]\n        },\n        \"tags\": {\n          \"oneOf\": [\n            {\n              \"type\": \"string\"\n            },\n            {\n              \"type\": \"array\"\n            }\n          ],\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"env\": {\n          \"additionalProperties\": {\n            \"type\": \"string\"\n          },\n          \"type\": \"object\"\n        },\n        \"priority\": {\n          \"type\": \"integer\"\n        },\n        \"fail_text\": {\n          \"type\": \"string\"\n        },\n        \"timeout\": {\n          \"type\": \"string\",\n          \"examples\": [\n            \"15s\"\n          ]\n        },\n        \"interactive\": {\n          \"type\": \"boolean\"\n        },\n        \"use_stdin\": {\n          \"type\": \"boolean\"\n        },\n        \"stage_fixed\": {\n          \"type\": \"boolean\"\n        }\n      },\n      \"additionalProperties\": false,\n      \"type\": \"object\"\n    },\n    \"SetupInstruction\": {\n      \"oneOf\": [\n        {\n          \"required\": [\n            \"run\"\n          ],\n          \"title\": \"Run a command\"\n        }\n      ],\n      \"properties\": {\n        \"run\": {\n          \"type\": \"string\"\n        }\n      },\n      \"additionalProperties\": false,\n      \"type\": \"object\"\n    }\n  },\n  \"$comment\": \"Last updated on 2026.02.28.\",\n  \"properties\": {\n    \"min_version\": {\n      \"type\": \"string\",\n      \"description\": \"Specify a minimum version for the lefthook binary\"\n    },\n    \"lefthook\": {\n      \"type\": \"string\",\n      \"description\": \"Lefthook executable path or command\"\n    },\n    \"source_dir\": {\n      \"type\": \"string\",\n      \"description\": \"Change a directory for script files. Directory for script files contains folders with git hook names which contain script files.\",\n      \"default\": \".lefthook/\"\n    },\n    \"source_dir_local\": {\n      \"type\": \"string\",\n      \"description\": \"Change a directory for local script files (not stored in VCS)\",\n      \"default\": \".lefthook-local/\"\n    },\n    \"rc\": {\n      \"type\": \"string\",\n      \"description\": \"Provide an rc file - a simple sh script\"\n    },\n    \"output\": {\n      \"oneOf\": [\n        {\n          \"type\": \"boolean\"\n        },\n        {\n          \"type\": \"array\"\n        }\n      ],\n      \"description\": \"Manage verbosity by skipping the printing of output of some steps\"\n    },\n    \"colors\": {\n      \"oneOf\": [\n        {\n          \"type\": \"boolean\"\n        },\n        {\n          \"type\": \"object\"\n        }\n      ],\n      \"description\": \"Enable disable or set your own colors for lefthook output\"\n    },\n    \"extends\": {\n      \"items\": {\n        \"type\": \"string\"\n      },\n      \"type\": \"array\",\n      \"description\": \"Specify files to extend config with\"\n    },\n    \"no_tty\": {\n      \"type\": \"boolean\",\n      \"description\": \"Whether hide spinner and other interactive things\"\n    },\n    \"assert_lefthook_installed\": {\n      \"type\": \"boolean\"\n    },\n    \"skip_lfs\": {\n      \"type\": \"boolean\",\n      \"description\": \"Skip running Git LFS hooks (enabled by default)\"\n    },\n    \"no_auto_install\": {\n      \"type\": \"boolean\",\n      \"description\": \"Do not automatically install hooks when running lefthook\"\n    },\n    \"install_non_git_hooks\": {\n      \"type\": \"boolean\",\n      \"description\": \"Install non-Git hooks to .git/hooks\"\n    },\n    \"glob_matcher\": {\n      \"type\": \"string\",\n      \"enum\": [\n        \"gobwas\",\n        \"doublestar\"\n      ],\n      \"description\": \"Choose the glob matching engine: 'gobwas' (default) or 'doublestar' (standard ** behavior)\",\n      \"default\": \"gobwas\"\n    },\n    \"remotes\": {\n      \"items\": {\n        \"$ref\": \"#/$defs/Remote\"\n      },\n      \"type\": \"array\",\n      \"description\": \"Provide multiple remote configs to use lefthook configurations shared across projects. Lefthook will automatically download and merge configurations into main config.\"\n    },\n    \"templates\": {\n      \"additionalProperties\": {\n        \"type\": \"string\"\n      },\n      \"type\": \"object\",\n      \"description\": \"Custom templates for replacements in run commands.\"\n    },\n    \"$schema\": {\n      \"type\": \"string\"\n    },\n    \"pre-commit\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"applypatch-msg\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"pre-applypatch\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"post-applypatch\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"pre-merge-commit\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"prepare-commit-msg\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"commit-msg\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"post-commit\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"pre-rebase\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"post-checkout\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"post-merge\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"pre-push\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"pre-receive\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"update\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"proc-receive\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"post-receive\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"post-update\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"reference-transaction\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"push-to-checkout\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"pre-auto-gc\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"post-rewrite\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"sendemail-validate\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"fsmonitor-watchman\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"p4-changelist\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"p4-prepare-changelist\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"p4-post-changelist\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"p4-pre-submit\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"post-index-change\": {\n      \"$ref\": \"#/$defs/Hook\"\n    }\n  },\n  \"additionalProperties\": {\n    \"properties\": {\n      \"parallel\": {\n        \"type\": \"boolean\"\n      },\n      \"piped\": {\n        \"type\": \"boolean\"\n      },\n      \"follow\": {\n        \"type\": \"boolean\"\n      },\n      \"fail_on_changes\": {\n        \"type\": \"string\",\n        \"enum\": [\n          \"true\",\n          \"1\",\n          \"0\",\n          \"false\",\n          \"never\",\n          \"always\",\n          \"ci\",\n          \"non-ci\"\n        ]\n      },\n      \"fail_on_changes_diff\": {\n        \"type\": \"boolean\"\n      },\n      \"files\": {\n        \"type\": \"string\"\n      },\n      \"exclude_tags\": {\n        \"items\": {\n          \"type\": \"string\"\n        },\n        \"type\": \"array\"\n      },\n      \"exclude\": {\n        \"items\": {\n          \"type\": \"string\"\n        },\n        \"type\": \"array\"\n      },\n      \"skip\": {\n        \"oneOf\": [\n          {\n            \"type\": \"boolean\"\n          },\n          {\n            \"type\": \"array\"\n          }\n        ]\n      },\n      \"only\": {\n        \"oneOf\": [\n          {\n            \"type\": \"boolean\"\n          },\n          {\n            \"type\": \"array\"\n          }\n        ]\n      },\n      \"setup\": {\n        \"items\": {\n          \"$ref\": \"#/$defs/SetupInstruction\"\n        },\n        \"type\": \"array\"\n      },\n      \"jobs\": {\n        \"items\": {\n          \"$ref\": \"#/$defs/Job\"\n        },\n        \"type\": \"array\"\n      },\n      \"commands\": {\n        \"additionalProperties\": {\n          \"$ref\": \"#/$defs/Command\"\n        },\n        \"type\": \"object\"\n      },\n      \"scripts\": {\n        \"additionalProperties\": {\n          \"$ref\": \"#/$defs/Script\"\n        },\n        \"type\": \"object\"\n      }\n    },\n    \"additionalProperties\": false,\n    \"type\": \"object\"\n  },\n  \"type\": \"object\"\n}"
  },
  {
    "path": "internal/config/load.go",
    "content": "package config\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"io/fs\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"slices\"\n\t\"strings\"\n\n\t\"github.com/knadh/koanf/maps\"\n\t\"github.com/knadh/koanf/parsers/json\"\n\t\"github.com/knadh/koanf/parsers/toml/v2\"\n\t\"github.com/knadh/koanf/parsers/yaml\"\n\tkfs \"github.com/knadh/koanf/providers/fs\"\n\t\"github.com/knadh/koanf/v2\"\n\t\"github.com/spf13/afero\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/git\"\n\t\"github.com/evilmartians/lefthook/v2/internal/log\"\n)\n\nconst (\n\tDefaultConfigName     = \"lefthook.yml\"\n\tDefaultSourceDir      = \".lefthook\"\n\tDefaultSourceDirLocal = \".lefthook-local\"\n)\n\nvar (\n\thookKeyRegexp    = regexp.MustCompile(`^(?P<hookName>[^.]+)\\.(?:scripts|commands|jobs)`)\n\tLocalConfigNames = []string{\"lefthook-local\", \".lefthook-local\", filepath.Join(\".config\", \"lefthook-local\")}\n\tMainConfigNames  = []string{\"lefthook\", \".lefthook\", filepath.Join(\".config\", \"lefthook\")}\n\tExtensions       = []string{\n\t\t\".yml\",\n\t\t\".yaml\",\n\t\t\".json\",\n\t\t\".jsonc\",\n\t\t\".toml\",\n\t}\n\tparsers = map[string]koanf.Parser{\n\t\t\".yml\":   yaml.Parser(),\n\t\t\".yaml\":  yaml.Parser(),\n\t\t\".json\":  json.Parser(),\n\t\t\".jsonc\": jsoncParser(),\n\t\t\".toml\":  toml.Parser(),\n\t}\n\n\tmergeJobsOption = koanf.WithMergeFunc(mergeHooks)\n)\n\n// ConfigNotFoundError.\ntype ConfigNotFoundError struct {\n\tmessage string\n}\n\nfunc (err ConfigNotFoundError) Error() string {\n\treturn err.message\n}\n\n// loadConfig loads the config at the given path.\nfunc loadConfig(k *koanf.Koanf, filesystem afero.Fs, path string) error {\n\textension := filepath.Ext(path)\n\tlog.Debug(\"loading config: \", path)\n\tif err := k.Load(kfs.Provider(newIOFS(filesystem), path), parsers[extension], mergeJobsOption); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\n// loadFirst loads the first existing config from given names and supported extensions.\nfunc loadFirst(k *koanf.Koanf, filesystem afero.Fs, root string, names []string) error {\n\tfor _, extension := range Extensions {\n\t\tfor _, name := range names {\n\t\t\tconfig := filepath.Join(root, name+extension)\n\t\t\tif ok, _ := afero.Exists(filesystem, config); !ok {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\treturn loadConfig(k, filesystem, config)\n\t\t}\n\t}\n\n\treturn ConfigNotFoundError{fmt.Sprintf(\"No config files with names %q have been found in \\\"%s\\\"\", names, root)}\n}\n\n// loadFirstMain loads the main config (e.g. lefthook.yml) or fallbacks to local config (e.g. lefthook-local.yml).\nfunc loadFirstMain(k *koanf.Koanf, filesystem afero.Fs, root string) error {\n\terr := loadFirst(k, filesystem, root, MainConfigNames)\n\tif ok := errors.As(err, &ConfigNotFoundError{}); ok {\n\t\tvar hasLocalConfig bool\n\tOUT:\n\t\tfor _, extension := range Extensions {\n\t\t\tfor _, name := range LocalConfigNames {\n\t\t\t\tif ok, _ := afero.Exists(filesystem, filepath.Join(root, name+extension)); ok {\n\t\t\t\t\thasLocalConfig = true\n\t\t\t\t\tbreak OUT\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\tif !hasLocalConfig {\n\t\t\treturn err\n\t\t}\n\t} else if err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc loadMain(filesystem afero.Fs, root string) (*koanf.Koanf, error) {\n\tmain := koanf.New(\".\")\n\n\tconfigOverride := os.Getenv(\"LEFTHOOK_CONFIG\")\n\tif len(configOverride) == 0 {\n\t\tif err := loadFirstMain(main, filesystem, root); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\treturn main, nil\n\t}\n\n\tif !filepath.IsAbs(configOverride) {\n\t\tconfigOverride = filepath.Join(root, configOverride)\n\t}\n\tif ok, _ := afero.Exists(filesystem, configOverride); !ok {\n\t\treturn nil, ConfigNotFoundError{fmt.Sprintf(\"Config file \\\"%s\\\" not found!\", configOverride)}\n\t}\n\n\tif err := loadConfig(main, filesystem, configOverride); err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn main, nil\n}\n\nfunc LoadSecondary(main *koanf.Koanf, filesystem afero.Fs, repo *git.Repository) (*koanf.Koanf, error) {\n\t// Save `extends` and `remotes`\n\textends := main.Strings(\"extends\")\n\tvar remotes []*Remote\n\tif err := main.Unmarshal(\"remotes\", &remotes); err != nil {\n\t\treturn nil, err\n\t}\n\n\tsecondary := koanf.New(\".\")\n\n\t// Load main `extends`\n\tif err := extend(secondary, filesystem, repo.RootPath, extends); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Some extends required the other extends and changed the list\n\t// We don't want to load those extends again, so unsetting them.\n\tif !slices.Equal(secondary.Strings(\"extends\"), extends) {\n\t\tsecondary.Delete(\"extends\")\n\t}\n\n\t// Load main `remotes`\n\tif err := loadRemotes(secondary, filesystem, repo, remotes); err != nil {\n\t\treturn nil, err\n\t}\n\n\t// Don't allow to set `lefthook` field from a remote config\n\tsecondary.Delete(\"lefthook\")\n\n\t// Load optional local config (e.g. lefthook-local.yml)\n\tvar noLocal bool\n\tif err := loadFirst(secondary, filesystem, repo.RootPath, LocalConfigNames); err != nil {\n\t\tif ok := errors.As(err, &ConfigNotFoundError{}); !ok {\n\t\t\treturn nil, err\n\t\t}\n\t\tnoLocal = true\n\t}\n\n\t// Load local `extends`\n\tlocalExtends := secondary.Strings(\"extends\")\n\tif !noLocal && !slices.Equal(extends, localExtends) {\n\t\tif err := extend(secondary, filesystem, repo.RootPath, localExtends); err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\treturn secondary, nil\n}\n\nfunc LoadKoanf(filesystem afero.Fs, repo *git.Repository) (*koanf.Koanf, *koanf.Koanf, error) {\n\t// Load main lefthook.yml\n\tmain, err := loadMain(filesystem, repo.RootPath)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\t// Load secondary extends, remotes and lefthook-local.yml\n\tsecondary, err := LoadSecondary(main, filesystem, repo)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\treturn main, secondary, nil\n}\n\n// Load loads configs from the given directory with extensions.\nfunc Load(filesystem afero.Fs, repo *git.Repository) (*Config, error) {\n\tmain, secondary, err := LoadKoanf(filesystem, repo)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn Unmarshal(main, secondary)\n}\n\nfunc Unmarshal(main *koanf.Koanf, secondary *koanf.Koanf) (*Config, error) {\n\tvar config Config\n\n\tconfig.SourceDir = DefaultSourceDir\n\tconfig.SourceDirLocal = DefaultSourceDirLocal\n\n\tif err := unmarshalConfigs(main, secondary, &config); err != nil {\n\t\treturn nil, err\n\t}\n\n\tlog.SetColors(config.Colors)\n\n\treturn &config, nil\n}\n\n// loadRemotes merges remote configs to the current one.\nfunc loadRemotes(k *koanf.Koanf, filesystem afero.Fs, repo *git.Repository, remotes []*Remote) error {\n\tfor _, remote := range remotes {\n\t\tif !remote.Configured() {\n\t\t\tcontinue\n\t\t}\n\n\t\tif len(remote.Configs) == 0 {\n\t\t\tremote.Configs = append(remote.Configs, DefaultConfigName)\n\t\t}\n\n\t\tfor _, config := range remote.Configs {\n\t\t\tremotePath := repo.RemoteFolder(remote.GitURL, remote.Ref)\n\t\t\tconfigFile := config\n\t\t\tconfigPath := filepath.Join(remotePath, configFile)\n\n\t\t\tlog.Debugf(\"Merging remote config: %s: %s\", remote.GitURL, configPath)\n\n\t\t\tif ok, err := afero.Exists(filesystem, configPath); !ok || err != nil {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tparser, ok := parsers[filepath.Ext(configPath)]\n\t\t\tif !ok {\n\t\t\t\treturn fmt.Errorf(\"can't parse config '%[1]s', file has unsupported or no extension\\nhint: rename %[1]s to %[1]s.yml\", configPath)\n\t\t\t}\n\n\t\t\tif err := k.Load(kfs.Provider(newIOFS(filesystem), configPath), parser, mergeJobsOption); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\textends := k.Strings(\"extends\")\n\t\t\tif err := extend(k, filesystem, filepath.Dir(configPath), extends); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\n\t\t// Reset extends to omit issues when extending with remote extends.\n\t\tif err := k.Set(\"extends\", []string(nil)); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\n// extend merges all files listed in 'extends' option into the config.\nfunc extend(k *koanf.Koanf, filesystem afero.Fs, root string, extends []string) error {\n\treturn extendRecursive(k, filesystem, root, extends, make(map[string]struct{}))\n}\n\n// extendRecursive merges extends.\n// If extends contain other extends they get merged too.\nfunc extendRecursive(k *koanf.Koanf, filesystem afero.Fs, root string, extends []string, visited map[string]struct{}) error {\n\tfor _, pathOrGlob := range extends {\n\t\tif !filepath.IsAbs(pathOrGlob) {\n\t\t\tpathOrGlob = filepath.Join(root, pathOrGlob)\n\t\t}\n\n\t\tpaths, err := afero.Glob(filesystem, pathOrGlob)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"bad glob syntax for '%s': %w\", pathOrGlob, err)\n\t\t}\n\n\t\tfor _, path := range paths {\n\t\t\tif _, contains := visited[path]; contains {\n\t\t\t\treturn fmt.Errorf(\"possible recursion in extends: path %s is specified multiple times\", path)\n\t\t\t}\n\t\t\tvisited[path] = struct{}{}\n\n\t\t\textent := koanf.New(\".\")\n\t\t\tparser, ok := parsers[filepath.Ext(path)]\n\t\t\tif !ok {\n\t\t\t\treturn fmt.Errorf(\"can't parse config '%[1]s', file has unsupported or no extension\\nhint: rename %[1]s to %[1]s.yml\", path)\n\t\t\t}\n\t\t\tif err := extent.Load(kfs.Provider(newIOFS(filesystem), path), parser, mergeJobsOption); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif err := extendRecursive(extent, filesystem, root, extent.Strings(\"extends\"), visited); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\n\t\t\tif err := k.Load(koanfProvider{extent}, nil, mergeJobsOption); err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc unmarshalConfigs(main, secondary *koanf.Koanf, c *Config) error {\n\tc.Hooks = make(map[string]*Hook)\n\n\tfor hookName := range AvailableHooks {\n\t\tif !main.Exists(hookName) && !secondary.Exists(hookName) {\n\t\t\tcontinue\n\t\t}\n\n\t\tif err := addHook(hookName, main, secondary, c); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// For extra non-git hooks.\n\t// Notice that with append we're allowing extra hooks to be added in local config\n\tfor _, maybeHook := range append(main.Keys(), secondary.Keys()...) {\n\t\tmatches := hookKeyRegexp.FindStringSubmatch(maybeHook)\n\t\tif matches == nil {\n\t\t\tcontinue\n\t\t}\n\t\thookName := matches[hookKeyRegexp.SubexpIndex(\"hookName\")]\n\t\tif _, ok := c.Hooks[hookName]; ok {\n\t\t\tcontinue\n\t\t}\n\n\t\tif err := addHook(hookName, main, secondary, c); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\t// Merge config and unmarshal it\n\tif err := main.Merge(secondary); err != nil {\n\t\treturn err\n\t}\n\n\tif err := main.Unmarshal(\"\", c); err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc addHook(name string, main, secondary *koanf.Koanf, c *Config) error {\n\tmainHook := main.Cut(name)\n\toverrideHook := secondary.Cut(name)\n\n\t// Special merge func to support merging {cmd} templates\n\toptions := koanf.WithMergeFunc(func(src, dest map[string]any) error {\n\t\tvar destCommands map[string]string\n\n\t\tswitch commands := dest[\"commands\"].(type) {\n\t\tcase map[string]any:\n\t\t\tdestCommands = make(map[string]string, len(commands))\n\t\t\tfor cmdName, command := range commands {\n\t\t\t\tswitch cmd := command.(type) {\n\t\t\t\tcase map[string]any:\n\t\t\t\t\tswitch run := cmd[\"run\"].(type) {\n\t\t\t\t\tcase string:\n\t\t\t\t\t\tdestCommands[cmdName] = run\n\t\t\t\t\tdefault:\n\t\t\t\t\t}\n\t\t\t\tdefault:\n\t\t\t\t}\n\t\t\t}\n\t\tdefault:\n\t\t}\n\n\t\tvar destJobs, srcJobs []any\n\t\tswitch jobs := dest[\"jobs\"].(type) {\n\t\tcase []any:\n\t\t\tdestJobs = jobs\n\t\tdefault:\n\t\t}\n\t\tswitch jobs := src[\"jobs\"].(type) {\n\t\tcase []any:\n\t\t\tsrcJobs = jobs\n\t\tdefault:\n\t\t}\n\n\t\tvar destSetup, srcSetup []any\n\t\tswitch setup := dest[\"setup\"].(type) {\n\t\tcase []any:\n\t\t\tdestSetup = setup\n\t\tdefault:\n\t\t}\n\t\tswitch setup := src[\"setup\"].(type) {\n\t\tcase []any:\n\t\t\tsrcSetup = setup\n\t\tdefault:\n\t\t}\n\n\t\tdestJobs = mergeJobsSlice(srcJobs, destJobs)\n\t\tdestSetup = slices.Concat(srcSetup, destSetup)\n\n\t\tmaps.Merge(src, dest)\n\n\t\tif len(destCommands) > 0 {\n\t\t\tswitch commands := dest[\"commands\"].(type) {\n\t\t\tcase map[string]any:\n\t\t\t\tfor cmdName, command := range commands {\n\t\t\t\t\tswitch cmd := command.(type) {\n\t\t\t\t\tcase map[string]any:\n\t\t\t\t\t\tswitch run := cmd[\"run\"].(type) {\n\t\t\t\t\t\tcase string:\n\t\t\t\t\t\t\tnewRun := strings.ReplaceAll(run, CMD, destCommands[cmdName])\n\t\t\t\t\t\t\tcommand.(map[string]any)[\"run\"] = newRun\n\t\t\t\t\t\tdefault:\n\t\t\t\t\t\t}\n\t\t\t\t\tdefault:\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\tdefault:\n\t\t\t}\n\t\t}\n\n\t\tif len(destJobs) > 0 {\n\t\t\tdest[\"jobs\"] = destJobs\n\t\t}\n\t\tif len(destSetup) > 0 {\n\t\t\tdest[\"setup\"] = destSetup\n\t\t}\n\n\t\treturn nil\n\t})\n\n\tif err := mainHook.Load(koanfProvider{overrideHook}, nil, options); err != nil {\n\t\treturn err\n\t}\n\tvar hook Hook\n\tif err := mainHook.Unmarshal(\"\", &hook); err != nil {\n\t\treturn err\n\t}\n\t// Assign custom hook name\n\thook.Name = name\n\n\tif tags := os.Getenv(\"LEFTHOOK_EXCLUDE\"); tags != \"\" {\n\t\thook.ExcludeTags = append(hook.ExcludeTags, strings.Split(tags, \",\")...)\n\t}\n\n\tc.Hooks[name] = &hook\n\treturn nil\n}\n\n// Rewritten from afero.NewIOFS to support opening paths starting with '/'.\n\ntype iofs struct {\n\tfs afero.Fs\n}\n\nfunc newIOFS(filesystem afero.Fs) iofs {\n\treturn iofs{filesystem}\n}\n\nfunc (iofs iofs) Open(name string) (fs.File, error) {\n\tfile, err := iofs.fs.Open(name)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"open failed: %s: %w\", name, err)\n\t}\n\n\treturn file, nil\n}\n\ntype koanfProvider struct {\n\tk *koanf.Koanf\n}\n\nfunc (k koanfProvider) Read() (map[string]any, error) {\n\treturn k.k.Raw(), nil\n}\n\nfunc (k koanfProvider) ReadBytes() ([]byte, error) {\n\tpanic(\"not implemented\")\n}\n\n// mergeHooks merges `jobs` and `setup` settings.\n//\n// `jobs` settings get overwritten by name or get appended to the end.\n// `setup` always get prepended.\nfunc mergeHooks(src, dest map[string]any) error {\n\tsrcJobs := make(map[string][]any)\n\tfor name, maybeHook := range src {\n\t\tswitch hook := maybeHook.(type) {\n\t\tcase map[string]any:\n\t\t\tswitch jobs := hook[\"jobs\"].(type) {\n\t\t\tcase []any:\n\t\t\t\tsrcJobs[name] = jobs\n\t\t\tdefault:\n\t\t\t}\n\t\tdefault:\n\t\t}\n\t}\n\n\tdestJobs := make(map[string][]any)\n\tfor name, maybeHook := range dest {\n\t\tswitch hook := maybeHook.(type) {\n\t\tcase map[string]any:\n\t\t\tswitch jobs := hook[\"jobs\"].(type) {\n\t\t\tcase []any:\n\t\t\t\tdestJobs[name] = jobs\n\t\t\tdefault:\n\t\t\t}\n\t\tdefault:\n\t\t}\n\t}\n\n\tsrcSetup := make(map[string][]any)\n\tfor name, maybeHook := range src {\n\t\tswitch hook := maybeHook.(type) {\n\t\tcase map[string]any:\n\t\t\tswitch setup := hook[\"setup\"].(type) {\n\t\t\tcase []any:\n\t\t\t\tsrcSetup[name] = setup\n\t\t\tdefault:\n\t\t\t}\n\t\tdefault:\n\t\t}\n\t}\n\n\tdestSetup := make(map[string][]any)\n\tfor name, maybeHook := range dest {\n\t\tswitch hook := maybeHook.(type) {\n\t\tcase map[string]any:\n\t\t\tswitch setup := hook[\"setup\"].(type) {\n\t\t\tcase []any:\n\t\t\t\tdestSetup[name] = setup\n\t\t\tdefault:\n\t\t\t}\n\t\tdefault:\n\t\t}\n\t}\n\n\tif (len(srcJobs) == 0 || len(destJobs) == 0) && (len(srcSetup) == 0 || len(destSetup) == 0) {\n\t\tmaps.Merge(src, dest)\n\t\treturn nil\n\t}\n\n\tfor hook, newJobs := range srcJobs {\n\t\toldJobs, ok := destJobs[hook]\n\t\tif !ok {\n\t\t\tdestJobs[hook] = newJobs\n\t\t\tcontinue\n\t\t}\n\n\t\tdestJobs[hook] = mergeJobsSlice(newJobs, oldJobs)\n\t}\n\n\tfor hook, newSetup := range srcSetup {\n\t\toldSetup, ok := destSetup[hook]\n\t\tif !ok {\n\t\t\tdestSetup[hook] = newSetup\n\t\t\tcontinue\n\t\t}\n\n\t\tdestSetup[hook] = slices.Concat(oldSetup, newSetup)\n\t}\n\n\tmaps.Merge(src, dest)\n\n\tfor name, maybeHook := range dest {\n\t\tif jobs, ok := destJobs[name]; ok {\n\t\t\tswitch hook := maybeHook.(type) {\n\t\t\tcase map[string]any:\n\t\t\t\thook[\"jobs\"] = jobs\n\t\t\tdefault:\n\t\t\t}\n\t\t}\n\t}\n\n\tfor name, maybeHook := range dest {\n\t\tif setup, ok := destSetup[name]; ok {\n\t\t\tswitch hook := maybeHook.(type) {\n\t\t\tcase map[string]any:\n\t\t\t\thook[\"setup\"] = setup\n\t\t\tdefault:\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc mergeJobsSlice(src, dest []any) []any {\n\tmergeable := make(map[string]map[string]any)\n\tresult := make([]any, 0, len(dest))\n\n\tfor _, maybeJob := range dest {\n\t\tswitch destJob := maybeJob.(type) {\n\t\tcase map[string]any:\n\t\t\tswitch name := destJob[\"name\"].(type) {\n\t\t\tcase string:\n\t\t\t\tmergeable[name] = destJob\n\t\t\tdefault:\n\t\t\t}\n\n\t\t\tresult = append(result, maybeJob)\n\t\tdefault:\n\t\t}\n\t}\n\n\tfor _, maybeJob := range src {\n\t\tswitch srcJob := maybeJob.(type) {\n\t\tcase map[string]any:\n\t\t\tswitch name := srcJob[\"name\"].(type) {\n\t\t\tcase string:\n\t\t\t\tdestJob, ok := mergeable[name]\n\t\t\t\tif ok {\n\t\t\t\t\tvar srcSubJobs []any\n\t\t\t\t\tvar destSubJobs []any\n\n\t\t\t\t\tswitch srcGroup := srcJob[\"group\"].(type) {\n\t\t\t\t\tcase map[string]any:\n\t\t\t\t\t\tswitch subJobs := srcGroup[\"jobs\"].(type) {\n\t\t\t\t\t\tcase []any:\n\t\t\t\t\t\t\tsrcSubJobs = subJobs\n\t\t\t\t\t\tdefault:\n\t\t\t\t\t\t}\n\t\t\t\t\tdefault:\n\t\t\t\t\t}\n\t\t\t\t\tswitch destGroup := destJob[\"group\"].(type) {\n\t\t\t\t\tcase map[string]any:\n\t\t\t\t\t\tswitch subJobs := destGroup[\"jobs\"].(type) {\n\t\t\t\t\t\tcase []any:\n\t\t\t\t\t\t\tdestSubJobs = subJobs\n\t\t\t\t\t\tdefault:\n\t\t\t\t\t\t}\n\t\t\t\t\tdefault:\n\t\t\t\t\t}\n\n\t\t\t\t\tif len(destSubJobs) != 0 && len(srcSubJobs) != 0 {\n\t\t\t\t\t\tdestSubJobs = mergeJobsSlice(srcSubJobs, destSubJobs)\n\t\t\t\t\t}\n\n\t\t\t\t\t// Replace possible {cmd} before merging the jobs\n\t\t\t\t\tswitch srcRun := srcJob[\"run\"].(type) {\n\t\t\t\t\tcase string:\n\t\t\t\t\t\tswitch destRun := destJob[\"run\"].(type) {\n\t\t\t\t\t\tcase string:\n\t\t\t\t\t\t\tnewRun := strings.ReplaceAll(srcRun, CMD, destRun)\n\t\t\t\t\t\t\tsrcJob[\"run\"] = newRun\n\t\t\t\t\t\tdefault:\n\t\t\t\t\t\t}\n\t\t\t\t\tdefault:\n\t\t\t\t\t}\n\n\t\t\t\t\tmaps.Merge(srcJob, destJob)\n\n\t\t\t\t\tif len(destSubJobs) != 0 {\n\t\t\t\t\t\tswitch destGroup := destJob[\"group\"].(type) {\n\t\t\t\t\t\tcase map[string]any:\n\t\t\t\t\t\t\tswitch destGroup[\"jobs\"].(type) {\n\t\t\t\t\t\t\tcase []any:\n\t\t\t\t\t\t\t\tdestGroup[\"jobs\"] = destSubJobs\n\t\t\t\t\t\t\tdefault:\n\t\t\t\t\t\t\t}\n\t\t\t\t\t\tdefault:\n\t\t\t\t\t\t}\n\t\t\t\t\t}\n\t\t\t\t\tcontinue\n\t\t\t\t}\n\t\t\tdefault:\n\t\t\t}\n\n\t\t\tresult = append(result, maybeJob)\n\t\tdefault:\n\t\t}\n\t}\n\n\treturn result\n}\n"
  },
  {
    "path": "internal/config/load_test.go",
    "content": "package config\n\nimport (\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/spf13/afero\"\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"github.com/evilmartians/lefthook/v2/tests/helpers/gittest\"\n)\n\n//gocyclo:ignore\nfunc TestLoad(t *testing.T) {\n\troot, err := filepath.Abs(\"\")\n\tassert.NoError(t, err)\n\n\tfor name, tt := range map[string]struct {\n\t\tfiles            map[string]string\n\t\tremote           string\n\t\tremoteConfigPath string\n\t\tpathOverride     string\n\t\tresult           *Config\n\t}{\n\t\t\"with .lefthook.yml\": {\n\t\t\tfiles: map[string]string{\n\t\t\t\t\".lefthook.yml\": `\npre-commit:\n  commands:\n    tests:\n      run: yarn test\n`,\n\t\t\t},\n\t\t\tresult: &Config{\n\t\t\t\tSourceDir:      DefaultSourceDir,\n\t\t\t\tSourceDirLocal: DefaultSourceDirLocal,\n\t\t\t\tColors:         nil,\n\t\t\t\tHooks: map[string]*Hook{\n\t\t\t\t\t\"pre-commit\": {\n\t\t\t\t\t\tName:     \"pre-commit\",\n\t\t\t\t\t\tParallel: false,\n\t\t\t\t\t\tCommands: map[string]*Command{\n\t\t\t\t\t\t\t\"tests\": {\n\t\t\t\t\t\t\t\tRun: \"yarn test\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"with lefthook.yml\": {\n\t\t\tfiles: map[string]string{\n\t\t\t\t\"lefthook.yml\": `\npre-commit:\n  commands:\n    tests:\n      run: yarn test\n`,\n\t\t\t},\n\t\t\tresult: &Config{\n\t\t\t\tSourceDir:      DefaultSourceDir,\n\t\t\t\tSourceDirLocal: DefaultSourceDirLocal,\n\t\t\t\tColors:         nil,\n\t\t\t\tHooks: map[string]*Hook{\n\t\t\t\t\t\"pre-commit\": {\n\t\t\t\t\t\tName:     \"pre-commit\",\n\t\t\t\t\t\tParallel: false,\n\t\t\t\t\t\tCommands: map[string]*Command{\n\t\t\t\t\t\t\t\"tests\": {\n\t\t\t\t\t\t\t\tRun: \"yarn test\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"with lefthook.yml and .lefthook.yml\": {\n\t\t\tfiles: map[string]string{\n\t\t\t\t\".lefthook.yml\": `\npre-commit:\n  commands:\n    tests:\n      run: yarn test1\n`,\n\t\t\t\t\"lefthook.yml\": `\npre-commit:\n  commands:\n    tests:\n      run: yarn test2\n`,\n\t\t\t},\n\t\t\tresult: &Config{\n\t\t\t\tSourceDir:      DefaultSourceDir,\n\t\t\t\tSourceDirLocal: DefaultSourceDirLocal,\n\t\t\t\tColors:         nil,\n\t\t\t\tHooks: map[string]*Hook{\n\t\t\t\t\t\"pre-commit\": {\n\t\t\t\t\t\tName:     \"pre-commit\",\n\t\t\t\t\t\tParallel: false,\n\t\t\t\t\t\tCommands: map[string]*Command{\n\t\t\t\t\t\t\t\"tests\": {\n\t\t\t\t\t\t\t\tRun: \"yarn test2\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"simple\": {\n\t\t\tfiles: map[string]string{\n\t\t\t\t\"lefthook.yml\": `\npre-commit:\n  commands:\n    tests:\n      run: yarn test\n`,\n\t\t\t\t\"lefthook-local.yml\": `\npost-commit:\n  commands:\n    ping-done:\n      run: curl -x POST status.com/done\n`,\n\t\t\t},\n\t\t\tresult: &Config{\n\t\t\t\tSourceDir:      DefaultSourceDir,\n\t\t\t\tSourceDirLocal: DefaultSourceDirLocal,\n\t\t\t\tColors:         nil,\n\t\t\t\tHooks: map[string]*Hook{\n\t\t\t\t\t\"pre-commit\": {\n\t\t\t\t\t\tName:     \"pre-commit\",\n\t\t\t\t\t\tParallel: false,\n\t\t\t\t\t\tCommands: map[string]*Command{\n\t\t\t\t\t\t\t\"tests\": {\n\t\t\t\t\t\t\t\tRun: \"yarn test\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\t\"post-commit\": {\n\t\t\t\t\t\tName:     \"post-commit\",\n\t\t\t\t\t\tParallel: false,\n\t\t\t\t\t\tCommands: map[string]*Command{\n\t\t\t\t\t\t\t\"ping-done\": {\n\t\t\t\t\t\t\t\tRun: \"curl -x POST status.com/done\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"with overrides\": {\n\t\t\tfiles: map[string]string{\n\t\t\t\t\"lefthook.yml\": `\nmin_version: 0.6.0\nsource_dir: $HOME/sources\nsource_dir_local: $HOME/sources_local\n\npre-commit:\n  parallel: true\n  commands:\n    tests:\n      run: bundle exec rspec\n      tags: [backend, test]\n    lint:\n      run: bundle exec rubocop\n      glob: \"*.rb\"\n      tags: [backend, linter]\n  scripts:\n    \"format.sh\":\n      runner: bash\n`,\n\t\t\t\t\"lefthook-local.yml\": `\nmin_version: 1.0.0\ncolors: false\n\npre-commit:\n  commands:\n    tests:\n      skip: true\n    lint:\n      run: docker exec -it ruby:2.7 {cmd}\n  scripts:\n    \"format.sh\":\n      only: true\n\npre-push:\n  commands:\n    rubocop:\n      run: bundle exec rubocop\n      tags: [backend, linter]\n`,\n\t\t\t},\n\t\t\tresult: &Config{\n\t\t\t\tMinVersion:     \"1.0.0\",\n\t\t\t\tColors:         false,\n\t\t\t\tSourceDir:      \"$HOME/sources\",\n\t\t\t\tSourceDirLocal: \"$HOME/sources_local\",\n\n\t\t\t\tHooks: map[string]*Hook{\n\t\t\t\t\t\"pre-commit\": {\n\t\t\t\t\t\tName:     \"pre-commit\",\n\t\t\t\t\t\tParallel: true,\n\t\t\t\t\t\tCommands: map[string]*Command{\n\t\t\t\t\t\t\t\"tests\": {\n\t\t\t\t\t\t\t\tSkip: true,\n\t\t\t\t\t\t\t\tRun:  \"bundle exec rspec\",\n\t\t\t\t\t\t\t\tTags: []string{\"backend\", \"test\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"lint\": {\n\t\t\t\t\t\t\t\tGlob: []string{\"*.rb\"},\n\t\t\t\t\t\t\t\tRun:  \"docker exec -it ruby:2.7 bundle exec rubocop\",\n\t\t\t\t\t\t\t\tTags: []string{\"backend\", \"linter\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\tScripts: map[string]*Script{\n\t\t\t\t\t\t\t\"format.sh\": {\n\t\t\t\t\t\t\t\tOnly:   true,\n\t\t\t\t\t\t\t\tRunner: \"bash\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\t\"pre-push\": {\n\t\t\t\t\t\tName: \"pre-push\",\n\t\t\t\t\t\tCommands: map[string]*Command{\n\t\t\t\t\t\t\t\"rubocop\": {\n\t\t\t\t\t\t\t\tRun:  \"bundle exec rubocop\",\n\t\t\t\t\t\t\t\tTags: []string{\"backend\", \"linter\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"with overrides from .lefthook-local.yml\": {\n\t\t\tfiles: map[string]string{\n\t\t\t\t\".lefthook.yml\": `\npre-push:\n  scripts:\n    \"global-extend.sh\":\n      runner: bash\n`,\n\t\t\t\t\".lefthook-local.yml\": `\npre-push:\n  scripts:\n    \"local-extend.sh\":\n      runner: bash\n`,\n\t\t\t},\n\t\t\tresult: &Config{\n\t\t\t\tSourceDir:      DefaultSourceDir,\n\t\t\t\tSourceDirLocal: DefaultSourceDirLocal,\n\t\t\t\tColors:         nil,\n\t\t\t\tHooks: map[string]*Hook{\n\t\t\t\t\t\"pre-push\": {\n\t\t\t\t\t\tName: \"pre-push\",\n\t\t\t\t\t\tScripts: map[string]*Script{\n\t\t\t\t\t\t\t\"global-extend.sh\": {\n\t\t\t\t\t\t\t\tRunner: \"bash\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"local-extend.sh\": {\n\t\t\t\t\t\t\t\tRunner: \"bash\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"with overrides, dot, nodot\": {\n\t\t\tfiles: map[string]string{\n\t\t\t\t\"lefthook.yml\": `\npre-push:\n  scripts:\n    \"global-extend\":\n      runner: bash\n`,\n\t\t\t\t\".lefthook-local.yml\": `\npre-push:\n  scripts:\n    \"local-extend\":\n      runner: bash\n`,\n\t\t\t},\n\t\t\tresult: &Config{\n\t\t\t\tSourceDir:      DefaultSourceDir,\n\t\t\t\tSourceDirLocal: DefaultSourceDirLocal,\n\t\t\t\tColors:         nil,\n\t\t\t\tHooks: map[string]*Hook{\n\t\t\t\t\t\"pre-push\": {\n\t\t\t\t\t\tName: \"pre-push\",\n\t\t\t\t\t\tScripts: map[string]*Script{\n\t\t\t\t\t\t\t\"global-extend\": {\n\t\t\t\t\t\t\t\tRunner: \"bash\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"local-extend\": {\n\t\t\t\t\t\t\t\tRunner: \"bash\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"with overrides, nodot has priority\": {\n\t\t\tfiles: map[string]string{\n\t\t\t\t\"lefthook.yml\": `\npre-push:\n  scripts:\n    \"global-extend\":\n      runner: bash\n`,\n\t\t\t\t\".lefthook-local.yml\": `\npre-push:\n  scripts:\n    \"local-extend\":\n      runner: bash1\n`,\n\t\t\t\t\"lefthook-local.yml\": `\npre-push:\n  scripts:\n    \"local-extend\":\n      runner: bash2\n`,\n\t\t\t},\n\t\t\tresult: &Config{\n\t\t\t\tSourceDir:      DefaultSourceDir,\n\t\t\t\tSourceDirLocal: DefaultSourceDirLocal,\n\t\t\t\tColors:         nil,\n\t\t\t\tHooks: map[string]*Hook{\n\t\t\t\t\t\"pre-push\": {\n\t\t\t\t\t\tName: \"pre-push\",\n\t\t\t\t\t\tScripts: map[string]*Script{\n\t\t\t\t\t\t\t\"global-extend\": {\n\t\t\t\t\t\t\t\tRunner: \"bash\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"local-extend\": {\n\t\t\t\t\t\t\t\tRunner: \"bash2\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"with extra hooks\": {\n\t\t\tfiles: map[string]string{\n\t\t\t\t\"lefthook.yml\": `\ntests:\n  commands:\n    tests:\n      run: go test ./...\n\nlints:\n  scripts:\n    \"linter.sh\":\n      runner: bash\n`,\n\t\t\t},\n\t\t\tresult: &Config{\n\t\t\t\tSourceDir:      DefaultSourceDir,\n\t\t\t\tSourceDirLocal: DefaultSourceDirLocal,\n\t\t\t\tColors:         nil,\n\t\t\t\tHooks: map[string]*Hook{\n\t\t\t\t\t\"tests\": {\n\t\t\t\t\t\tName:     \"tests\",\n\t\t\t\t\t\tParallel: false,\n\t\t\t\t\t\tCommands: map[string]*Command{\n\t\t\t\t\t\t\t\"tests\": {\n\t\t\t\t\t\t\t\tRun: \"go test ./...\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\t\"lints\": {\n\t\t\t\t\t\tName: \"lints\",\n\t\t\t\t\t\tScripts: map[string]*Script{\n\t\t\t\t\t\t\t\"linter.sh\": {\n\t\t\t\t\t\t\t\tRunner: \"bash\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"with extra hooks only in local config\": {\n\t\t\tfiles: map[string]string{\n\t\t\t\t\"lefthook.yml\": `\ncolors:\n  yellow: '#FFE4B5'\n  red: 196\ntests:\n  commands:\n    tests:\n      run: go test ./...\n`,\n\t\t\t\t\"lefthook-local.yml\": `\nlints:\n  scripts:\n    \"linter.sh\":\n      runner: bash\n`,\n\t\t\t},\n\t\t\tresult: &Config{\n\t\t\t\tSourceDir:      DefaultSourceDir,\n\t\t\t\tSourceDirLocal: DefaultSourceDirLocal,\n\t\t\t\tColors:         map[string]any{\"yellow\": \"#FFE4B5\", \"red\": 196},\n\t\t\t\tHooks: map[string]*Hook{\n\t\t\t\t\t\"tests\": {\n\t\t\t\t\t\tName:     \"tests\",\n\t\t\t\t\t\tParallel: false,\n\t\t\t\t\t\tCommands: map[string]*Command{\n\t\t\t\t\t\t\t\"tests\": {\n\t\t\t\t\t\t\t\tRun: \"go test ./...\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\t\"lints\": {\n\t\t\t\t\t\tName: \"lints\",\n\t\t\t\t\t\tScripts: map[string]*Script{\n\t\t\t\t\t\t\t\"linter.sh\": {\n\t\t\t\t\t\t\t\tRunner: \"bash\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"with remote\": {\n\t\t\tfiles: map[string]string{\n\t\t\t\t\"lefthook.yml\": `\nremotes:\n  - git_url: git@github.com:evilmartians/lefthook\n`,\n\t\t\t},\n\t\t\tremote: `\npre-commit:\n  commands:\n    lint:\n      run: yarn lint\n  scripts:\n    \"test.sh\":\n      runner: bash\n`,\n\t\t\tremoteConfigPath: filepath.Join(root, \".git\", \"info\", \"lefthook-remotes\", \"lefthook\", \"lefthook.yml\"),\n\t\t\tresult: &Config{\n\t\t\t\tSourceDir:      DefaultSourceDir,\n\t\t\t\tSourceDirLocal: DefaultSourceDirLocal,\n\t\t\t\tColors:         nil,\n\t\t\t\tRemotes: []*Remote{\n\t\t\t\t\t{\n\t\t\t\t\t\tGitURL: \"git@github.com:evilmartians/lefthook\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tHooks: map[string]*Hook{\n\t\t\t\t\t\"pre-commit\": {\n\t\t\t\t\t\tName: \"pre-commit\",\n\t\t\t\t\t\tCommands: map[string]*Command{\n\t\t\t\t\t\t\t\"lint\": {\n\t\t\t\t\t\t\t\tRun: \"yarn lint\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\tScripts: map[string]*Script{\n\t\t\t\t\t\t\t\"test.sh\": {\n\t\t\t\t\t\t\t\tRunner: \"bash\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"with remote and custom config name\": {\n\t\t\tfiles: map[string]string{\n\t\t\t\t\"lefthook.yml\": `\nremotes:\n  - git_url: git@github.com:evilmartians/lefthook\n    ref: v1.0.0\n    configs:\n      - examples/custom.yml\n\npre-commit:\n  only:\n    - ref: main\n  commands:\n    global:\n      run: echo 'Global!'\n    lint:\n      run: this will be overwritten\n`,\n\t\t\t},\n\t\t\tremote: `\npre-commit:\n  commands:\n    lint:\n      only:\n        - merge\n        - rebase\n      run: yarn lint\n  scripts:\n    \"test.sh\":\n      skip:\n        - merge\n      runner: bash\n`,\n\t\t\tremoteConfigPath: filepath.Join(root, \".git\", \"info\", \"lefthook-remotes\", \"lefthook-v1.0.0\", \"examples\", \"custom.yml\"),\n\t\t\tresult: &Config{\n\t\t\t\tSourceDir:      DefaultSourceDir,\n\t\t\t\tSourceDirLocal: DefaultSourceDirLocal,\n\t\t\t\tColors:         nil,\n\t\t\t\tRemotes: []*Remote{\n\t\t\t\t\t{\n\t\t\t\t\t\tGitURL:  \"git@github.com:evilmartians/lefthook\",\n\t\t\t\t\t\tRef:     \"v1.0.0\",\n\t\t\t\t\t\tConfigs: []string{\"examples/custom.yml\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tHooks: map[string]*Hook{\n\t\t\t\t\t\"pre-commit\": {\n\t\t\t\t\t\tName: \"pre-commit\",\n\t\t\t\t\t\tOnly: []any{map[string]any{\"ref\": \"main\"}},\n\t\t\t\t\t\tCommands: map[string]*Command{\n\t\t\t\t\t\t\t\"lint\": {\n\t\t\t\t\t\t\t\tRun:  \"yarn lint\",\n\t\t\t\t\t\t\t\tOnly: []any{\"merge\", \"rebase\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"global\": {\n\t\t\t\t\t\t\t\tRun: \"echo 'Global!'\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\tScripts: map[string]*Script{\n\t\t\t\t\t\t\t\"test.sh\": {\n\t\t\t\t\t\t\t\tRunner: \"bash\",\n\t\t\t\t\t\t\t\tSkip:   []any{\"merge\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"with extends\": {\n\t\t\tfiles: map[string]string{\n\t\t\t\t\"lefthook.yml\": `\nextends:\n  - global-extend.yml\n\nremotes:\n  - git_url: https://github.com/evilmartians/lefthook\n    configs:\n      - examples/config.yml\n\npre-push:\n  commands:\n    global:\n      run: echo global\n`,\n\t\t\t\t\"lefthook-local.yml\": `\nextends:\n  - local-extend.yml\n\npre-push:\n  commands:\n    local:\n      run: echo local\n`,\n\t\t\t\t\"global-extend.yml\": `\npre-push:\n  scripts:\n    \"global-extend\":\n      runner: bash\n`,\n\t\t\t\t\"local-extend.yml\": `\npre-push:\n  scripts:\n    \"local-extend\":\n      runner: bash\n`,\n\t\t\t\t\".git/info/lefthook-remotes/lefthook/remote-extend.yml\": `\npre-push:\n  scripts:\n    \"remote-extend\":\n      runner: bash\n`,\n\t\t\t},\n\t\t\tremote: `\nextends:\n  - ../remote-extend.yml\n\npre-push:\n  commands:\n    remote:\n      run: echo remote\n`,\n\t\t\tremoteConfigPath: filepath.Join(root, \".git\", \"info\", \"lefthook-remotes\", \"lefthook\", \"examples\", \"config.yml\"),\n\t\t\tresult: &Config{\n\t\t\t\tSourceDir:      DefaultSourceDir,\n\t\t\t\tSourceDirLocal: DefaultSourceDirLocal,\n\t\t\t\tColors:         nil,\n\t\t\t\tRemotes: []*Remote{\n\t\t\t\t\t{\n\t\t\t\t\t\tGitURL:  \"https://github.com/evilmartians/lefthook\",\n\t\t\t\t\t\tConfigs: []string{\"examples/config.yml\"},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tExtends: []string{\"local-extend.yml\"},\n\t\t\t\tHooks: map[string]*Hook{\n\t\t\t\t\t\"pre-push\": {\n\t\t\t\t\t\tName: \"pre-push\",\n\t\t\t\t\t\tCommands: map[string]*Command{\n\t\t\t\t\t\t\t\"global\": {\n\t\t\t\t\t\t\t\tRun: \"echo global\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"local\": {\n\t\t\t\t\t\t\t\tRun: \"echo local\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"remote\": {\n\t\t\t\t\t\t\t\tRun: \"echo remote\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\tScripts: map[string]*Script{\n\t\t\t\t\t\t\t\"global-extend\": {\n\t\t\t\t\t\t\t\tRunner: \"bash\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"local-extend\": {\n\t\t\t\t\t\t\t\tRunner: \"bash\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"remote-extend\": {\n\t\t\t\t\t\t\t\tRunner: \"bash\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"with extends and local\": {\n\t\t\tfiles: map[string]string{\n\t\t\t\t\"lefthook.yml\": `\nextends:\n  - global-extend.yml\npre-commit:\n  parallel: true\n  exclude_tags: [linter]\n  commands:\n    global-lint:\n      run: bundle exec rubocop\n      glob: \"*.rb\"\n      tags: [backend, linter]\n    global-other:\n      run: bundle exec rubocop\n      tags: [other]\n`,\n\t\t\t\t\"lefthook-local.yml\": `\npre-commit:\n  exclude_tags: [backend]\n`,\n\t\t\t\t\"global-extend.yml\": `\npre-commit:\n  exclude_tags: [test]\n  commands:\n    extended-tests:\n      run: bundle exec rspec\n      tags: [backend, test]\n`,\n\t\t\t},\n\t\t\tresult: &Config{\n\t\t\t\tSourceDir:      DefaultSourceDir,\n\t\t\t\tSourceDirLocal: DefaultSourceDirLocal,\n\t\t\t\tExtends:        []string{\"global-extend.yml\"},\n\t\t\t\tHooks: map[string]*Hook{\n\t\t\t\t\t\"pre-commit\": {\n\t\t\t\t\t\tName:        \"pre-commit\",\n\t\t\t\t\t\tParallel:    true,\n\t\t\t\t\t\tExcludeTags: []string{\"backend\"},\n\t\t\t\t\t\tCommands: map[string]*Command{\n\t\t\t\t\t\t\t\"global-lint\": {\n\t\t\t\t\t\t\t\tRun:  \"bundle exec rubocop\",\n\t\t\t\t\t\t\t\tTags: []string{\"backend\", \"linter\"},\n\t\t\t\t\t\t\t\tGlob: []string{\"*.rb\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"global-other\": {\n\t\t\t\t\t\t\t\tRun:  \"bundle exec rubocop\",\n\t\t\t\t\t\t\t\tTags: []string{\"other\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"extended-tests\": {\n\t\t\t\t\t\t\t\tRun:  \"bundle exec rspec\",\n\t\t\t\t\t\t\t\tTags: []string{\"backend\", \"test\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"with glob in extends\": {\n\t\t\tfiles: map[string]string{\n\t\t\t\t\"lefthook.yml\": `\nextends:\n  - dir/*/config.yml\n`,\n\t\t\t\t\"dir/a/config.yml\": `\npre-commit:\n  commands:\n    a:\n      run: echo A\n`,\n\t\t\t\t\"dir/b/config.yml\": `\npre-commit:\n  commands:\n    b:\n      run: echo B\n`,\n\t\t\t\t\"dir/b/c/config.yml\": `\npre-commit:\n  commands:\n    c:\n      run: echo C\n`,\n\t\t\t},\n\t\t\tresult: &Config{\n\t\t\t\tSourceDir:      DefaultSourceDir,\n\t\t\t\tSourceDirLocal: DefaultSourceDirLocal,\n\t\t\t\tExtends:        []string{\"dir/*/config.yml\"},\n\t\t\t\tColors:         nil,\n\t\t\t\tHooks: map[string]*Hook{\n\t\t\t\t\t\"pre-commit\": {\n\t\t\t\t\t\tName:     \"pre-commit\",\n\t\t\t\t\t\tParallel: false,\n\t\t\t\t\t\tCommands: map[string]*Command{\n\t\t\t\t\t\t\t\"a\": {\n\t\t\t\t\t\t\t\tRun: \"echo A\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"b\": {\n\t\t\t\t\t\t\t\tRun: \"echo B\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"with jobs\": {\n\t\t\tfiles: map[string]string{\n\t\t\t\t\"lefthook.yml\": `\npre-commit:\n  jobs:\n    - run: 1\n    - run: 2\n      name: second\n`,\n\t\t\t\t\"lefthook-local.yml\": `\npre-commit:\n  jobs:\n    - run: 3\n    - run: local 2\n      name: second\n`,\n\t\t\t},\n\t\t\tresult: &Config{\n\t\t\t\tSourceDir:      \".lefthook\",\n\t\t\t\tSourceDirLocal: \".lefthook-local\",\n\t\t\t\tHooks: map[string]*Hook{\n\t\t\t\t\t\"pre-commit\": {\n\t\t\t\t\t\tName: \"pre-commit\",\n\t\t\t\t\t\tJobs: []*Job{\n\t\t\t\t\t\t\t{Run: \"1\"},\n\t\t\t\t\t\t\t{Run: \"local 2\", Name: \"second\"},\n\t\t\t\t\t\t\t{Run: \"3\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"with nested jobs\": {\n\t\t\tfiles: map[string]string{\n\t\t\t\t\"lefthook.yml\": `\npre-commit:\n  jobs:\n    - name: group 1\n      group:\n        jobs:\n          - run: 1.1\n          - run: 1.2\n          - name: nested\n            group:\n              jobs:\n                - run: 1.nested.1\n                - run: 1.nested.2\n                  name: nested 2\n`,\n\t\t\t\t\"lefthook-local.yml\": `\npre-commit:\n  jobs:\n    - name: group 1\n      glob: \"*.rb\"\n      group:\n        parallel: true\n        jobs:\n          - name: nested\n            group:\n              jobs:\n                - run: 1.nested.2 local\n                  name: nested 2\n                - run: 1.nested.3\n          - run: 1.3\n          - run: 1.4\n`,\n\t\t\t},\n\t\t\tresult: &Config{\n\t\t\t\tSourceDir:      \".lefthook\",\n\t\t\t\tSourceDirLocal: \".lefthook-local\",\n\t\t\t\tHooks: map[string]*Hook{\n\t\t\t\t\t\"pre-commit\": {\n\t\t\t\t\t\tName: \"pre-commit\",\n\t\t\t\t\t\tJobs: []*Job{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tName: \"group 1\",\n\t\t\t\t\t\t\t\tGlob: []string{\"*.rb\"},\n\t\t\t\t\t\t\t\tGroup: &Group{\n\t\t\t\t\t\t\t\t\tParallel: true,\n\t\t\t\t\t\t\t\t\tJobs: []*Job{\n\t\t\t\t\t\t\t\t\t\t{Run: \"1.1\"},\n\t\t\t\t\t\t\t\t\t\t{Run: \"1.2\"},\n\t\t\t\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\t\t\tName: \"nested\",\n\t\t\t\t\t\t\t\t\t\t\tGroup: &Group{\n\t\t\t\t\t\t\t\t\t\t\t\tJobs: []*Job{\n\t\t\t\t\t\t\t\t\t\t\t\t\t{Run: \"1.nested.1\"},\n\t\t\t\t\t\t\t\t\t\t\t\t\t{Run: \"1.nested.2 local\", Name: \"nested 2\"},\n\t\t\t\t\t\t\t\t\t\t\t\t\t{Run: \"1.nested.3\"},\n\t\t\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t\t\t{Run: \"1.3\"},\n\t\t\t\t\t\t\t\t\t\t{Run: \"1.4\"},\n\t\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"with jobs overwrite\": {\n\t\t\tfiles: map[string]string{\n\t\t\t\t\"lefthook.yml\": `\npre-commit:\n  jobs:\n    - name: job 1\n      run: echo from job 1\n`,\n\t\t\t\t\"lefthook-local.yml\": `\npre-commit:\n  jobs:\n    - name: job 1\n      run: wrap {cmd}\n`,\n\t\t\t},\n\t\t\tresult: &Config{\n\t\t\t\tSourceDir:      \".lefthook\",\n\t\t\t\tSourceDirLocal: \".lefthook-local\",\n\t\t\t\tHooks: map[string]*Hook{\n\t\t\t\t\t\"pre-commit\": {\n\t\t\t\t\t\tName: \"pre-commit\",\n\t\t\t\t\t\tJobs: []*Job{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tName: \"job 1\",\n\t\t\t\t\t\t\t\tRun:  \"wrap echo from job 1\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"with .config/lefthook.yml\": {\n\t\t\tfiles: map[string]string{\n\t\t\t\tfilepath.Join(\".config\", \"lefthook.yml\"): `\npre-commit:\n  jobs:\n    - name: job 1\n      run: echo from job 1\n`,\n\t\t\t\tfilepath.Join(\".config\", \"lefthook-local.yml\"): `\npre-commit:\n  jobs:\n    - name: job 1\n      run: wrap {cmd}\n`,\n\t\t\t},\n\t\t\tresult: &Config{\n\t\t\t\tSourceDir:      \".lefthook\",\n\t\t\t\tSourceDirLocal: \".lefthook-local\",\n\t\t\t\tHooks: map[string]*Hook{\n\t\t\t\t\t\"pre-commit\": {\n\t\t\t\t\t\tName: \"pre-commit\",\n\t\t\t\t\t\tJobs: []*Job{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\tName: \"job 1\",\n\t\t\t\t\t\t\t\tRun:  \"wrap echo from job 1\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"with lefthook-local.yml only\": {\n\t\t\tfiles: map[string]string{\n\t\t\t\t\"lefthook-local.yml\": `\npre-commit:\n  commands:\n    tests:\n      run: yarn test\npost-commit:\n  commands:\n    ping-done:\n      run: curl -x POST status.com/done\n`,\n\t\t\t},\n\t\t\tresult: &Config{\n\t\t\t\tSourceDir:      DefaultSourceDir,\n\t\t\t\tSourceDirLocal: DefaultSourceDirLocal,\n\t\t\t\tColors:         nil,\n\t\t\t\tHooks: map[string]*Hook{\n\t\t\t\t\t\"pre-commit\": {\n\t\t\t\t\t\tName:     \"pre-commit\",\n\t\t\t\t\t\tParallel: false,\n\t\t\t\t\t\tCommands: map[string]*Command{\n\t\t\t\t\t\t\t\"tests\": {\n\t\t\t\t\t\t\t\tRun: \"yarn test\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t\t\"post-commit\": {\n\t\t\t\t\t\tName:     \"post-commit\",\n\t\t\t\t\t\tParallel: false,\n\t\t\t\t\t\tCommands: map[string]*Command{\n\t\t\t\t\t\t\t\"ping-done\": {\n\t\t\t\t\t\t\t\tRun: \"curl -x POST status.com/done\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"with .lefthook-local.yml only\": {\n\t\t\tfiles: map[string]string{\n\t\t\t\t\".lefthook-local.yml\": `\npre-commit:\n  commands:\n    tests:\n      run: yarn test\n`,\n\t\t\t},\n\t\t\tresult: &Config{\n\t\t\t\tSourceDir:      DefaultSourceDir,\n\t\t\t\tSourceDirLocal: DefaultSourceDirLocal,\n\t\t\t\tColors:         nil,\n\t\t\t\tHooks: map[string]*Hook{\n\t\t\t\t\t\"pre-commit\": {\n\t\t\t\t\t\tName:     \"pre-commit\",\n\t\t\t\t\t\tParallel: false,\n\t\t\t\t\t\tCommands: map[string]*Command{\n\t\t\t\t\t\t\t\"tests\": {\n\t\t\t\t\t\t\t\tRun: \"yarn test\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"custom config path\": {\n\t\t\tfiles: map[string]string{\n\t\t\t\t\"lefthook_custom.yml\": `\npre-commit:\n  commands:\n    tests:\n      run: yarn test\n`,\n\t\t\t},\n\t\t\tpathOverride: \"lefthook_custom.yml\",\n\t\t\tresult: &Config{\n\t\t\t\tSourceDir:      DefaultSourceDir,\n\t\t\t\tSourceDirLocal: DefaultSourceDirLocal,\n\t\t\t\tColors:         nil,\n\t\t\t\tHooks: map[string]*Hook{\n\t\t\t\t\t\"pre-commit\": {\n\t\t\t\t\t\tName:     \"pre-commit\",\n\t\t\t\t\t\tParallel: false,\n\t\t\t\t\t\tCommands: map[string]*Command{\n\t\t\t\t\t\t\t\"tests\": {\n\t\t\t\t\t\t\t\tRun: \"yarn test\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t\t\"with setup instructions\": {\n\t\t\tfiles: map[string]string{\n\t\t\t\t\"lefthook.yml\": `\nextends:\n  - extend1.yml\n  - extend2.yml\n\npre-commit:\n  setup:\n    - run: 5\n`,\n\t\t\t\t\"lefthook-local.yml\": `\npre-commit:\n  setup:\n    - run: 4\n`,\n\t\t\t\t\"extend1.yml\": `\nextends:\n  - extend3.yml\n\npre-commit:\n  setup:\n    - run: 1\n`,\n\t\t\t\t\"extend2.yml\": `\npre-commit:\n  setup:\n    - run: 3\n`,\n\t\t\t\t\"extend3.yml\": `\npre-commit:\n  setup:\n    - run: 2\n`,\n\t\t\t},\n\t\t\tresult: &Config{\n\t\t\t\tExtends: []string{\n\t\t\t\t\t\"extend1.yml\",\n\t\t\t\t\t\"extend2.yml\",\n\t\t\t\t},\n\t\t\t\tSourceDir:      DefaultSourceDir,\n\t\t\t\tSourceDirLocal: DefaultSourceDirLocal,\n\t\t\t\tHooks: map[string]*Hook{\n\t\t\t\t\t\"pre-commit\": {\n\t\t\t\t\t\tName: \"pre-commit\",\n\t\t\t\t\t\tSetup: []*SetupInstruction{\n\t\t\t\t\t\t\t{Run: \"1\"},\n\t\t\t\t\t\t\t{Run: \"2\"},\n\t\t\t\t\t\t\t{Run: \"3\"},\n\t\t\t\t\t\t\t{Run: \"4\"},\n\t\t\t\t\t\t\t{Run: \"5\"},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t} {\n\t\tfs := afero.Afero{Fs: afero.NewMemMapFs()}\n\t\trepo := gittest.NewRepositoryBuilder().Fs(fs).Root(root).Build()\n\t\tt.Run(name, func(t *testing.T) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tfor name, content := range tt.files {\n\t\t\t\tpath := filepath.Join(\n\t\t\t\t\troot,\n\t\t\t\t\tfilepath.Join(strings.Split(name, \"/\")...),\n\t\t\t\t)\n\t\t\t\tdir := filepath.Dir(path)\n\n\t\t\t\tassert.NoError(fs.MkdirAll(dir, 0o775))\n\t\t\t\tassert.NoError(fs.WriteFile(path, []byte(content), 0o644))\n\t\t\t}\n\n\t\t\tif len(tt.remoteConfigPath) > 0 {\n\t\t\t\tassert.NoError(fs.MkdirAll(filepath.Base(tt.remoteConfigPath), 0o755))\n\t\t\t\tassert.NoError(fs.WriteFile(tt.remoteConfigPath, []byte(tt.remote), 0o644))\n\t\t\t}\n\n\t\t\tt.Setenv(\"LEFTHOOK_CONFIG\", tt.pathOverride)\n\n\t\t\tresult, err := Load(fs.Fs, repo)\n\t\t\tassert.NoError(err)\n\t\t\tassert.Equal(tt.result, result)\n\t\t})\n\t}\n\n\tfor i, tt := range [...]struct {\n\t\tname             string\n\t\tyaml, json, toml string\n\t\tresult           *Config\n\t}{\n\t\t{\n\t\t\tname: \"simple configs\",\n\t\t\tyaml: `\npre-commit:\n  commands:\n    echo:\n      run: echo 1\n`,\n\t\t\tjson: `\n{\n  \"pre-commit\": {\n    \"commands\": {\n      \"echo\": { \"run\": \"echo 1\" }\n    }\n  }\n}`,\n\t\t\ttoml: `\n[pre-commit.commands.echo]\nrun = \"echo 1\"\n`,\n\t\t\tresult: &Config{\n\t\t\t\tSourceDir:      DefaultSourceDir,\n\t\t\t\tSourceDirLocal: DefaultSourceDirLocal,\n\t\t\t\tHooks: map[string]*Hook{\n\t\t\t\t\t\"pre-commit\": {\n\t\t\t\t\t\tName: \"pre-commit\",\n\t\t\t\t\t\tCommands: map[string]*Command{\n\t\t\t\t\t\t\t\"echo\": {\n\t\t\t\t\t\t\t\tRun: \"echo 1\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t} {\n\t\tfs := afero.Afero{Fs: afero.NewMemMapFs()}\n\t\trepo := gittest.NewRepositoryBuilder().Root(root).Fs(fs).Build()\n\n\t\tt.Run(fmt.Sprintf(\"%d: %s\", i, tt.name), func(t *testing.T) {\n\t\t\tassert := assert.New(t)\n\n\t\t\t// YAML\n\t\t\tyamlConfig := filepath.Join(root, \"lefthook.yml\")\n\t\t\tassert.NoError(fs.WriteFile(yamlConfig, []byte(tt.yaml), 0o644))\n\n\t\t\tresult, err := Load(fs.Fs, repo)\n\t\t\tassert.NoError(err)\n\t\t\tassert.Equal(result, tt.result)\n\n\t\t\tassert.NoError(fs.Remove(yamlConfig))\n\n\t\t\t// JSON\n\t\t\tjsonConfig := filepath.Join(root, \"lefthook.json\")\n\t\t\tassert.NoError(fs.WriteFile(jsonConfig, []byte(tt.json), 0o644))\n\n\t\t\tresult, err = Load(fs.Fs, repo)\n\t\t\tassert.NoError(err)\n\t\t\tassert.Equal(result, tt.result)\n\n\t\t\tassert.NoError(fs.Remove(jsonConfig))\n\n\t\t\t// TOML\n\t\t\ttomlConfig := filepath.Join(root, \"lefthook.toml\")\n\t\t\tassert.NoError(fs.WriteFile(tomlConfig, []byte(tt.toml), 0o644))\n\n\t\t\tresult, err = Load(fs.Fs, repo)\n\t\t\tassert.NoError(err)\n\t\t\tassert.Equal(result, tt.result)\n\n\t\t\tassert.NoError(fs.Remove(tomlConfig))\n\t\t})\n\t}\n\n\ttype remote struct {\n\t\tRemoteConfigPath string\n\t\tContent          string\n\t}\n\tfor name, tt := range map[string]struct {\n\t\tfiles   map[string]string\n\t\tremotes []remote\n\t\tresult  *Config\n\t}{\n\t\t\"with remotes, config and configs\": {\n\t\t\tfiles: map[string]string{\n\t\t\t\t\"lefthook.yml\": `\npre-commit:\n  only:\n    - ref: main\n  commands:\n    global:\n      run: echo 'Global!'\n    lint:\n      run: this will be overwritten\nremotes:\n  - git_url: https://github.com/evilmartians/lefthook\n    ref: v1.0.0\n    configs:\n      - examples/custom.yml\n  - git_url: https://github.com/evilmartians/lefthook\n    configs:\n      - examples/remote/ping.yml\n    ref: v1.5.5\n`,\n\t\t\t},\n\t\t\tremotes: []remote{\n\t\t\t\t{\n\t\t\t\t\tRemoteConfigPath: filepath.Join(root, \".git\", \"info\", \"lefthook-remotes\", \"lefthook-v1.0.0\", \"examples\", \"custom.yml\"),\n\t\t\t\t\tContent: `\npre-commit:\n  commands:\n    lint:\n      only:\n        - merge\n        - rebase\n      run: yarn lint\n  scripts:\n    \"test.sh\":\n      skip:\n        - merge\n      runner: bash\n`,\n\t\t\t\t},\n\t\t\t\t{\n\t\t\t\t\tRemoteConfigPath: filepath.Join(root, \".git\", \"info\", \"lefthook-remotes\", \"lefthook-v1.5.5\", \"examples\", \"remote\", \"ping.yml\"),\n\t\t\t\t\tContent: `\npre-commit:\n  commands:\n    ping:\n      run: echo pong\n`,\n\t\t\t\t},\n\t\t\t},\n\t\t\tresult: &Config{\n\t\t\t\tSourceDir:      DefaultSourceDir,\n\t\t\t\tSourceDirLocal: DefaultSourceDirLocal,\n\t\t\t\tColors:         nil,\n\t\t\t\tRemotes: []*Remote{\n\t\t\t\t\t{\n\t\t\t\t\t\tGitURL:  \"https://github.com/evilmartians/lefthook\",\n\t\t\t\t\t\tRef:     \"v1.0.0\",\n\t\t\t\t\t\tConfigs: []string{\"examples/custom.yml\"},\n\t\t\t\t\t},\n\t\t\t\t\t{\n\t\t\t\t\t\tGitURL: \"https://github.com/evilmartians/lefthook\",\n\t\t\t\t\t\tRef:    \"v1.5.5\",\n\t\t\t\t\t\tConfigs: []string{\n\t\t\t\t\t\t\t\"examples/remote/ping.yml\",\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tHooks: map[string]*Hook{\n\t\t\t\t\t\"pre-commit\": {\n\t\t\t\t\t\tName: \"pre-commit\",\n\t\t\t\t\t\tOnly: []any{map[string]any{\"ref\": \"main\"}},\n\t\t\t\t\t\tCommands: map[string]*Command{\n\t\t\t\t\t\t\t\"lint\": {\n\t\t\t\t\t\t\t\tRun:  \"yarn lint\",\n\t\t\t\t\t\t\t\tOnly: []any{\"merge\", \"rebase\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"ping\": {\n\t\t\t\t\t\t\t\tRun: \"echo pong\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t\"global\": {\n\t\t\t\t\t\t\t\tRun: \"echo 'Global!'\",\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t\tScripts: map[string]*Script{\n\t\t\t\t\t\t\t\"test.sh\": {\n\t\t\t\t\t\t\t\tRunner: \"bash\",\n\t\t\t\t\t\t\t\tSkip:   []any{\"merge\"},\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t} {\n\t\tfs := afero.Afero{Fs: afero.NewMemMapFs()}\n\t\trepo := gittest.NewRepositoryBuilder().Root(root).Fs(fs).Build()\n\n\t\tt.Run(name, func(t *testing.T) {\n\t\t\tassert := assert.New(t)\n\n\t\t\tfor name, content := range tt.files {\n\t\t\t\tpath := filepath.Join(\n\t\t\t\t\troot,\n\t\t\t\t\tfilepath.Join(strings.Split(name, \"/\")...),\n\t\t\t\t)\n\t\t\t\tdir := filepath.Dir(path)\n\n\t\t\t\tassert.NoError(fs.MkdirAll(dir, 0o775))\n\t\t\t\tassert.NoError(fs.WriteFile(path, []byte(content), 0o644))\n\t\t\t}\n\n\t\t\tfor _, remote := range tt.remotes {\n\t\t\t\tassert.NoError(fs.MkdirAll(filepath.Base(remote.RemoteConfigPath), 0o755))\n\t\t\t\tassert.NoError(fs.WriteFile(remote.RemoteConfigPath, []byte(remote.Content), 0o644))\n\t\t\t}\n\n\t\t\tresult, err := Load(fs.Fs, repo)\n\t\t\tassert.NoError(err)\n\t\t\tassert.Equal(result, tt.result)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/config/remote.go",
    "content": "package config\n\ntype Remote struct {\n\tGitURL 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\"`\n\n\tRef string `json:\"ref,omitempty\" jsonschema:\"description=An optional *branch* or *tag* name\" mapstructure:\"ref,omitempty\" toml:\"ref,omitempty\" yaml:\",omitempty\"`\n\n\tConfigs []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\"`\n\n\tRefetch 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\"`\n\n\tRefetchFrequency 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\"`\n}\n\nfunc (r *Remote) Configured() bool {\n\tif r == nil {\n\t\treturn false\n\t}\n\n\treturn len(r.GitURL) > 0\n}\n"
  },
  {
    "path": "internal/config/script.go",
    "content": "package config\n\nimport (\n\t\"cmp\"\n\t\"slices\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\t\"unicode\"\n)\n\ntype Script struct {\n\tRunner string `json:\"runner,omitempty\" mapstructure:\"runner\" toml:\"runner,omitempty\" yaml:\"runner,omitempty\"`\n\tArgs   string `json:\"args,omitempty\"   mapstructure:\"args\"   toml:\"args,omitempty\"   yaml:\",omitempty\"`\n\n\tSkip     any               `json:\"skip,omitempty\"     jsonschema:\"oneof_type=boolean;array\" mapstructure:\"skip\"       toml:\"skip,omitempty,inline\" yaml:\",omitempty\"`\n\tOnly     any               `json:\"only,omitempty\"     jsonschema:\"oneof_type=boolean;array\" mapstructure:\"only\"       toml:\"only,omitempty,inline\" yaml:\",omitempty\"`\n\tTags     []string          `json:\"tags,omitempty\"     jsonschema:\"oneof_type=string;array\"  mapstructure:\"tags\"       toml:\"tags,omitempty\"        yaml:\",omitempty\"`\n\tEnv      map[string]string `json:\"env,omitempty\"      mapstructure:\"env\"                    toml:\"env,omitempty\"      yaml:\",omitempty\"`\n\tPriority int               `json:\"priority,omitempty\" mapstructure:\"priority\"               toml:\"priority,omitempty\" yaml:\",omitempty\"`\n\n\tFailText    string        `json:\"fail_text,omitempty\"   koanf:\"fail_text\"                    mapstructure:\"fail_text\"     toml:\"fail_text,omitempty\"   yaml:\"fail_text,omitempty\"`\n\tTimeout     time.Duration `json:\"timeout,omitempty\"     jsonschema:\"type=string,example=15s\" mapstructure:\"timeout\"       toml:\"timeout,omitempty\"     yaml:\",omitempty\"`\n\tInteractive bool          `json:\"interactive,omitempty\" mapstructure:\"interactive\"           toml:\"interactive,omitempty\" yaml:\",omitempty\"`\n\tUseStdin    bool          `json:\"use_stdin,omitempty\"   koanf:\"use_stdin\"                    mapstructure:\"use_stdin\"     toml:\"use_stdin,omitempty\"   yaml:\"use_stdin,omitempty\"`\n\tStageFixed  bool          `json:\"stage_fixed,omitempty\" koanf:\"stage_fixed\"                  mapstructure:\"stage_fixed\"   toml:\"stage_fixed,omitempty\" yaml:\"stage_fixed,omitempty\"`\n}\n\nfunc ScriptsToJobs(scripts map[string]*Script) []*Job {\n\tjobs := make([]*Job, 0, len(scripts))\n\tfor name, script := range scripts {\n\t\tjobs = append(jobs, &Job{\n\t\t\tName:        name,\n\t\t\tScript:      name,\n\t\t\tRunner:      script.Runner,\n\t\t\tArgs:        script.Args,\n\t\t\tFailText:    script.FailText,\n\t\t\tTimeout:     script.Timeout,\n\t\t\tTags:        script.Tags,\n\t\t\tEnv:         script.Env,\n\t\t\tInteractive: script.Interactive,\n\t\t\tUseStdin:    script.UseStdin,\n\t\t\tStageFixed:  script.StageFixed,\n\t\t\tSkip:        script.Skip,\n\t\t\tOnly:        script.Only,\n\t\t})\n\t}\n\n\t// ASC\n\tslices.SortFunc(jobs, func(i, j *Job) int {\n\t\ta := scripts[i.Name]\n\t\tb := scripts[j.Name]\n\n\t\tif a.Priority != 0 || b.Priority != 0 {\n\t\t\t// Script without a priority must be the last\n\t\t\tif a.Priority == 0 {\n\t\t\t\treturn 1\n\t\t\t}\n\t\t\tif b.Priority == 0 {\n\t\t\t\treturn -1\n\t\t\t}\n\n\t\t\treturn cmp.Compare(a.Priority, b.Priority)\n\t\t}\n\n\t\tiNum := parseNum(i.Name)\n\t\tjNum := parseNum(j.Name)\n\n\t\tif iNum == -1 && jNum == -1 {\n\t\t\treturn strings.Compare(i.Name, j.Name)\n\t\t}\n\n\t\tif iNum == -1 {\n\t\t\treturn 1\n\t\t}\n\n\t\tif jNum == -1 {\n\t\t\treturn -1\n\t\t}\n\n\t\treturn cmp.Compare(iNum, jNum)\n\t})\n\n\treturn jobs\n}\n\nfunc parseNum(str string) int {\n\tnumEnds := -1\n\tfor idx, ch := range str {\n\t\tif unicode.IsDigit(ch) {\n\t\t\tnumEnds = idx\n\t\t} else {\n\t\t\tbreak\n\t\t}\n\t}\n\n\tif numEnds == -1 {\n\t\treturn -1\n\t}\n\tnum, err := strconv.Atoi(str[:numEnds+1])\n\tif err != nil {\n\t\treturn -1\n\t}\n\n\treturn num\n}\n"
  },
  {
    "path": "internal/config/script_test.go",
    "content": "package config\n\nimport (\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestScriptsToJobs(t *testing.T) {\n\tscripts := map[string]*Script{\n\t\t\"check.sh\": {\n\t\t\tRunner:   \"bash\",\n\t\t\tPriority: 150,\n\t\t},\n\t\t\"10_test.sh\": {\n\t\t\tRunner:     \"bash\",\n\t\t\tStageFixed: true,\n\t\t},\n\t\t\"2_test.sh\": {\n\t\t\tRunner:     \"bash\",\n\t\t\tStageFixed: true,\n\t\t},\n\t\t\"first.sh\": {\n\t\t\tRunner:   \"bash\",\n\t\t\tPriority: 1,\n\t\t},\n\t\t\"last.sh\": {\n\t\t\tRunner: \"bash\",\n\t\t},\n\t}\n\n\tjobs := ScriptsToJobs(scripts)\n\n\tassert.Equal(t, jobs, []*Job{\n\t\t{Name: \"first.sh\", Script: \"first.sh\", Runner: \"bash\"},\n\t\t{Name: \"check.sh\", Script: \"check.sh\", Runner: \"bash\"},\n\t\t{Name: \"2_test.sh\", Script: \"2_test.sh\", Runner: \"bash\", StageFixed: true},\n\t\t{Name: \"10_test.sh\", Script: \"10_test.sh\", Runner: \"bash\", StageFixed: true},\n\t\t{Name: \"last.sh\", Script: \"last.sh\", Runner: \"bash\"},\n\t})\n}\n\nfunc TestScriptsToJobsWithTimeout(t *testing.T) {\n\tscripts := map[string]*Script{\n\t\t\"lint.sh\": {\n\t\t\tRunner:   \"bash\",\n\t\t\tTimeout:  30 * time.Second,\n\t\t\tPriority: 1,\n\t\t},\n\t\t\"test.sh\": {\n\t\t\tRunner:  \"bash\",\n\t\t\tTimeout: 10 * time.Minute,\n\t\t},\n\t}\n\n\tjobs := ScriptsToJobs(scripts)\n\n\tassert.Equal(t, jobs, []*Job{\n\t\t{Name: \"lint.sh\", Script: \"lint.sh\", Runner: \"bash\", Timeout: 30 * time.Second},\n\t\t{Name: \"test.sh\", Script: \"test.sh\", Runner: \"bash\", Timeout: 10 * time.Minute},\n\t})\n}\n"
  },
  {
    "path": "internal/config/skip_checker.go",
    "content": "package config\n\nimport (\n\t\"github.com/gobwas/glob\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/git\"\n\t\"github.com/evilmartians/lefthook/v2/internal/log\"\n\t\"github.com/evilmartians/lefthook/v2/internal/system\"\n)\n\ntype skipChecker struct {\n\texec *commandExecutor\n}\n\nfunc NewSkipChecker(cmd system.Command) *skipChecker {\n\treturn &skipChecker{&commandExecutor{cmd}}\n}\n\n// Check returns the result of applying a skip/only setting which can be a branch, git state, shell command, etc.\nfunc (sc *skipChecker) Check(state func() git.State, skip any, only any) bool {\n\tif skip == nil && only == nil {\n\t\treturn false\n\t}\n\n\tif skip != nil {\n\t\tif sc.matches(state, skip) {\n\t\t\treturn true\n\t\t}\n\t}\n\n\tif only != nil {\n\t\treturn !sc.matches(state, only)\n\t}\n\n\treturn false\n}\n\nfunc (sc *skipChecker) matches(state func() git.State, value any) bool {\n\tswitch typedValue := value.(type) {\n\tcase bool:\n\t\treturn typedValue\n\tcase string:\n\t\treturn typedValue == state().State\n\tcase []any:\n\t\treturn sc.matchesSlices(state, typedValue)\n\t}\n\treturn false\n}\n\nfunc (sc *skipChecker) matchesSlices(gitState func() git.State, slice []any) bool {\n\tfor _, state := range slice {\n\t\tswitch typedState := state.(type) {\n\t\tcase string:\n\t\t\tif typedState == gitState().State {\n\t\t\t\treturn true\n\t\t\t}\n\t\tcase map[string]any:\n\t\t\tif sc.matchesRef(gitState, typedState) {\n\t\t\t\treturn true\n\t\t\t}\n\n\t\t\tif sc.matchesCommands(typedState) {\n\t\t\t\treturn true\n\t\t\t}\n\t\t}\n\t}\n\n\treturn false\n}\n\nfunc (sc *skipChecker) matchesRef(state func() git.State, typedState map[string]any) bool {\n\tref, ok := typedState[\"ref\"].(string)\n\tif !ok {\n\t\treturn false\n\t}\n\n\tbranch := state().Branch\n\tif ref == branch {\n\t\treturn true\n\t}\n\n\tg := glob.MustCompile(ref)\n\n\treturn g.Match(branch)\n}\n\nfunc (sc *skipChecker) matchesCommands(typedState map[string]any) bool {\n\tcommandLine, ok := typedState[\"run\"].(string)\n\tif !ok {\n\t\treturn false\n\t}\n\n\tresult := sc.exec.execute(commandLine)\n\n\tlog.Builder(log.DebugLevel, \"[lefthook] \").\n\t\tAdd(\"skip/only: \", commandLine).\n\t\tAdd(\"result:    \", result).\n\t\tLog()\n\n\treturn result\n}\n"
  },
  {
    "path": "internal/config/skip_checker_test.go",
    "content": "package config\n\nimport (\n\t\"errors\"\n\t\"io\"\n\t\"testing\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/git\"\n\t\"github.com/evilmartians/lefthook/v2/internal/system\"\n)\n\ntype mockCmd struct{}\n\nfunc (mc mockCmd) WithoutEnvs(...string) system.Command {\n\treturn mc\n}\n\nfunc (mc mockCmd) Run(cmd []string, _root string, _in io.Reader, _out io.Writer, _errOut io.Writer) error {\n\tif len(cmd) == 3 && cmd[2] == \"success\" {\n\t\treturn nil\n\t} else {\n\t\treturn errors.New(\"failure\")\n\t}\n}\n\nfunc TestSkipChecker_Check(t *testing.T) {\n\tskipChecker := NewSkipChecker(mockCmd{})\n\n\tfor _, tt := range [...]struct {\n\t\tname       string\n\t\tstate      func() git.State\n\t\tskip, only any\n\t\tskipped    bool\n\t}{\n\t\t{\n\t\t\tname:    \"when true\",\n\t\t\tstate:   func() git.State { return git.State{} },\n\t\t\tskip:    true,\n\t\t\tskipped: true,\n\t\t},\n\t\t{\n\t\t\tname:    \"when false\",\n\t\t\tstate:   func() git.State { return git.State{} },\n\t\t\tskip:    false,\n\t\t\tskipped: false,\n\t\t},\n\t\t{\n\t\t\tname:    \"when merge\",\n\t\t\tstate:   func() git.State { return git.State{State: \"merge\"} },\n\t\t\tskip:    \"merge\",\n\t\t\tskipped: true,\n\t\t},\n\t\t{\n\t\t\tname:    \"when merge-commit\",\n\t\t\tstate:   func() git.State { return git.State{State: \"merge-commit\"} },\n\t\t\tskip:    \"merge-commit\",\n\t\t\tskipped: true,\n\t\t},\n\t\t{\n\t\t\tname:    \"when rebase (but want merge)\",\n\t\t\tstate:   func() git.State { return git.State{State: \"rebase\"} },\n\t\t\tskip:    \"merge\",\n\t\t\tskipped: false,\n\t\t},\n\t\t{\n\t\t\tname:    \"when rebase\",\n\t\t\tstate:   func() git.State { return git.State{State: \"rebase\"} },\n\t\t\tskip:    []any{\"rebase\"},\n\t\t\tskipped: true,\n\t\t},\n\t\t{\n\t\t\tname:    \"when rebase (but want merge)\",\n\t\t\tstate:   func() git.State { return git.State{State: \"rebase\"} },\n\t\t\tskip:    []any{\"merge\"},\n\t\t\tskipped: false,\n\t\t},\n\t\t{\n\t\t\tname:    \"when branch\",\n\t\t\tstate:   func() git.State { return git.State{Branch: \"feat/skipme\"} },\n\t\t\tskip:    []any{map[string]any{\"ref\": \"feat/skipme\"}},\n\t\t\tskipped: true,\n\t\t},\n\t\t{\n\t\t\tname:    \"when branch doesn't match\",\n\t\t\tstate:   func() git.State { return git.State{Branch: \"feat/important\"} },\n\t\t\tskip:    []any{map[string]any{\"ref\": \"feat/skipme\"}},\n\t\t\tskipped: false,\n\t\t},\n\t\t{\n\t\t\tname:    \"when branch glob\",\n\t\t\tstate:   func() git.State { return git.State{Branch: \"feat/important\"} },\n\t\t\tskip:    []any{map[string]any{\"ref\": \"feat/*\"}},\n\t\t\tskipped: true,\n\t\t},\n\t\t{\n\t\t\tname:    \"when branch glob doesn't match\",\n\t\t\tstate:   func() git.State { return git.State{Branch: \"feat\"} },\n\t\t\tskip:    []any{map[string]any{\"ref\": \"feat/*\"}},\n\t\t\tskipped: false,\n\t\t},\n\t\t{\n\t\t\tname:    \"when only specified\",\n\t\t\tstate:   func() git.State { return git.State{Branch: \"feat\"} },\n\t\t\tonly:    []any{map[string]any{\"ref\": \"feat\"}},\n\t\t\tskipped: false,\n\t\t},\n\t\t{\n\t\t\tname:    \"when only branch doesn't match\",\n\t\t\tstate:   func() git.State { return git.State{Branch: \"dev\"} },\n\t\t\tonly:    []any{map[string]any{\"ref\": \"feat\"}},\n\t\t\tskipped: true,\n\t\t},\n\t\t{\n\t\t\tname:    \"when only branch with glob\",\n\t\t\tstate:   func() git.State { return git.State{Branch: \"feat/important\"} },\n\t\t\tonly:    []any{map[string]any{\"ref\": \"feat/*\"}},\n\t\t\tskipped: false,\n\t\t},\n\t\t{\n\t\t\tname:    \"when only merge\",\n\t\t\tstate:   func() git.State { return git.State{State: \"merge\"} },\n\t\t\tonly:    []any{\"merge\"},\n\t\t\tskipped: false,\n\t\t},\n\t\t{\n\t\t\tname:    \"when only and skip\",\n\t\t\tstate:   func() git.State { return git.State{State: \"rebase\"} },\n\t\t\tskip:    []any{map[string]any{\"ref\": \"feat/*\"}},\n\t\t\tonly:    \"rebase\",\n\t\t\tskipped: false,\n\t\t},\n\t\t{\n\t\t\tname:    \"when only and skip applies skip\",\n\t\t\tstate:   func() git.State { return git.State{State: \"rebase\"} },\n\t\t\tskip:    []any{\"rebase\"},\n\t\t\tonly:    \"rebase\",\n\t\t\tskipped: true,\n\t\t},\n\t\t{\n\t\t\tname:    \"when skip with run command\",\n\t\t\tstate:   func() git.State { return git.State{} },\n\t\t\tskip:    []any{map[string]any{\"run\": \"success\"}},\n\t\t\tskipped: true,\n\t\t},\n\t\t{\n\t\t\tname:    \"when skip with multi-run command\",\n\t\t\tstate:   func() git.State { return git.State{Branch: \"feat\"} },\n\t\t\tskip:    []any{map[string]any{\"run\": \"success\", \"ref\": \"feat\"}},\n\t\t\tskipped: true,\n\t\t},\n\t\t{\n\t\t\tname:    \"when only with run command\",\n\t\t\tstate:   func() git.State { return git.State{} },\n\t\t\tonly:    []any{map[string]any{\"run\": \"fail\"}},\n\t\t\tskipped: true,\n\t\t},\n\t} {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tif skipChecker.Check(tt.state, tt.skip, tt.only) != tt.skipped {\n\t\t\t\tt.Errorf(\"Expected: %v, Was %v\", tt.skipped, !tt.skipped)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/git/command_executor.go",
    "content": "package git\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"path/filepath\"\n\t\"strings\"\n\t\"sync\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/log\"\n\t\"github.com/evilmartians/lefthook/v2/internal/system\"\n)\n\n// CommandExecutor provides some methods that take some effect on execution and/or result data.\ntype CommandExecutor struct {\n\tmu  *sync.Mutex\n\tcmd system.Command\n\n\t// Execute command in the specific directory\n\troot string\n\n\t// Split one command into multiple, respecting supported max command length\n\tmaxCmdLen int\n\n\t// Print all logs in Debug level\n\tonlyDebugLogs bool\n\n\t// Do not trim leading and ending spaces\n\tnoTrimOut bool\n}\n\n// NewExecutor returns an object that executes given commands in the OS.\nfunc NewExecutor(cmd system.Command) *CommandExecutor {\n\treturn &CommandExecutor{\n\t\tmu:        new(sync.Mutex),\n\t\tcmd:       cmd,\n\t\tmaxCmdLen: system.MaxCmdLen(),\n\t}\n}\n\nfunc (c CommandExecutor) WithoutEnvs(envs ...string) CommandExecutor {\n\tc.cmd = c.cmd.WithoutEnvs(envs...)\n\treturn c\n}\n\nfunc (c CommandExecutor) OnlyDebugLogs() CommandExecutor {\n\tc.onlyDebugLogs = true\n\treturn c\n}\n\nfunc (c CommandExecutor) WithoutTrim() CommandExecutor {\n\tc.noTrimOut = true\n\treturn c\n}\n\n// Cmd runs plain string command.\nfunc (c CommandExecutor) Cmd(cmd []string) (string, error) {\n\tout, err := c.execute(cmd, c.root)\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tif !c.noTrimOut {\n\t\tout = strings.TrimSpace(out)\n\t}\n\n\treturn out, nil\n}\n\n// BatchedCmd runs the command with any number of appended arguments batched in chunks to match the OS limits.\nfunc (c CommandExecutor) BatchedCmd(cmd []string, args []string) (string, error) {\n\tresult := strings.Builder{}\n\n\targsBatched := batchByLength(args, c.maxCmdLen-len(cmd))\n\tfor i, batch := range argsBatched {\n\t\tout, err := c.Cmd(append(cmd, batch...))\n\t\tif err != nil {\n\t\t\treturn \"\", fmt.Errorf(\"error in batch %d: %w\", i, err)\n\t\t}\n\t\tresult.WriteString(strings.TrimRight(out, \"\\n\"))\n\t\tresult.WriteString(\"\\n\")\n\t}\n\n\treturn result.String(), nil\n}\n\n// CmdLines runs plain string command, returns its output split by newline.\nfunc (c CommandExecutor) CmdLines(cmd []string) ([]string, error) {\n\tout, err := c.Cmd(cmd)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn strings.Split(out, \"\\n\"), nil\n}\n\n// CmdLinesWithinFolder runs plain string command, returns its output split by newline.\nfunc (c CommandExecutor) CmdLinesWithinFolder(cmd []string, folder string) ([]string, error) {\n\troot := filepath.Join(c.root, folder)\n\tout, err := c.execute(cmd, root)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tif !c.noTrimOut {\n\t\tout = strings.TrimSpace(out)\n\t}\n\n\treturn strings.Split(out, \"\\n\"), nil\n}\n\nfunc (c CommandExecutor) execute(cmd []string, root string) (string, error) {\n\tif len(cmd) > 0 && cmd[0] == \"git\" {\n\t\t// Preventing Git lock issues for all Git commands\n\t\tc.mu.Lock()\n\t\tdefer c.mu.Unlock()\n\t}\n\tstdout := new(bytes.Buffer)\n\tstderr := new(bytes.Buffer)\n\terr := c.cmd.Run(cmd, root, system.NullReader, stdout, stderr)\n\toutString := stdout.String()\n\terrString := stderr.String()\n\n\tlog.Builder(log.DebugLevel, \"[lefthook] \").\n\t\tAdd(\"git: \", strings.Join(cmd, \" \")).\n\t\tAdd(\"out: \", outString).\n\t\tLog()\n\n\tif err != nil {\n\t\tif len(errString) > 0 {\n\t\t\tlogLevel := log.ErrorLevel\n\t\t\tif c.onlyDebugLogs {\n\t\t\t\tlogLevel = log.DebugLevel\n\t\t\t}\n\t\t\tlog.Builder(logLevel, \"> \").\n\t\t\t\tAdd(\"\", strings.Join(cmd, \" \")).\n\t\t\t\tAdd(\"\", errString).\n\t\t\t\tLog()\n\t\t}\n\t}\n\n\treturn outString, err\n}\n\nfunc batchByLength(s []string, length int) [][]string {\n\tbatches := make([][]string, 0)\n\n\tvar acc, prev int\n\tfor i := range s {\n\t\tacc += len(s[i])\n\t\tif acc > length {\n\t\t\tif i == prev {\n\t\t\t\tbatches = append(batches, s[prev:i+1])\n\t\t\t\tprev = i + 1\n\t\t\t} else {\n\t\t\t\tbatches = append(batches, s[prev:i])\n\t\t\t\tprev = i\n\t\t\t}\n\t\t\tacc = len(s[i])\n\t\t}\n\t}\n\tif acc > 0 {\n\t\tbatches = append(batches, s[prev:])\n\t}\n\n\treturn batches\n}\n"
  },
  {
    "path": "internal/git/command_executor_test.go",
    "content": "package git\n\nimport (\n\t\"io\"\n\t\"sync\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/system\"\n)\n\ntype mockCmd struct{}\n\nfunc (m mockCmd) WithoutEnvs(...string) system.Command { return mockCmd{} }\nfunc (m mockCmd) Run(cmd []string, root string, in io.Reader, out io.Writer, errOut io.Writer) error {\n\tfor _, str := range cmd {\n\t\t_, _ = out.Write([]byte(str))\n\t\t_, _ = out.Write([]byte(\"\\n\"))\n\t}\n\n\treturn nil\n}\n\nfunc TestBatchedCmd(t *testing.T) {\n\tassert := assert.New(t)\n\tc := CommandExecutor{cmd: mockCmd{}, mu: new(sync.Mutex), maxCmdLen: 2}\n\tout, err := c.BatchedCmd([]string{\"hello\"}, []string{\"1\", \"2\", \"3\", \"4\"})\n\tassert.NoError(err)\n\n\tassert.Equal(\"hello\\n1\\nhello\\n2\\nhello\\n3\\nhello\\n4\\n\", out)\n}\n"
  },
  {
    "path": "internal/git/lfs.go",
    "content": "package git\n\nimport (\n\t\"os/exec\"\n)\n\nconst (\n\tLFSRequiredFile = \".lfs-required\"\n\tLFSConfigFile   = \".lfsconfig\"\n)\n\nvar lfsHooks = map[string]struct{}{\n\t\"post-checkout\": {},\n\t\"post-commit\":   {},\n\t\"post-merge\":    {},\n\t\"pre-push\":      {},\n}\n\n// IsLFSAvailable returns 'true' if git-lfs is installed.\nfunc IsLFSAvailable() bool {\n\t_, err := exec.LookPath(\"git-lfs\")\n\n\treturn err == nil\n}\n\n// IsLFSHook returns whether the hookName is supported by Git LFS.\nfunc IsLFSHook(hookName string) bool {\n\t_, ok := lfsHooks[hookName]\n\treturn ok\n}\n"
  },
  {
    "path": "internal/git/remote.go",
    "content": "package git\n\nimport (\n\t\"errors\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/log\"\n)\n\nconst (\n\tremotesFolder     = \"lefthook-remotes\"\n\tremotesFolderMode = 0o755\n)\n\n// RemoteFolder returns the path to the folder where the remote\n// repository is located.\nfunc (r *Repository) RemoteFolder(url string, ref string) string {\n\treturn filepath.Join(\n\t\tr.RemotesFolder(),\n\t\tRemoteDirectoryName(url, ref),\n\t)\n}\n\n// RemotesFolder returns the path to the lefthook remotes folder.\nfunc (r *Repository) RemotesFolder() string {\n\treturn filepath.Join(r.InfoPath, remotesFolder)\n}\n\n// SyncRemote clones or pulls the latest changes for a git repository that was\n// specified as a remote config repository. If successful, the path to the root\n// of the repository will be returned.\nfunc (r *Repository) SyncRemote(url, ref string, force bool) error {\n\tremotesPath := r.RemotesFolder()\n\n\terr := r.Fs.MkdirAll(remotesPath, remotesFolderMode)\n\tif err != nil && !errors.Is(err, os.ErrExist) {\n\t\treturn err\n\t}\n\n\tlog.SetName(\"fetching remotes\")\n\tlog.StartSpinner()\n\tdefer log.StopSpinner()\n\tdefer log.UnsetName(\"fetching remotes\")\n\n\tdirectoryName := RemoteDirectoryName(url, ref)\n\tremotePath := filepath.Join(remotesPath, directoryName)\n\n\tif force {\n\t\terr = r.Fs.RemoveAll(remotePath)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t} else {\n\t\t_, err = r.Fs.Stat(remotePath)\n\t\tif err == nil {\n\t\t\treturn r.updateRemote(remotePath, ref)\n\t\t}\n\t}\n\n\treturn r.cloneRemote(remotesPath, directoryName, url, ref)\n}\n\nfunc (r *Repository) updateRemote(path, ref string) error {\n\tlog.Debugf(\"Updating remote config repository: %s\", path)\n\n\t// This is overwriting ENVs for worktrees, otherwise it does not work.\n\tgit := r.Git.WithoutEnvs(\"GIT_DIR\", \"GIT_INDEX_FILE\").OnlyDebugLogs()\n\n\tif len(ref) != 0 {\n\t\t_, err := git.Cmd([]string{\n\t\t\t\"git\", \"-C\", path, \"fetch\", \"--quiet\", \"--depth\", \"1\",\n\t\t\t\"origin\", \"--\", ref,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t_, err = git.Cmd([]string{\n\t\t\t\"git\", \"-C\", path, \"checkout\", \"FETCH_HEAD\",\n\t\t})\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t} else {\n\t\t_, err := git.Cmd([]string{\"git\", \"-C\", path, \"pull\", \"--quiet\"})\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (r *Repository) cloneRemote(dest, directoryName, url, ref string) error {\n\tlog.Debugf(\"Cloning remote config repository: %v/%v\", dest, directoryName)\n\n\tcmdClone := []string{\"git\", \"-C\", dest, \"clone\", \"--quiet\", \"--origin\", \"origin\", \"--depth\", \"1\"}\n\tif len(ref) > 0 {\n\t\tcmdClone = append(cmdClone, \"--branch\", ref)\n\t}\n\tcmdClone = append(cmdClone, url, directoryName)\n\n\tgit := r.Git.WithoutEnvs(\"GIT_DIR\", \"GIT_INDEX_FILE\").OnlyDebugLogs()\n\t_, err := git.Cmd(cmdClone)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tpath := filepath.Join(dest, directoryName)\n\tif len(ref) != 0 {\n\t\t_, err := git.Cmd([]string{\n\t\t\t\"git\", \"-C\", path, \"fetch\", \"--quiet\", \"--depth\", \"1\",\n\t\t\t\"origin\", \"--\", ref,\n\t\t})\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\t_, err = git.Cmd([]string{\n\t\t\t\"git\", \"-C\", path, \"checkout\", \"FETCH_HEAD\",\n\t\t})\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc RemoteDirectoryName(url, ref string) string {\n\tname := filepath.Base(\n\t\tstrings.TrimSuffix(url, filepath.Ext(url)),\n\t)\n\n\tif ref != \"\" {\n\t\tname = name + \"-\" + ref\n\t}\n\n\treturn name\n}\n"
  },
  {
    "path": "internal/git/repository.go",
    "content": "package git\n\nimport (\n\t\"bufio\"\n\t\"errors\"\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"slices\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\n\t\"github.com/spf13/afero\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/log\"\n\t\"github.com/evilmartians/lefthook/v2/internal/version\"\n)\n\nconst (\n\tminGitVersion     = \"2.31.0\"\n\tstashMessage      = \"lefthook auto backup\"\n\tunstagedPatchName = \"lefthook-unstaged.patch\"\n\tinfoDirMode       = 0o775\n\n\t// The result of `git hash-object -t tree /dev/null`.\n\temptyTreeSHA = \"4b825dc642cb6eb9a060e54bf8d69288fbee4904\"\n)\n\nvar (\n\treHeadBranch              = regexp.MustCompile(`HEAD -> (?P<name>.*)$`)\n\treOriginHeadBranch        = regexp.MustCompile(`ref: refs/remotes/origin/(?P<name>.*)$`)\n\treVersion                 = regexp.MustCompile(`\\d+\\.\\d+\\.(\\d+|\\w+)`)\n\tcmdPushFilesBase          = []string{\"git\", \"diff\", \"--name-only\", \"HEAD\", \"@{push}\"}\n\tcmdPushFilesHead          = []string{\"git\", \"diff\", \"--name-only\", \"HEAD\"}\n\tcmdStagedFiles            = []string{\"git\", \"diff\", \"--name-only\", \"--cached\", \"--diff-filter=ACMR\"}\n\tcmdStagedFilesWithDeleted = []string{\"git\", \"diff\", \"--name-only\", \"--cached\", \"--diff-filter=ACMRD\"}\n\tcmdStatusShort            = []string{\"git\", \"status\", \"--short\", \"--porcelain\", \"-z\"}\n\tcmdListStash              = []string{\"git\", \"stash\", \"list\"}\n\tcmdPaths                  = []string{\n\t\t\"git\", \"rev-parse\", \"--path-format=absolute\",\n\t\t\"--show-toplevel\",\n\t\t\"--git-path\", \"hooks\",\n\t\t\"--git-path\", \"info\",\n\t\t\"--git-dir\",\n\t}\n\tcmdAllFiles     = []string{\"git\", \"ls-files\", \"--cached\"}\n\tcmdCreateStash  = []string{\"git\", \"stash\", \"create\"}\n\tcmdStageFiles   = []string{\"git\", \"add\", \"--force\", \"--\"}\n\tcmdRemotes      = []string{\"git\", \"branch\", \"--remotes\"}\n\tcmdHideUnstaged = []string{\"git\", \"checkout\", \"--force\", \"--\"}\n\tcmdGitVersion   = []string{\"git\", \"version\"}\n)\n\n// Repository represents a git repository.\ntype Repository struct {\n\tFs                afero.Fs\n\tGit               *CommandExecutor\n\tHooksPath         string\n\tRootPath          string\n\tGitPath           string\n\tInfoPath          string\n\tunstagedPatchPath string\n\theadBranch        string\n\n\tstagedFilesOnce            func() ([]string, error)\n\tstagedFilesWithDeletedOnce func() ([]string, error)\n\tstatusShortOnce            func() ([]string, error)\n\tstateOnce                  func() State\n}\n\n// NewRepository returns a Repository or an error, if git repository it not initialized.\nfunc NewRepository(fs afero.Fs, git *CommandExecutor) (*Repository, error) {\n\tgitVersionOut, err := git.Cmd(cmdGitVersion)\n\tif err == nil {\n\t\tgitVersion := reVersion.FindString(gitVersionOut)\n\t\tif err = version.Check(minGitVersion, gitVersion); err != nil {\n\t\t\tlog.Debugf(\"[lefthook] version check warning: %s %s\", gitVersion, err)\n\n\t\t\tif errors.Is(err, version.ErrUncoveredVersion) {\n\t\t\t\tlog.Warn(\"Git version is too old. Minimum supported version is \" + minGitVersion)\n\t\t\t}\n\t\t}\n\t}\n\n\tpaths, err := git.Cmd(cmdPaths)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tpathsSplit := strings.Split(paths, \"\\n\")\n\trootPath := pathsSplit[0]\n\thooksPath := pathsSplit[1]\n\tinfoPath := filepath.Clean(pathsSplit[2])\n\tgitPath := pathsSplit[3]\n\n\tif exists, _ := afero.DirExists(fs, infoPath); !exists {\n\t\terr = fs.Mkdir(infoPath, infoDirMode)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t}\n\n\tgit.root = rootPath\n\n\tr := &Repository{\n\t\tFs:        fs,\n\t\tGit:       git,\n\t\tHooksPath: hooksPath,\n\t\tRootPath:  rootPath,\n\t\tGitPath:   gitPath,\n\t\tInfoPath:  infoPath,\n\t}\n\n\tr.Setup()\n\n\treturn r, nil\n}\n\n// Precompute runs various Git commands in the background so the results are ready.\n// This returns a function which can be used to wait for the result. This should\n// be invoked to ensure we're not holding any locks on the Git repository.\nfunc (r *Repository) Precompute() func() {\n\tvar wg sync.WaitGroup\n\n\twg.Go(func() {\n\t\t_, _ = r.stagedFilesOnce()\n\t})\n\n\twg.Go(func() {\n\t\t_, _ = r.stagedFilesWithDeletedOnce()\n\t})\n\n\twg.Go(func() {\n\t\t_, _ = r.statusShortOnce()\n\t})\n\n\treturn wg.Wait\n}\n\n// Setup must be called after you've constructed a Repository directly.\n// It's not necessary to invoke if you've used NewRepository.\n//\n// This can also be called multiple times to reset the cache.\nfunc (r *Repository) Setup() {\n\tr.stagedFilesOnce = sync.OnceValues(func() ([]string, error) {\n\t\treturn r.FindExistingFiles(cmdStagedFiles, \"\")\n\t})\n\n\tr.stagedFilesWithDeletedOnce = sync.OnceValues(func() ([]string, error) {\n\t\treturn r.FindAllFiles(cmdStagedFilesWithDeleted, \"\")\n\t})\n\n\tr.statusShortOnce = sync.OnceValues(func() ([]string, error) {\n\t\treturn r.statusShort()\n\t})\n\n\tr.stateOnce = sync.OnceValue(func() State {\n\t\treturn r.state()\n\t})\n\n\tr.unstagedPatchPath = filepath.Join(r.InfoPath, unstagedPatchName)\n}\n\n// StagedFiles returns a list of staged files which exist on file system.\nfunc (r *Repository) StagedFiles() ([]string, error) {\n\treturn r.stagedFilesOnce()\n}\n\n// StagedFilesWithDeleted returns a list of staged files with deleted files.\nfunc (r *Repository) StagedFilesWithDeleted() ([]string, error) {\n\treturn r.stagedFilesWithDeletedOnce()\n}\n\n// AllFiles returns a list of all files in repository.\nfunc (r *Repository) AllFiles() ([]string, error) {\n\treturn r.FindExistingFiles(cmdAllFiles, \"\")\n}\n\n// PushFiles returns a list of files that are ready to be pushed.\nfunc (r *Repository) PushFiles() ([]string, error) {\n\t// Try with @{push}\n\tlines, err := r.Git.OnlyDebugLogs().CmdLinesWithinFolder(cmdPushFilesBase, \"\")\n\tif err == nil {\n\t\treturn r.extractFiles(lines, true)\n\t}\n\n\t// Try read .git/refs/origin/HEAD\n\tif len(r.headBranch) == 0 {\n\t\tr.headBranch = r.readOriginHead()\n\t}\n\n\t// Try walking through the remotes\n\tif len(r.headBranch) == 0 {\n\t\tbranches, err := r.Git.CmdLines(cmdRemotes)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\n\t\tfor _, branch := range branches {\n\t\t\tmatches := reHeadBranch.FindStringSubmatch(branch)\n\t\t\tif matches == nil {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tr.headBranch = matches[reHeadBranch.SubexpIndex(\"name\")]\n\t\t\tbreak\n\t\t}\n\t}\n\n\t// Nothing has been pushed yet or upstream is not set\n\tif len(r.headBranch) == 0 {\n\t\tr.headBranch = emptyTreeSHA\n\t}\n\n\treturn r.FindExistingFiles(append(cmdPushFilesHead, r.headBranch), \"\")\n}\n\n// PartiallyStagedFiles returns the list of files that have both staged and\n// unstaged changes.\n// See https://git-scm.com/docs/git-status#_short_format.\nfunc (r *Repository) PartiallyStagedFiles() ([]string, error) {\n\tpartiallyStaged := make([]string, 0)\n\n\tlines, err := r.statusShortOnce()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tr.parseStatusShort(lines, func(path string, index, worktree rune) {\n\t\tif index != ' ' && index != '?' && worktree != ' ' && worktree != '?' {\n\t\t\tpartiallyStaged = append(partiallyStaged, path)\n\t\t}\n\t})\n\n\treturn partiallyStaged, nil\n}\n\nfunc (r *Repository) SaveUnstaged(files []string) error {\n\t_, err := r.Git.BatchedCmd(\n\t\t[]string{\n\t\t\t\"git\",\n\t\t\t\"diff\",\n\t\t\t\"--binary\",          // support binary files\n\t\t\t\"--unified=0\",       // do not add lines around diff for consistent behavior\n\t\t\t\"--no-color\",        // disable colors for consistent behavior\n\t\t\t\"--no-ext-diff\",     // disable external diff tools for consistent behavior\n\t\t\t\"--src-prefix=a/\",   // force prefix for consistent behavior\n\t\t\t\"--dst-prefix=b/\",   // force prefix for consistent behavior\n\t\t\t\"--patch\",           // output a patch that can be applied\n\t\t\t\"--submodule=short\", // always use the default short format for submodules\n\t\t\t\"--output\",\n\t\t\tr.unstagedPatchPath,\n\t\t\t\"--\",\n\t\t}, files)\n\n\treturn err\n}\n\nfunc (r *Repository) RevertUnstagedChanges(files []string) error {\n\t_, err := r.Git.BatchedCmd(cmdHideUnstaged, files)\n\n\treturn err\n}\n\nfunc (r *Repository) RestoreUnstaged() error {\n\tif ok, _ := afero.Exists(r.Fs, r.unstagedPatchPath); !ok {\n\t\treturn nil\n\t}\n\n\tstat, err := r.Fs.Stat(r.unstagedPatchPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif stat.Size() == 0 {\n\t\terr = r.Fs.Remove(r.unstagedPatchPath)\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"couldn't remove the patch %s: %w\", r.unstagedPatchPath, err)\n\t\t}\n\n\t\treturn nil\n\t}\n\n\t_, err = r.Git.Cmd([]string{\n\t\t\"git\",\n\t\t\"apply\",\n\t\t\"-v\",\n\t\t\"--whitespace=nowarn\",\n\t\t\"--recount\",\n\t\t\"--unidiff-zero\",\n\t\t\"--\",\n\t\tr.unstagedPatchPath,\n\t})\n\tif err != nil {\n\t\treturn fmt.Errorf(\"couldn't apply the patch %s: %w\", r.unstagedPatchPath, err)\n\t}\n\n\terr = r.Fs.Remove(r.unstagedPatchPath)\n\tif err != nil {\n\t\treturn fmt.Errorf(\"couldn't remove the patch %s: %w\", r.unstagedPatchPath, err)\n\t}\n\n\treturn nil\n}\n\nfunc (r *Repository) StashUnstaged() error {\n\tstashHash, err := r.Git.Cmd(cmdCreateStash)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\t_, err = r.Git.Cmd([]string{\n\t\t\"git\",\n\t\t\"stash\",\n\t\t\"store\",\n\t\t\"--quiet\",\n\t\t\"--message\",\n\t\tstashMessage,\n\t\tstashHash,\n\t})\n\tif err != nil {\n\t\treturn err\n\t}\n\n\treturn nil\n}\n\nfunc (r *Repository) DropUnstagedStash() error {\n\tlines, err := r.Git.CmdLines(cmdListStash)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tstashRegexp := regexp.MustCompile(`^(?P<stash>[^ ]+):\\s*` + stashMessage)\n\tfor i := range lines {\n\t\tline := lines[len(lines)-i-1]\n\t\tmatches := stashRegexp.FindStringSubmatch(line)\n\t\tif matches == nil {\n\t\t\tcontinue\n\t\t}\n\n\t\tstashID := stashRegexp.SubexpIndex(\"stash\")\n\n\t\tif len(matches[stashID]) > 0 {\n\t\t\t_, err := r.Git.Cmd([]string{\n\t\t\t\t\"git\",\n\t\t\t\t\"stash\",\n\t\t\t\t\"drop\",\n\t\t\t\t\"--quiet\",\n\t\t\t\t\"--\",\n\t\t\t\tmatches[stashID],\n\t\t\t})\n\t\t\tif err != nil {\n\t\t\t\treturn err\n\t\t\t}\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (r *Repository) AddFiles(files []string) error {\n\tif len(files) == 0 {\n\t\treturn nil\n\t}\n\n\t_, err := r.Git.BatchedCmd(cmdStageFiles, files)\n\n\treturn err\n}\n\n// Changeset returns a map of files and their hashes that are different from the index.\n// The hash for a deleted file is \"deleted\", and \"directory\" for a directory.\nfunc (r *Repository) Changeset() (map[string]string, error) {\n\tchangeset := make(map[string]string)\n\tpathsToHash := make([]string, 0)\n\n\tlines, err := r.statusShort()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\tr.parseStatusShort(lines, func(path string, index, worktree rune) {\n\t\tif index == 'D' || worktree == 'D' {\n\t\t\tchangeset[path] = \"deleted\"\n\t\t\treturn\n\t\t}\n\t\tif strings.HasSuffix(path, \"/\") {\n\t\t\tchangeset[path] = \"directory\"\n\t\t\treturn\n\t\t}\n\n\t\tpathsToHash = append(pathsToHash, path)\n\t})\n\n\tif len(pathsToHash) == 0 {\n\t\treturn changeset, nil\n\t}\n\n\tout, err := r.Git.BatchedCmd([]string{\"git\", \"hash-object\", \"--\"}, pathsToHash)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\thashes := strings.Split(strings.TrimSpace(out), \"\\n\")\n\tfor i, hash := range hashes {\n\t\tchangeset[pathsToHash[i]] = hash\n\t}\n\n\treturn changeset, nil\n}\n\nfunc (r *Repository) PrintDiff(files []string) {\n\tslices.Sort(files)\n\n\tdiffCmd := make([]string, 0, 4) //nolint:mnd // 3 or 4 elements\n\tdiffCmd = append(diffCmd, \"git\", \"diff\")\n\tif log.Colorized() {\n\t\tdiffCmd = append(diffCmd, \"--color\")\n\t}\n\tdiffCmd = append(diffCmd, \"--\")\n\tdiff, err := r.Git.BatchedCmd(diffCmd, files)\n\tif err != nil {\n\t\tlog.Warnf(\"Couldn't diff changed files: %s\", err)\n\t\treturn\n\t}\n\n\tlog.Warn(diff)\n}\n\nfunc (r *Repository) statusShort() ([]string, error) {\n\treturn r.Git.WithoutTrim().CmdLines(cmdStatusShort)\n}\n\n// parseStatusShort parses short NUL separated porcelain v1 status output.\n// https://git-scm.com/docs/git-status#_short_format\nfunc (r *Repository) parseStatusShort(lines []string, cb func(path string, index, worktree rune)) {\n\toutput := strings.Join(lines, \"\") // there should be only one line with -z\n\tskip := false\n\tfor item := range strings.SplitSeq(output, \"\\x00\") {\n\t\tif skip {\n\t\t\tskip = false\n\t\t\tcontinue\n\t\t}\n\t\trs := []rune(item)\n\t\tif len(rs) < 4 || rs[2] != ' ' { // two status characters, space, and a filename\n\t\t\tcontinue\n\t\t}\n\t\tif slices.ContainsFunc(rs[0:2], func(r rune) bool {\n\t\t\treturn r == 'C' || r == 'R'\n\t\t}) {\n\t\t\t// Next item after a Copy or Rename one is expected to be the old name, which we ignore\n\t\t\tskip = true\n\t\t}\n\t\tcb(string(rs[3:]), rs[0], rs[1])\n\t}\n}\n\n// FindAllFiles accepts git command and returns its result as a list of filepaths.\nfunc (r *Repository) FindAllFiles(command []string, folder string) ([]string, error) {\n\tlines, err := r.Git.CmdLinesWithinFolder(command, folder)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn r.extractFiles(lines, false)\n}\n\n// FindExistingFiles accepts git command and returns its result as a list of filepaths.\nfunc (r *Repository) FindExistingFiles(command []string, folder string) ([]string, error) {\n\tlines, err := r.Git.CmdLinesWithinFolder(command, folder)\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn r.extractFiles(lines, true)\n}\n\nfunc (r *Repository) extractFiles(lines []string, checkExistence bool) ([]string, error) {\n\tvar files []string\n\n\tfor _, line := range lines {\n\t\tfile := strings.TrimSpace(line)\n\t\tif len(file) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tunescaped, err := strconv.Unquote(file)\n\t\tif err == nil {\n\t\t\tfile = unescaped\n\t\t}\n\n\t\tif !checkExistence {\n\t\t\tfiles = append(files, file)\n\t\t\tcontinue\n\t\t}\n\n\t\tisFile, err := r.isFile(file)\n\t\tif err != nil {\n\t\t\treturn nil, err\n\t\t}\n\t\tif isFile {\n\t\t\tfiles = append(files, file)\n\t\t}\n\t}\n\n\treturn files, nil\n}\n\nfunc (r *Repository) isFile(path string) (bool, error) {\n\tif !strings.HasPrefix(path, r.RootPath) {\n\t\tpath = filepath.Join(r.RootPath, path)\n\t}\n\tstat, err := r.Fs.Stat(path)\n\tif err != nil {\n\t\tif os.IsNotExist(err) {\n\t\t\treturn false, nil\n\t\t}\n\t\treturn false, err\n\t}\n\n\treturn !stat.IsDir(), nil\n}\n\nfunc (r *Repository) readOriginHead() string {\n\toriginHead := filepath.Join(r.GitPath, \"refs\", \"remotes\", \"origin\", \"HEAD\")\n\tif _, err := r.Fs.Stat(originHead); os.IsNotExist(err) {\n\t\treturn \"\"\n\t}\n\n\tfile, err := r.Fs.Open(originHead)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tdefer func() {\n\t\tif err := file.Close(); err != nil {\n\t\t\tlog.Warnf(\"Could not close %s: %s\", originHead, err)\n\t\t}\n\t}()\n\n\tscanner := bufio.NewScanner(file)\n\t_ = scanner.Scan()\n\tmatch := reOriginHeadBranch.FindStringSubmatch(scanner.Text())\n\tif match == nil {\n\t\treturn \"\"\n\t}\n\n\treturn match[reHeadBranch.SubexpIndex(\"name\")]\n}\n"
  },
  {
    "path": "internal/git/repository_test.go",
    "content": "package git\n\nimport (\n\t\"fmt\"\n\t\"sync\"\n\t\"testing\"\n\n\t\"github.com/evilmartians/lefthook/v2/tests/helpers/cmdtest\"\n)\n\nfunc TestPartiallyStagedFiles(t *testing.T) {\n\tfor i, tt := range [...]struct {\n\t\tname   string\n\t\tgit    []cmdtest.Out\n\t\terror  bool\n\t\tresult []string\n\t}{\n\t\t{\n\t\t\tgit: []cmdtest.Out{\n\t\t\t\t{\n\t\t\t\t\tCommand: \"git status --short --porcelain -z\",\n\t\t\t\t\tOutput: \"RM new file\\x00old-file\\x00\" +\n\t\t\t\t\t\t\"M  staged\\x00\" +\n\t\t\t\t\t\t\"MM staged but changed\\x00\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tresult: []string{\"new file\", \"staged but changed\"},\n\t\t},\n\t} {\n\t\tt.Run(fmt.Sprintf(\"%d: %s\", i, tt.name), func(t *testing.T) {\n\t\t\trepository := &Repository{\n\t\t\t\tGit: &CommandExecutor{\n\t\t\t\t\tmu:  new(sync.Mutex),\n\t\t\t\t\tcmd: cmdtest.NewOrdered(t, tt.git),\n\t\t\t\t},\n\t\t\t}\n\t\t\trepository.Setup()\n\n\t\t\tfiles, err := repository.PartiallyStagedFiles()\n\t\t\tif tt.error && err != nil {\n\t\t\t\tt.Errorf(\"expected an error\")\n\t\t\t}\n\n\t\t\tif len(files) != len(tt.result) {\n\t\t\t\tt.Errorf(\"expected %d files, but %d returned\", len(tt.result), len(files))\n\t\t\t}\n\n\t\t\tfor j, file := range files {\n\t\t\t\tif tt.result[j] != file {\n\t\t\t\t\tt.Errorf(\"file at index %d don't match: %s - %s\", j, tt.result[j], file)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestChangeset(t *testing.T) {\n\tfor i, tt := range [...]struct {\n\t\tname        string\n\t\tgit         []cmdtest.Out\n\t\tpathsToHash []string\n\t\tresult      map[string]string\n\t}{\n\t\t{\n\t\t\tname: \"no changes\",\n\t\t\tgit: []cmdtest.Out{\n\t\t\t\t{Command: \"git status --short --porcelain -z\", Output: \"\"},\n\t\t\t},\n\t\t\tresult: map[string]string{},\n\t\t},\n\t\t{\n\t\t\tname: \"modified file\",\n\t\t\tgit: []cmdtest.Out{\n\t\t\t\t{Command: \"git status --short --porcelain -z\", Output: \" M modified.txt\\x00\"},\n\t\t\t\t{Command: \"git hash-object -- modified.txt\", Output: \"123456\"},\n\t\t\t},\n\t\t\tpathsToHash: []string{\"modified.txt\"},\n\t\t\tresult: map[string]string{\n\t\t\t\t\"modified.txt\": \"123456\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"deleted file\",\n\t\t\tgit: []cmdtest.Out{\n\t\t\t\t{Command: \"git status --short --porcelain -z\", Output: \"D  deleted.txt\\x00\"},\n\t\t\t},\n\t\t\tresult: map[string]string{\n\t\t\t\t\"deleted.txt\": \"deleted\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"new file\",\n\t\t\tgit: []cmdtest.Out{\n\t\t\t\t{Command: \"git status --short --porcelain -z\", Output: \"?? new.txt\\x00\"},\n\t\t\t\t{Command: \"git hash-object -- new.txt\", Output: \"654321\"},\n\t\t\t},\n\t\t\tpathsToHash: []string{\"new.txt\"},\n\t\t\tresult: map[string]string{\n\t\t\t\t\"new.txt\": \"654321\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"new dir\",\n\t\t\tgit: []cmdtest.Out{\n\t\t\t\t{Command: \"git status --short --porcelain -z\", Output: \"?? new-dir/\\x00\"},\n\t\t\t},\n\t\t\tpathsToHash: []string{},\n\t\t\tresult: map[string]string{\n\t\t\t\t\"new-dir/\": \"directory\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"mixed changes\",\n\t\t\tgit: []cmdtest.Out{\n\t\t\t\t{\n\t\t\t\t\tCommand: \"git status --short --porcelain -z\",\n\t\t\t\t\tOutput: \"M  modified.txt\\x00\" +\n\t\t\t\t\t\t\"CT copied to\\x00copied from\\x00\" +\n\t\t\t\t\t\t\" D deleted.txt\\x00\" +\n\t\t\t\t\t\t\"?? new.txt\\x00\" +\n\t\t\t\t\t\t\"?? new-dir/\\x00\" +\n\t\t\t\t\t\t\"RM new-file\\x00old-file\\x00\" +\n\t\t\t\t\t\t\"A  foo -> bar\\x00\" +\n\t\t\t\t\t\t\"MM back\\\\slashes\\x00\" +\n\t\t\t\t\t\t\"R  this is the new filename\\x00R  this is really the old name, does it throw off the parser\\x00\" +\n\t\t\t\t\t\t\"??  leading-space\\x00\",\n\t\t\t\t},\n\n\t\t\t\t{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\"},\n\t\t\t},\n\t\t\t// pathsToHash: []string{\"modified.txt\", \"copied to\", \"new.txt\", \"new-file\", \"foo -> bar\", `back\\slashes`, \"this is the new filename\", \" leading-space\"},\n\t\t\tresult: map[string]string{\n\t\t\t\t\"modified.txt\":             \"123456\",\n\t\t\t\t\"copied to\":                \"c0c0c0\",\n\t\t\t\t\"deleted.txt\":              \"deleted\",\n\t\t\t\t\"new.txt\":                  \"654321\",\n\t\t\t\t\"new-dir/\":                 \"directory\",\n\t\t\t\t\"new-file\":                 \"758213\",\n\t\t\t\t\"foo -> bar\":               \"fbfbfb\",\n\t\t\t\t`back\\slashes`:             \"bbbbbb\",\n\t\t\t\t\"this is the new filename\": \"ffffff\",\n\t\t\t\t\" leading-space\":           \"cccccc\",\n\t\t\t},\n\t\t},\n\t} {\n\t\tt.Run(fmt.Sprintf(\"%d: %s\", i, tt.name), func(t *testing.T) {\n\t\t\trepository := &Repository{\n\t\t\t\tGit: &CommandExecutor{\n\t\t\t\t\tmu:        new(sync.Mutex),\n\t\t\t\t\tcmd:       cmdtest.NewOrdered(t, tt.git),\n\t\t\t\t\tmaxCmdLen: 7000,\n\t\t\t\t},\n\t\t\t}\n\t\t\trepository.Setup()\n\n\t\t\tchangeset, err := repository.Changeset()\n\t\t\tif err != nil {\n\t\t\t\tt.Errorf(\"unexpected error: %s\", err)\n\t\t\t}\n\n\t\t\tif len(changeset) != len(tt.result) {\n\t\t\t\tt.Errorf(\"expected %d files, but %d returned\", len(tt.result), len(changeset))\n\t\t\t}\n\n\t\t\tfor file, hash := range tt.result {\n\t\t\t\tif changeset[file] != hash {\n\t\t\t\t\tt.Errorf(\"expected hash %s for file %s, but got %s\", hash, file, changeset[file])\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/git/state.go",
    "content": "package git\n\nimport (\n\t\"bufio\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strings\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/log\"\n)\n\ntype State struct {\n\tBranch, State string\n}\n\nconst (\n\tNil         string = \"\"\n\tMerge       string = \"merge\"\n\tMergeCommit string = \"merge-commit\"\n\tRebase      string = \"rebase\"\n)\n\nvar (\n\trefBranchRegexp  = regexp.MustCompile(`^ref:\\s*refs/heads/(.+)$`)\n\tcmdParentCommits = []string{\"git\", \"show\", \"--no-patch\", `--format=\"%P\"`}\n)\n\nfunc (r *Repository) State() State {\n\treturn r.stateOnce()\n}\n\nfunc (r *Repository) state() State {\n\tvar state State\n\n\tbranch := r.branch()\n\tif r.inMergeState() {\n\t\tstate = State{\n\t\t\tBranch: branch,\n\t\t\tState:  Merge,\n\t\t}\n\t\treturn state\n\t}\n\tif r.inRebaseState() {\n\t\tstate = State{\n\t\t\tBranch: branch,\n\t\t\tState:  Rebase,\n\t\t}\n\t\treturn state\n\t}\n\tif r.inMergeCommitState() {\n\t\tstate = State{\n\t\t\tBranch: branch,\n\t\t\tState:  MergeCommit,\n\t\t}\n\t\treturn state\n\t}\n\n\tstate = State{\n\t\tBranch: branch,\n\t\tState:  Nil,\n\t}\n\n\treturn state\n}\n\nfunc (r *Repository) branch() string {\n\theadFile := filepath.Join(r.GitPath, \"HEAD\")\n\tif _, err := r.Fs.Stat(headFile); os.IsNotExist(err) {\n\t\treturn \"\"\n\t}\n\n\tfile, err := r.Fs.Open(headFile)\n\tif err != nil {\n\t\treturn \"\"\n\t}\n\tdefer func() {\n\t\tif cErr := file.Close(); cErr != nil {\n\t\t\tlog.Warnf(\"Could not close %s: %s\", headFile, cErr)\n\t\t}\n\t}()\n\n\tscanner := bufio.NewScanner(file)\n\n\tfor scanner.Scan() {\n\t\tmatch := refBranchRegexp.FindStringSubmatch(scanner.Text())\n\n\t\tif match != nil {\n\t\t\treturn match[1]\n\t\t}\n\t}\n\tif err = scanner.Err(); err != nil {\n\t\tlog.Warnf(\"Could not read %s: %s\", file.Name(), err)\n\t}\n\n\treturn \"\"\n}\n\nfunc (r *Repository) inMergeState() bool {\n\tif _, err := r.Fs.Stat(filepath.Join(r.GitPath, \"MERGE_HEAD\")); os.IsNotExist(err) {\n\t\treturn false\n\t}\n\treturn true\n}\n\nfunc (r *Repository) inRebaseState() bool {\n\tif _, mergeErr := r.Fs.Stat(filepath.Join(r.GitPath, \"rebase-merge\")); os.IsNotExist(mergeErr) {\n\t\tif _, applyErr := r.Fs.Stat(filepath.Join(r.GitPath, \"rebase-apply\")); os.IsNotExist(applyErr) {\n\t\t\treturn false\n\t\t}\n\t}\n\n\treturn true\n}\n\nfunc (r *Repository) inMergeCommitState() bool {\n\tparents, err := r.Git.Cmd(cmdParentCommits)\n\tif err != nil {\n\t\treturn false\n\t}\n\n\treturn strings.Contains(parents, \" \")\n}\n"
  },
  {
    "path": "internal/log/builder.go",
    "content": "package log\n\nimport (\n\t\"fmt\"\n\t\"strings\"\n)\n\ntype builder interface {\n\tAdd(string, any) builder\n\tString() string\n\tLog()\n}\n\ntype dummyBuilder struct{}\n\ntype logBuilder struct {\n\tlevel   Level\n\tprefix  string\n\tbuilder strings.Builder\n}\n\nfunc Builder(level Level, prefix string) builder {\n\tif !std.IsLevelEnabled(level) {\n\t\treturn dummyBuilder{}\n\t}\n\n\treturn &logBuilder{\n\t\tprefix:  prefix,\n\t\tlevel:   level,\n\t\tbuilder: strings.Builder{},\n\t}\n}\n\nfunc (b *logBuilder) Add(prefix string, data any) builder {\n\tvar lines []string\n\tswitch v := data.(type) {\n\tcase string:\n\t\tlines = strings.Split(strings.TrimSpace(v), \"\\n\")\n\tcase []string:\n\t\tlines = v\n\tdefault:\n\t\tlines = strings.Split(fmt.Sprint(data), \"\\n\")\n\t}\n\tfor i, line := range lines {\n\t\tline = strings.TrimSpace(line)\n\t\tif len(line) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tswitch {\n\t\tcase b.builder.Len() == 0:\n\t\t\tb.builder.WriteString(b.prefix + prefix + line + \"\\n\")\n\t\tcase i == 0:\n\t\t\tb.builder.WriteString(strings.Repeat(\" \", len(b.prefix)) + prefix + line + \"\\n\")\n\t\tdefault:\n\t\t\tb.builder.WriteString(strings.Repeat(\" \", len(b.prefix)+len(prefix)) + line + \"\\n\")\n\t\t}\n\t}\n\n\treturn b\n}\n\nfunc (b *logBuilder) Log() {\n\tswitch b.level {\n\tcase DebugLevel:\n\t\tDebug(b.builder.String())\n\tcase InfoLevel:\n\t\tInfo(b.builder.String())\n\tcase ErrorLevel:\n\t\tError(b.builder.String())\n\tcase WarnLevel:\n\t\tWarn(b.builder.String())\n\t}\n}\n\nfunc (b *logBuilder) String() string {\n\treturn b.builder.String()\n}\n\nfunc (d dummyBuilder) Add(_ string, _ any) builder { return d }\nfunc (dummyBuilder) Log()                          {}\nfunc (dummyBuilder) String() string                { return \"\" }\n"
  },
  {
    "path": "internal/log/execution.go",
    "content": "package log\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\n\t\"github.com/charmbracelet/lipgloss\"\n)\n\nconst execLogPadding = 2\n\nfunc Execution(name string, err error, out io.Reader) {\n\tif err == nil && !Settings.LogExecution() {\n\t\treturn\n\t}\n\n\tvar execLog string\n\tvar color lipgloss.TerminalColor\n\tswitch {\n\tcase !Settings.LogExecutionInfo():\n\t\texecLog = \"\"\n\tcase err != nil:\n\t\texecLog = Red(fmt.Sprintf(\"%s ❯ \", name))\n\t\tcolor = ColorRed\n\tdefault:\n\t\texecLog = Cyan(fmt.Sprintf(\"%s ❯ \", name))\n\t\tcolor = ColorCyan\n\t}\n\n\tif execLog != \"\" {\n\t\tStyled().\n\t\t\tWithLeftBorder(lipgloss.ThickBorder(), color).\n\t\t\tWithPadding(execLogPadding).\n\t\t\tInfo(execLog)\n\t\tInfo()\n\t}\n\n\tif err == nil && !Settings.LogExecutionOutput() {\n\t\treturn\n\t}\n\n\tif out != nil {\n\t\tInfo(out)\n\t}\n\n\tif err != nil {\n\t\tInfof(\"%s\", err)\n\t}\n}\n"
  },
  {
    "path": "internal/log/log.go",
    "content": "package log\n\nimport (\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"strconv\"\n\t\"strings\"\n\t\"sync\"\n\t\"time\"\n\n\t\"github.com/briandowns/spinner\"\n\t\"github.com/charmbracelet/lipgloss\"\n\t\"github.com/mattn/go-isatty\"\n\t\"github.com/mattn/go-runewidth\"\n\t\"golang.org/x/term\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/version\"\n)\n\nvar (\n\tColorRed    lipgloss.TerminalColor = lipgloss.CompleteColor{TrueColor: \"#ff6347\", ANSI256: \"196\", ANSI: \"9\"}\n\tColorGreen  lipgloss.TerminalColor = lipgloss.CompleteColor{TrueColor: \"#32cd32\", ANSI256: \"148\", ANSI: \"2\"}\n\tColorYellow lipgloss.TerminalColor = lipgloss.CompleteColor{TrueColor: \"#fada5e\", ANSI256: \"191\", ANSI: \"11\"}\n\tColorCyan   lipgloss.TerminalColor = lipgloss.CompleteColor{TrueColor: \"#70C0BA\", ANSI256: \"37\", ANSI: \"14\"}\n\tGolorGray   lipgloss.TerminalColor = lipgloss.CompleteColor{TrueColor: \"#808080\", ANSI256: \"244\", ANSI: \"7\"}\n\tcolorBorder lipgloss.TerminalColor = lipgloss.Color(\"#383838\")\n\n\tstd = New()\n\n\tseparatorWidth  = 36\n\tseparatorMargin = 2\n\tpadding         = 2\n)\n\ntype Level uint32\n\nconst (\n\tErrorLevel Level = iota\n\tWarnLevel\n\tInfoLevel\n\tDebugLevel\n\n\tspinnerCharSet     = 14\n\tspinnerRefreshRate = 100 * time.Millisecond\n\tspinnerText        = \" waiting\"\n\n\tColorAuto = iota\n\tColorOn\n\tColorOff\n)\n\ntype StyleLogger struct {\n\tstyle lipgloss.Style\n}\n\ntype Logger struct {\n\tlevel         Level\n\tout           io.Writer\n\tmu            sync.Mutex\n\tcolors        int\n\tterminalWidth int\n\tnames         []string\n\tspinner       *spinner.Spinner\n}\n\nfunc New() *Logger {\n\treturn &Logger{\n\t\tlevel:         InfoLevel,\n\t\tout:           os.Stdout,\n\t\tcolors:        ColorAuto,\n\t\tterminalWidth: terminalWidth(),\n\t\tspinner: spinner.New(\n\t\t\tspinner.CharSets[spinnerCharSet],\n\t\t\tspinnerRefreshRate,\n\t\t\tspinner.WithSuffix(spinnerText),\n\t\t),\n\t}\n}\n\nfunc Colors() int {\n\treturn std.colors\n}\n\nfunc Colorized() bool {\n\treturn std.colors == ColorAuto || std.colors == ColorOn\n}\n\nfunc StartSpinner() {\n\tstd.spinner.Start()\n}\n\nfunc StopSpinner() {\n\tstd.spinner.Stop()\n}\n\nfunc Styled() StyleLogger {\n\treturn StyleLogger{\n\t\tstyle: lipgloss.NewStyle(),\n\t}\n}\n\nfunc (s StyleLogger) WithLeftBorder(border lipgloss.Border, color lipgloss.TerminalColor) StyleLogger {\n\ts.style = s.style.BorderStyle(border).BorderLeft(true).BorderForeground(color)\n\n\treturn s\n}\n\nfunc (s StyleLogger) WithPadding(m int) StyleLogger {\n\ts.style = s.style.PaddingLeft(m)\n\n\treturn s\n}\n\nfunc (s StyleLogger) Info(str string) {\n\tInfo(\n\t\tlipgloss.JoinVertical(\n\t\t\tlipgloss.Left,\n\t\t\ts.style.Render(str),\n\t\t),\n\t)\n}\n\nfunc Debug(args ...any) {\n\tres := strings.TrimSpace(fmt.Sprint(args...))\n\tstd.Debug(color(GolorGray).Render(res))\n}\n\nfunc Debugf(format string, args ...any) {\n\tDebug(fmt.Sprintf(format, args...))\n}\n\nfunc Info(args ...any) {\n\tstd.Info(args...)\n}\n\nfunc InfoPad(s string) {\n\tInfo(\n\t\tlipgloss.NewStyle().\n\t\t\tBorderStyle(lipgloss.NormalBorder()).\n\t\t\tBorderLeft(true).\n\t\t\tBorderForeground(ColorCyan).\n\t\t\tRender(s),\n\t)\n}\n\nfunc Infof(format string, args ...any) {\n\tstd.Infof(format, args...)\n}\n\nfunc Error(args ...any) {\n\tres := fmt.Sprint(args...)\n\tstd.Error(Red(res))\n}\n\nfunc Errorf(format string, args ...any) {\n\tError(fmt.Sprintf(format, args...))\n}\n\nfunc Warn(args ...any) {\n\tres := fmt.Sprint(args...)\n\tstd.Warn(Yellow(res))\n}\n\nfunc Warnf(format string, args ...any) {\n\tWarn(fmt.Sprintf(format, args...))\n}\n\nfunc Println(args ...any) {\n\tstd.Println(args...)\n}\n\nfunc Printf(format string, args ...any) {\n\tstd.Printf(format, args...)\n}\n\nfunc SetLevel(level Level) {\n\tstd.SetLevel(level)\n}\n\nfunc SetColors(colors any) {\n\tif colors == nil {\n\t\treturn\n\t}\n\n\tswitch typedColors := colors.(type) {\n\tcase string:\n\t\tswitch typedColors {\n\t\tcase \"on\":\n\t\t\tstd.colors = ColorOn\n\t\t\tsetColor(lipgloss.CompleteColor{TrueColor: \"#ff6347\", ANSI256: \"196\", ANSI: \"9\"}, &ColorRed)\n\t\t\tsetColor(lipgloss.CompleteColor{TrueColor: \"#32cd32\", ANSI256: \"148\", ANSI: \"2\"}, &ColorGreen)\n\t\t\tsetColor(lipgloss.CompleteColor{TrueColor: \"#fada5e\", ANSI256: \"191\", ANSI: \"11\"}, &ColorYellow)\n\t\t\tsetColor(lipgloss.CompleteColor{TrueColor: \"#70C0BA\", ANSI256: \"37\", ANSI: \"14\"}, &ColorCyan)\n\t\t\tsetColor(lipgloss.CompleteColor{TrueColor: \"#808080\", ANSI256: \"244\", ANSI: \"7\"}, &GolorGray)\n\t\t\tsetColor(lipgloss.Color(\"#383838\"), &colorBorder)\n\t\tcase \"off\":\n\t\t\tstd.colors = ColorOff\n\t\t\tsetColor(lipgloss.NoColor{}, &ColorRed)\n\t\t\tsetColor(lipgloss.NoColor{}, &ColorGreen)\n\t\t\tsetColor(lipgloss.NoColor{}, &ColorYellow)\n\t\t\tsetColor(lipgloss.NoColor{}, &ColorCyan)\n\t\t\tsetColor(lipgloss.NoColor{}, &GolorGray)\n\t\t\tsetColor(lipgloss.NoColor{}, &colorBorder)\n\t\tdefault:\n\t\t\tstd.colors = ColorAuto\n\t\t}\n\tcase bool:\n\t\tif typedColors {\n\t\t\tstd.colors = ColorOn\n\t\t\treturn\n\t\t}\n\n\t\tstd.colors = ColorOff\n\t\tsetColor(lipgloss.NoColor{}, &ColorRed)\n\t\tsetColor(lipgloss.NoColor{}, &ColorGreen)\n\t\tsetColor(lipgloss.NoColor{}, &ColorYellow)\n\t\tsetColor(lipgloss.NoColor{}, &ColorCyan)\n\t\tsetColor(lipgloss.NoColor{}, &GolorGray)\n\t\tsetColor(lipgloss.NoColor{}, &colorBorder)\n\tcase map[string]any:\n\t\tstd.colors = ColorOn\n\t\tsetColor(typedColors[\"red\"], &ColorRed)\n\t\tsetColor(typedColors[\"green\"], &ColorGreen)\n\t\tsetColor(typedColors[\"yellow\"], &ColorYellow)\n\t\tsetColor(typedColors[\"cyan\"], &ColorCyan)\n\t\tsetColor(typedColors[\"gray\"], &GolorGray)\n\t\tsetColor(typedColors[\"gray\"], &colorBorder)\n\tdefault:\n\t\tstd.colors = ColorAuto\n\t}\n}\n\nfunc setColor(colorCode any, adaptiveColor *lipgloss.TerminalColor) {\n\tvar code string\n\tswitch typedCode := colorCode.(type) {\n\tcase int:\n\t\tcode = strconv.Itoa(typedCode)\n\tcase string:\n\t\tcode = typedCode\n\tcase lipgloss.NoColor:\n\t\t*adaptiveColor = typedCode\n\t\treturn\n\tcase lipgloss.TerminalColor:\n\t\t*adaptiveColor = typedCode\n\tdefault:\n\t\treturn\n\t}\n\n\tif len(code) == 0 {\n\t\treturn\n\t}\n\n\t*adaptiveColor = lipgloss.Color(code)\n}\n\nfunc Cyan(s string) string {\n\treturn color(ColorCyan).Render(s)\n}\n\nfunc Green(s string) string {\n\treturn color(ColorGreen).Render(s)\n}\n\nfunc Red(s string) string {\n\treturn color(ColorRed).Render(s)\n}\n\nfunc Yellow(s string) string {\n\treturn color(ColorYellow).Render(s)\n}\n\nfunc Gray(s string) string {\n\treturn color(GolorGray).Render(s)\n}\n\nfunc Bold(s string) string {\n\tif !Colorized() {\n\t\treturn lipgloss.NewStyle().Render(s)\n\t}\n\n\treturn lipgloss.NewStyle().Bold(true).Render(s)\n}\n\nfunc LogMeta(hookName string) {\n\tname := \"🥊 lefthook \"\n\tif !Colorized() {\n\t\tname = \"lefthook \"\n\t}\n\n\tbox(\n\t\tCyan(name)+Gray(fmt.Sprintf(\"v%s\", version.Version(false))),\n\t\tGray(\"hook: \")+Bold(hookName),\n\t)\n}\n\nfunc Success(indent int, name string, duration time.Duration) {\n\tformat := \"%s✔️ %s %s\\n\"\n\tif !Colorized() {\n\t\tformat = \"%s✓ %s %s\\n\"\n\t}\n\tInfof(\n\t\tformat,\n\t\tstrings.Repeat(\"  \", indent),\n\t\tGreen(name),\n\t\tGray(fmt.Sprintf(\"(%.2f seconds)\", duration.Seconds())),\n\t)\n}\n\nfunc Failure(indent int, name, failText string, duration time.Duration) {\n\tif len(failText) != 0 {\n\t\tfailText = fmt.Sprintf(\": %s\", failText)\n\t}\n\n\tformat := \"%s🥊 %s%s %s\\n\"\n\tif !Colorized() {\n\t\tformat = \"%s✗ %s%s %s\\n\"\n\t}\n\tInfof(\n\t\tformat,\n\t\tstrings.Repeat(\"  \", indent),\n\t\tRed(name),\n\t\tRed(failText),\n\t\tGray(fmt.Sprintf(\"(%.2f seconds)\", duration.Seconds())),\n\t)\n}\n\nfunc box(left, right string) {\n\tInfo(\n\t\tlipgloss.JoinHorizontal(\n\t\t\tlipgloss.Top,\n\t\t\tlipgloss.NewStyle().\n\t\t\t\tBorder(lipgloss.RoundedBorder(), true, false, true, true).\n\t\t\t\tBorderForeground(colorBorder).\n\t\t\t\tPadding(0, 1).\n\t\t\t\tRender(left),\n\t\t\tlipgloss.NewStyle().\n\t\t\t\tBorder(lipgloss.RoundedBorder(), true, true, true, false).\n\t\t\t\tBorderForeground(colorBorder).\n\t\t\t\tPadding(0, 1).\n\t\t\t\tRender(right),\n\t\t),\n\t)\n}\n\nfunc Separate(s string) {\n\tInfo(\n\t\tlipgloss.JoinVertical(\n\t\t\tlipgloss.Left,\n\t\t\tlipgloss.NewStyle().\n\t\t\t\tBorderStyle(lipgloss.NormalBorder()).\n\t\t\t\tBorderBottom(true).\n\t\t\t\tBorderForeground(colorBorder).\n\t\t\t\tWidth(separatorWidth).\n\t\t\t\tMarginLeft(separatorMargin).\n\t\t\t\tRender(\"\"),\n\t\t\ts,\n\t\t),\n\t)\n}\n\nfunc color(clr lipgloss.TerminalColor) lipgloss.Style {\n\treturn lipgloss.NewStyle().Foreground(clr)\n}\n\nfunc SetOutput(out io.Writer) {\n\tstd.SetOutput(out)\n}\n\nfunc ParseLevel(lvl string) (Level, error) {\n\tswitch strings.ToLower(lvl) {\n\tcase \"error\":\n\t\treturn ErrorLevel, nil\n\tcase \"info\":\n\t\treturn InfoLevel, nil\n\tcase \"debug\":\n\t\treturn DebugLevel, nil\n\t}\n\n\tvar l Level\n\treturn l, fmt.Errorf(\"not a valid Level: %q\", lvl)\n}\n\nfunc (l *Logger) SetLevel(level Level) {\n\tl.mu.Lock()\n\tdefer l.mu.Unlock()\n\tl.level = level\n}\n\nfunc (l *Logger) SetOutput(out io.Writer) {\n\tl.mu.Lock()\n\tdefer l.mu.Unlock()\n\tl.out = out\n}\n\nfunc (l *Logger) Info(args ...any) {\n\tl.Log(InfoLevel, args...)\n}\n\nfunc (l *Logger) Debug(args ...string) {\n\tleftBorder := lipgloss.NewStyle().\n\t\tBorderStyle(lipgloss.NormalBorder()).\n\t\tBorderLeft(true).\n\t\tBorderForeground(colorBorder).\n\t\tPaddingLeft(padding).\n\t\tRender(args...)\n\tl.Log(DebugLevel, leftBorder)\n}\n\nfunc (l *Logger) Error(args ...string) {\n\tleftBorder := lipgloss.NewStyle().\n\t\tBorderStyle(lipgloss.NormalBorder()).\n\t\tBorderLeft(true).\n\t\tBorderForeground(ColorRed).\n\t\tPaddingLeft(padding).\n\t\tRender(args...)\n\tl.Log(ErrorLevel, leftBorder)\n}\n\nfunc (l *Logger) Warn(args ...string) {\n\tleftBorder := lipgloss.NewStyle().\n\t\tBorderStyle(lipgloss.NormalBorder()).\n\t\tBorderLeft(true).\n\t\tBorderForeground(ColorYellow).\n\t\tPaddingLeft(padding).\n\t\tRender(args...)\n\tl.Log(WarnLevel, leftBorder)\n}\n\nfunc (l *Logger) Infof(format string, args ...any) {\n\tl.Logf(InfoLevel, format, args...)\n}\n\nfunc (l *Logger) Debugf(format string, args ...any) {\n\tl.Logf(DebugLevel, format, args...)\n}\n\nfunc (l *Logger) Errorf(format string, args ...any) {\n\tl.Logf(ErrorLevel, format, args...)\n}\n\nfunc (l *Logger) Warnf(format string, args ...any) {\n\tl.Logf(WarnLevel, format, args...)\n}\n\nfunc (l *Logger) Log(level Level, args ...any) {\n\tif l.IsLevelEnabled(level) {\n\t\tl.Println(args...)\n\t}\n}\n\nfunc SetName(name string) {\n\tstd.SetName(name)\n}\n\nfunc UnsetName(name string) {\n\tstd.UnsetName(name)\n}\n\nfunc (l *Logger) SetName(name string) {\n\tl.mu.Lock()\n\tdefer l.mu.Unlock()\n\n\tif l.spinner.Active() {\n\t\tl.spinner.Stop()\n\t\tdefer l.spinner.Start()\n\t}\n\n\tl.names = append(l.names, name)\n\tl.spinner.Suffix = l.formatSpinnerSuffix(l.names)\n}\n\nfunc (l *Logger) UnsetName(name string) {\n\tl.mu.Lock()\n\tdefer l.mu.Unlock()\n\n\tif l.spinner.Active() {\n\t\tl.spinner.Stop()\n\t\tdefer l.spinner.Start()\n\t}\n\n\tcapacity := len(l.names)\n\tif capacity > 0 {\n\t\tcapacity--\n\t}\n\tnewNames := make([]string, 0, capacity)\n\tfor _, n := range l.names {\n\t\tif n != name {\n\t\t\tnewNames = append(newNames, n)\n\t\t}\n\t}\n\n\tl.names = newNames\n\tl.spinner.Suffix = l.formatSpinnerSuffix(l.names)\n}\n\nfunc (l *Logger) Logf(level Level, format string, args ...any) {\n\tif l.IsLevelEnabled(level) {\n\t\tl.Printf(format, args...)\n\t}\n}\n\nfunc (l *Logger) Println(args ...any) {\n\tl.mu.Lock()\n\tdefer l.mu.Unlock()\n\n\tif l.spinner.Active() {\n\t\tl.spinner.Stop()\n\t\tdefer l.spinner.Start()\n\t}\n\n\t_, _ = fmt.Fprintln(l.out, args...)\n}\n\nfunc (l *Logger) Printf(format string, args ...any) {\n\tl.mu.Lock()\n\tdefer l.mu.Unlock()\n\n\tif l.spinner.Active() {\n\t\tl.spinner.Stop()\n\t\tdefer l.spinner.Start()\n\t}\n\n\t_, _ = fmt.Fprintf(l.out, format, args...)\n}\n\nfunc (l *Logger) IsLevelEnabled(level Level) bool {\n\treturn l.level >= level\n}\n\n// formatSpinnerSuffix creates a spinner suffix that respects terminal width constraints.\nfunc (l *Logger) formatSpinnerSuffix(names []string) string {\n\tif len(names) == 0 {\n\t\treturn spinnerText\n\t}\n\n\tterminalWidth := l.terminalWidth\n\tif terminalWidth <= 0 {\n\t\treturn fmt.Sprintf(\"%s: %s\", spinnerText, strings.Join(names, \", \"))\n\t}\n\n\t// Width calculation: Reserve space for spinner character (1) + space (1) + padding (8)\n\t// This accounts for the spinning character and reasonable display margin\n\tconst spinnerReservedWidth = 10\n\tavailableWidth := terminalWidth - spinnerReservedWidth\n\n\t// Strategy 1: Try to fit all names with full formatting\n\tfullSuffix := fmt.Sprintf(\"%s: %s\", spinnerText, strings.Join(names, \", \"))\n\tif runewidth.StringWidth(fullSuffix) <= availableWidth {\n\t\treturn fullSuffix\n\t}\n\n\t// Strategy 2: Try showing just the count\n\tcountSuffix := fmt.Sprintf(\"%s: %d hook%s\", spinnerText, len(names), pluralize(len(names)))\n\tif runewidth.StringWidth(countSuffix) <= availableWidth {\n\t\treturn countSuffix\n\t}\n\n\t// Strategy 3: Show as many individual names as possible\n\treturn formatWithPartialNames(names, availableWidth)\n}\n\n// terminalWidth attempts to detect the current terminal width.\nfunc terminalWidth() int {\n\t// Check if we're writing to a TTY\n\tif !isatty.IsTerminal(os.Stdout.Fd()) {\n\t\treturn 0 // Not a terminal, don't constrain\n\t}\n\n\t// Try to get terminal size\n\twidth, _, err := term.GetSize(int(os.Stdout.Fd()))\n\tif err != nil {\n\t\treturn 0 // Can't determine size, don't constrain\n\t}\n\n\treturn width\n}\n\n// formatWithPartialNames shows as many hook names as possible, then adds count for remaining.\nfunc formatWithPartialNames(names []string, availableWidth int) string {\n\tif len(names) == 0 {\n\t\treturn spinnerText\n\t}\n\n\tbaseText := spinnerText + \": \"\n\tbaseWidth := runewidth.StringWidth(baseText)\n\tremainingWidth := availableWidth - baseWidth\n\n\t// Try to fit names one by one\n\tvar fittingNames []string\n\tcurrentWidth := 0\n\n\tfor i, name := range names {\n\t\tnameWidth := runewidth.StringWidth(name)\n\n\t\t// Add comma and space for all but first name\n\t\tif i > 0 {\n\t\t\tnameWidth += 2 // \", \"\n\t\t}\n\n\t\t// Check if we need space for \"... (N more)\" suffix\n\t\tremainingCount := len(names) - i\n\t\tif remainingCount > 1 {\n\t\t\tmoreSuffix := fmt.Sprintf(\", ... (%d more)\", remainingCount-1)\n\t\t\tmoreSuffixWidth := runewidth.StringWidth(moreSuffix)\n\n\t\t\tif currentWidth+nameWidth+moreSuffixWidth > remainingWidth {\n\t\t\t\t// Add the \"more\" suffix and break\n\t\t\t\tif len(fittingNames) > 0 {\n\t\t\t\t\treturn fmt.Sprintf(\"%s%s, ... (%d more)\", baseText, strings.Join(fittingNames, \", \"), remainingCount)\n\t\t\t\t}\n\t\t\t\t// If we can't fit even one name, just show count\n\t\t\t\treturn fmt.Sprintf(\"%s%d hook%s\", baseText, len(names), pluralize(len(names)))\n\t\t\t}\n\t\t}\n\n\t\tif currentWidth+nameWidth <= remainingWidth {\n\t\t\tfittingNames = append(fittingNames, name)\n\t\t\tcurrentWidth += nameWidth\n\t\t} else {\n\t\t\t// This name doesn't fit\n\t\t\tif len(fittingNames) == 0 {\n\t\t\t\t// Can't fit any names, just show count\n\t\t\t\treturn fmt.Sprintf(\"%s%d hook%s\", baseText, len(names), pluralize(len(names)))\n\t\t\t}\n\t\t\t// Show what we have plus count\n\t\t\tremainingCount := len(names) - len(fittingNames)\n\t\t\treturn fmt.Sprintf(\"%s%s, ... (%d more)\", baseText, strings.Join(fittingNames, \", \"), remainingCount)\n\t\t}\n\t}\n\n\t// All names fit\n\treturn fmt.Sprintf(\"%s%s\", baseText, strings.Join(fittingNames, \", \"))\n}\n\n// pluralize returns \"s\" for counts != 1, empty string otherwise.\nfunc pluralize(count int) string {\n\tif count == 1 {\n\t\treturn \"\"\n\t}\n\treturn \"s\"\n}\n"
  },
  {
    "path": "internal/log/log_test.go",
    "content": "package log\n\nimport (\n\t\"bytes\"\n\t\"fmt\"\n\t\"strings\"\n\t\"sync\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/briandowns/spinner\"\n\t\"github.com/stretchr/testify/assert\"\n)\n\nconst (\n\t// Test constants for concurrent access.\n\ttestConcurrentGoroutines   = 10\n\ttestOperationsPerGoroutine = 50\n)\n\nfunc TestLogger_SetName(t *testing.T) {\n\tfor name, tt := range map[string]struct {\n\t\tinitialNames   []string\n\t\tnameToAdd      string\n\t\texpectedNames  []string\n\t\texpectedSuffix string\n\t}{\n\t\t\"add first name\": {\n\t\t\tinitialNames:   []string{},\n\t\t\tnameToAdd:      \"test-hook\",\n\t\t\texpectedNames:  []string{\"test-hook\"},\n\t\t\texpectedSuffix: \" waiting: test-hook\",\n\t\t},\n\t\t\"add second name\": {\n\t\t\tinitialNames:   []string{\"first-hook\"},\n\t\t\tnameToAdd:      \"second-hook\",\n\t\t\texpectedNames:  []string{\"first-hook\", \"second-hook\"},\n\t\t\texpectedSuffix: \" waiting: first-hook, second-hook\",\n\t\t},\n\t\t\"add multiple names\": {\n\t\t\tinitialNames:   []string{\"hook1\", \"hook2\"},\n\t\t\tnameToAdd:      \"hook3\",\n\t\t\texpectedNames:  []string{\"hook1\", \"hook2\", \"hook3\"},\n\t\t\texpectedSuffix: \" waiting: hook1, hook2, hook3\",\n\t\t},\n\t\t\"add empty name\": {\n\t\t\tinitialNames:   []string{\"existing\"},\n\t\t\tnameToAdd:      \"\",\n\t\t\texpectedNames:  []string{\"existing\", \"\"},\n\t\t\texpectedSuffix: \" waiting: existing, \",\n\t\t},\n\t\t\"add duplicate name\": {\n\t\t\tinitialNames:   []string{\"hook1\"},\n\t\t\tnameToAdd:      \"hook1\",\n\t\t\texpectedNames:  []string{\"hook1\", \"hook1\"},\n\t\t\texpectedSuffix: \" waiting: hook1, hook1\",\n\t\t},\n\t\t\"add name with spaces\": {\n\t\t\tinitialNames:   []string{},\n\t\t\tnameToAdd:      \"hook with spaces\",\n\t\t\texpectedNames:  []string{\"hook with spaces\"},\n\t\t\texpectedSuffix: \" waiting: hook with spaces\",\n\t\t},\n\t\t\"add name with unicode\": {\n\t\t\tinitialNames:   []string{},\n\t\t\tnameToAdd:      \"🥊-hook\",\n\t\t\texpectedNames:  []string{\"🥊-hook\"},\n\t\t\texpectedSuffix: \" waiting: 🥊-hook\",\n\t\t},\n\t} {\n\t\tt.Run(name, func(t *testing.T) {\n\t\t\tassert := assert.New(t)\n\t\t\tlogger := createTestLogger()\n\n\t\t\t// Set up initial state\n\t\t\tlogger.names = make([]string, len(tt.initialNames))\n\t\t\tcopy(logger.names, tt.initialNames)\n\n\t\t\t// Call SetName\n\t\t\tlogger.SetName(tt.nameToAdd)\n\n\t\t\t// Verify names slice\n\t\t\tassert.Equal(tt.expectedNames, logger.names)\n\n\t\t\t// Verify spinner suffix\n\t\t\tassert.Equal(tt.expectedSuffix, logger.spinner.Suffix)\n\t\t})\n\t}\n}\n\nfunc TestLogger_UnsetName(t *testing.T) {\n\tfor name, tt := range map[string]struct {\n\t\tinitialNames   []string\n\t\tnameToRemove   string\n\t\texpectedNames  []string\n\t\texpectedSuffix string\n\t}{\n\t\t\"remove only name\": {\n\t\t\tinitialNames:   []string{\"test-hook\"},\n\t\t\tnameToRemove:   \"test-hook\",\n\t\t\texpectedNames:  []string{},\n\t\t\texpectedSuffix: \" waiting\",\n\t\t},\n\t\t\"remove first of two names\": {\n\t\t\tinitialNames:   []string{\"first-hook\", \"second-hook\"},\n\t\t\tnameToRemove:   \"first-hook\",\n\t\t\texpectedNames:  []string{\"second-hook\"},\n\t\t\texpectedSuffix: \" waiting: second-hook\",\n\t\t},\n\t\t\"remove second of two names\": {\n\t\t\tinitialNames:   []string{\"first-hook\", \"second-hook\"},\n\t\t\tnameToRemove:   \"second-hook\",\n\t\t\texpectedNames:  []string{\"first-hook\"},\n\t\t\texpectedSuffix: \" waiting: first-hook\",\n\t\t},\n\t\t\"remove middle name\": {\n\t\t\tinitialNames:   []string{\"hook1\", \"hook2\", \"hook3\"},\n\t\t\tnameToRemove:   \"hook2\",\n\t\t\texpectedNames:  []string{\"hook1\", \"hook3\"},\n\t\t\texpectedSuffix: \" waiting: hook1, hook3\",\n\t\t},\n\t\t\"remove non-existent name\": {\n\t\t\tinitialNames:   []string{\"hook1\", \"hook2\"},\n\t\t\tnameToRemove:   \"hook3\",\n\t\t\texpectedNames:  []string{\"hook1\", \"hook2\"},\n\t\t\texpectedSuffix: \" waiting: hook1, hook2\",\n\t\t},\n\t\t\"remove from empty list\": {\n\t\t\tinitialNames:   []string{},\n\t\t\tnameToRemove:   \"hook1\",\n\t\t\texpectedNames:  []string{},\n\t\t\texpectedSuffix: \" waiting\",\n\t\t},\n\t\t\"remove empty name\": {\n\t\t\tinitialNames:   []string{\"hook1\", \"\", \"hook2\"},\n\t\t\tnameToRemove:   \"\",\n\t\t\texpectedNames:  []string{\"hook1\", \"hook2\"},\n\t\t\texpectedSuffix: \" waiting: hook1, hook2\",\n\t\t},\n\t\t\"remove all duplicates\": {\n\t\t\tinitialNames:   []string{\"hook1\", \"hook1\", \"hook2\"},\n\t\t\tnameToRemove:   \"hook1\",\n\t\t\texpectedNames:  []string{\"hook2\"},\n\t\t\texpectedSuffix: \" waiting: hook2\",\n\t\t},\n\t\t\"remove unicode name\": {\n\t\t\tinitialNames:   []string{\"🥊-hook\", \"normal-hook\"},\n\t\t\tnameToRemove:   \"🥊-hook\",\n\t\t\texpectedNames:  []string{\"normal-hook\"},\n\t\t\texpectedSuffix: \" waiting: normal-hook\",\n\t\t},\n\t} {\n\t\tt.Run(name, func(t *testing.T) {\n\t\t\tassert := assert.New(t)\n\t\t\tlogger := createTestLogger()\n\n\t\t\t// Set up initial state\n\t\t\tlogger.names = make([]string, len(tt.initialNames))\n\t\t\tcopy(logger.names, tt.initialNames)\n\n\t\t\t// Call UnsetName\n\t\t\tlogger.UnsetName(tt.nameToRemove)\n\n\t\t\t// Verify names slice\n\t\t\tassert.Equal(tt.expectedNames, logger.names)\n\n\t\t\t// Verify spinner suffix\n\t\t\tassert.Equal(tt.expectedSuffix, logger.spinner.Suffix)\n\t\t})\n\t}\n}\n\nfunc TestLogger_SetName_UnsetName_Integration(t *testing.T) {\n\tassert := assert.New(t)\n\tlogger := createTestLogger()\n\n\t// Start with empty state\n\tassert.Equal([]string{}, logger.names)\n\tassert.Equal(\" waiting\", logger.spinner.Suffix)\n\n\t// Add first name\n\tlogger.SetName(\"hook1\")\n\tassert.Equal([]string{\"hook1\"}, logger.names)\n\tassert.Equal(\" waiting: hook1\", logger.spinner.Suffix)\n\n\t// Add second name\n\tlogger.SetName(\"hook2\")\n\tassert.Equal([]string{\"hook1\", \"hook2\"}, logger.names)\n\tassert.Equal(\" waiting: hook1, hook2\", logger.spinner.Suffix)\n\n\t// Add third name\n\tlogger.SetName(\"hook3\")\n\tassert.Equal([]string{\"hook1\", \"hook2\", \"hook3\"}, logger.names)\n\tassert.Equal(\" waiting: hook1, hook2, hook3\", logger.spinner.Suffix)\n\n\t// Remove middle name\n\tlogger.UnsetName(\"hook2\")\n\tassert.Equal([]string{\"hook1\", \"hook3\"}, logger.names)\n\tassert.Equal(\" waiting: hook1, hook3\", logger.spinner.Suffix)\n\n\t// Remove first name\n\tlogger.UnsetName(\"hook1\")\n\tassert.Equal([]string{\"hook3\"}, logger.names)\n\tassert.Equal(\" waiting: hook3\", logger.spinner.Suffix)\n\n\t// Remove last name\n\tlogger.UnsetName(\"hook3\")\n\tassert.Equal([]string{}, logger.names)\n\tassert.Equal(\" waiting\", logger.spinner.Suffix)\n}\n\nfunc TestLogger_LongHookNames(t *testing.T) {\n\tassert := assert.New(t)\n\tlogger := createTestLogger()\n\n\t// This test documents current behavior that causes terminal wrapping.\n\t// See issue #1144 for planned terminal width handling.\n\t// Test with very long hook names that would exceed typical terminal width\n\tlongNames := []string{\n\t\t\"very-long-hook-name-that-exceeds-normal-terminal-width-and-would-cause-wrapping-issues\",\n\t\t\"another-extremely-long-hook-name-with-many-hyphens-and-descriptive-text-that-goes-on-and-on\",\n\t\t\"packwerk_check_unused_dependencies_and_validate_all_boundaries_with_strict_mode_enabled\",\n\t\t\"eslint_with_typescript_support_and_custom_rules_for_react_components_and_styled_components\",\n\t}\n\n\t// Add all long names\n\tfor _, name := range longNames {\n\t\tlogger.SetName(name)\n\t}\n\n\t// Verify all names are stored\n\tassert.Equal(longNames, logger.names)\n\n\t// Verify the suffix contains all names (this is the current behavior that causes the issue)\n\texpectedSuffix := \" waiting: \" + strings.Join(longNames, \", \")\n\tassert.Equal(expectedSuffix, logger.spinner.Suffix)\n\n\t// Document the current problematic behavior\n\tt.Logf(\"Current suffix length: %d characters\", len(logger.spinner.Suffix))\n\tt.Logf(\"This would cause wrapping issues in terminals narrower than %d columns\", len(logger.spinner.Suffix))\n}\n\nfunc TestLogger_ConcurrentAccess(t *testing.T) {\n\tassert := assert.New(t)\n\tlogger := createTestLogger()\n\n\tvar wg sync.WaitGroup\n\n\t// Start goroutines that concurrently add and remove names\n\tfor i := range testConcurrentGoroutines {\n\t\twg.Add(1)\n\t\tgo func(id int) {\n\t\t\tdefer wg.Done()\n\n\t\t\tfor j := range testOperationsPerGoroutine {\n\t\t\t\thookName := fmt.Sprintf(\"hook-%d-%d\", id, j)\n\n\t\t\t\t// Add name\n\t\t\t\tlogger.SetName(hookName)\n\n\t\t\t\t// Small delay to increase chance of race conditions\n\t\t\t\ttime.Sleep(time.Microsecond)\n\n\t\t\t\t// Remove name\n\t\t\t\tlogger.UnsetName(hookName)\n\t\t\t}\n\t\t}(i)\n\t}\n\n\twg.Wait()\n\n\t// After all operations, names slice should be empty\n\tassert.Equal([]string{}, logger.names)\n\tassert.Equal(\" waiting\", logger.spinner.Suffix)\n}\n\nfunc TestLogger_SpinnerActiveHandling(t *testing.T) {\n\tassert := assert.New(t)\n\tlogger := createTestLogger()\n\n\t// Test that SetName and UnsetName don't panic when spinner is active\n\tlogger.spinner.Start()\n\tinitialActive := logger.spinner.Active()\n\n\t// SetName should handle active spinner without panicking\n\tlogger.SetName(\"test-hook\")\n\tassert.Equal([]string{\"test-hook\"}, logger.names)\n\tassert.Equal(\" waiting: test-hook\", logger.spinner.Suffix)\n\n\t// UnsetName should handle active spinner without panicking\n\tlogger.UnsetName(\"test-hook\")\n\tassert.Equal([]string{}, logger.names)\n\tassert.Equal(\" waiting\", logger.spinner.Suffix)\n\n\t// Clean up\n\tlogger.spinner.Stop()\n\n\t// Document the behavior for future reference\n\tt.Logf(\"Spinner was initially active: %v\", initialActive)\n}\n\nfunc TestGlobalSetNameUnsetName(t *testing.T) {\n\tassert := assert.New(t)\n\n\t// Test the global functions that use the standard logger\n\toriginalNames := make([]string, len(std.names))\n\tcopy(originalNames, std.names)\n\toriginalSuffix := std.spinner.Suffix\n\n\t// Clean up after test\n\tdefer func() {\n\t\tstd.names = originalNames\n\t\tstd.spinner.Suffix = originalSuffix\n\t}()\n\n\t// Reset to clean state\n\tstd.names = []string{}\n\tstd.spinner.Suffix = \" waiting\"\n\n\t// Test global SetName\n\tSetName(\"global-hook\")\n\tassert.Equal([]string{\"global-hook\"}, std.names)\n\tassert.Equal(\" waiting: global-hook\", std.spinner.Suffix)\n\n\t// Test global UnsetName\n\tUnsetName(\"global-hook\")\n\tassert.Equal([]string{}, std.names)\n\tassert.Equal(\" waiting\", std.spinner.Suffix)\n}\n\n// Helper function to create a test logger.\nfunc createTestLogger() *Logger {\n\treturn &Logger{\n\t\tlevel:  InfoLevel,\n\t\tout:    &bytes.Buffer{},\n\t\tcolors: ColorOff,\n\t\tnames:  []string{},\n\t\tspinner: spinner.New(\n\t\t\tspinner.CharSets[spinnerCharSet],\n\t\t\tspinnerRefreshRate,\n\t\t\tspinner.WithSuffix(spinnerText),\n\t\t),\n\t}\n}\n\n// Terminal width handling tests.\nfunc TestLogger_FormatSpinnerSuffix(t *testing.T) {\n\ttests := []struct {\n\t\tname          string\n\t\tnames         []string\n\t\tterminalWidth int\n\t\texpected      string\n\t}{\n\t\t{\n\t\t\tname:          \"empty names\",\n\t\t\tnames:         []string{},\n\t\t\tterminalWidth: 80,\n\t\t\texpected:      \" waiting\",\n\t\t},\n\t\t{\n\t\t\tname:          \"single short name fits\",\n\t\t\tnames:         []string{\"test\"},\n\t\t\tterminalWidth: 80,\n\t\t\texpected:      \" waiting: test\",\n\t\t},\n\t\t{\n\t\t\tname:          \"multiple short names fit\",\n\t\t\tnames:         []string{\"hook1\", \"hook2\", \"hook3\"},\n\t\t\tterminalWidth: 80,\n\t\t\texpected:      \" waiting: hook1, hook2, hook3\",\n\t\t},\n\t\t{\n\t\t\tname:          \"names too long, show count\",\n\t\t\tnames:         []string{\"very-long-hook-name-1\", \"very-long-hook-name-2\", \"very-long-hook-name-3\"},\n\t\t\tterminalWidth: 30,\n\t\t\texpected:      \" waiting: 3 hooks\",\n\t\t},\n\t\t{\n\t\t\tname:          \"single hook, singular\",\n\t\t\tnames:         []string{\"hook1\"},\n\t\t\tterminalWidth: 20,\n\t\t\texpected:      \" waiting: 1 hook\",\n\t\t},\n\t\t{\n\t\t\tname:          \"short names all fit in available width\",\n\t\t\tnames:         []string{\"a\", \"b\", \"c\", \"d\", \"e\"},\n\t\t\tterminalWidth: 35, // All short names fit\n\t\t\texpected:      \" waiting: a, b, c, d, e\",\n\t\t},\n\t\t{\n\t\t\tname:          \"names too wide, fallback to count\",\n\t\t\tnames:         []string{\"hook\", \"test\", \"verylongname\", \"another\", \"final\"},\n\t\t\tterminalWidth: 30, // Too narrow, shows count instead\n\t\t\texpected:      \" waiting: 5 hooks\",\n\t\t},\n\t\t{\n\t\t\tname:          \"unicode characters handled correctly\",\n\t\t\tnames:         []string{\"🥊-hook\", \"test\"},\n\t\t\tterminalWidth: 80,\n\t\t\texpected:      \" waiting: 🥊-hook, test\",\n\t\t},\n\t\t{\n\t\t\tname:          \"no terminal width constraint (width 0)\",\n\t\t\tnames:         []string{\"hook1\", \"hook2\", \"very-long-name\"},\n\t\t\tterminalWidth: 0,\n\t\t\texpected:      \" waiting: hook1, hook2, very-long-name\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tlogger := createTestLoggerWithWidth(tt.terminalWidth)\n\t\t\tresult := logger.formatSpinnerSuffix(tt.names)\n\n\t\t\t// Debug output for failing tests\n\t\t\tif result != tt.expected {\n\t\t\t\tt.Logf(\"Terminal width: %d\", tt.terminalWidth)\n\t\t\t\tt.Logf(\"Available width: %d\", tt.terminalWidth-10)\n\t\t\t\tt.Logf(\"Expected: %q\", tt.expected)\n\t\t\t\tt.Logf(\"Actual: %q\", result)\n\t\t\t}\n\n\t\t\tassert.Equal(t, tt.expected, result)\n\t\t})\n\t}\n}\n\nfunc TestLogger_FormatWithPartialNames(t *testing.T) {\n\ttests := []struct {\n\t\tname           string\n\t\tnames          []string\n\t\tavailableWidth int\n\t\texpectedResult string\n\t}{\n\t\t{\n\t\t\tname:           \"empty names\",\n\t\t\tnames:          []string{},\n\t\t\tavailableWidth: 50,\n\t\t\texpectedResult: \" waiting\",\n\t\t},\n\t\t{\n\t\t\tname:           \"all names fit\",\n\t\t\tnames:          []string{\"a\", \"b\", \"c\"},\n\t\t\tavailableWidth: 50,\n\t\t\texpectedResult: \" waiting: a, b, c\",\n\t\t},\n\t\t{\n\t\t\tname:           \"partial names fit\",\n\t\t\tnames:          []string{\"hook1\", \"hook2\", \"very-long-hook-name\"},\n\t\t\tavailableWidth: 30,\n\t\t\texpectedResult: \" waiting: hook1, ... (2 more)\",\n\t\t},\n\t\t{\n\t\t\tname:           \"very narrow width, show count only\",\n\t\t\tnames:          []string{\"hook1\", \"hook2\"},\n\t\t\tavailableWidth: 15,\n\t\t\texpectedResult: \" waiting: 2 hooks\",\n\t\t},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(tt.name, func(t *testing.T) {\n\t\t\tresult := formatWithPartialNames(tt.names, tt.availableWidth)\n\t\t\tassert.Equal(t, tt.expectedResult, result)\n\t\t})\n\t}\n}\n\nfunc TestPluralize(t *testing.T) {\n\ttests := [...]struct {\n\t\tcount    int\n\t\texpected string\n\t}{\n\t\t{0, \"s\"},\n\t\t{1, \"\"},\n\t\t{2, \"s\"},\n\t\t{10, \"s\"},\n\t}\n\n\tfor _, tt := range tests {\n\t\tt.Run(fmt.Sprintf(\"pluralize_%d\", tt.count), func(t *testing.T) {\n\t\t\tresult := pluralize(tt.count)\n\t\t\tassert.Equal(t, tt.expected, result)\n\t\t})\n\t}\n}\n\nfunc TestLogger_TerminalWidthIntegration(t *testing.T) {\n\t// Test the integration of SetName/UnsetName with terminal width handling\n\tlogger := createTestLoggerWithWidth(50) // Simulate 50-character terminal\n\n\t// Add hooks that would exceed width\n\tlongHooks := []string{\n\t\t\"very-long-hook-name-1\",\n\t\t\"very-long-hook-name-2\",\n\t\t\"very-long-hook-name-3\",\n\t\t\"very-long-hook-name-4\",\n\t}\n\n\tfor _, hook := range longHooks {\n\t\tlogger.SetName(hook)\n\t}\n\n\t// Should show count instead of all names\n\tassert.Contains(t, logger.spinner.Suffix, \"hooks\")\n\tassert.NotContains(t, logger.spinner.Suffix, \"very-long-hook-name-4\")\n\n\t// Remove some hooks\n\tlogger.UnsetName(\"very-long-hook-name-1\")\n\tlogger.UnsetName(\"very-long-hook-name-2\")\n\n\t// Should still be truncated\n\tassert.Contains(t, logger.spinner.Suffix, \"waiting:\")\n\n\t// Remove all hooks\n\tlogger.UnsetName(\"very-long-hook-name-3\")\n\tlogger.UnsetName(\"very-long-hook-name-4\")\n\n\t// Should be back to basic waiting\n\tassert.Equal(t, \" waiting\", logger.spinner.Suffix)\n}\n\n// Helper function to create a test logger with simulated terminal width.\nfunc createTestLoggerWithWidth(width int) *Logger {\n\treturn &Logger{\n\t\tlevel:         InfoLevel,\n\t\tout:           &bytes.Buffer{},\n\t\tcolors:        ColorOff,\n\t\tterminalWidth: width,\n\t\tnames:         []string{},\n\t\tspinner: spinner.New(\n\t\t\tspinner.CharSets[spinnerCharSet],\n\t\t\tspinnerRefreshRate,\n\t\t\tspinner.WithSuffix(spinnerText),\n\t\t),\n\t}\n}\n"
  },
  {
    "path": "internal/log/settings.go",
    "content": "package log\n\nimport (\n\t\"strings\"\n)\n\nconst (\n\tmeta = 1 << iota\n\tsuccess\n\tfailure\n\tsummary\n\tskips\n\texecution\n\texecutionOutput\n\texecutionInfo\n\temptySummary\n\tsetup\n)\n\nconst disableAll = 0\n\ntype LogSettings struct {\n\tbitmap int16\n}\n\nvar Settings LogSettings\n\nfunc InitSettings() {\n\tSettings = NewSettings()\n}\n\nfunc NewSettings() LogSettings {\n\treturn LogSettings{^disableAll}\n}\n\nfunc ApplySettings(enableTags string, enable any) {\n\tSettings.Apply(enableTags, enable)\n}\n\nfunc (s *LogSettings) Apply(enableTags string, enable any) {\n\tif enableTags == \"\" && (enable == nil || enable == \"\") {\n\t\ts.enableAll()\n\t\treturn\n\t}\n\n\tif enableOutput, ok := enable.(bool); ok && enableTags == \"\" {\n\t\tif enableOutput {\n\t\t\ts.enableAll()\n\t\t} else {\n\t\t\ts.disableAll()\n\t\t}\n\t\treturn\n\t}\n\n\tif enableOptions, ok := enable.([]any); ok {\n\t\tif len(enableOptions) != 0 {\n\t\t\ts.bitmap = disableAll\n\t\t}\n\n\t\tfor _, option := range enableOptions {\n\t\t\tif value, ok := option.(string); ok {\n\t\t\t\ts.enable(value)\n\t\t\t}\n\t\t}\n\t}\n\n\tif enableTags != \"\" {\n\t\ts.bitmap = disableAll\n\n\t\tfor tag := range strings.SplitSeq(enableTags, \",\") {\n\t\t\ts.enable(tag)\n\t\t}\n\t}\n}\n\nfunc (s *LogSettings) enable(setting string) {\n\tswitch setting {\n\tcase \"meta\":\n\t\ts.bitmap |= meta\n\tcase \"success\":\n\t\ts.bitmap |= success\n\tcase \"failure\":\n\t\ts.bitmap |= failure\n\tcase \"summary\":\n\t\ts.bitmap |= summary | success | failure\n\tcase \"skips\":\n\t\ts.bitmap |= skips\n\tcase \"execution\", \"jobs\":\n\t\ts.bitmap |= execution | executionOutput | executionInfo\n\tcase \"execution_out\", \"jobs_out\":\n\t\ts.bitmap |= executionOutput | execution\n\tcase \"execution_info\", \"jobs_info\":\n\t\ts.bitmap |= executionInfo | execution\n\tcase \"empty_summary\":\n\t\ts.bitmap |= emptySummary\n\tcase \"setup\":\n\t\ts.bitmap |= setup\n\t}\n}\n\nfunc (s *LogSettings) enableAll() {\n\ts.bitmap = ^disableAll\n}\n\nfunc (s *LogSettings) disableAll() {\n\ts.bitmap = failure\n}\n\n// Checks the state of params.\nfunc (s LogSettings) isEnable(option int16) bool {\n\treturn s.bitmap&option != 0\n}\n\nfunc (s LogSettings) LogSuccess() bool {\n\treturn s.isEnable(success)\n}\n\nfunc (s LogSettings) LogFailure() bool {\n\treturn s.isEnable(failure)\n}\n\nfunc (s LogSettings) LogSummary() bool {\n\treturn s.isEnable(summary)\n}\n\nfunc (s LogSettings) LogMeta() bool {\n\treturn s.isEnable(meta)\n}\n\nfunc (s LogSettings) LogExecution() bool {\n\treturn s.isEnable(execution)\n}\n\nfunc (s LogSettings) LogExecutionOutput() bool {\n\treturn s.isEnable(executionOutput)\n}\n\nfunc (s LogSettings) LogExecutionInfo() bool {\n\treturn s.isEnable(executionInfo)\n}\n\nfunc (s LogSettings) LogSkips() bool {\n\treturn s.isEnable(skips)\n}\n\nfunc (s LogSettings) LogEmptySummary() bool {\n\treturn s.isEnable(emptySummary)\n}\n\nfunc (s LogSettings) LogSetup() bool {\n\treturn s.isEnable(setup)\n}\n"
  },
  {
    "path": "internal/log/settings_test.go",
    "content": "package log\n\nimport (\n\t\"strconv\"\n\t\"testing\"\n)\n\nfunc TestSetting(t *testing.T) {\n\tfor i, tt := range [...]struct {\n\t\tenableTags     string\n\t\tenableSettings any\n\t\tresults        map[string]bool\n\t}{\n\t\t{\n\t\t\tenableTags:     \"\",\n\t\t\tenableSettings: []any{},\n\t\t\tresults: map[string]bool{\n\t\t\t\t\"meta\":           true,\n\t\t\t\t\"summary\":        true,\n\t\t\t\t\"success\":        true,\n\t\t\t\t\"failure\":        true,\n\t\t\t\t\"skips\":          true,\n\t\t\t\t\"execution\":      true,\n\t\t\t\t\"execution_out\":  true,\n\t\t\t\t\"execution_info\": true,\n\t\t\t\t\"empty_summary\":  true,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tenableTags:     \"\",\n\t\t\tenableSettings: false,\n\t\t\tresults: map[string]bool{\n\t\t\t\t\"failure\": true,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tenableTags:     \"\",\n\t\t\tenableSettings: []any{\"success\"},\n\t\t\tresults: map[string]bool{\n\t\t\t\t\"success\": true,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tenableTags:     \"\",\n\t\t\tenableSettings: []any{\"summary\"},\n\t\t\tresults: map[string]bool{\n\t\t\t\t\"summary\": true,\n\t\t\t\t\"success\": true,\n\t\t\t\t\"failure\": true,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tenableTags:     \"\",\n\t\t\tenableSettings: []any{\"failure\", \"execution\"},\n\t\t\tresults: map[string]bool{\n\t\t\t\t\"failure\":        true,\n\t\t\t\t\"execution\":      true,\n\t\t\t\t\"execution_info\": true,\n\t\t\t\t\"execution_out\":  true,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tenableTags:     \"\",\n\t\t\tenableSettings: []any{\"failure\", \"execution_out\"},\n\t\t\tresults: map[string]bool{\n\t\t\t\t\"failure\":       true,\n\t\t\t\t\"execution\":     true,\n\t\t\t\t\"execution_out\": true,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tenableTags:     \"\",\n\t\t\tenableSettings: []any{\"failure\", \"execution_info\"},\n\t\t\tresults: map[string]bool{\n\t\t\t\t\"failure\":        true,\n\t\t\t\t\"execution\":      true,\n\t\t\t\t\"execution_info\": true,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tenableTags: \"\",\n\t\t\tenableSettings: []any{\n\t\t\t\t\"meta\",\n\t\t\t\t\"summary\",\n\t\t\t\t\"success\",\n\t\t\t\t\"failure\",\n\t\t\t\t\"skips\",\n\t\t\t\t\"execution\",\n\t\t\t\t\"execution_out\",\n\t\t\t\t\"execution_info\",\n\t\t\t\t\"empty_summary\",\n\t\t\t},\n\t\t\tresults: map[string]bool{\n\t\t\t\t\"meta\":           true,\n\t\t\t\t\"summary\":        true,\n\t\t\t\t\"success\":        true,\n\t\t\t\t\"failure\":        true,\n\t\t\t\t\"skips\":          true,\n\t\t\t\t\"execution\":      true,\n\t\t\t\t\"execution_out\":  true,\n\t\t\t\t\"execution_info\": true,\n\t\t\t\t\"empty_summary\":  true,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tenableTags:     \"\",\n\t\t\tenableSettings: true,\n\t\t\tresults: map[string]bool{\n\t\t\t\t\"meta\":           true,\n\t\t\t\t\"summary\":        true,\n\t\t\t\t\"success\":        true,\n\t\t\t\t\"failure\":        true,\n\t\t\t\t\"skips\":          true,\n\t\t\t\t\"execution\":      true,\n\t\t\t\t\"execution_out\":  true,\n\t\t\t\t\"execution_info\": true,\n\t\t\t\t\"empty_summary\":  true,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tenableTags:     \"meta,summary,skips,empty_summary\",\n\t\t\tenableSettings: nil,\n\t\t\tresults: map[string]bool{\n\t\t\t\t\"meta\":          true,\n\t\t\t\t\"summary\":       true,\n\t\t\t\t\"success\":       true,\n\t\t\t\t\"failure\":       true,\n\t\t\t\t\"skips\":         true,\n\t\t\t\t\"empty_summary\": true,\n\t\t\t},\n\t\t},\n\t} {\n\t\tt.Run(strconv.Itoa(i), func(t *testing.T) {\n\t\t\tsettings := NewSettings()\n\t\t\tsettings.Apply(tt.enableTags, tt.enableSettings)\n\n\t\t\tif settings.LogMeta() != tt.results[\"meta\"] {\n\t\t\t\tt.Errorf(\"expected LogMeta to be %v\", tt.results[\"meta\"])\n\t\t\t}\n\n\t\t\tif settings.LogSuccess() != tt.results[\"success\"] {\n\t\t\t\tt.Errorf(\"expected LogSuccess to be %v\", tt.results[\"success\"])\n\t\t\t}\n\n\t\t\tif settings.LogFailure() != tt.results[\"failure\"] {\n\t\t\t\tt.Errorf(\"expected LogFailure to be %v\", tt.results[\"failure\"])\n\t\t\t}\n\n\t\t\tif settings.LogSummary() != tt.results[\"summary\"] {\n\t\t\t\tt.Errorf(\"expected LogSummary to be %v\", tt.results[\"summary\"])\n\t\t\t}\n\n\t\t\tif settings.LogExecution() != tt.results[\"execution\"] {\n\t\t\t\tt.Errorf(\"expected LogExecution to be %v\", tt.results[\"execution\"])\n\t\t\t}\n\n\t\t\tif settings.LogExecutionOutput() != tt.results[\"execution_out\"] {\n\t\t\t\tt.Errorf(\"expected LogExecutionOutput to be %v\", tt.results[\"execution_out\"])\n\t\t\t}\n\n\t\t\tif settings.LogExecutionInfo() != tt.results[\"execution_info\"] {\n\t\t\t\tt.Errorf(\"expected LogExecutionInfo to be %v\", tt.results[\"execution_info\"])\n\t\t\t}\n\n\t\t\tif settings.LogEmptySummary() != tt.results[\"empty_summary\"] {\n\t\t\t\tt.Errorf(\"expected LogEmptySummary to be %v\", tt.results[\"empty_summary\"])\n\t\t\t}\n\n\t\t\tif settings.LogSkips() != tt.results[\"skips\"] {\n\t\t\t\tt.Errorf(\"expected LogSkips to be %v\", tt.results[\"skip\"])\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/log/setup.go",
    "content": "package log\n\nimport (\n\t\"io\"\n\t\"os\"\n\n\t\"github.com/charmbracelet/lipgloss\"\n)\n\nfunc LogSetup(r io.Reader) {\n\tgo func() {\n\t\tif !Settings.LogSetup() {\n\t\t\t_, _ = io.Copy(io.Discard, r)\n\t\t\treturn\n\t\t}\n\n\t\tStyled().\n\t\t\tWithLeftBorder(lipgloss.ThickBorder(), ColorYellow).\n\t\t\tWithPadding(execLogPadding).\n\t\t\tInfo(Yellow(\"setup ❯ \"))\n\n\t\t_, _ = io.Copy(os.Stdout, r)\n\t}()\n}\n"
  },
  {
    "path": "internal/log/skip.go",
    "content": "package log\n\nimport (\n\t\"github.com/charmbracelet/lipgloss\"\n)\n\nconst skipLogPadding = 2\n\nfunc Skip(name, reason string) {\n\tif !Settings.LogSkips() {\n\t\treturn\n\t}\n\n\tStyled().\n\t\tWithLeftBorder(lipgloss.NormalBorder(), ColorCyan).\n\t\tWithPadding(skipLogPadding).\n\t\tInfo(\n\t\t\tCyan(Bold(name)) + \" \" +\n\t\t\t\tGray(\"(skip)\") + \" \" +\n\t\t\t\tYellow(reason),\n\t\t)\n}\n"
  },
  {
    "path": "internal/run/controller/command/build.go",
    "content": "package command\n\nimport (\n\t\"github.com/evilmartians/lefthook/v2/internal/config\"\n\t\"github.com/evilmartians/lefthook/v2/internal/git\"\n)\n\ntype JobParams struct {\n\tName         string\n\tRun          string\n\tRoot         string\n\tRunner       string\n\tArgs         string\n\tScript       string\n\tFilesCmd     string\n\tFileTypes    []string\n\tTags         []string\n\tGlob         []string\n\tExcludeFiles []string\n\tOnly         any\n\tSkip         any\n}\n\ntype BuilderOptions struct {\n\tHookName    string\n\tGitArgs     []string\n\tForceFiles  []string\n\tSourceDirs  []string\n\tTemplates   map[string]string\n\tGlobMatcher string\n\tForce       bool\n}\n\ntype Builder struct {\n\tgit  *git.Repository\n\topts BuilderOptions\n}\n\nfunc NewBuilder(repo *git.Repository, opts BuilderOptions) *Builder {\n\treturn &Builder{\n\t\tgit:  repo,\n\t\topts: opts,\n\t}\n}\n\n// BuildCommands returns the list of commands and the list of files touched by the command.\nfunc (b *Builder) BuildCommands(params *JobParams) ([]string, []string, error) {\n\tif len(params.Run) != 0 {\n\t\treturn b.buildCommand(params)\n\t} else {\n\t\treturn b.buildScript(params)\n\t}\n}\n\nfunc (p *JobParams) validateCommand() error {\n\tif !config.IsRunFilesCompatible(p.Run) {\n\t\treturn config.ErrFilesIncompatible\n\t}\n\n\treturn nil\n}\n\nfunc (p *JobParams) validateScript() error {\n\treturn nil\n}\n"
  },
  {
    "path": "internal/run/controller/command/build_command.go",
    "content": "package command\n\nimport (\n\t\"strings\"\n\n\t\"github.com/alessio/shellescape\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/config\"\n\t\"github.com/evilmartians/lefthook/v2/internal/run/controller/command/replacer\"\n\t\"github.com/evilmartians/lefthook/v2/internal/run/controller/filter\"\n\t\"github.com/evilmartians/lefthook/v2/internal/system\"\n)\n\nfunc (b *Builder) buildCommand(params *JobParams) ([]string, []string, error) {\n\tif err := params.validateCommand(); err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\treplacer := b.buildReplacer(params)\n\tfilter := b.buildFilter(params)\n\n\tcommand := strings.Join([]string{params.Run, params.Args}, \" \")\n\terr := replacer.Discover(command, filter)\n\tif err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\t// Checking substitutions and skipping execution if it is empty.\n\tif !b.opts.Force && replacer.HasEmpty() {\n\t\treturn nil, nil, SkipError{\"no files for inspection\"}\n\t}\n\n\t// Special case when `files` option specified but not referenced in `run`: return if the result is empty.\n\tif !b.opts.Force && len(params.FilesCmd) > 0 && replacer.Empty(config.SubFiles) {\n\t\tfiles, err := replacer.Files(config.SubFiles, filter)\n\t\tif err != nil {\n\t\t\treturn nil, nil, err\n\t\t}\n\n\t\tif len(files) == 0 {\n\t\t\treturn nil, nil, SkipError{\"no files for inspection\"}\n\t\t}\n\t}\n\n\tcommands, replacedFiles := replacer.ReplaceAndSplit(command, system.MaxCmdLen())\n\n\tif b.opts.Force || len(replacedFiles) != 0 {\n\t\treturn commands, replacedFiles, nil\n\t}\n\n\t// Skip if no files were staged (including deleted)\n\t//nolint:nestif\n\tif config.HookUsesStagedFiles(b.opts.HookName) {\n\t\tfiles, err := replacer.Files(config.SubStagedFiles, filter)\n\t\tif err != nil {\n\t\t\treturn nil, nil, err\n\t\t}\n\n\t\tif len(files) == 0 {\n\t\t\tfiles, err = b.git.StagedFilesWithDeleted()\n\t\t\tif err != nil {\n\t\t\t\treturn nil, nil, err\n\t\t\t}\n\n\t\t\tif len(filter.Apply(files)) == 0 {\n\t\t\t\treturn nil, nil, SkipError{\"no matching staged files\"}\n\t\t\t}\n\t\t}\n\t}\n\n\t// Skip if no files were to be pushed\n\tif config.HookUsesPushFiles(b.opts.HookName) {\n\t\tfiles, err := replacer.Files(config.SubPushFiles, filter)\n\t\tif err != nil {\n\t\t\treturn nil, nil, err\n\t\t}\n\n\t\tif len(files) == 0 {\n\t\t\treturn nil, nil, SkipError{\"no matching push files\"}\n\t\t}\n\t}\n\n\treturn commands, replacedFiles, nil\n}\n\n// buildReplacer creates the replacer with all supported templates for files and arguments.\nfunc (b *Builder) buildReplacer(params *JobParams) replacer.Replacer {\n\tvar r replacer.Replacer\n\tif len(b.opts.ForceFiles) > 0 {\n\t\tr = replacer.NewMocked(b.opts.ForceFiles)\n\t} else {\n\t\tr = replacer.New(b.git, params.Root, params.FilesCmd)\n\t}\n\n\treturn r.\n\t\tAddTemplates(b.opts.Templates).\n\t\tAddTemplates(map[string]string{\n\t\t\t\"lefthook_job_name\": shellescape.Quote(params.Name),\n\t\t}).\n\t\tAddGitArgs(b.opts.GitArgs)\n}\n\nfunc (b *Builder) buildFilter(params *JobParams) *filter.Filter {\n\treturn filter.New(b.git.Fs, filter.Params{\n\t\tGlob:         params.Glob,\n\t\tExcludeFiles: params.ExcludeFiles,\n\t\tRoot:         params.Root,\n\t\tFileTypes:    params.FileTypes,\n\t\tGlobMatcher:  b.opts.GlobMatcher,\n\t})\n}\n"
  },
  {
    "path": "internal/run/controller/command/build_script.go",
    "content": "package command\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/alessio/shellescape\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/log\"\n\t\"github.com/evilmartians/lefthook/v2/internal/run/controller/command/replacer\"\n\t\"github.com/evilmartians/lefthook/v2/internal/system\"\n)\n\nconst (\n\texecutableFileMode os.FileMode = 0o751\n\texecutableMask     os.FileMode = 0o111\n)\n\ntype scriptNotExistsError struct {\n\tscriptPath string\n}\n\nfunc (s scriptNotExistsError) Error() string {\n\treturn fmt.Sprintf(\"script does not exist: %s\", s.scriptPath)\n}\n\nfunc (b *Builder) buildScript(params *JobParams) ([]string, []string, error) {\n\tif err := params.validateScript(); err != nil {\n\t\treturn nil, nil, err\n\t}\n\n\tvar replacer replacer.Replacer\n\tif len(params.Args) > 0 {\n\t\treplacer = b.buildReplacer(params)\n\t\tfilter := b.buildFilter(params)\n\t\terr := replacer.Discover(params.Args, filter)\n\t\tif err != nil {\n\t\t\treturn nil, nil, err\n\t\t}\n\n\t\tif !b.opts.Force && replacer.HasEmpty() {\n\t\t\treturn nil, nil, SkipError{\"no files for inspection\"}\n\t\t}\n\t}\n\n\tvar scriptExists bool\n\texecs := make([]string, 0)\n\tfor _, sourceDir := range b.opts.SourceDirs {\n\t\tscriptPath := filepath.Join(sourceDir, b.opts.HookName, params.Script)\n\t\tfileInfo, err := b.git.Fs.Stat(scriptPath)\n\t\tif os.IsNotExist(err) {\n\t\t\tlog.Debugf(\"[lefthook] script doesn't exist: %s\", scriptPath)\n\t\t\tcontinue\n\t\t}\n\t\tif err != nil {\n\t\t\tlog.Errorf(\"Failed to get info about a script: %s\", params.Script)\n\t\t\treturn nil, nil, err\n\t\t}\n\n\t\tscriptExists = true\n\n\t\tif !fileInfo.Mode().IsRegular() {\n\t\t\tlog.Debugf(\"[lefthook] script '%s' is not a regular file, skipping\", scriptPath)\n\t\t\treturn nil, nil, SkipError{\"not a regular file\"}\n\t\t}\n\n\t\t// Make sure file is executable\n\t\tif (fileInfo.Mode() & executableMask) == 0 {\n\t\t\tif err := b.git.Fs.Chmod(scriptPath, executableFileMode); err != nil {\n\t\t\t\tlog.Errorf(\"Couldn't change file mode to make file executable: %s\", err)\n\t\t\t\treturn nil, nil, err\n\t\t\t}\n\t\t}\n\n\t\tvar args []string\n\t\tif len(params.Runner) > 0 {\n\t\t\targs = append(args, params.Runner)\n\t\t}\n\n\t\targs = append(args, shellescape.Quote(scriptPath))\n\t\tif len(params.Args) > 0 {\n\t\t\targs = append(args, params.Args)\n\t\t\tcommand := strings.Join(args, \" \")\n\t\t\tcommands, _ := replacer.ReplaceAndSplit(command, system.MaxCmdLen())\n\t\t\texecs = append(execs, commands...)\n\t\t} else {\n\t\t\targs = append(args, b.opts.GitArgs...)\n\t\t\texecs = append(execs, strings.Join(args, \" \"))\n\t\t}\n\t}\n\n\tif !scriptExists {\n\t\treturn nil, nil, scriptNotExistsError{params.Script}\n\t}\n\n\treturn execs, nil, nil\n}\n"
  },
  {
    "path": "internal/run/controller/command/replacer/replacer.go",
    "content": "package replacer\n\nimport (\n\t\"fmt\"\n\t\"regexp\"\n\t\"runtime\"\n\t\"strconv\"\n\t\"strings\"\n\n\t\"github.com/alessio/shellescape\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/config\"\n\t\"github.com/evilmartians/lefthook/v2/internal/git\"\n\t\"github.com/evilmartians/lefthook/v2/internal/log\"\n\t\"github.com/evilmartians/lefthook/v2/internal/run/controller/filter\"\n)\n\nvar surroundingQuotesRegexp = regexp.MustCompile(`^'(.*)'$`)\n\ntype entry struct {\n\titems []string\n\tcnt   int\n}\n\ntype Replacer struct {\n\tcache     map[string]*entry\n\tfiles     map[string]func() ([]string, error)\n\ttemplates map[string]string\n}\n\nfunc New(\n\tgit *git.Repository,\n\troot string,\n\tfilesCmd string,\n) Replacer {\n\tvar (\n\t\tstaged = git.StagedFiles\n\t\tpush   = git.PushFiles\n\t\tall    = git.AllFiles\n\t\tcmd    = func() ([]string, error) {\n\t\t\tvar cmd []string\n\t\t\tif runtime.GOOS == \"windows\" {\n\t\t\t\tcmd = strings.Split(filesCmd, \" \")\n\t\t\t} else {\n\t\t\t\tcmd = []string{\"sh\", \"-c\", filesCmd}\n\t\t\t}\n\t\t\treturn git.FindExistingFiles(cmd, root)\n\t\t}\n\t)\n\n\treturn Replacer{\n\t\tcache: make(map[string]*entry),\n\t\tfiles: map[string]func() ([]string, error){\n\t\t\tconfig.SubStagedFiles: staged,\n\t\t\tconfig.SubPushFiles:   push,\n\t\t\tconfig.SubAllFiles:    all,\n\t\t\tconfig.SubFiles:       cmd,\n\t\t},\n\t}\n}\n\nfunc (r Replacer) AddTemplates(templates map[string]string) Replacer {\n\tif r.templates == nil {\n\t\tr.templates = make(map[string]string)\n\t}\n\n\tfor key, replacement := range templates {\n\t\tr.templates[\"{\"+key+\"}\"] = replacement\n\t}\n\n\treturn r\n}\n\nfunc (r Replacer) AddGitArgs(args []string) Replacer {\n\tif r.templates == nil {\n\t\tr.templates = make(map[string]string)\n\t}\n\n\tr.templates[\"{0}\"] = strings.Join(args, \" \")\n\tfor i, arg := range args {\n\t\tr.templates[\"{\"+strconv.Itoa(i+1)+\"}\"] = arg\n\t}\n\n\treturn r\n}\n\nfunc NewMocked(files []string) Replacer {\n\tforceFilesFn := func() ([]string, error) { return files, nil } //nolint:unparam\n\n\treturn Replacer{\n\t\tcache: make(map[string]*entry),\n\t\tfiles: map[string]func() ([]string, error){\n\t\t\tconfig.SubStagedFiles: forceFilesFn,\n\t\t\tconfig.SubPushFiles:   forceFilesFn,\n\t\t\tconfig.SubAllFiles:    forceFilesFn,\n\t\t\tconfig.SubFiles:       forceFilesFn,\n\t\t},\n\t}\n}\n\n// Discover finds patterns in `source` and caches the results.\nfunc (r Replacer) Discover(source string, filter *filter.Filter) error {\n\tfor template, fn := range r.files {\n\t\tcnt := strings.Count(source, template)\n\t\tif cnt == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tfiles, err := fn()\n\t\tif err != nil {\n\t\t\treturn fmt.Errorf(\"error replacing %s: %w\", template, err)\n\t\t}\n\n\t\tfiles = filter.Apply(files)\n\n\t\tr.cache[template] = &entry{items: files, cnt: cnt}\n\t}\n\n\tfor template, replacement := range r.templates {\n\t\tcnt := strings.Count(source, template)\n\t\tif cnt == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tr.cache[template] = &entry{items: []string{replacement}, cnt: cnt}\n\t}\n\n\treturn nil\n}\n\nfunc (r Replacer) HasEmpty() bool {\n\tfor _, entry := range r.cache {\n\t\tif len(entry.items) == 0 {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n\nfunc (r Replacer) Cached(key string) bool {\n\t_, ok := r.cache[key]\n\n\treturn ok\n}\n\nfunc (r Replacer) Empty(key string) bool {\n\tentry, ok := r.cache[key]\n\tif !ok {\n\t\treturn true\n\t}\n\n\treturn len(entry.items) == 0\n}\n\nfunc (r Replacer) Files(template string, filter *filter.Filter) ([]string, error) {\n\tentry, ok := r.cache[template]\n\tif ok {\n\t\treturn entry.items, nil\n\t}\n\n\tfn, ok := r.files[template]\n\tif !ok {\n\t\tpanic(\"filtering: no such files template: \" + template)\n\t}\n\n\tfiles, err := fn()\n\tif err != nil {\n\t\treturn nil, err\n\t}\n\n\treturn filter.Apply(files), nil\n}\n\nfunc (r Replacer) ReplaceAndSplit(command string, maxlen int) ([]string, []string) {\n\tif len(r.cache) == 0 {\n\t\treturn []string{command}, nil\n\t}\n\n\tvar cnt int\n\n\tallFiles := make([]string, 0)\n\tfor template, entry := range r.cache {\n\t\tif entry.cnt == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\tcnt += entry.cnt\n\t\tmaxlen += entry.cnt * len(template)\n\t\tif _, ok := r.files[template]; ok {\n\t\t\tallFiles = append(allFiles, entry.items...)\n\t\t\t// Only escape file templates, not custom templates\n\t\t\tentry.items = escapeFiles(entry.items)\n\t\t}\n\t}\n\n\tmaxlen -= len(command)\n\n\tif cnt > 0 {\n\t\tmaxlen /= cnt\n\t}\n\n\tvar exhausted int\n\tcommands := make([]string, 0)\n\tfor {\n\t\tresult := command\n\t\tfor template, entry := range r.cache {\n\t\t\tadded, rest := getNChars(entry.items, maxlen)\n\t\t\tif len(rest) == 0 {\n\t\t\t\texhausted += 1\n\t\t\t} else {\n\t\t\t\tentry.items = rest\n\t\t\t}\n\t\t\tresult = replaceQuoted(result, template, added)\n\t\t}\n\n\t\tlog.Debug(\"[lefthook] job: \", result)\n\t\tcommands = append(commands, result)\n\t\tif exhausted >= len(r.cache) {\n\t\t\tbreak\n\t\t}\n\t}\n\n\treturn commands, allFiles\n}\n\n// Escape file names to prevent unexpected bugs.\nfunc escapeFiles(files []string) []string {\n\tvar filesEsc []string\n\tfor _, fileName := range files {\n\t\tif len(fileName) > 0 {\n\t\t\tfilesEsc = append(filesEsc, shellescape.Quote(fileName))\n\t\t}\n\t}\n\n\tlog.Builder(log.DebugLevel, \"[lefthook] \").\n\t\tAdd(\"files after escaping: \", filesEsc).\n\t\tLog()\n\n\treturn filesEsc\n}\n\nfunc getNChars(s []string, n int) ([]string, []string) {\n\tif len(s) == 0 {\n\t\treturn nil, nil\n\t}\n\n\tvar cnt int\n\tfor i, str := range s {\n\t\tcnt += len(str)\n\t\tif i > 0 {\n\t\t\tcnt += 1 // a space\n\t\t}\n\t\tif cnt > n {\n\t\t\tif i == 0 {\n\t\t\t\ti = 1\n\t\t\t}\n\t\t\treturn s[:i], s[i:]\n\t\t}\n\t}\n\n\treturn s, nil\n}\n\nfunc replaceQuoted(source, substitution string, files []string) string {\n\tfor _, elem := range [][]string{\n\t\t{\"\\\"\", \"\\\"\" + substitution + \"\\\"\"},\n\t\t{\"'\", \"'\" + substitution + \"'\"},\n\t\t{\"\", substitution},\n\t} {\n\t\tquote := elem[0]\n\t\tsub := elem[1]\n\t\tif !strings.Contains(source, sub) {\n\t\t\tcontinue\n\t\t}\n\n\t\tquotedFiles := files\n\t\tif len(quote) != 0 {\n\t\t\tquotedFiles = make([]string, 0, len(files))\n\t\t\tfor _, fileName := range files {\n\t\t\t\tquotedFiles = append(quotedFiles,\n\t\t\t\t\tquote+surroundingQuotesRegexp.ReplaceAllString(fileName, \"$1\")+quote)\n\t\t\t}\n\t\t}\n\n\t\tsource = strings.ReplaceAll(\n\t\t\tsource, sub, strings.Join(quotedFiles, \" \"),\n\t\t)\n\t}\n\n\treturn source\n}\n"
  },
  {
    "path": "internal/run/controller/command/replacer/replacer_test.go",
    "content": "package replacer\n\nimport (\n\t\"fmt\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/config\"\n\t\"github.com/evilmartians/lefthook/v2/internal/run/controller/filter\"\n)\n\nfunc Test_getNChars(t *testing.T) {\n\tfor i, tt := range [...]struct {\n\t\tsource, cut, rest []string\n\t\tn                 int\n\t}{\n\t\t{\n\t\t\tsource: []string{\"str1\", \"str2\", \"str3\"},\n\t\t\tn:      0,\n\t\t\tcut:    []string{\"str1\"},\n\t\t\trest:   []string{\"str2\", \"str3\"},\n\t\t},\n\t\t{\n\t\t\tsource: []string{\"str1\", \"str2\", \"str3\"},\n\t\t\tn:      4,\n\t\t\tcut:    []string{\"str1\"},\n\t\t\trest:   []string{\"str2\", \"str3\"},\n\t\t},\n\t\t{\n\t\t\tsource: []string{\"str1\", \"str2\", \"str3\"},\n\t\t\tn:      6,\n\t\t\tcut:    []string{\"str1\"},\n\t\t\trest:   []string{\"str2\", \"str3\"},\n\t\t},\n\t\t{\n\t\t\tsource: []string{\"str1\", \"str2\", \"str3\"},\n\t\t\tn:      8,\n\t\t\tcut:    []string{\"str1\"}, // because we need to add a space\n\t\t\trest:   []string{\"str2\", \"str3\"},\n\t\t},\n\t\t{\n\t\t\tsource: []string{\"str1\", \"str2\", \"str3\"},\n\t\t\tn:      9,\n\t\t\tcut:    []string{\"str1\", \"str2\"},\n\t\t\trest:   []string{\"str3\"},\n\t\t},\n\t\t{\n\t\t\tsource: []string{\"str1\", \"str2\", \"str3\"},\n\t\t\tn:      500,\n\t\t\tcut:    []string{\"str1\", \"str2\", \"str3\"},\n\t\t\trest:   nil,\n\t\t},\n\t\t{\n\t\t\tsource: nil,\n\t\t\tn:      2,\n\t\t\tcut:    nil,\n\t\t\trest:   nil,\n\t\t},\n\t} {\n\t\tt.Run(fmt.Sprintf(\"test %d\", i), func(t *testing.T) {\n\t\t\tassert := assert.New(t)\n\t\t\tcut, rest := getNChars(tt.source, tt.n)\n\n\t\t\tassert.EqualValues(cut, tt.cut)\n\t\t\tassert.EqualValues(rest, tt.rest)\n\t\t})\n\t}\n}\n\nfunc Test_ReplaceAndSplit(t *testing.T) {\n\ttype result struct {\n\t\tcommands []string\n\t\tfiles    []string\n\t}\n\tfor i, tt := range [...]struct {\n\t\tcommand string\n\t\tmaxlen  int\n\t\tcache   map[string]*entry\n\t\tresult  result\n\t}{\n\t\t{\n\t\t\tcommand: \"echo {staged_files}\",\n\t\t\tcache: map[string]*entry{\n\t\t\t\t\"{staged_files}\": {\n\t\t\t\t\titems: []string{\"file1\", \"file2\", \"file3\"},\n\t\t\t\t\tcnt:   1,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmaxlen: 300,\n\t\t\tresult: result{\n\t\t\t\tcommands: []string{\"echo file1 file2 file3\"},\n\t\t\t\tfiles:    []string{\"file1\", \"file2\", \"file3\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tcommand: \"echo {staged_files}\",\n\t\t\tcache: map[string]*entry{\n\t\t\t\t\"{staged_files}\": {\n\t\t\t\t\titems: []string{\"file1\", \"file2\", \"file3\"},\n\t\t\t\t\tcnt:   1,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmaxlen: 10,\n\t\t\tresult: result{\n\t\t\t\tcommands: []string{\n\t\t\t\t\t\"echo file1\",\n\t\t\t\t\t\"echo file2\",\n\t\t\t\t\t\"echo file3\",\n\t\t\t\t},\n\t\t\t\tfiles: []string{\"file1\", \"file2\", \"file3\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tcommand: \"echo {files} && git add {files}\",\n\t\t\tcache: map[string]*entry{\n\t\t\t\t\"{files}\": {\n\t\t\t\t\titems: []string{\"file1\", \"file2\", \"file3\"},\n\t\t\t\t\tcnt:   2,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmaxlen: 49, // (49 - 17(len of command without templates)) / 2 = 16, but we need 17 (3 words + 2 spaces)\n\t\t\tresult: result{\n\t\t\t\tcommands: []string{\n\t\t\t\t\t\"echo file1 file2 && git add file1 file2\",\n\t\t\t\t\t\"echo file3 && git add file3\",\n\t\t\t\t},\n\t\t\t\tfiles: []string{\"file1\", \"file2\", \"file3\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tcommand: \"echo {files} && git add {files}\",\n\t\t\tcache: map[string]*entry{\n\t\t\t\t\"{files}\": {\n\t\t\t\t\titems: []string{\"file1\", \"file2\", \"file3\"},\n\t\t\t\t\tcnt:   2,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmaxlen: 51,\n\t\t\tresult: result{\n\t\t\t\tcommands: []string{\n\t\t\t\t\t\"echo file1 file2 file3 && git add file1 file2 file3\",\n\t\t\t\t},\n\t\t\t\tfiles: []string{\"file1\", \"file2\", \"file3\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tcommand: \"echo {push_files} && git add {files}\",\n\t\t\tcache: map[string]*entry{\n\t\t\t\t\"{push_files}\": {\n\t\t\t\t\titems: []string{\"push-file\"},\n\t\t\t\t\tcnt:   1,\n\t\t\t\t},\n\t\t\t\t\"{files}\": {\n\t\t\t\t\titems: []string{\"file1\", \"file2\"},\n\t\t\t\t\tcnt:   1,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmaxlen: 10,\n\t\t\tresult: result{\n\t\t\t\tcommands: []string{\n\t\t\t\t\t\"echo push-file && git add file1\",\n\t\t\t\t\t\"echo push-file && git add file2\",\n\t\t\t\t},\n\t\t\t\tfiles: []string{\"push-file\", \"file1\", \"file2\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tcommand: \"echo {push_files} && git add {files}\",\n\t\t\tcache: map[string]*entry{\n\t\t\t\t\"{push_files}\": {\n\t\t\t\t\titems: []string{\"push1\", \"push2\", \"push3\"},\n\t\t\t\t\tcnt:   1,\n\t\t\t\t},\n\t\t\t\t\"{files}\": {\n\t\t\t\t\titems: []string{\"file1\", \"file2\"},\n\t\t\t\t\tcnt:   1,\n\t\t\t\t},\n\t\t\t},\n\t\t\tmaxlen: 27,\n\t\t\tresult: result{\n\t\t\t\tcommands: []string{\n\t\t\t\t\t\"echo push1 && git add file1\",\n\t\t\t\t\t\"echo push2 && git add file2\",\n\t\t\t\t\t\"echo push3 && git add file2\",\n\t\t\t\t},\n\t\t\t\tfiles: []string{\"push1\", \"push2\", \"push3\", \"file1\", \"file2\"},\n\t\t\t},\n\t\t},\n\t} {\n\t\tt.Run(fmt.Sprintf(\"test %d\", i), func(t *testing.T) {\n\t\t\tassert := assert.New(t)\n\t\t\tr := Replacer{\n\t\t\t\tcache: tt.cache,\n\t\t\t\tfiles: map[string]func() ([]string, error){\n\t\t\t\t\tconfig.SubStagedFiles: func() ([]string, error) { return nil, nil },\n\t\t\t\t\tconfig.SubPushFiles:   func() ([]string, error) { return nil, nil },\n\t\t\t\t\tconfig.SubAllFiles:    func() ([]string, error) { return nil, nil },\n\t\t\t\t\tconfig.SubFiles:       func() ([]string, error) { return nil, nil },\n\t\t\t\t},\n\t\t\t}\n\t\t\tcommands, files := r.ReplaceAndSplit(tt.command, tt.maxlen)\n\n\t\t\tassert.ElementsMatch(files, tt.result.files)\n\t\t\tassert.Equal(commands, tt.result.commands)\n\t\t})\n\t}\n}\n\nfunc Test_ReplaceAndSplit_CustomTemplates(t *testing.T) {\n\tt.Run(\"custom templates should not be escaped\", func(t *testing.T) {\n\t\tassert := assert.New(t)\n\n\t\t// Create a replacer with custom templates (note: keys include braces)\n\t\tr := NewMocked([]string{\"file1.js\"}).AddTemplates(\n\t\t\tmap[string]string{\n\t\t\t\t\"use-mise\": `eval \"$(mise env)\"`,\n\t\t\t},\n\t\t)\n\n\t\t// Discover templates in the command (use empty filter)\n\t\temptyFilter := &filter.Filter{}\n\t\terr := r.Discover(\"{use-mise} prettier {staged_files}\", emptyFilter)\n\t\tassert.NoError(err)\n\n\t\t// Replace templates\n\t\tcommands, files := r.ReplaceAndSplit(\"{use-mise} prettier {staged_files}\", 300)\n\n\t\t// Custom template should NOT be escaped (no quotes around it)\n\t\tassert.Equal([]string{`eval \"$(mise env)\" prettier file1.js`}, commands)\n\t\tassert.Equal([]string{\"file1.js\"}, files)\n\t})\n\n\tt.Run(\"file templates should still be escaped\", func(t *testing.T) {\n\t\tassert := assert.New(t)\n\n\t\t// Create a replacer with a file that needs escaping\n\t\tr := NewMocked([]string{\"file with spaces.js\"})\n\n\t\t// Discover templates in the command (use empty filter)\n\t\temptyFilter := &filter.Filter{}\n\t\terr := r.Discover(\"prettier {staged_files}\", emptyFilter)\n\t\tassert.NoError(err)\n\n\t\t// Replace templates\n\t\tcommands, _ := r.ReplaceAndSplit(\"prettier {staged_files}\", 300)\n\n\t\t// File template SHOULD be escaped (with quotes)\n\t\tassert.Equal([]string{`prettier 'file with spaces.js'`}, commands)\n\t})\n}\n\nfunc Test_replaceQuoted(t *testing.T) {\n\tfor i, tt := range [...]struct {\n\t\tname, source, substitution string\n\t\tfiles                      []string\n\t\tresult                     string\n\t}{\n\t\t{\n\t\t\tname:         \"without substitutions\",\n\t\t\tsource:       \"echo\",\n\t\t\tsubstitution: \"{staged_files}\",\n\t\t\tfiles:        []string{\"a\", \"b\"},\n\t\t\tresult:       \"echo\",\n\t\t},\n\t\t{\n\t\t\tname:         \"with simple substitution\",\n\t\t\tsource:       \"echo {staged_files}\",\n\t\t\tsubstitution: \"{staged_files}\",\n\t\t\tfiles:        []string{\"test.rb\", \"README\"},\n\t\t\tresult:       \"echo test.rb README\",\n\t\t},\n\t\t{\n\t\t\tname:         \"with single quoted substitution\",\n\t\t\tsource:       \"echo '{staged_files}'\",\n\t\t\tsubstitution: \"{staged_files}\",\n\t\t\tfiles:        []string{\"test.rb\", \"README\"},\n\t\t\tresult:       \"echo 'test.rb' 'README'\",\n\t\t},\n\t\t{\n\t\t\tname:         \"with double quoted substitution\",\n\t\t\tsource:       `echo \"{staged_files}\"`,\n\t\t\tsubstitution: \"{staged_files}\",\n\t\t\tfiles:        []string{\"test.rb\", \"README\"},\n\t\t\tresult:       `echo \"test.rb\" \"README\"`,\n\t\t},\n\t\t{\n\t\t\tname:         \"with escaped files double quoted\",\n\t\t\tsource:       `echo \"{staged_files}\"`,\n\t\t\tsubstitution: \"{staged_files}\",\n\t\t\tfiles:        []string{\"'test me.rb'\", \"README\"},\n\t\t\tresult:       `echo \"test me.rb\" \"README\"`,\n\t\t},\n\t\t{\n\t\t\tname:         \"with escaped files single quoted\",\n\t\t\tsource:       \"echo '{staged_files}'\",\n\t\t\tsubstitution: \"{staged_files}\",\n\t\t\tfiles:        []string{\"'test me.rb'\", \"README\"},\n\t\t\tresult:       `echo 'test me.rb' 'README'`,\n\t\t},\n\t\t{\n\t\t\tname:         \"with escaped files\",\n\t\t\tsource:       \"echo {staged_files}\",\n\t\t\tsubstitution: \"{staged_files}\",\n\t\t\tfiles:        []string{\"'test me.rb'\", \"README\"},\n\t\t\tresult:       `echo 'test me.rb' README`,\n\t\t},\n\t\t{\n\t\t\tname:         \"with many substitutions\",\n\t\t\tsource:       `echo \"{staged_files}\" {staged_files}`,\n\t\t\tsubstitution: \"{staged_files}\",\n\t\t\tfiles:        []string{\"'test me.rb'\", \"README\"},\n\t\t\tresult:       `echo \"test me.rb\" \"README\" 'test me.rb' README`,\n\t\t},\n\t} {\n\t\tt.Run(fmt.Sprintf(\"%d: %s\", i, tt.name), func(t *testing.T) {\n\t\t\tresult := replaceQuoted(tt.source, tt.substitution, tt.files)\n\t\t\tassert.Equal(t, result, tt.result)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/run/controller/command/skip_error.go",
    "content": "package command\n\n// SkipError implements error interface but indicates that the execution needs to be skipped.\ntype SkipError struct {\n\treason string\n}\n\nfunc (r SkipError) Error() string {\n\treturn r.reason\n}\n"
  },
  {
    "path": "internal/run/controller/controller.go",
    "content": "// Package controller handles ordering, filtering, substitutions while running\n// jobs for a given hook.\npackage controller\n\nimport (\n\t\"context\"\n\t\"io\"\n\t\"os\"\n\t\"strconv\"\n\t\"sync\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/config\"\n\t\"github.com/evilmartians/lefthook/v2/internal/git\"\n\t\"github.com/evilmartians/lefthook/v2/internal/log\"\n\t\"github.com/evilmartians/lefthook/v2/internal/run/controller/exec\"\n\t\"github.com/evilmartians/lefthook/v2/internal/run/controller/utils\"\n\t\"github.com/evilmartians/lefthook/v2/internal/run/result\"\n\t\"github.com/evilmartians/lefthook/v2/internal/system\"\n)\n\ntype Controller struct {\n\tgit         *git.Repository\n\tcachedStdin io.Reader\n\texecutor    exec.Executor\n\tcmd         system.CommandWithContext\n}\n\ntype Options struct {\n\tGitArgs           []string\n\tExcludeFiles      []string\n\tFiles             []string\n\tRunOnlyJobs       []string\n\tRunOnlyTags       []string\n\tSourceDirs        []string\n\tTemplates         map[string]string\n\tGlobMatcher       string\n\tDisableTTY        bool\n\tFailOnChanges     bool\n\tFailOnChangesDiff bool\n\tForce             bool\n\tSkipLFS           bool\n\tNoStageFixed      bool\n}\n\nfunc NewController(repo *git.Repository) *Controller {\n\treturn &Controller{\n\t\tgit: repo,\n\n\t\t// Some hooks use STDIN for parsing data from Git. To allow multiple commands\n\t\t// and scripts access the same Git data STDIN is cached via CachedReader.\n\t\tcachedStdin: utils.NewCachedReader(os.Stdin),\n\n\t\t// Executor interface for jobs\n\t\texecutor: exec.CommandExecutor{},\n\n\t\t// Command interface (for LFS hooks)\n\t\tcmd: system.Cmd,\n\t}\n}\n\nfunc (c *Controller) RunHook(ctx context.Context, opts Options, hook *config.Hook) ([]result.Result, error) {\n\tresults := make([]result.Result, 0, len(hook.Jobs))\n\n\tif config.NewSkipChecker(system.Cmd).Check(c.git.State, hook.Skip, hook.Only) {\n\t\tlog.Skip(hook.Name, \"hook setting\")\n\t\treturn results, nil\n\t}\n\n\tif !opts.SkipLFS {\n\t\tif err := c.runLFSHook(ctx, hook.Name, opts.GitArgs); err != nil {\n\t\t\treturn results, err\n\t\t}\n\t}\n\n\tif err := c.setup(ctx, opts, hook.Setup); err != nil {\n\t\tlog.Warnf(\"Failed to run setup: %s\\n\", err)\n\t}\n\n\tif !opts.DisableTTY && !hook.Follow {\n\t\tlog.StartSpinner()\n\t\tdefer log.StopSpinner()\n\t}\n\n\tguard := newGuard(c.git, !opts.NoStageFixed && config.HookUsesStagedFiles(hook.Name), opts.FailOnChanges, opts.FailOnChangesDiff)\n\tscope := newScope(hook, opts)\n\terr := guard.wrap(func() {\n\t\tif hook.Parallel {\n\t\t\tresults = c.concurrently(ctx, scope, hook.Jobs)\n\t\t} else {\n\t\t\tresults = c.sequentially(ctx, scope, hook.Jobs, hook.Piped)\n\t\t}\n\t})\n\n\treturn results, err\n}\n\nfunc (c *Controller) concurrently(ctx context.Context, scope *scope, jobs []*config.Job) []result.Result {\n\tvar wg sync.WaitGroup\n\n\tresults := make([]result.Result, 0, len(jobs))\n\tresultsChan := make(chan result.Result, len(jobs))\n\n\tfor i, job := range jobs {\n\t\tid := strconv.Itoa(i)\n\n\t\twg.Add(1)\n\t\tgo func(job *config.Job) {\n\t\t\tdefer wg.Done()\n\t\t\tresultsChan <- c.runJob(ctx, scope, id, job)\n\t\t}(job)\n\t}\n\n\twg.Wait()\n\tclose(resultsChan)\n\tfor result := range resultsChan {\n\t\tresults = append(results, result)\n\t}\n\n\treturn results\n}\n\nfunc (c *Controller) sequentially(ctx context.Context, scope *scope, jobs []*config.Job, piped bool) []result.Result {\n\tresults := make([]result.Result, 0, len(jobs))\n\tvar failPipe bool\n\n\tfor i, job := range jobs {\n\t\tid := strconv.Itoa(i)\n\n\t\tif piped && failPipe {\n\t\t\tlog.Skip(job.PrintableName(id), \"broken pipe\")\n\t\t\tcontinue\n\t\t}\n\n\t\tresult := c.runJob(ctx, scope, id, job)\n\t\tif piped && result.Failure() {\n\t\t\tfailPipe = true\n\t\t}\n\n\t\tresults = append(results, result)\n\t}\n\n\treturn results\n}\n"
  },
  {
    "path": "internal/run/controller/controller_test.go",
    "content": "package controller\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"io\"\n\t\"path/filepath\"\n\t\"regexp\"\n\t\"strings\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/spf13/afero\"\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/config\"\n\t\"github.com/evilmartians/lefthook/v2/internal/run/controller/exec\"\n\t\"github.com/evilmartians/lefthook/v2/internal/run/result\"\n\t\"github.com/evilmartians/lefthook/v2/tests/helpers/cmdtest\"\n\t\"github.com/evilmartians/lefthook/v2/tests/helpers/configtest\"\n\t\"github.com/evilmartians/lefthook/v2/tests/helpers/gittest\"\n)\n\ntype (\n\texecutor struct{}\n)\n\nfunc succeeded(name string) result.Result {\n\treturn result.Success(name, time.Second)\n}\n\nfunc failed(name, failText string) result.Result {\n\treturn result.Failure(name, failText, time.Second)\n}\n\nfunc (e executor) Execute(_ctx context.Context, opts exec.Options, _in io.Reader, _out io.Writer) (err error) {\n\tif strings.HasPrefix(opts.Commands[0], \"success\") {\n\t\terr = nil\n\t} else {\n\t\terr = errors.New(opts.Commands[0])\n\t}\n\n\treturn err\n}\n\n// func (g *gitCmd) WithoutEnvs(...string) system.Command {\n// \treturn g\n// }\n//\n// func (g *gitCmd) Run(cmd []string, _root string, _in io.Reader, out io.Writer, _errOut io.Writer) error {\n// \tg.mux.Lock()\n// \tg.commands = append(g.commands, strings.Join(cmd, \" \"))\n// \tg.mux.Unlock()\n//\n// \tcmdLine := strings.Join(cmd, \" \")\n// \tif cmdLine == \"git diff --name-only --cached --diff-filter=ACMR\" ||\n// \t\tcmdLine == \"git diff --name-only --cached --diff-filter=ACMRD\" ||\n// \t\tcmdLine == \"git diff --name-only HEAD @{push}\" {\n// \t\troot, _ := filepath.Abs(\"src\")\n// \t\t_, err := out.Write([]byte(strings.Join([]string{\n// \t\t\tfilepath.Join(root, \"scripts\", \"script.sh\"),\n// \t\t\tfilepath.Join(root, \"README.md\"),\n// \t\t}, \"\\n\")))\n// \t\tif err != nil {\n// \t\t\treturn err\n// \t\t}\n// \t}\n//\n// \treturn nil\n// }\n//\n// func (g *gitCmd) reset() {\n// \tg.mux.Lock()\n// \tg.commands = []string{}\n// \tg.mux.Unlock()\n// }\n\nfunc TestRunAll(t *testing.T) {\n\troot, err := filepath.Abs(\"src\")\n\tassert.NoError(t, err)\n\n\tgitPath := gittest.GitPath(root)\n\n\tfor name, tt := range map[string]struct {\n\t\tbranch, hookName string\n\t\targs             []string\n\t\tsourceDirs       []string\n\t\texistingFiles    []string\n\t\thook             *config.Hook\n\t\tsuccess, fail    []result.Result\n\t\tgitCommands      []string\n\t\tforce            bool\n\t\tskipLFS          bool\n\t}{\n\t\t\"empty hook\": {\n\t\t\thookName: \"post-commit\",\n\t\t\thook: configtest.ParseHook(`\n        piped: true\n      `),\n\t\t},\n\t\t\"with simple command\": {\n\t\t\thookName: \"post-commit\",\n\t\t\thook: configtest.ParseHook(`\n        jobs:\n          - name: test\n            run: success\n      `),\n\t\t\tsuccess: []result.Result{succeeded(\"test\")},\n\t\t},\n\t\t\"with simple command in follow mode\": {\n\t\t\thookName: \"post-commit\",\n\t\t\thook: configtest.ParseHook(`\n        follow: true\n        jobs:\n          - name: test\n            run: \"success\"\n      `),\n\t\t\tsuccess: []result.Result{succeeded(\"test\")},\n\t\t},\n\t\t\"with multiple commands ran in parallel\": {\n\t\t\thookName: \"post-commit\",\n\t\t\thook: configtest.ParseHook(`\n        jobs:\n          - name: test\n            run: success\n          - name: lint\n            run: success\n          - name: type-check\n            run: fail\n      `),\n\t\t\tsuccess: []result.Result{\n\t\t\t\tsucceeded(\"test\"),\n\t\t\t\tsucceeded(\"lint\"),\n\t\t\t},\n\t\t\tfail: []result.Result{failed(\"type-check\", \"\")},\n\t\t},\n\t\t\"with exclude tags\": {\n\t\t\thookName: \"post-commit\",\n\t\t\thook: configtest.ParseHook(`\n        exclude_tags: [test, formatter]\n        jobs:\n          - name: test\n            run: success\n          - name: formatter\n            run: success\n          - name: lint\n            run: success\n      `),\n\t\t\tsuccess: []result.Result{succeeded(\"lint\")},\n\t\t},\n\t\t\"with skip=true\": {\n\t\t\thookName: \"post-commit\",\n\t\t\thook: configtest.ParseHook(`\n        jobs:\n          - name: test\n            run: success\n            skip: true\n          - name: lint\n            run: success\n      `),\n\t\t\tsuccess: []result.Result{succeeded(\"lint\")},\n\t\t},\n\t\t\"with skip=merge\": {\n\t\t\thookName: \"post-commit\",\n\t\t\texistingFiles: []string{\n\t\t\t\tfilepath.Join(gitPath, \"MERGE_HEAD\"),\n\t\t\t},\n\t\t\thook: configtest.ParseHook(`\n        jobs:\n          - name: test\n            run: success\n            skip: merge\n          - name: lint\n            run: success\n      `),\n\t\t\tsuccess: []result.Result{succeeded(\"lint\")},\n\t\t},\n\t\t\"with only=merge match\": {\n\t\t\thookName: \"post-commit\",\n\t\t\texistingFiles: []string{\n\t\t\t\tfilepath.Join(gitPath, \"MERGE_HEAD\"),\n\t\t\t},\n\t\t\thook: configtest.ParseHook(`\n        jobs:\n          - name: test\n            run: success\n            only: merge\n          - name: lint\n            run: success\n            skip: merge\n      `),\n\t\t\tsuccess: []result.Result{\n\t\t\t\tsucceeded(\"test\"),\n\t\t\t},\n\t\t},\n\t\t\"with only=merge no match\": {\n\t\t\thookName: \"post-commit\",\n\t\t\thook: configtest.ParseHook(`\n        jobs:\n          - name: test\n            run: success\n            only: merge\n          - name: lint\n            run: success\n      `),\n\t\t\tgitCommands: []string{`git show --no-patch --format=\"%P\"`},\n\t\t\tsuccess:     []result.Result{succeeded(\"lint\")},\n\t\t},\n\t\t\"with hook's skip=merge match\": {\n\t\t\thookName: \"post-commit\",\n\t\t\texistingFiles: []string{\n\t\t\t\tfilepath.Join(gitPath, \"MERGE_HEAD\"),\n\t\t\t},\n\t\t\thook: configtest.ParseHook(`\n        skip: merge\n        jobs:\n          - name: test\n            run: success\n          - name: lint\n            run: success\n      `),\n\t\t\tsuccess: []result.Result{},\n\t\t},\n\t\t\"with hook's only=merge no match\": {\n\t\t\thookName: \"post-commit\",\n\t\t\thook: configtest.ParseHook(`\n        only: merge\n        jobs:\n          - name: test\n            run: success\n          - name: lint\n            run: success\n      `),\n\t\t\tgitCommands: []string{`git show --no-patch --format=\"%P\"`},\n\t\t\tsuccess:     []result.Result{},\n\t\t},\n\t\t\"with hook's only=merge match\": {\n\t\t\thookName: \"post-commit\",\n\t\t\texistingFiles: []string{\n\t\t\t\tfilepath.Join(gitPath, \"MERGE_HEAD\"),\n\t\t\t},\n\t\t\thook: configtest.ParseHook(`\n        only: merge\n        jobs:\n          - name: test\n            run: success\n          - name: lint\n            run: success\n      `),\n\t\t\tsuccess: []result.Result{\n\t\t\t\tsucceeded(\"lint\"),\n\t\t\t\tsucceeded(\"test\"),\n\t\t\t},\n\t\t},\n\t\t\"with skip=[merge, rebase] match rebase\": {\n\t\t\thookName: \"post-commit\",\n\t\t\texistingFiles: []string{\n\t\t\t\tfilepath.Join(gitPath, \"rebase-merge\"),\n\t\t\t\tfilepath.Join(gitPath, \"rebase-apply\"),\n\t\t\t},\n\t\t\thook: configtest.ParseHook(`\n        jobs:\n          - name: test\n            run: success\n            skip:\n              - merge\n              - rebase\n          - name: lint\n            run: success\n      `),\n\t\t\tsuccess: []result.Result{succeeded(\"lint\")},\n\t\t},\n\t\t\"with skip=ref match\": {\n\t\t\tbranch: \"main\",\n\t\t\texistingFiles: []string{\n\t\t\t\tfilepath.Join(gitPath, \"HEAD\"),\n\t\t\t},\n\t\t\thookName: \"post-commit\",\n\t\t\thook: configtest.ParseHook(`\n        skip:\n          - merge\n          - ref: main\n        jobs:\n          - name: test\n            run: success\n          - name: lint\n            run: success\n      `),\n\t\t\tgitCommands: []string{`git show --no-patch --format=\"%P\"`},\n\t\t\tsuccess:     []result.Result{},\n\t\t},\n\t\t\"with hook's only=ref match\": {\n\t\t\tbranch: \"main\",\n\t\t\texistingFiles: []string{\n\t\t\t\tfilepath.Join(gitPath, \"HEAD\"),\n\t\t\t},\n\t\t\thookName: \"post-commit\",\n\t\t\thook: configtest.ParseHook(`\n        only:\n          - merge\n          - ref: main\n        jobs:\n          - name: test\n            run: success\n          - name: lint\n            run: success\n      `),\n\t\t\tgitCommands: []string{`git show --no-patch --format=\"%P\"`},\n\t\t\tsuccess: []result.Result{\n\t\t\t\tsucceeded(\"lint\"),\n\t\t\t\tsucceeded(\"test\"),\n\t\t\t},\n\t\t},\n\t\t\"with hook's only=ref no match\": {\n\t\t\tbranch: \"develop\",\n\t\t\texistingFiles: []string{\n\t\t\t\tfilepath.Join(gitPath, \"HEAD\"),\n\t\t\t},\n\t\t\thookName: \"post-commit\",\n\t\t\thook: configtest.ParseHook(`\n        only:\n          - merge\n          - ref: main\n        jobs:\n          - name: test\n            run: success\n          - name: lint\n            run: success\n      `),\n\t\t\tgitCommands: []string{`git show --no-patch --format=\"%P\"`},\n\t\t\tsuccess:     []result.Result{},\n\t\t},\n\t\t\"with hook's skip=ref no match\": {\n\t\t\tbranch: \"fix\",\n\t\t\texistingFiles: []string{\n\t\t\t\tfilepath.Join(gitPath, \"HEAD\"),\n\t\t\t},\n\t\t\thookName: \"post-commit\",\n\t\t\thook: configtest.ParseHook(`\n        skip:\n          - merge\n          - ref: main\n        jobs:\n          - name: test\n            run: success\n          - name: lint\n            run: success\n      `),\n\t\t\tgitCommands: []string{`git show --no-patch --format=\"%P\"`},\n\t\t\tsuccess: []result.Result{\n\t\t\t\tsucceeded(\"test\"),\n\t\t\t\tsucceeded(\"lint\"),\n\t\t\t},\n\t\t},\n\t\t\"with fail\": {\n\t\t\thookName: \"post-commit\",\n\t\t\thook: configtest.ParseHook(`\n        jobs:\n          - name: test\n            run: fail\n            fail_text: try 'success'\n      `),\n\t\t\tfail: []result.Result{failed(\"test\", \"try 'success'\")},\n\t\t},\n\t\t\"with simple scripts\": {\n\t\t\tsourceDirs: []string{filepath.Join(root, config.DefaultSourceDir)},\n\t\t\texistingFiles: []string{\n\t\t\t\tfilepath.Join(root, config.DefaultSourceDir, \"post-commit\", \"script.sh\"),\n\t\t\t\tfilepath.Join(root, config.DefaultSourceDir, \"post-commit\", \"failing.js\"),\n\t\t\t},\n\t\t\thookName: \"post-commit\",\n\t\t\thook: configtest.ParseHook(`\n        jobs:\n          - script: \"script.sh\"\n            runner: success\n          - script: \"failing.js\"\n            runner: fail\n            fail_text: install node\n      `),\n\t\t\tsuccess: []result.Result{succeeded(\"script.sh\")},\n\t\t\tfail:    []result.Result{failed(\"failing.js\", \"install node\")},\n\t\t},\n\t\t\"with simple scripts and only=merge match\": {\n\t\t\tsourceDirs: []string{filepath.Join(root, config.DefaultSourceDir)},\n\t\t\texistingFiles: []string{\n\t\t\t\tfilepath.Join(root, config.DefaultSourceDir, \"post-commit\", \"script.sh\"),\n\t\t\t\tfilepath.Join(root, config.DefaultSourceDir, \"post-commit\", \"failing.js\"),\n\t\t\t\tfilepath.Join(gitPath, \"MERGE_HEAD\"),\n\t\t\t},\n\t\t\thookName: \"post-commit\",\n\t\t\thook: configtest.ParseHook(`\n        jobs:\n          - script: \"script.sh\"\n            runner: success\n            only: merge\n          - script: \"failing.js\"\n            only: merge\n            runner: fail\n            fail_text: install node\n      `),\n\t\t\tsuccess: []result.Result{succeeded(\"script.sh\")},\n\t\t\tfail:    []result.Result{failed(\"failing.js\", \"install node\")},\n\t\t},\n\t\t\"with simple scripts and only=merge no match\": {\n\t\t\tsourceDirs: []string{filepath.Join(root, config.DefaultSourceDir)},\n\t\t\texistingFiles: []string{\n\t\t\t\tfilepath.Join(root, config.DefaultSourceDir, \"post-commit\", \"script.sh\"),\n\t\t\t\tfilepath.Join(root, config.DefaultSourceDir, \"post-commit\", \"failing.js\"),\n\t\t\t},\n\t\t\thookName: \"post-commit\",\n\t\t\thook: configtest.ParseHook(`\n        jobs:\n          - script: \"script.sh\"\n            runner: success\n            only: merge\n          - script: \"failing.js\"\n            only: merge\n            runner: fail\n            fail_text: install node\n      `),\n\t\t\tgitCommands: []string{`git show --no-patch --format=\"%P\"`},\n\t\t\tsuccess:     []result.Result{},\n\t\t\tfail:        []result.Result{},\n\t\t},\n\t\t\"with interactive=true, parallel=true\": {\n\t\t\tsourceDirs: []string{filepath.Join(root, config.DefaultSourceDir)},\n\t\t\texistingFiles: []string{\n\t\t\t\tfilepath.Join(root, config.DefaultSourceDir, \"post-commit\", \"script.sh\"),\n\t\t\t\tfilepath.Join(root, config.DefaultSourceDir, \"post-commit\", \"failing.js\"),\n\t\t\t},\n\t\t\thookName: \"post-commit\",\n\t\t\thook: configtest.ParseHook(`\n        parallel: true\n        jobs:\n          - name: ok\n            run: success\n            interactive: true\n          - name: fail\n            run: fail\n          - script: \"script.sh\"\n            runner: success\n            interactive: true\n          - script: \"failing.js\"\n            runner: fail\n      `),\n\t\t\tsuccess: []result.Result{succeeded(\"ok\"), succeeded(\"script.sh\")},\n\t\t\tfail:    []result.Result{failed(\"failing.js\", \"\"), failed(\"fail\", \"\")},\n\t\t},\n\t\t\"with stage_fixed=true\": {\n\t\t\tsourceDirs: []string{filepath.Join(root, config.DefaultSourceDir)},\n\t\t\texistingFiles: []string{\n\t\t\t\tfilepath.Join(root, config.DefaultSourceDir, \"post-commit\", \"success.sh\"),\n\t\t\t\tfilepath.Join(root, config.DefaultSourceDir, \"post-commit\", \"failing.js\"),\n\t\t\t},\n\t\t\thookName: \"post-commit\",\n\t\t\thook: configtest.ParseHook(`\n        jobs:\n          - name: ok\n            run: success\n            stage_fixed: true\n          - name: fail\n            run: fail\n            stage_fixed: true\n          - script: \"success.sh\"\n            runner: success\n            stage_fixed: true\n          - script: \"failing.js\"\n            runner: fail\n            stage_fixed: true\n      `),\n\t\t\tsuccess: []result.Result{succeeded(\"ok\"), succeeded(\"success.sh\")},\n\t\t\tfail:    []result.Result{failed(\"fail\", \"\"), failed(\"failing.js\", \"\")},\n\t\t},\n\t\t\"with simple pre-commit\": {\n\t\t\thookName:   \"pre-commit\",\n\t\t\tsourceDirs: []string{filepath.Join(root, config.DefaultSourceDir)},\n\t\t\texistingFiles: []string{\n\t\t\t\tfilepath.Join(root, config.DefaultSourceDir, \"pre-commit\", \"success.sh\"),\n\t\t\t\tfilepath.Join(root, config.DefaultSourceDir, \"pre-commit\", \"failing.js\"),\n\t\t\t\tfilepath.Join(root, \"scripts\", \"script.sh\"),\n\t\t\t\tfilepath.Join(root, \"README.md\"),\n\t\t\t},\n\t\t\thook: configtest.ParseHook(`\n        jobs:\n          - name: ok\n            run: success\n            stage_fixed: true\n          - name: fail\n            run: fail\n            stage_fixed: true\n          - script: \"success.sh\"\n            runner: success\n            stage_fixed: true\n          - script: \"failing.js\"\n            runner: fail\n            stage_fixed: true\n      `),\n\t\t\tsuccess: []result.Result{succeeded(\"ok\"), succeeded(\"success.sh\")},\n\t\t\tfail:    []result.Result{failed(\"fail\", \"\"), failed(\"failing.js\", \"\")},\n\t\t\tgitCommands: []string{\n\t\t\t\t\"git status --short\",\n\t\t\t\t\"git diff --name-only --cached --diff-filter=ACMR\",\n\t\t\t\t\"git add --force -- .*script.sh.*README.md\",\n\t\t\t\t\"git add --force -- .*script.sh.*README.md\",\n\t\t\t},\n\t\t},\n\t\t\"with pre-commit skip\": {\n\t\t\thookName: \"pre-commit\",\n\t\t\texistingFiles: []string{\n\t\t\t\tfilepath.Join(root, \"README.md\"),\n\t\t\t},\n\t\t\thook: configtest.ParseHook(`\n        jobs:\n          - name: ok\n            run: success\n            stage_fixed: true\n            glob:\n              - \"*.md\"\n          - name: fail\n            run: fail\n            stage_fixed: true\n            glob:\n              - \"*.txt\"\n      `),\n\t\t\tsuccess: []result.Result{succeeded(\"ok\")},\n\t\t\tgitCommands: []string{\n\t\t\t\t\"git status --short\",\n\t\t\t\t\"git diff --name-only --cached --diff-filter=ACMR\",\n\t\t\t\t\"git add --force -- .*README.md\",\n\t\t\t\t\"git diff --name-only --cached --diff-filter=ACMRD\",\n\t\t\t},\n\t\t},\n\t\t\"with pre-commit skip but forced\": {\n\t\t\thookName: \"pre-commit\",\n\t\t\texistingFiles: []string{\n\t\t\t\tfilepath.Join(root, \"README.md\"),\n\t\t\t},\n\t\t\thook: configtest.ParseHook(`\n        jobs:\n          - name: ok\n            run: success\n            stage_fixed: true\n            glob:\n              - \"*.md\"\n          - name: fail\n            run: fail\n            stage_fixed: true\n            glob:\n              - \"*.sh\"\n      `),\n\t\t\tforce:   true,\n\t\t\tsuccess: []result.Result{succeeded(\"ok\")},\n\t\t\tfail:    []result.Result{failed(\"fail\", \"\")},\n\t\t\tgitCommands: []string{\n\t\t\t\t\"git status --short\",\n\t\t\t\t\"git diff --name-only --cached --diff-filter=ACMR\",\n\t\t\t\t\"git add --force -- .*README.md\",\n\t\t\t},\n\t\t},\n\t\t\"with pre-commit and stage_fixed=true under root\": {\n\t\t\thookName: \"pre-commit\",\n\t\t\texistingFiles: []string{\n\t\t\t\tfilepath.Join(root, \"scripts\", \"script.sh\"),\n\t\t\t\tfilepath.Join(root, \"README.md\"),\n\t\t\t},\n\t\t\thook: &config.Hook{\n\t\t\t\tJobs: []*config.Job{{\n\t\t\t\t\tName:       \"ok\",\n\t\t\t\t\tRun:        \"success\",\n\t\t\t\t\tRoot:       filepath.Join(root, \"scripts\"),\n\t\t\t\t\tStageFixed: true,\n\t\t\t\t}},\n\t\t\t},\n\t\t\tsuccess: []result.Result{succeeded(\"ok\")},\n\t\t\tgitCommands: []string{\n\t\t\t\t\"git status --short\",\n\t\t\t\t\"git diff --name-only --cached --diff-filter=ACMR\",\n\t\t\t\t\"git add --force -- .*scripts.*script.sh\",\n\t\t\t},\n\t\t},\n\t\t\"with pre-push skip\": {\n\t\t\thookName: \"pre-push\",\n\t\t\texistingFiles: []string{\n\t\t\t\tfilepath.Join(root, \"README.md\"),\n\t\t\t},\n\t\t\thook: configtest.ParseHook(`\n        jobs:\n          - name: ok\n            run: success\n            stage_fixed: true\n            glob:\n              - \"*.md\"\n          - name: fail\n            run: fail\n            stage_fixed: true\n            glob:\n              - \"*.sh\"\n      `),\n\t\t\tsuccess: []result.Result{succeeded(\"ok\")},\n\t\t\tgitCommands: []string{\n\t\t\t\t\"git diff --name-only HEAD @{push}\",\n\t\t\t\t\"git diff --name-only HEAD @{push}\",\n\t\t\t},\n\t\t},\n\t\t\"with LFS disabled\": {\n\t\t\thookName: \"post-checkout\",\n\t\t\tskipLFS:  true,\n\t\t\texistingFiles: []string{\n\t\t\t\tfilepath.Join(root, \"README.md\"),\n\t\t\t},\n\t\t\thook: configtest.ParseHook(`\n        jobs:\n          - name: ok\n            run: success\n      `),\n\t\t\tsuccess: []result.Result{succeeded(\"ok\")},\n\t\t},\n\t} {\n\t\tfs := afero.NewMemMapFs()\n\n\t\tcmdExecutor := cmdtest.NewTracking(func(command string, root string, out io.Writer) error {\n\t\t\tif command == \"git diff --name-only --cached --diff-filter=ACMR\" ||\n\t\t\t\tcommand == \"git diff --name-only --cached --diff-filter=ACMRD\" ||\n\t\t\t\tcommand == \"git diff --name-only HEAD @{push}\" {\n\t\t\t\troot, _ := filepath.Abs(\"src\")\n\t\t\t\t_, err := out.Write([]byte(strings.Join([]string{\n\t\t\t\t\tfilepath.Join(root, \"scripts\", \"script.sh\"),\n\t\t\t\t\tfilepath.Join(root, \"README.md\"),\n\t\t\t\t}, \"\\n\")))\n\t\t\t\tif err != nil {\n\t\t\t\t\treturn err\n\t\t\t\t}\n\t\t\t}\n\n\t\t\treturn nil\n\t\t})\n\n\t\trepo := gittest.NewRepositoryBuilder().\n\t\t\tRoot(root).\n\t\t\tCmd(cmdExecutor).\n\t\t\tFs(fs).\n\t\t\tBuild()\n\t\tcontroller := &Controller{\n\t\t\tgit:      repo,\n\t\t\texecutor: executor{},\n\t\t\tcmd:      cmdtest.NewTracking(nil), // lfs hooks ignored in this test\n\t\t}\n\t\tcmdExecutor.Reset()\n\n\t\tfor _, file := range tt.existingFiles {\n\t\t\tassert.NoError(t, fs.MkdirAll(filepath.Dir(file), 0o755))\n\t\t\tassert.NoError(t, afero.WriteFile(fs, file, []byte{}, 0o755))\n\t\t}\n\n\t\tif len(tt.branch) > 0 {\n\t\t\tassert.NoError(t, afero.WriteFile(fs, filepath.Join(repo.GitPath, \"HEAD\"), []byte(\"ref: refs/heads/\"+tt.branch), 0o644))\n\t\t}\n\n\t\tt.Run(name, func(t *testing.T) {\n\t\t\tassert := assert.New(t)\n\t\t\trepo.Setup()\n\t\t\tcmdExecutor.Reset()\n\n\t\t\topts := Options{\n\t\t\t\tGitArgs:    tt.args,\n\t\t\t\tForce:      tt.force,\n\t\t\t\tSkipLFS:    tt.skipLFS,\n\t\t\t\tSourceDirs: tt.sourceDirs,\n\t\t\t}\n\t\t\ttt.hook.Name = tt.hookName\n\t\t\tresults, err := controller.RunHook(t.Context(), opts, tt.hook)\n\t\t\tassert.NoError(err)\n\n\t\t\tvar success, fail []result.Result\n\t\t\tfor _, result := range results {\n\t\t\t\tif result.Success() {\n\t\t\t\t\tsuccess = append(success, succeeded(result.Name))\n\t\t\t\t} else if result.Failure() {\n\t\t\t\t\tfail = append(fail, failed(result.Name, result.Text()))\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tassert.ElementsMatch(success, tt.success)\n\t\t\tassert.ElementsMatch(fail, tt.fail)\n\n\t\t\tif len(tt.gitCommands) > 0 {\n\t\t\t\tassert.Len(cmdExecutor.Commands, len(tt.gitCommands))\n\t\t\t\tfor i, commandRe := range tt.gitCommands {\n\t\t\t\t\tre := regexp.MustCompile(commandRe)\n\t\t\t\t\tcommand := cmdExecutor.Commands[i]\n\t\t\t\t\tif !re.MatchString(command) {\n\t\t\t\t\t\tt.Errorf(\"wrong git command regexp #%d\\nExpected: %s\\nWas: %s\", i, commandRe, command)\n\t\t\t\t\t}\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/run/controller/exec/exec_unix.go",
    "content": "//go:build !windows\n\npackage exec\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\n\t\"github.com/creack/pty\"\n\t\"github.com/mattn/go-isatty\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/log\"\n)\n\ntype CommandExecutor struct{}\n\ntype executeArgs struct {\n\tin                    io.Reader\n\tout                   io.Writer\n\tenvs                  []string\n\troot                  string\n\tinteractive, useStdin bool\n}\n\nfunc (e CommandExecutor) Execute(ctx context.Context, opts Options, in io.Reader, out io.Writer) error {\n\tif opts.Interactive && !isatty.IsTerminal(os.Stdin.Fd()) {\n\t\ttty, err := os.Open(\"/dev/tty\")\n\t\tif err == nil {\n\t\t\tdefer func() {\n\t\t\t\tif cErr := tty.Close(); cErr != nil {\n\t\t\t\t\tlog.Warnf(\"Could not close TTY input: %s\\n\", cErr)\n\t\t\t\t}\n\t\t\t}()\n\t\t\tin = tty\n\t\t} else {\n\t\t\tlog.Errorf(\"Couldn't enable TTY input: %s\\n\", err)\n\t\t}\n\t}\n\n\troot, _ := filepath.Abs(opts.Root)\n\tenvs := make([]string, 0, len(opts.Env))\n\tfor name, value := range opts.Env {\n\t\tenvs = append(\n\t\t\tenvs,\n\t\t\tfmt.Sprintf(\"%s=%s\", name, os.ExpandEnv(value)),\n\t\t)\n\t}\n\tswitch log.Colors() {\n\tcase log.ColorOn:\n\t\tenvs = append(envs, \"CLICOLOR_FORCE=true\")\n\tcase log.ColorOff:\n\t\tenvs = append(envs, \"NO_COLOR=true\")\n\t}\n\n\targs := &executeArgs{\n\t\tin:          in,\n\t\tout:         out,\n\t\tenvs:        envs,\n\t\troot:        root,\n\t\tinteractive: opts.Interactive,\n\t\tuseStdin:    opts.UseStdin,\n\t}\n\n\t// We can have one command split into separate to fit into shell command max length.\n\t// In this case we execute those commands one by one.\n\tfor _, command := range opts.Commands {\n\t\tif err := e.execute(ctx, command, args); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (e CommandExecutor) execute(ctx context.Context, cmdstr string, args *executeArgs) error {\n\tlog.Debug(\"[lefthook] run: \", cmdstr)\n\tcommand := exec.CommandContext(ctx, \"sh\", \"-c\", cmdstr)\n\tcommand.Dir = args.root\n\tcommand.Env = append(os.Environ(), args.envs...)\n\n\tif args.interactive || args.useStdin {\n\t\tcommand.Stdout = args.out\n\t\tcommand.Stdin = args.in\n\t\tcommand.Stderr = os.Stderr\n\t\terr := command.Start()\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\t} else {\n\t\tp, err := pty.Start(command)\n\t\tif err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\tdefer func() { _ = p.Close() }()\n\n\t\t_, _ = io.Copy(args.out, p)\n\t}\n\n\tdefer func() { _ = command.Process.Kill() }()\n\n\treturn command.Wait()\n}\n"
  },
  {
    "path": "internal/run/controller/exec/exec_windows.go",
    "content": "package exec\n\nimport (\n\t\"context\"\n\t\"fmt\"\n\t\"io\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"syscall\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/log\"\n\t\"github.com/evilmartians/lefthook/v2/internal/system\"\n\n\t\"github.com/mattn/go-isatty\"\n\t\"github.com/mattn/go-tty\"\n)\n\ntype CommandExecutor struct{}\ntype executeArgs struct {\n\tin   io.Reader\n\tout  io.Writer\n\tenvs []string\n\troot string\n}\n\nfunc (e CommandExecutor) Execute(ctx context.Context, opts Options, in io.Reader, out io.Writer) error {\n\tif opts.Interactive && !isatty.IsTerminal(os.Stdin.Fd()) {\n\t\ttty, err := tty.Open()\n\t\tif err == nil {\n\t\t\tdefer tty.Close()\n\t\t\tin = tty.Input()\n\t\t} else {\n\t\t\tlog.Errorf(\"Couldn't enable TTY input: %s\\n\", err)\n\t\t}\n\t}\n\n\troot, _ := filepath.Abs(opts.Root)\n\tenvs := make([]string, len(opts.Env))\n\tfor name, value := range opts.Env {\n\t\tenvs = append(\n\t\t\tenvs,\n\t\t\tfmt.Sprintf(\"%s=%s\", name, os.ExpandEnv(value)),\n\t\t)\n\t}\n\tswitch log.Colors() {\n\tcase log.ColorOn:\n\t\tenvs = append(envs, \"CLICOLOR_FORCE=true\")\n\tcase log.ColorOff:\n\t\tenvs = append(envs, \"NO_COLOR=true\")\n\t}\n\n\targs := &executeArgs{\n\t\tin:   in,\n\t\tout:  out,\n\t\tenvs: envs,\n\t\troot: root,\n\t}\n\n\tfor _, command := range opts.Commands {\n\t\tif err := e.execute(ctx, command, args); err != nil {\n\t\t\treturn err\n\t\t}\n\t}\n\n\treturn nil\n}\n\nfunc (e CommandExecutor) execute(ctx context.Context, cmdstr string, args *executeArgs) error {\n\tsh, err := system.Sh()\n\tif err != nil {\n\t\tlog.Errorf(\"Couldn't find sh.exe: %s\\n\", err)\n\t\treturn err\n\t}\n\n\t// This change is breaking but might be useful. Consider quoting if it fixes all possible\n\t// options for {staged_files}, '{staged_files}', and \"{staged_files}\".\n\t// cmdStrQuoted := strings.ReplaceAll(strings.ReplaceAll(cmdstr, \"\\\\\", \"\\\\\\\\\"), \"\\\"\", \"\\\\\\\"\")\n\tcmdLine := \"\\\"\" + sh + \"\\\"\" + \" -c \" + \"\\\"\" + cmdstr + \"\\\"\"\n\tlog.Debug(\"[lefthook] run: \", cmdLine)\n\n\tcommand := exec.CommandContext(ctx, sh)\n\tcommand.SysProcAttr = &syscall.SysProcAttr{\n\t\tCmdLine: cmdLine,\n\t}\n\tcommand.Dir = args.root\n\tcommand.Env = append(os.Environ(), args.envs...)\n\n\tcommand.Stdout = args.out\n\tcommand.Stdin = args.in\n\tcommand.Stderr = os.Stderr\n\terr = command.Start()\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tdefer func() { _ = command.Process.Kill() }()\n\n\treturn command.Wait()\n}\n"
  },
  {
    "path": "internal/run/controller/exec/executor.go",
    "content": "package exec\n\nimport (\n\t\"context\"\n\t\"io\"\n)\n\n// Options contains the data that controls the execution.\ntype Options struct {\n\tRoot                  string\n\tCommands              []string\n\tEnv                   map[string]string\n\tInteractive, UseStdin bool\n}\n\n// Executor provides an interface for command execution.\n// It is used here for testing purpose mostly.\ntype Executor interface {\n\tExecute(context.Context, Options, io.Reader, io.Writer) error\n}\n"
  },
  {
    "path": "internal/run/controller/filter/detect_text.go",
    "content": "package filter\n\nimport (\n\t\"bytes\"\n)\n\n// See: https://github.com/gabriel-vasile/mimetype/blob/6e3aeb1/internal/charset/charset.go\n\nvar boms = [][]byte{\n\t{0xEF, 0xBB, 0xBF},       // utf-8\n\t{0x00, 0x00, 0xFE, 0xFF}, // utf-32be\n\t{0xFF, 0xFE, 0x00, 0x00}, // utf-32le\n\t{0xFE, 0xFF},             // utf-16be\n\t{0xFF, 0xFE},             // utf-16le\n}\n\n// hasBOM returns true if the charset declared in the BOM of content.\nfunc hasBOM(content []byte) bool {\n\tfor _, bom := range boms {\n\t\tif bytes.HasPrefix(content, bom) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\n// detectText checks if a sequence contains of a plain text bytes.\n//\n// This function does not parse BOM-less UTF16 and UTF32 files. Not really\n// sure it should. Linux file utility also requires a BOM for UTF16 and UTF32.\nfunc detectText(bytes []byte) bool {\n\tif hasBOM(bytes) {\n\t\treturn true\n\t}\n\n\t// Binary data bytes as defined here: https://mimesniff.spec.whatwg.org/#binary-data-byte\n\tfor _, b := range bytes {\n\t\tif b <= 0x08 ||\n\t\t\tb == 0x0B ||\n\t\t\t0x0E <= b && b <= 0x1A ||\n\t\t\t0x1C <= b && b <= 0x1F {\n\t\t\treturn false\n\t\t}\n\t}\n\n\treturn true\n}\n"
  },
  {
    "path": "internal/run/controller/filter/detect_text_test.go",
    "content": "package filter\n\nimport (\n\t\"fmt\"\n\t\"testing\"\n)\n\nfunc TestDetectText(t *testing.T) {\n\tfor i, tt := range [...]struct {\n\t\tbytes  []byte\n\t\tresult bool\n\t}{\n\t\t{\n\t\t\tbytes:  []byte{},\n\t\t\tresult: true,\n\t\t},\n\t\t{\n\t\t\tbytes:  []byte{0xEF, 0xBB, 0xBF}, // utf-8 BOM\n\t\t\tresult: true,\n\t\t},\n\t\t{\n\t\t\tbytes:  []byte{0x00, 0x00, 0xFE, 0xFF}, // utf-32be BOM\n\t\t\tresult: true,\n\t\t},\n\t\t{\n\t\t\tbytes:  []byte{0xFF, 0xFE, 0x00, 0x00}, // utf-32le BOM\n\t\t\tresult: true,\n\t\t},\n\t\t{\n\t\t\tbytes:  []byte{0xFE, 0xFF}, // utf-16be BOM\n\t\t\tresult: true,\n\t\t},\n\t\t{\n\t\t\tbytes:  []byte{0xFF, 0xFE}, // utf-16le BOM\n\t\t\tresult: true,\n\t\t},\n\t\t{\n\t\t\tbytes:  []byte{0xFA, 0xCF, 0xFE, 0xED, 0x00, 0x0C},\n\t\t\tresult: false,\n\t\t},\n\t\t{\n\t\t\tbytes:  []byte{0x70, 0x5B, 0x65, 0x72, 0x63, 0x2D}, // .lefthook.toml\n\t\t\tresult: true,\n\t\t},\n\t\t{\n\t\t\tbytes:  []byte{0x5B, 0x21, 0x75, 0x42, 0x6C, 0x69, 0x20, 0x64, 0x74, 0x53, 0x74, 0x61, 0x73, 0x75, 0x28, 0x5D}, // README.md\n\t\t\tresult: true,\n\t\t},\n\t} {\n\t\tt.Run(fmt.Sprintf(\"#%d:\", i), func(t *testing.T) {\n\t\t\tif detectText(tt.bytes) != tt.result {\n\t\t\t\tt.Error(\"results don't match; expected\", tt.result)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/run/controller/filter/filter.go",
    "content": "package filter\n\nimport (\n\t\"errors\"\n\t\"io\"\n\t\"os\"\n\t\"strings\"\n\n\t\"github.com/bmatcuk/doublestar/v4\"\n\t\"github.com/gabriel-vasile/mimetype\"\n\t\"github.com/gobwas/glob\"\n\t\"github.com/spf13/afero\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/log\"\n)\n\ntype fileTypeFilter struct {\n\tsimpleTypes int\n\tmimeTypes   []string\n}\n\nconst (\n\ttypeExecutable int = 1 << iota\n\ttypeNotExecutable\n\ttypeSymlink\n\ttypeNotSymlink\n\ttypeText\n\ttypeBinary\n\n\tdetectTypes    = typeText | typeBinary\n\tdetectBufSize  = 1024\n\texecutableMask = 0o111\n)\n\ntype Params struct {\n\tRoot         string\n\tGlob         []string\n\tFileTypes    []string\n\tExcludeFiles []string\n\tGlobMatcher  string\n}\n\ntype Filter struct {\n\tParams\n\n\tfs afero.Fs\n}\n\nfunc New(fs afero.Fs, params Params) *Filter {\n\treturn &Filter{fs: fs, Params: params}\n}\n\nfunc (f *Filter) Apply(files []string) []string {\n\tif len(files) == 0 {\n\t\treturn nil\n\t}\n\n\tb := log.Builder(log.DebugLevel, \"[lefthook] \").\n\t\tAdd(\"filtered [ ]: \", files)\n\n\tfiles = byGlob(files, f.Glob, f.GlobMatcher)\n\tfiles = byExclude(files, f.ExcludeFiles, f.GlobMatcher)\n\tfiles = byRoot(files, f.Root)\n\tfiles = byType(f.fs, files, f.FileTypes)\n\n\tb.Add(\"filtered [x]: \", files).\n\t\tLog()\n\n\treturn files\n}\n\nfunc byGlob(vs []string, matchers []string, globMatcher string) []string {\n\tif len(matchers) == 0 {\n\t\treturn vs\n\t}\n\n\tvar hasNonEmpty bool\n\tvsf := make([]string, 0)\n\tfor _, matcher := range matchers {\n\t\tif len(matcher) == 0 {\n\t\t\tcontinue\n\t\t}\n\n\t\thasNonEmpty = true\n\t\tvsf = append(vsf, matchFiles(vs, matcher, globMatcher)...)\n\t}\n\n\tif !hasNonEmpty {\n\t\treturn vs\n\t}\n\n\treturn vsf\n}\n\nfunc matchFiles(vs []string, matcher string, globMatcher string) []string {\n\tvar matched []string\n\tlowerMatcher := strings.ToLower(matcher)\n\n\tif globMatcher == \"doublestar\" {\n\t\tmatched = matchFilesDoublestar(vs, lowerMatcher)\n\t} else {\n\t\tmatched = matchFilesGobwas(vs, lowerMatcher)\n\t}\n\n\treturn matched\n}\n\nfunc matchFilesDoublestar(vs []string, lowerMatcher string) []string {\n\tvar matched []string\n\tfor _, v := range vs {\n\t\tisMatched, err := doublestar.Match(lowerMatcher, strings.ToLower(v))\n\t\tif err == nil && isMatched {\n\t\t\tmatched = append(matched, v)\n\t\t}\n\t}\n\treturn matched\n}\n\nfunc matchFilesGobwas(vs []string, lowerMatcher string) []string {\n\tvar matched []string\n\tg := glob.MustCompile(lowerMatcher)\n\tfor _, v := range vs {\n\t\tif g.Match(strings.ToLower(v)) {\n\t\t\tmatched = append(matched, v)\n\t\t}\n\t}\n\treturn matched\n}\n\nfunc byExclude(vs []string, exclude []string, globMatcher string) []string {\n\tif len(exclude) == 0 {\n\t\treturn vs\n\t}\n\n\tif globMatcher == \"doublestar\" {\n\t\treturn byExcludeDoublestar(vs, exclude)\n\t}\n\treturn byExcludeGobwas(vs, exclude)\n}\n\nfunc byExcludeDoublestar(vs []string, exclude []string) []string {\n\tvsf := make([]string, 0)\n\tfor _, v := range vs {\n\t\tif !matchesAnyDoublestar(v, exclude) {\n\t\t\tvsf = append(vsf, v)\n\t\t}\n\t}\n\treturn vsf\n}\n\nfunc byExcludeGobwas(vs []string, exclude []string) []string {\n\tglobs := make([]glob.Glob, 0, len(exclude))\n\tfor _, name := range exclude {\n\t\tglobs = append(globs, glob.MustCompile(name))\n\t}\n\n\tvsf := make([]string, 0)\n\tfor _, v := range vs {\n\t\tif !matchesAnyGobwas(v, globs) {\n\t\t\tvsf = append(vsf, v)\n\t\t}\n\t}\n\treturn vsf\n}\n\nfunc matchesAnyDoublestar(path string, patterns []string) bool {\n\tfor _, pattern := range patterns {\n\t\tmatched, err := doublestar.Match(pattern, path)\n\t\tif err == nil && matched {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc matchesAnyGobwas(path string, globs []glob.Glob) bool {\n\tfor _, g := range globs {\n\t\tif g.Match(path) {\n\t\t\treturn true\n\t\t}\n\t}\n\treturn false\n}\n\nfunc byRoot(vs []string, matcher string) []string {\n\tif matcher == \"\" {\n\t\treturn vs\n\t}\n\n\tvsf := make([]string, 0)\n\tfor _, v := range vs {\n\t\tif strings.HasPrefix(v, matcher) {\n\t\t\tvsf = append(vsf, strings.Replace(v, matcher, \"./\", 1))\n\t\t}\n\t}\n\treturn vsf\n}\n\nfunc byType(fs afero.Fs, vs []string, types []string) []string {\n\tif len(types) == 0 {\n\t\treturn vs\n\t}\n\n\tfilter := parseFileTypeFilter(types)\n\n\tvsf := make([]string, 0)\n\tfor _, v := range vs {\n\t\tvar err error\n\t\tvar fileInfo os.FileInfo\n\t\tlfs, ok := fs.(afero.Lstater)\n\t\tif ok {\n\t\t\tfileInfo, _, err = lfs.LstatIfPossible(v)\n\t\t} else {\n\t\t\tfileInfo, err = fs.Stat(v)\n\t\t}\n\t\tif err != nil {\n\t\t\tlog.Errorf(\"Couldn't check file type of %s: %s\", v, err)\n\t\t\tcontinue\n\t\t}\n\n\t\tisSymlink := fileInfo.Mode()&os.ModeSymlink != 0\n\t\tisExecutable := fileInfo.Mode().Perm()&executableMask != 0\n\t\tif filter.simpleTypes&typeSymlink != 0 && !isSymlink {\n\t\t\tcontinue\n\t\t}\n\t\tif filter.simpleTypes&typeNotSymlink != 0 && isSymlink {\n\t\t\tcontinue\n\t\t}\n\t\tif filter.simpleTypes&typeExecutable != 0 && (!isExecutable || isSymlink) {\n\t\t\tcontinue\n\t\t}\n\t\tif filter.simpleTypes&typeNotExecutable != 0 && (isExecutable && !isSymlink) {\n\t\t\tcontinue\n\t\t}\n\n\t\tif filter.simpleTypes&detectTypes != 0 {\n\t\t\tif !fileInfo.Mode().IsRegular() {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\ttext := checkIsText(fs, v)\n\t\t\tbinary := !text\n\n\t\t\tif filter.simpleTypes&typeText != 0 && binary {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tif filter.simpleTypes&typeBinary != 0 && text {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\n\t\tif len(filter.mimeTypes) != 0 {\n\t\t\tif !fileInfo.Mode().IsRegular() {\n\t\t\t\tcontinue\n\t\t\t}\n\n\t\t\tvar found bool\n\t\t\tfileMimeType, err := mimetype.DetectFile(v)\n\t\t\tif err != nil {\n\t\t\t\tlog.Errorf(\"Couldn't check mime type of file %s: %s\", v, err)\n\t\t\t\tcontinue\n\t\t\t}\n\t\t\tfor _, mime := range filter.mimeTypes {\n\t\t\t\tif fileMimeType.Is(mime) {\n\t\t\t\t\tfound = true\n\t\t\t\t}\n\t\t\t}\n\t\t\tif !found {\n\t\t\t\tcontinue\n\t\t\t}\n\t\t}\n\n\t\tvsf = append(vsf, v)\n\t}\n\n\treturn vsf\n}\n\nfunc parseFileTypeFilter(types []string) fileTypeFilter {\n\tvar filter fileTypeFilter\n\n\tfor _, t := range types {\n\t\tswitch {\n\t\tcase t == \"executable\":\n\t\t\tfilter.simpleTypes |= typeExecutable\n\t\tcase t == \"symlink\":\n\t\t\tfilter.simpleTypes |= typeSymlink\n\t\tcase t == \"not executable\":\n\t\t\tfilter.simpleTypes |= typeNotExecutable\n\t\tcase t == \"not symlink\":\n\t\t\tfilter.simpleTypes |= typeNotSymlink\n\t\tcase t == \"binary\":\n\t\t\tfilter.simpleTypes |= typeBinary\n\t\tcase t == \"text\":\n\t\t\tfilter.simpleTypes |= typeText\n\t\tcase strings.Contains(t, \"/\") && mimetype.Lookup(t) != nil:\n\t\t\tfilter.mimeTypes = append(filter.mimeTypes, t)\n\t\tdefault:\n\t\t\tlog.Warn(\"Unknown filter type: \", t)\n\t\t}\n\t}\n\n\treturn filter\n}\n\nfunc checkIsText(fs afero.Fs, filepath string) bool {\n\tfile, err := fs.Open(filepath)\n\tif err != nil {\n\t\tlog.Error(\"Couldn't open file for content detecting: \", err)\n\t\treturn false\n\t}\n\n\tbuf := make([]byte, detectBufSize)\n\tn, err := io.ReadFull(file, buf)\n\tif err != nil && !errors.Is(err, io.EOF) && !errors.Is(err, io.ErrUnexpectedEOF) {\n\t\tlog.Error(\"Couldn't read file for content detecting: \", err)\n\t\treturn false\n\t}\n\n\treturn detectText(buf[:n])\n}\n"
  },
  {
    "path": "internal/run/controller/filter/filter_test.go",
    "content": "package filter\n\nimport (\n\t\"fmt\"\n\t\"testing\"\n)\n\nfunc slicesEqual(a, b []string) bool {\n\tif len(a) != len(b) {\n\t\treturn false\n\t}\n\n\tr := make(map[string]struct{})\n\n\tfor _, item := range a {\n\t\tr[item] = struct{}{}\n\t}\n\n\tfor _, item := range b {\n\t\tif _, ok := r[item]; !ok {\n\t\t\treturn false\n\t\t}\n\t}\n\n\treturn true\n}\n\nfunc TestByGlob(t *testing.T) {\n\tfor i, tt := range [...]struct {\n\t\tsource, result []string\n\t\tglob           []string\n\t\tglobMatcher    string\n\t}{\n\t\t{\n\t\t\tsource:      []string{\"folder/subfolder/0.rb\", \"1.txt\", \"2.RB\", \"3.rbs\"},\n\t\t\tglob:        []string{},\n\t\t\tglobMatcher: \"\",\n\t\t\tresult:      []string{\"folder/subfolder/0.rb\", \"1.txt\", \"2.RB\", \"3.rbs\"},\n\t\t},\n\t\t{\n\t\t\tsource:      []string{\"folder/subfolder/0.rb\", \"1.txt\", \"2.RB\", \"3.rbs\"},\n\t\t\tglob:        []string{\"*.rb\"},\n\t\t\tglobMatcher: \"\",\n\t\t\tresult:      []string{\"folder/subfolder/0.rb\", \"2.RB\"},\n\t\t},\n\t\t{\n\t\t\tsource:      []string{\"folder/subfolder/0.rb\", \"1.rbs\"},\n\t\t\tglob:        []string{\"**/*.rb\"},\n\t\t\tglobMatcher: \"\",\n\t\t\tresult:      []string{\"folder/subfolder/0.rb\"},\n\t\t},\n\t\t{\n\t\t\tsource:      []string{\"folder/0.rb\", \"1.rBs\", \"2.rbv\"},\n\t\t\tglob:        []string{\"*.rb?\"},\n\t\t\tglobMatcher: \"\",\n\t\t\tresult:      []string{\"1.rBs\", \"2.rbv\"},\n\t\t},\n\t\t{\n\t\t\tsource:      []string{\"f.a\", \"f.b\", \"f.c\", \"f.cn\"},\n\t\t\tglob:        []string{\"*.{a,b,cn}\"},\n\t\t\tglobMatcher: \"\",\n\t\t\tresult:      []string{\"f.a\", \"f.b\", \"f.cn\"},\n\t\t},\n\t} {\n\t\tt.Run(fmt.Sprintf(\"%d:\", i), func(t *testing.T) {\n\t\t\tres := byGlob(tt.source, tt.glob, tt.globMatcher)\n\t\t\tif !slicesEqual(res, tt.result) {\n\t\t\t\tt.Errorf(\"expected %v to be equal to %v\", res, tt.result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestByGlobDoublestar(t *testing.T) {\n\tfor i, tt := range [...]struct {\n\t\tsource, result []string\n\t\tglob           []string\n\t\tglobMatcher    string\n\t}{\n\t\t{\n\t\t\tsource:      []string{\"0.rb\", \"folder/1.rb\", \"folder/subfolder/2.rb\"},\n\t\t\tglob:        []string{\"**/*.rb\"},\n\t\t\tglobMatcher: \"doublestar\",\n\t\t\tresult:      []string{\"0.rb\", \"folder/1.rb\", \"folder/subfolder/2.rb\"},\n\t\t},\n\t\t{\n\t\t\tsource:      []string{\"0.rb\", \"folder/1.rb\", \"folder/subfolder/2.rb\"},\n\t\t\tglob:        []string{\"**/*.rb\"},\n\t\t\tglobMatcher: \"\",\n\t\t\tresult:      []string{\"folder/1.rb\", \"folder/subfolder/2.rb\"},\n\t\t},\n\t\t{\n\t\t\tsource:      []string{\"a/b.go\", \"a/c/d.go\", \"e.go\"},\n\t\t\tglob:        []string{\"**/*.go\"},\n\t\t\tglobMatcher: \"doublestar\",\n\t\t\tresult:      []string{\"a/b.go\", \"a/c/d.go\", \"e.go\"},\n\t\t},\n\t\t{\n\t\t\tsource:      []string{\"a/b.go\", \"a/c/d.go\", \"e.go\"},\n\t\t\tglob:        []string{\"**/*.go\"},\n\t\t\tglobMatcher: \"\",\n\t\t\tresult:      []string{\"a/b.go\", \"a/c/d.go\"},\n\t\t},\n\t\t{\n\t\t\tsource:      []string{\"test.js\", \"src/app.js\", \"src/lib/util.js\"},\n\t\t\tglob:        []string{\"**/*.js\"},\n\t\t\tglobMatcher: \"doublestar\",\n\t\t\tresult:      []string{\"test.js\", \"src/app.js\", \"src/lib/util.js\"},\n\t\t},\n\t} {\n\t\tt.Run(fmt.Sprintf(\"doublestar-%d:\", i), func(t *testing.T) {\n\t\t\tres := byGlob(tt.source, tt.glob, tt.globMatcher)\n\t\t\tif !slicesEqual(res, tt.result) {\n\t\t\t\tt.Errorf(\"expected %v to be equal to %v\", res, tt.result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestByExclude(t *testing.T) {\n\tfor i, tt := range [...]struct {\n\t\tsource, result []string\n\t\texclude        []string\n\t\tglobMatcher    string\n\t}{\n\t\t{\n\t\t\tsource:      []string{\"folder/subfolder/0.rb\", \"1.txt\", \"2.RB\", \"3.rb\"},\n\t\t\texclude:     []string{},\n\t\t\tglobMatcher: \"\",\n\t\t\tresult:      []string{\"folder/subfolder/0.rb\", \"1.txt\", \"2.RB\", \"3.rb\"},\n\t\t},\n\t\t{\n\t\t\tsource:      []string{\"f.a\", \"f.b\", \"f.c\", \"f.cn\"},\n\t\t\texclude:     []string{\"*.a\", \"*.b\", \"*.cn\"},\n\t\t\tglobMatcher: \"\",\n\t\t\tresult:      []string{\"f.c\"},\n\t\t},\n\t} {\n\t\tt.Run(fmt.Sprintf(\"%d:\", i), func(t *testing.T) {\n\t\t\tres := byExclude(tt.source, tt.exclude, tt.globMatcher)\n\t\t\tif !slicesEqual(res, tt.result) {\n\t\t\t\tt.Errorf(\"expected %v to be equal to %v\", res, tt.result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestByExcludeDoublestar(t *testing.T) {\n\tfor i, tt := range [...]struct {\n\t\tsource, result []string\n\t\texclude        []string\n\t\tglobMatcher    string\n\t}{\n\t\t{\n\t\t\tsource:      []string{\"0.rb\", \"folder/1.rb\", \"folder/subfolder/2.rb\", \"test.js\"},\n\t\t\texclude:     []string{\"**/*.rb\"},\n\t\t\tglobMatcher: \"doublestar\",\n\t\t\tresult:      []string{\"test.js\"},\n\t\t},\n\t\t{\n\t\t\tsource:      []string{\"0.rb\", \"folder/1.rb\", \"folder/subfolder/2.rb\", \"test.js\"},\n\t\t\texclude:     []string{\"**/*.rb\"},\n\t\t\tglobMatcher: \"\",\n\t\t\tresult:      []string{\"0.rb\", \"test.js\"},\n\t\t},\n\t\t{\n\t\t\tsource:      []string{\"src/app.js\", \"src/lib/util.js\", \"test.py\", \"src/test.py\"},\n\t\t\texclude:     []string{\"**/*.py\"},\n\t\t\tglobMatcher: \"doublestar\",\n\t\t\tresult:      []string{\"src/app.js\", \"src/lib/util.js\"},\n\t\t},\n\t\t{\n\t\t\tsource:      []string{\"a.go\", \"src/b.go\", \"src/lib/c.go\"},\n\t\t\texclude:     []string{\"**/*.go\"},\n\t\t\tglobMatcher: \"doublestar\",\n\t\t\tresult:      []string{},\n\t\t},\n\t} {\n\t\tt.Run(fmt.Sprintf(\"doublestar-%d:\", i), func(t *testing.T) {\n\t\t\tres := byExclude(tt.source, tt.exclude, tt.globMatcher)\n\t\t\tif !slicesEqual(res, tt.result) {\n\t\t\t\tt.Errorf(\"expected %v to be equal to %v\", res, tt.result)\n\t\t\t}\n\t\t})\n\t}\n}\n\nfunc TestByRoot(t *testing.T) {\n\tfor i, tt := range [...]struct {\n\t\tsource, result []string\n\t\tpath           string\n\t}{\n\t\t{\n\t\t\tsource: []string{\"folder/subfolder/0.rb\", \"1.txt\", \"2.RB\", \"3.rb\"},\n\t\t\tpath:   \"\",\n\t\t\tresult: []string{\"folder/subfolder/0.rb\", \"1.txt\", \"2.RB\", \"3.rb\"},\n\t\t},\n\t\t{\n\t\t\tsource: []string{\"folder/subfolder/0.rb\", \"subfolder/1.txt\", \"folder/2.RB\", \"3.rbs\"},\n\t\t\tpath:   \"folder\",\n\t\t\tresult: []string{\".//subfolder/0.rb\", \".//2.RB\"},\n\t\t},\n\t\t{\n\t\t\tsource: []string{\"folder/subfolder/0.rb\", \"folder/1.rbs\"},\n\t\t\tpath:   \"folder/subfolder\",\n\t\t\tresult: []string{\".//0.rb\"},\n\t\t},\n\t\t{\n\t\t\tsource: []string{\"folder/subfolder/0.rb\", \"folder/1.rbs\"},\n\t\t\tpath:   \"folder/subfolder/\",\n\t\t\tresult: []string{\"./0.rb\"},\n\t\t},\n\t} {\n\t\tt.Run(fmt.Sprintf(\"%d:\", i), func(t *testing.T) {\n\t\t\tres := byRoot(tt.source, tt.path)\n\t\t\tif !slicesEqual(res, tt.result) {\n\t\t\t\tt.Errorf(\"expected %v to be equal to %v\", res, tt.result)\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/run/controller/guard.go",
    "content": "package controller\n\nimport (\n\t\"errors\"\n\t\"fmt\"\n\t\"maps\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/git\"\n\t\"github.com/evilmartians/lefthook/v2/internal/log\"\n)\n\ntype FailOnChangesError struct {\n\tchangedFiles []string\n}\n\nfunc (e *FailOnChangesError) Error() string {\n\treturn \"files were modified by a hook, and fail_on_changes is enabled\"\n}\n\ntype guard struct {\n\tgit *git.Repository\n\n\tstashUnstagedChanges bool\n\tfailOnChanges        bool\n\tfailOnChangesDiff    bool\n}\n\nfunc newGuard(repo *git.Repository, stashUnstagedChanges bool, failOnChanges bool, failOnChangesDiff bool) *guard {\n\treturn &guard{\n\t\tgit:                  repo,\n\t\tstashUnstagedChanges: stashUnstagedChanges,\n\t\tfailOnChanges:        failOnChanges,\n\t\tfailOnChangesDiff:    failOnChangesDiff,\n\t}\n}\n\nfunc (g *guard) wrap(fn func()) error {\n\tif !g.failOnChanges && !g.stashUnstagedChanges {\n\t\tfn()\n\n\t\treturn nil\n\t}\n\n\treturn g.withHiddenUnstagedChanges(\n\t\tfunc() error {\n\t\t\treturn g.withFailOnChanges(fn)\n\t\t},\n\t)\n}\n\nfunc (g *guard) withHiddenUnstagedChanges(fn func() error) error {\n\tif !g.stashUnstagedChanges {\n\t\treturn fn()\n\t}\n\n\tpartiallyStagedFiles, err := g.git.PartiallyStagedFiles()\n\tif err != nil {\n\t\tlog.Warnf(\"Couldn't find partially staged files: %s\\n\", err)\n\t\treturn err\n\t}\n\n\tif len(partiallyStagedFiles) == 0 {\n\t\treturn fn()\n\t}\n\n\tlog.Debug(\"[lefthook] saving partially staged files\")\n\n\tif err := g.git.SaveUnstaged(partiallyStagedFiles); err != nil {\n\t\tlog.Warnf(\"Couldn't save unstaged changes: %s\\n\", err)\n\t\treturn err\n\t}\n\n\tif err := g.git.StashUnstaged(); err != nil {\n\t\tlog.Warnf(\"Couldn't stash partially staged files: %s\\n\", err)\n\t\treturn err\n\t}\n\n\tlog.Builder(log.DebugLevel, \"[lefthook] \").\n\t\tAdd(\"hide partially staged files: \", partiallyStagedFiles).\n\t\tLog()\n\n\tif err := g.git.RevertUnstagedChanges(partiallyStagedFiles); err != nil {\n\t\tlog.Warnf(\"Couldn't hide unstaged files: %s\\n\", err)\n\t\treturn err\n\t}\n\n\twrappedErr := fn()\n\n\tvar failOnChangesErr *FailOnChangesError\n\tif errors.As(wrappedErr, &failOnChangesErr) {\n\t\tif err := g.git.RevertUnstagedChanges(failOnChangesErr.changedFiles); err != nil {\n\t\t\tlog.Warnf(\"Couldn't hide unstaged files: %s\\n\", err)\n\t\t\treturn wrappedErr\n\t\t}\n\t}\n\n\tif err := g.git.RestoreUnstaged(); err != nil {\n\t\tlog.Warnf(\"Couldn't restore unstaged files: %s\\n\", err)\n\t\treturn wrappedErr\n\t}\n\n\tif err := g.git.DropUnstagedStash(); err != nil {\n\t\tlog.Warnf(\"Couldn't remove unstaged files backup: %s\\n\", err)\n\t\treturn wrappedErr\n\t}\n\n\treturn wrappedErr\n}\n\nfunc (g *guard) withFailOnChanges(fn func()) error {\n\tif !g.failOnChanges {\n\t\tfn()\n\t\treturn nil\n\t}\n\n\tchangesetBefore, err := g.git.Changeset()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"couldn't calculate changeset: %w\", err)\n\t}\n\n\tfn()\n\n\tchangesetAfter, err := g.git.Changeset()\n\tif err != nil {\n\t\treturn fmt.Errorf(\"couldn't calculate changeset: %w\", err)\n\t}\n\tif !maps.Equal(changesetBefore, changesetAfter) {\n\t\tchangedFiles := g.printDiff(changesetBefore, changesetAfter)\n\t\treturn &FailOnChangesError{changedFiles: changedFiles}\n\t}\n\n\treturn nil\n}\n\nfunc (g *guard) printDiff(changesetBefore, changesetAfter map[string]string) []string {\n\tchangedFiles := g.getChangedFiles(changesetBefore, changesetAfter)\n\n\tif g.failOnChangesDiff && len(changedFiles) > 0 {\n\t\tg.git.PrintDiff(changedFiles)\n\t}\n\n\treturn changedFiles\n}\n\nfunc (g *guard) getChangedFiles(changesetBefore, changesetAfter map[string]string) []string {\n\tchanged := make([]string, 0, len(changesetBefore))\n\tfor f, hashBefore := range changesetBefore {\n\t\tif hashAfter, ok := changesetAfter[f]; !ok || hashBefore != hashAfter {\n\t\t\tchanged = append(changed, f)\n\t\t}\n\t}\n\n\tfor f := range changesetAfter {\n\t\tif _, ok := changesetBefore[f]; !ok {\n\t\t\tchanged = append(changed, f)\n\t\t}\n\t}\n\n\treturn changed\n}\n"
  },
  {
    "path": "internal/run/controller/guard_test.go",
    "content": "package controller\n\nimport (\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/spf13/afero\"\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"github.com/evilmartians/lefthook/v2/tests/helpers/cmdtest\"\n\t\"github.com/evilmartians/lefthook/v2/tests/helpers/gittest\"\n)\n\nfunc Test_guard_wrap(t *testing.T) {\n\tfor name, tt := range map[string]struct {\n\t\tstashUnstagedChanges bool\n\t\tfailOnChanges        bool\n\t\tfailOnChangesDiff    bool\n\t\tcommands             []cmdtest.Out\n\t\terr                  error\n\t}{\n\t\t\"just call\": {\n\t\t\tstashUnstagedChanges: false,\n\t\t\tfailOnChanges:        false,\n\t\t\tcommands:             []cmdtest.Out{},\n\t\t},\n\t\t\"failOnChanges=true no files\": {\n\t\t\tstashUnstagedChanges: false,\n\t\t\tfailOnChanges:        true,\n\t\t\tcommands: []cmdtest.Out{\n\t\t\t\t{Command: \"git status --short --porcelain -z\", Output: \"\"},\n\t\t\t\t{Command: \"git status --short --porcelain -z\", Output: \"\"},\n\t\t\t},\n\t\t},\n\t\t\"failOnChanges=true no fail\": {\n\t\t\tstashUnstagedChanges: false,\n\t\t\tfailOnChanges:        true,\n\t\t\tcommands: []cmdtest.Out{\n\t\t\t\t{Command: \"git status --short --porcelain -z\", Output: \" M file1\\x00 M file2\\x00\"},\n\t\t\t\t{Command: \"git hash-object -- file1 file2\", Output: \"0\\n1\\n\"},\n\t\t\t\t{Command: \"git status --short --porcelain -z\", Output: \" M file1\\x00 M file2\\x00\"},\n\t\t\t\t{Command: \"git hash-object -- file1 file2\", Output: \"0\\n1\\n\"},\n\t\t\t},\n\t\t},\n\t\t\"failOnChanges=true fail with changeset different with diff\": {\n\t\t\tstashUnstagedChanges: false,\n\t\t\tfailOnChanges:        true,\n\t\t\tfailOnChangesDiff:    true,\n\t\t\tcommands: []cmdtest.Out{\n\t\t\t\t{Command: \"git status --short --porcelain -z\", Output: \" M file1\\x00 M file2\\x00\"},\n\t\t\t\t{Command: \"git hash-object -- file1 file2\", Output: \"0\\n1\\n\"},\n\t\t\t\t{Command: \"git status --short --porcelain -z\", Output: \" M file1\\x00 M file2\\x00\"},\n\t\t\t\t{Command: \"git hash-object -- file1 file2\", Output: \"2\\n3\\n\"},\n\t\t\t\t{Command: \"git diff --color -- file1 file2\", Output: \"diff --git a/file1 b/file1\\n...\"},\n\t\t\t},\n\t\t\terr: &FailOnChangesError{[]string{\"file1\", \"file2\"}},\n\t\t},\n\t\t\"failOnChanges=true fail with extra files without diff\": {\n\t\t\tstashUnstagedChanges: false,\n\t\t\tfailOnChanges:        true,\n\t\t\tcommands: []cmdtest.Out{\n\t\t\t\t{Command: \"git status --short --porcelain -z\", Output: \"\"},\n\t\t\t\t{Command: \"git status --short --porcelain -z\", Output: \" M file1\\x00 M file2\\x00\"},\n\t\t\t\t{Command: \"git hash-object -- file1 file2\", Output: \"0\\n1\\n\"},\n\t\t\t},\n\t\t\terr: &FailOnChangesError{[]string{\"file1\", \"file2\"}},\n\t\t},\n\t\t\"stashUnstagedChanges=true no files\": {\n\t\t\tstashUnstagedChanges: true,\n\t\t\tfailOnChanges:        false,\n\t\t\tcommands: []cmdtest.Out{\n\t\t\t\t{Command: \"git status --short --porcelain -z\", Output: \"\"},\n\t\t\t},\n\t\t},\n\t\t\"stashUnstagedChanges=true no unstaged\": {\n\t\t\tstashUnstagedChanges: true,\n\t\t\tfailOnChanges:        false,\n\t\t\tcommands: []cmdtest.Out{\n\t\t\t\t{Command: \"git status --short --porcelain -z\", Output: \"M  file1\\x00M  file2\\x00M  file3\\x00\"},\n\t\t\t},\n\t\t},\n\t\t\"stashUnstagedChanges=true with partially staged\": {\n\t\t\tstashUnstagedChanges: true,\n\t\t\tfailOnChanges:        false,\n\t\t\tcommands: []cmdtest.Out{\n\t\t\t\t{Command: \"git status --short --porcelain -z\", Output: \"AM file1\\x00 M file2\\x00 A file3\\x00\"},\n\t\t\t\t{Command: \"git diff --binary --unified=0 --no-color --no-ext-diff --src-prefix=a/ --dst-prefix=b/ --patch --submodule=short --output \" +\n\t\t\t\t\tfilepath.Join(\"root\", \".git\", \"info\", \"lefthook-unstaged.patch\") +\n\t\t\t\t\t\" -- file1\", Output: \"\"},\n\t\t\t\t{Command: \"git stash create\", Output: \"<stash-hash>\"},\n\t\t\t\t{Command: \"git stash store --quiet --message lefthook auto backup <stash-hash>\", Output: \"\"},\n\t\t\t\t{Command: \"git checkout --force -- file1\", Output: \"\"},\n\t\t\t\t{Command: \"git stash list\", Output: \"0: my stash\\n1: lefthook auto backup\\n2: my second stash\\n\"},\n\t\t\t\t{Command: \"git stash drop --quiet -- 1\", Output: \"\"},\n\t\t\t},\n\t\t},\n\t\t\"stashUnstagedChanges=true failOnChanges=true with partially staged no hook changes\": {\n\t\t\tstashUnstagedChanges: true,\n\t\t\tfailOnChanges:        true,\n\t\t\tcommands: []cmdtest.Out{\n\t\t\t\t{Command: \"git status --short --porcelain -z\", Output: \"AM file1\\x00 M file2\\x00\"},\n\t\t\t\t{Command: \"git diff --binary --unified=0 --no-color --no-ext-diff --src-prefix=a/ --dst-prefix=b/ --patch --submodule=short --output \" +\n\t\t\t\t\tfilepath.Join(\"root\", \".git\", \"info\", \"lefthook-unstaged.patch\") +\n\t\t\t\t\t\" -- file1\", Output: \"\"},\n\t\t\t\t{Command: \"git stash create\", Output: \"<stash-hash>\"},\n\t\t\t\t{Command: \"git stash store --quiet --message lefthook auto backup <stash-hash>\", Output: \"\"},\n\t\t\t\t{Command: \"git checkout --force -- file1\", Output: \"\"},\n\t\t\t\t{Command: \"git status --short --porcelain -z\", Output: \"A file1\\x00\"},\n\t\t\t\t// job run\n\t\t\t\t{Command: \"git status --short --porcelain -z\", Output: \"A file1\\x00\"},\n\t\t\t\t{Command: \"git stash list\", Output: \"0: my stash\\n1: lefthook auto backup\\n2: my second stash\\n\"},\n\t\t\t\t{Command: \"git stash drop --quiet -- 1\", Output: \"\"},\n\t\t\t},\n\t\t},\n\t\t\"stashUnstagedChanges=true failOnChanges=true with partially staged and hook changes with diff\": {\n\t\t\tstashUnstagedChanges: true,\n\t\t\tfailOnChanges:        true,\n\t\t\tfailOnChangesDiff:    true,\n\t\t\tcommands: []cmdtest.Out{\n\t\t\t\t{Command: \"git status --short --porcelain -z\", Output: \"AM file1\\x00\"},\n\t\t\t\t{Command: \"git diff --binary --unified=0 --no-color --no-ext-diff --src-prefix=a/ --dst-prefix=b/ --patch --submodule=short --output \" +\n\t\t\t\t\tfilepath.Join(\"root\", \".git\", \"info\", \"lefthook-unstaged.patch\") +\n\t\t\t\t\t\" -- file1\", Output: \"\"},\n\t\t\t\t{Command: \"git stash create\", Output: \"<stash-hash>\"},\n\t\t\t\t{Command: \"git stash store --quiet --message lefthook auto backup <stash-hash>\", Output: \"\"},\n\t\t\t\t{Command: \"git checkout --force -- file1\", Output: \"\"},\n\t\t\t\t{Command: \"git status --short --porcelain -z\", Output: \"A  file1\\x00\"},\n\t\t\t\t{Command: \"git hash-object -- file1\", Output: \"hash1\\n\"},\n\t\t\t\t// job run\n\t\t\t\t{Command: \"git status --short --porcelain -z\", Output: \"AM file1\\x00\"},\n\t\t\t\t{Command: \"git hash-object -- file1\", Output: \"hash2\\n\"},\n\t\t\t\t{Command: \"git diff --color -- file1\", Output: \"diff --git a/file1 b/file1\\n...\"},\n\t\t\t\t{Command: \"git checkout --force -- file1\", Output: \"\"},\n\t\t\t\t{Command: \"git stash list\", Output: \"0: my stash\\n1: lefthook auto backup\\n2: my second stash\\n\"},\n\t\t\t\t{Command: \"git stash drop --quiet -- 1\", Output: \"\"},\n\t\t\t},\n\t\t\terr: &FailOnChangesError{[]string{\"file2\"}},\n\t\t},\n\t\t\"failOnChanges=true with deleted file no change\": {\n\t\t\tstashUnstagedChanges: false,\n\t\t\tfailOnChanges:        true,\n\t\t\tcommands: []cmdtest.Out{\n\t\t\t\t// Deleted file in before and after - same state, no change\n\t\t\t\t{Command: \"git status --short --porcelain -z\", Output: \"D  file1\\x00\"},\n\t\t\t\t{Command: \"git status --short --porcelain -z\", Output: \"D  file1\\x00\"},\n\t\t\t},\n\t\t},\n\t\t\"failOnChanges=true with directory\": {\n\t\t\tstashUnstagedChanges: false,\n\t\t\tfailOnChanges:        true,\n\t\t\tcommands: []cmdtest.Out{\n\t\t\t\t// Directory in before() - marked as \"directory\"\n\t\t\t\t{Command: \"git status --short --porcelain -z\", Output: \"?? dir/\\x00\"},\n\t\t\t\t// Directory still there in after() - same state, no change\n\t\t\t\t{Command: \"git status --short --porcelain -z\", Output: \"?? dir/\\x00\"},\n\t\t\t},\n\t\t},\n\t\t\"failOnChanges=true with changeset error in before\": {\n\t\t\tstashUnstagedChanges: false,\n\t\t\tfailOnChanges:        true,\n\t\t\tcommands: []cmdtest.Out{\n\t\t\t\t// Changeset() error in before() - empty output simulates error\n\t\t\t\t{Command: \"git status --short --porcelain -z\", Output: \"\"},\n\t\t\t\t{Command: \"git status --short --porcelain -z\", Output: \"\"},\n\t\t\t},\n\t\t},\n\t\t\"failOnChanges=true failOnChangesDiff=true with no changed files\": {\n\t\t\tstashUnstagedChanges: false,\n\t\t\tfailOnChanges:        true,\n\t\t\tfailOnChangesDiff:    true,\n\t\t\tcommands: []cmdtest.Out{\n\t\t\t\t{Command: \"git status --short --porcelain -z\", Output: \"\"},\n\t\t\t\t{Command: \"git status --short --porcelain -z\", Output: \"\"},\n\t\t\t},\n\t\t},\n\t\t\"failOnChanges=true with deleted file in changeset\": {\n\t\t\tstashUnstagedChanges: false,\n\t\t\tfailOnChanges:        true,\n\t\t\tcommands: []cmdtest.Out{\n\t\t\t\t{Command: \"git status --short --porcelain -z\", Output: \" M file1\\x00\"},\n\t\t\t\t{Command: \"git hash-object -- file1\", Output: \"hash1\\n\"},\n\t\t\t\t{Command: \"git status --short --porcelain -z\", Output: \"D  file1\\x00\"},\n\t\t\t\t// file1 was deleted, so it's in changesetAfter but marked as \"deleted\"\n\t\t\t},\n\t\t\terr: &FailOnChangesError{[]string{\"file1\"}},\n\t\t},\n\t} {\n\t\tt.Run(name, func(t *testing.T) {\n\t\t\tassert := assert.New(t)\n\n\t\t\trepo := gittest.NewRepositoryBuilder().\n\t\t\t\tCmd(cmdtest.NewOrdered(t, tt.commands)).\n\t\t\t\tFs(afero.NewMemMapFs()).\n\t\t\t\tRoot(\"root\").\n\t\t\t\tBuild()\n\t\t\trepo.Setup()\n\t\t\tg := newGuard(repo, tt.stashUnstagedChanges, tt.failOnChanges, tt.failOnChangesDiff)\n\n\t\t\tvar beenCalled bool\n\t\t\terr := g.wrap(func() { beenCalled = true })\n\t\t\tif tt.err != nil {\n\t\t\t\tassert.ErrorAs(tt.err, &err)\n\t\t\t} else {\n\t\t\t\tassert.NoError(err)\n\t\t\t}\n\n\t\t\tassert.Equal(true, beenCalled)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/run/controller/job.go",
    "content": "package controller\n\nimport (\n\t\"context\"\n\t\"errors\"\n\t\"maps\"\n\t\"path/filepath\"\n\t\"slices\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/config\"\n\t\"github.com/evilmartians/lefthook/v2/internal/log\"\n\t\"github.com/evilmartians/lefthook/v2/internal/run/controller/command\"\n\t\"github.com/evilmartians/lefthook/v2/internal/run/controller/exec\"\n\t\"github.com/evilmartians/lefthook/v2/internal/run/controller/filter\"\n\t\"github.com/evilmartians/lefthook/v2/internal/run/controller/utils\"\n\t\"github.com/evilmartians/lefthook/v2/internal/run/result\"\n\t\"github.com/evilmartians/lefthook/v2/internal/system\"\n)\n\nconst (\n\tinvalidJobError = \"either `run`,`script`, or `group` must be provided for a job\"\n\temptyGroupError = \"group must have `jobs`\"\n)\n\nfunc (c *Controller) runJob(ctx context.Context, scope *scope, id string, job *config.Job) result.Result {\n\t// Check if do job is properly configured\n\tif len(job.Run) > 0 && len(job.Script) > 0 {\n\t\treturn result.Failure(job.PrintableName(id), invalidJobError, 0)\n\t}\n\tif len(job.Run) == 0 && len(job.Script) == 0 && job.Group == nil {\n\t\treturn result.Failure(job.PrintableName(id), invalidJobError, 0)\n\t}\n\n\tstartTime := time.Now()\n\tif job.Interactive && !scope.opts.DisableTTY && !scope.follow {\n\t\tlog.StopSpinner()\n\t\tdefer log.StartSpinner()\n\t}\n\n\tif len(job.Run) != 0 || len(job.Script) != 0 {\n\t\tif len(scope.opts.RunOnlyJobs) != 0 && !slices.Contains(scope.opts.RunOnlyJobs, job.Name) {\n\t\t\treturn result.Skip(job.PrintableName(id))\n\t\t}\n\n\t\tif len(scope.opts.RunOnlyTags) != 0 && (!utils.Intersect(scope.opts.RunOnlyTags, job.Tags) && !utils.Intersect(scope.opts.RunOnlyTags, scope.tags)) {\n\t\t\treturn result.Skip(job.PrintableName(id))\n\t\t}\n\n\t\treturn c.runSingleJob(ctx, scope, id, job)\n\t}\n\n\tif job.Group != nil {\n\t\textendedScope := scope.extend(job)\n\t\tgroupName := utils.FirstNonBlank(job.Name, \"group (\"+id+\")\")\n\n\t\tif reason := c.skipReason(extendedScope, job, groupName); len(reason) > 0 {\n\t\t\tlog.Skip(groupName, reason)\n\n\t\t\treturn result.Skip(groupName)\n\t\t}\n\n\t\textendedScope.names = append(extendedScope.names, groupName)\n\n\t\tif len(job.Group.Jobs) == 0 {\n\t\t\treturn result.Failure(groupName, emptyGroupError, 0)\n\t\t}\n\n\t\tvar results []result.Result\n\t\tif job.Group.Parallel {\n\t\t\tresults = c.concurrently(ctx, extendedScope, job.Group.Jobs)\n\t\t} else {\n\t\t\tresults = c.sequentially(ctx, extendedScope, job.Group.Jobs, job.Group.Piped)\n\t\t}\n\n\t\treturn result.Group(groupName, results)\n\t}\n\n\treturn result.Failure(job.PrintableName(id), invalidJobError, time.Since(startTime))\n}\n\nfunc (c *Controller) runSingleJob(ctx context.Context, scope *scope, id string, job *config.Job) result.Result {\n\tstartTime := time.Now()\n\n\tname := job.PrintableName(id)\n\tscope = scope.extend(job)\n\n\tif reason := c.skipReason(scope, job, name); len(reason) > 0 {\n\t\tlog.Skip(name, reason)\n\n\t\treturn result.Skip(name)\n\t}\n\n\tbuilder := command.NewBuilder(c.git, command.BuilderOptions{\n\t\tHookName:    scope.hookName,\n\t\tForceFiles:  scope.opts.Files,\n\t\tForce:       scope.opts.Force,\n\t\tSourceDirs:  scope.opts.SourceDirs,\n\t\tGitArgs:     scope.opts.GitArgs,\n\t\tTemplates:   scope.opts.Templates,\n\t\tGlobMatcher: scope.opts.GlobMatcher,\n\t})\n\tcommands, files, err := builder.BuildCommands(&command.JobParams{\n\t\tName:         name,\n\t\tRun:          job.Run,\n\t\tRunner:       job.Runner,\n\t\tArgs:         job.Args,\n\t\tScript:       job.Script,\n\t\tOnly:         job.Only,\n\t\tSkip:         job.Skip,\n\t\tRoot:         scope.root,\n\t\tFileTypes:    scope.fileTypes,\n\t\tGlob:         scope.glob,\n\t\tFilesCmd:     scope.filesCmd,\n\t\tTags:         scope.tags,\n\t\tExcludeFiles: scope.excludeFiles,\n\t})\n\tif err != nil {\n\t\tlog.Skip(name, err.Error())\n\n\t\tvar skipErr command.SkipError\n\t\tif errors.As(err, &skipErr) {\n\t\t\treturn result.Skip(name)\n\t\t}\n\n\t\treturn result.Failure(name, err.Error(), time.Since(startTime))\n\t}\n\n\tenv := maps.Clone(scope.env)\n\tmaps.Copy(env, job.Env)\n\n\tif job.Timeout > 0 {\n\t\tvar cancel context.CancelFunc\n\t\tctx, cancel = context.WithTimeout(ctx, job.Timeout)\n\t\tdefer cancel()\n\t}\n\terr = c.run(ctx, strings.Join(append(scope.names, name), \" ❯ \"), scope.follow, exec.Options{\n\t\tRoot:        filepath.Join(c.git.RootPath, scope.root),\n\t\tCommands:    commands,\n\t\tInteractive: job.Interactive && !scope.opts.DisableTTY,\n\t\tUseStdin:    job.UseStdin,\n\t\tEnv:         env,\n\t})\n\n\texecutionTime := time.Since(startTime)\n\n\tif err != nil {\n\t\tif ctx.Err() == context.DeadlineExceeded {\n\t\t\treturn result.Failure(name, \"timeout (\"+job.Timeout.String()+\")\", executionTime)\n\t\t}\n\n\t\treturn result.Failure(name, job.FailText, executionTime)\n\t}\n\n\tif config.HookUsesStagedFiles(scope.hookName) && job.StageFixed && !scope.opts.NoStageFixed {\n\t\tif len(files) == 0 {\n\t\t\tvar err error\n\t\t\tfiles, err = c.git.StagedFiles()\n\t\t\tif err != nil {\n\t\t\t\tlog.Warn(\"Couldn't stage fixed files:\", err)\n\t\t\t\treturn result.Success(name, executionTime)\n\t\t\t}\n\n\t\t\tfiles = filter.New(c.git.Fs, filter.Params{\n\t\t\t\tGlob:         scope.glob,\n\t\t\t\tRoot:         scope.root,\n\t\t\t\tExcludeFiles: scope.excludeFiles,\n\t\t\t\tFileTypes:    scope.fileTypes,\n\t\t\t\tGlobMatcher:  scope.opts.GlobMatcher,\n\t\t\t}).Apply(files)\n\t\t}\n\n\t\tif len(scope.root) > 0 {\n\t\t\tfor i, file := range files {\n\t\t\t\tfiles[i] = filepath.Join(scope.root, file)\n\t\t\t}\n\t\t}\n\n\t\tc.addStagedFiles(files)\n\t}\n\n\treturn result.Success(name, executionTime)\n}\n\nfunc (c *Controller) addStagedFiles(files []string) {\n\tif err := c.git.AddFiles(files); err != nil {\n\t\tlog.Warn(\"Couldn't stage fixed files:\", err)\n\t}\n}\n\nfunc (c *Controller) skipReason(scope *scope, job *config.Job, name string) string {\n\tskipChecker := config.NewSkipChecker(system.Cmd)\n\tif skipChecker.Check(c.git.State, job.Skip, job.Only) {\n\t\treturn \"by condition\"\n\t}\n\n\tif utils.Intersect(scope.excludeTags, scope.tags) {\n\t\treturn \"tags\"\n\t}\n\n\tif utils.Intersect(scope.excludeTags, []string{name}) {\n\t\treturn \"name\"\n\t}\n\n\treturn \"\"\n}\n"
  },
  {
    "path": "internal/run/controller/lfs.go",
    "content": "package controller\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"errors\"\n\t\"path/filepath\"\n\t\"strings\"\n\n\t\"github.com/spf13/afero\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/git\"\n\t\"github.com/evilmartians/lefthook/v2/internal/log\"\n)\n\nfunc (c *Controller) runLFSHook(ctx context.Context, hookName string, args []string) error {\n\tif !git.IsLFSHook(hookName) {\n\t\treturn nil\n\t}\n\n\t// Skip running git-lfs for pre-push hook when triggered manually\n\tif len(args) == 0 && hookName == \"pre-push\" {\n\t\treturn nil\n\t}\n\n\tlfsRequiredFile := filepath.Join(c.git.RootPath, git.LFSRequiredFile)\n\tlfsConfigFile := filepath.Join(c.git.RootPath, git.LFSConfigFile)\n\n\trequiredExists, err := afero.Exists(c.git.Fs, lfsRequiredFile)\n\tif err != nil {\n\t\treturn err\n\t}\n\tconfigExists, err := afero.Exists(c.git.Fs, lfsConfigFile)\n\tif err != nil {\n\t\treturn err\n\t}\n\n\tif !git.IsLFSAvailable() {\n\t\tif requiredExists || configExists {\n\t\t\tlog.Errorf(\n\t\t\t\t\"This Repository requires Git LFS, but 'git-lfs' wasn't found.\\n\"+\n\t\t\t\t\t\"Install 'git-lfs' or consider reviewing the files:\\n\"+\n\t\t\t\t\t\"  - %s\\n\"+\n\t\t\t\t\t\"  - %s\\n\",\n\t\t\t\tlfsRequiredFile, lfsConfigFile,\n\t\t\t)\n\t\t\treturn errors.New(\"git-lfs is required\")\n\t\t}\n\n\t\treturn nil\n\t}\n\n\tlog.Debugf(\n\t\t\"[git-lfs] executing hook: git lfs %s %s\", hookName, strings.Join(args, \" \"),\n\t)\n\tout := new(bytes.Buffer)\n\terrOut := new(bytes.Buffer)\n\terr = c.cmd.RunWithContext(\n\t\tctx,\n\t\tappend(\n\t\t\t[]string{\"git\", \"lfs\", hookName},\n\t\t\targs...,\n\t\t),\n\t\t\"\",\n\t\tc.cachedStdin,\n\t\tout,\n\t\terrOut,\n\t)\n\n\toutString := strings.Trim(out.String(), \"\\n\")\n\tif outString != \"\" {\n\t\tlog.Debug(\"[git-lfs] stdout: \", outString)\n\t}\n\terrString := strings.Trim(errOut.String(), \"\\n\")\n\tif errString != \"\" {\n\t\tlog.Debug(\"[git-lfs] stderr: \", errString)\n\t}\n\tif err != nil {\n\t\tlog.Debug(\"[git-lfs] error:  \", err)\n\t}\n\n\tif err == nil && outString != \"\" {\n\t\tlog.Info(\"[git-lfs] stdout: \", outString)\n\t}\n\n\tif err != nil && (requiredExists || configExists) {\n\t\tlog.Warn(\"git-lfs command failed\")\n\t\tif len(outString) > 0 {\n\t\t\tlog.Warn(\"[git-lfs] stdout: \", outString)\n\t\t}\n\t\tif len(errString) > 0 {\n\t\t\tlog.Warn(\"[git-lfs] stderr: \", errString)\n\t\t}\n\t\treturn err\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/run/controller/run.go",
    "content": "package controller\n\nimport (\n\t\"bytes\"\n\t\"context\"\n\t\"io\"\n\t\"os\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/log\"\n\t\"github.com/evilmartians/lefthook/v2/internal/run/controller/exec\"\n\t\"github.com/evilmartians/lefthook/v2/internal/system\"\n)\n\nfunc (c *Controller) run(ctx context.Context, name string, follow bool, opts exec.Options) error {\n\tlog.SetName(name)\n\tdefer log.UnsetName(name)\n\n\t// If the command does not explicitly `use_stdin` no input will be provided.\n\tvar in io.Reader = system.NullReader\n\tif opts.UseStdin {\n\t\tin = c.cachedStdin\n\t}\n\n\tif (follow || opts.Interactive) && log.Settings.LogExecution() {\n\t\tlog.Execution(name, nil, nil)\n\n\t\tvar out io.Writer\n\t\tif log.Settings.LogExecutionOutput() {\n\t\t\tout = os.Stdout\n\t\t} else {\n\t\t\tout = io.Discard\n\t\t}\n\n\t\treturn c.executor.Execute(ctx, opts, in, out)\n\t}\n\n\tout := new(bytes.Buffer)\n\terr := c.executor.Execute(ctx, opts, in, out)\n\tlog.Execution(name, err, out)\n\n\treturn err\n}\n"
  },
  {
    "path": "internal/run/controller/scope.go",
    "content": "package controller\n\nimport (\n\t\"maps\"\n\t\"slices\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/config\"\n\t\"github.com/evilmartians/lefthook/v2/internal/run/controller/utils\"\n)\n\ntype scope struct {\n\tfollow bool\n\n\tglob         []string\n\ttags         []string\n\texcludeTags  []string // Consider removing this setting\n\tnames        []string\n\tfileTypes    []string\n\texcludeFiles []string\n\tenv          map[string]string\n\troot         string\n\thookName     string\n\tfilesCmd     string\n\topts         Options\n}\n\nfunc newScope(hook *config.Hook, opts Options) *scope {\n\texcludeFiles := make([]string, len(opts.ExcludeFiles)+len(hook.Exclude))\n\n\ti := 0\n\tfor _, e := range opts.ExcludeFiles {\n\t\texcludeFiles[i] = e\n\t\ti += 1\n\t}\n\tfor _, e := range hook.Exclude {\n\t\texcludeFiles[i] = e\n\t\ti += 1\n\t}\n\n\treturn &scope{\n\t\thookName:     hook.Name,\n\t\tfollow:       hook.Follow,\n\t\tfilesCmd:     hook.Files,\n\t\texcludeTags:  hook.ExcludeTags,\n\t\texcludeFiles: excludeFiles,\n\t\tenv:          make(map[string]string),\n\t\topts:         opts,\n\t}\n}\n\nfunc (s *scope) extend(job *config.Job) *scope {\n\tnewScope := *s\n\tnewScope.glob = slices.Concat(newScope.glob, job.Glob)\n\tnewScope.tags = slices.Concat(newScope.tags, job.Tags)\n\tnewScope.root = utils.FirstNonBlank(job.Root, s.root)\n\tnewScope.filesCmd = utils.FirstNonBlank(job.Files, s.filesCmd)\n\tnewScope.fileTypes = slices.Concat(newScope.fileTypes, job.FileTypes)\n\n\tif len(job.Exclude) > 0 {\n\t\tnewScope.excludeFiles = append(newScope.excludeFiles, job.Exclude...)\n\t}\n\n\t// Overwrite --job option for nested groups: if group name given, run all its jobs\n\tif len(s.opts.RunOnlyJobs) != 0 && job.Group != nil && slices.Contains(s.opts.RunOnlyJobs, job.Name) {\n\t\tnewScope.opts.RunOnlyJobs = []string{}\n\t}\n\n\t// Copy env, avoid race conditions\n\tif len(job.Env) > 0 {\n\t\tif len(newScope.env) > 0 {\n\t\t\tenv := make(map[string]string)\n\t\t\tmaps.Copy(env, newScope.env)\n\t\t\tmaps.Copy(env, job.Env)\n\t\t\tnewScope.env = env\n\t\t} else {\n\t\t\tnewScope.env = job.Env\n\t\t}\n\t}\n\n\treturn &newScope\n}\n"
  },
  {
    "path": "internal/run/controller/scope_test.go",
    "content": "package controller\n\nimport (\n\t\"strconv\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/config\"\n\t\"github.com/evilmartians/lefthook/v2/tests/helpers/configtest\"\n)\n\nfunc Test_newScope(t *testing.T) {\n\tt.Run(\"with excluded files in hook and opts\", func(t *testing.T) {\n\t\topts := Options{\n\t\t\tExcludeFiles: []string{\n\t\t\t\t\"file1.txt\",\n\t\t\t\t\"file2.txt\",\n\t\t\t},\n\t\t}\n\t\thook := &config.Hook{\n\t\t\tExclude: []string{\n\t\t\t\t\"file3.txt\",\n\t\t\t\t\"file4.txt\",\n\t\t\t\t\"file5.txt\",\n\t\t\t},\n\t\t}\n\n\t\tscope := newScope(hook, opts)\n\t\tassert.Equal(t, scope.excludeFiles, []string{\n\t\t\t\"file1.txt\",\n\t\t\t\"file2.txt\",\n\t\t\t\"file3.txt\",\n\t\t\t\"file4.txt\",\n\t\t\t\"file5.txt\",\n\t\t})\n\t\tassert.NotEqual(t, scope.env, nil)\n\t})\n\n\tt.Run(\"without excluded files\", func(t *testing.T) {\n\t\topts := Options{}\n\t\thook := &config.Hook{}\n\n\t\tscope := newScope(hook, opts)\n\t\tassert.Equal(t, scope.excludeFiles, []string{})\n\t})\n\n\tt.Run(\"without excluded files from hook only\", func(t *testing.T) {\n\t\topts := Options{}\n\t\thook := &config.Hook{\n\t\t\tExclude: []string{\n\t\t\t\t\"file1.txt\",\n\t\t\t\t\"file2.txt\",\n\t\t\t},\n\t\t}\n\n\t\tscope := newScope(hook, opts)\n\t\tassert.Equal(t, scope.excludeFiles, []string{\n\t\t\t\"file1.txt\",\n\t\t\t\"file2.txt\",\n\t\t})\n\t})\n}\n\nfunc TestScope_extend(t *testing.T) {\n\tfor i, tt := range [...]struct {\n\t\tinitial *scope\n\t\tjob     *config.Job\n\t\tresult  *scope\n\t}{\n\t\t{\n\t\t\tinitial: &scope{},\n\t\t\tjob: configtest.ParseJob(`\n        run: echo\n        glob:\n          - \"*.js\"\n          - \"*.jsx\"\n        exclude:\n          - \"folder/*.sh\"\n      `),\n\t\t\tresult: &scope{\n\t\t\t\tglob:         []string{\"*.js\", \"*.jsx\"},\n\t\t\t\texcludeFiles: []string{\"folder/*.sh\"},\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tinitial: &scope{},\n\t\t\tjob: configtest.ParseJob(`\n        run: echo\n        glob:\n          - \"*.js\"\n          - \"*.jsx\"\n        env:\n          VERSION: 1\n          UI_ENABLE: false\n          SERVICE_TOKEN: \"secret\"\n        files: ls -A\n        root: subdir/\n      `),\n\t\t\tresult: &scope{\n\t\t\t\tglob: []string{\"*.js\", \"*.jsx\"},\n\t\t\t\tenv: map[string]string{\n\t\t\t\t\t\"VERSION\":       \"1\",\n\t\t\t\t\t\"UI_ENABLE\":     \"false\",\n\t\t\t\t\t\"SERVICE_TOKEN\": \"secret\",\n\t\t\t\t},\n\t\t\t\tfilesCmd: \"ls -A\",\n\t\t\t\troot:     \"subdir/\",\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tinitial: &scope{\n\t\t\t\tfileTypes: []string{\n\t\t\t\t\t\"text\",\n\t\t\t\t\t\"not executable\",\n\t\t\t\t},\n\t\t\t},\n\t\t\tjob: configtest.ParseJob(`\n         file_types:\n           - not symlink\n      `),\n\t\t\tresult: &scope{\n\t\t\t\tfileTypes: []string{\n\t\t\t\t\t\"text\",\n\t\t\t\t\t\"not executable\",\n\t\t\t\t\t\"not symlink\",\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t} {\n\t\tt.Run(strconv.Itoa(i), func(t *testing.T) {\n\t\t\tresult := tt.initial.extend(tt.job)\n\t\t\tassert.Equal(t, tt.result, result)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/run/controller/setup.go",
    "content": "package controller\n\nimport (\n\t\"context\"\n\t\"io\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/config\"\n\t\"github.com/evilmartians/lefthook/v2/internal/log\"\n\t\"github.com/evilmartians/lefthook/v2/internal/run/controller/command/replacer\"\n\t\"github.com/evilmartians/lefthook/v2/internal/run/controller/exec\"\n\t\"github.com/evilmartians/lefthook/v2/internal/system\"\n)\n\nfunc (c *Controller) setup(\n\tctx context.Context,\n\topts Options,\n\tsetupInstructions []*config.SetupInstruction,\n) error {\n\tif len(setupInstructions) == 0 {\n\t\treturn nil\n\t}\n\n\tlog.StopSpinner()\n\tdefer log.StartSpinner()\n\n\treplacer := replacer.New(c.git, \"\", \"\").\n\t\tAddTemplates(opts.Templates).\n\t\tAddGitArgs(opts.GitArgs)\n\n\tcommands := make([]string, 0, len(setupInstructions))\n\tfor _, instr := range setupInstructions {\n\t\tif err := replacer.Discover(instr.Run, nil); err != nil {\n\t\t\treturn err\n\t\t}\n\n\t\trawCommands, _ := replacer.ReplaceAndSplit(instr.Run, system.MaxCmdLen())\n\t\tcommands = append(commands, rawCommands...)\n\t}\n\n\tr, w := io.Pipe()\n\tlog.LogSetup(r)\n\n\terr := c.executor.Execute(ctx, exec.Options{Commands: commands}, system.NullReader, w)\n\t_ = w.Close()\n\n\treturn err\n}\n"
  },
  {
    "path": "internal/run/controller/utils/cached_reader.go",
    "content": "package utils\n\nimport (\n\t\"bytes\"\n\t\"io\"\n)\n\n// CachedReader reads from the provided `io.Reader` until `io.EOF` and saves\n// the read content into the inner buffer.\n//\n// After `io.EOF` it will be providing the read data again and again.\ntype CachedReader struct {\n\tin        io.Reader\n\tuseBuffer bool\n\tbuf       []byte\n\treader    *bytes.Reader\n}\n\nfunc NewCachedReader(in io.Reader) *CachedReader {\n\treturn &CachedReader{\n\t\tin:     in,\n\t\tbuf:    []byte{},\n\t\treader: bytes.NewReader([]byte{}),\n\t}\n}\n\nfunc (r *CachedReader) Read(p []byte) (int, error) {\n\tif r.useBuffer {\n\t\tn, err := r.reader.Read(p)\n\t\tif err == io.EOF {\n\t\t\t_, seekErr := r.reader.Seek(0, io.SeekStart)\n\t\t\tif seekErr != nil {\n\t\t\t\tpanic(seekErr)\n\t\t\t}\n\n\t\t\treturn n, err\n\t\t}\n\n\t\treturn n, err\n\t}\n\n\tn, err := r.in.Read(p)\n\tr.buf = append(r.buf, p[:n]...)\n\tif err == io.EOF {\n\t\tr.useBuffer = true\n\t\tr.reader = bytes.NewReader(r.buf)\n\t}\n\treturn n, err\n}\n"
  },
  {
    "path": "internal/run/controller/utils/cached_reader_test.go",
    "content": "package utils\n\nimport (\n\t\"bytes\"\n\t\"io\"\n\t\"testing\"\n)\n\nfunc TestCachedReader(t *testing.T) {\n\ttestSlice := []byte(\"Some example string\\nMultiline\")\n\n\tcachedReader := NewCachedReader(bytes.NewReader(testSlice))\n\n\tfor range 5 {\n\t\tres, err := io.ReadAll(cachedReader)\n\t\tif err != nil {\n\t\t\tt.Errorf(\"unexpected err: %s\", err)\n\t\t}\n\n\t\tif !bytes.Equal(res, testSlice) {\n\t\t\tt.Errorf(\"expected %v to be equal to %v\", res, testSlice)\n\t\t}\n\t}\n}\n"
  },
  {
    "path": "internal/run/controller/utils/firstNonBlank.go",
    "content": "package utils\n\n// FirstNonBlank returns first non-empty string from given args.\nfunc FirstNonBlank(args ...string) string {\n\tfor _, a := range args {\n\t\tif len(a) > 0 {\n\t\t\treturn a\n\t\t}\n\t}\n\n\treturn \"\"\n}\n"
  },
  {
    "path": "internal/run/controller/utils/intersect.go",
    "content": "package utils\n\n// Intersect returns true if values of two slices have at least one similar value.\nfunc Intersect[K comparable](a, b []K) bool {\n\tintersections := make(map[K]struct{}, len(a))\n\n\tfor _, v := range a {\n\t\tintersections[v] = struct{}{}\n\t}\n\n\tfor _, v := range b {\n\t\tif _, ok := intersections[v]; ok {\n\t\t\treturn true\n\t\t}\n\t}\n\n\treturn false\n}\n"
  },
  {
    "path": "internal/run/result/result.go",
    "content": "package result\n\nimport \"time\"\n\ntype status int8\n\nconst (\n\tsuccess status = iota\n\tfailure\n\tskip\n)\n\n// Result contains name of a command/script, an optional fail string, and execution duration.\ntype Result struct {\n\tSub      []Result\n\tName     string\n\ttext     string\n\tstatus   status\n\tDuration time.Duration\n}\n\nfunc (r Result) Success() bool {\n\treturn r.status == success\n}\n\nfunc (r Result) Failure() bool {\n\treturn r.status == failure\n}\n\nfunc (r Result) Text() string {\n\treturn r.text\n}\n\nfunc Skip(name string) Result {\n\treturn Result{Name: name, status: skip}\n}\n\nfunc Success(name string, duration time.Duration) Result {\n\treturn Result{Name: name, status: success, Duration: duration}\n}\n\nfunc Failure(name, text string, duration time.Duration) Result {\n\treturn Result{Name: name, status: failure, text: text, Duration: duration}\n}\n\nfunc Group(name string, results []Result) Result {\n\tstat := success\n\tallSkip := true\n\tvar totalDuration time.Duration\n\tfor _, res := range results {\n\t\tswitch res.status {\n\t\tcase success:\n\t\t\tallSkip = false\n\t\tcase failure:\n\t\t\tstat = failure\n\t\t\tallSkip = false\n\t\tcase skip:\n\t\t}\n\t\ttotalDuration += res.Duration\n\t}\n\n\tif allSkip {\n\t\tstat = skip\n\t}\n\n\treturn Result{Name: name, status: stat, Sub: results, Duration: totalDuration}\n}\n"
  },
  {
    "path": "internal/run/result/result_test.go",
    "content": "package result\n\nimport (\n\t\"fmt\"\n\t\"testing\"\n\t\"time\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestGroup(t *testing.T) {\n\tfor i, tt := range [...]struct {\n\t\tname     string\n\t\tresults  []Result\n\t\texpected Result\n\t}{\n\t\t{\n\t\t\tname:    \"empty results\",\n\t\t\tresults: []Result{},\n\t\t\texpected: Result{\n\t\t\t\tName:     \"test-group\",\n\t\t\t\tstatus:   skip,\n\t\t\t\tSub:      []Result{},\n\t\t\t\tDuration: 0,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"all success results\",\n\t\t\tresults: []Result{\n\t\t\t\tSuccess(\"cmd1\", 100*time.Millisecond),\n\t\t\t\tSuccess(\"cmd2\", 200*time.Millisecond),\n\t\t\t\tSuccess(\"cmd3\", 150*time.Millisecond),\n\t\t\t},\n\t\t\texpected: Result{\n\t\t\t\tName:   \"test-group\",\n\t\t\t\tstatus: success,\n\t\t\t\tSub: []Result{\n\t\t\t\t\tSuccess(\"cmd1\", 100*time.Millisecond),\n\t\t\t\t\tSuccess(\"cmd2\", 200*time.Millisecond),\n\t\t\t\t\tSuccess(\"cmd3\", 150*time.Millisecond),\n\t\t\t\t},\n\t\t\t\tDuration: 450 * time.Millisecond,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"all skip results\",\n\t\t\tresults: []Result{\n\t\t\t\tSkip(\"cmd1\"),\n\t\t\t\tSkip(\"cmd2\"),\n\t\t\t\tSkip(\"cmd3\"),\n\t\t\t},\n\t\t\texpected: Result{\n\t\t\t\tName:   \"test-group\",\n\t\t\t\tstatus: skip,\n\t\t\t\tSub: []Result{\n\t\t\t\t\tSkip(\"cmd1\"),\n\t\t\t\t\tSkip(\"cmd2\"),\n\t\t\t\t\tSkip(\"cmd3\"),\n\t\t\t\t},\n\t\t\t\tDuration: 0,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"all failure results\",\n\t\t\tresults: []Result{\n\t\t\t\tFailure(\"cmd1\", \"error 1\", 50*time.Millisecond),\n\t\t\t\tFailure(\"cmd2\", \"error 2\", 75*time.Millisecond),\n\t\t\t},\n\t\t\texpected: Result{\n\t\t\t\tName:   \"test-group\",\n\t\t\t\tstatus: failure,\n\t\t\t\tSub: []Result{\n\t\t\t\t\tFailure(\"cmd1\", \"error 1\", 50*time.Millisecond),\n\t\t\t\t\tFailure(\"cmd2\", \"error 2\", 75*time.Millisecond),\n\t\t\t\t},\n\t\t\t\tDuration: 125 * time.Millisecond,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"mixed success and skip\",\n\t\t\tresults: []Result{\n\t\t\t\tSuccess(\"cmd1\", 100*time.Millisecond),\n\t\t\t\tSkip(\"cmd2\"),\n\t\t\t\tSuccess(\"cmd3\", 200*time.Millisecond),\n\t\t\t},\n\t\t\texpected: Result{\n\t\t\t\tName:   \"test-group\",\n\t\t\t\tstatus: success,\n\t\t\t\tSub: []Result{\n\t\t\t\t\tSuccess(\"cmd1\", 100*time.Millisecond),\n\t\t\t\t\tSkip(\"cmd2\"),\n\t\t\t\t\tSuccess(\"cmd3\", 200*time.Millisecond),\n\t\t\t\t},\n\t\t\t\tDuration: 300 * time.Millisecond,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"mixed success and failure\",\n\t\t\tresults: []Result{\n\t\t\t\tSuccess(\"cmd1\", 100*time.Millisecond),\n\t\t\t\tFailure(\"cmd2\", \"failed\", 50*time.Millisecond),\n\t\t\t\tSuccess(\"cmd3\", 75*time.Millisecond),\n\t\t\t},\n\t\t\texpected: Result{\n\t\t\t\tName:   \"test-group\",\n\t\t\t\tstatus: failure,\n\t\t\t\tSub: []Result{\n\t\t\t\t\tSuccess(\"cmd1\", 100*time.Millisecond),\n\t\t\t\t\tFailure(\"cmd2\", \"failed\", 50*time.Millisecond),\n\t\t\t\t\tSuccess(\"cmd3\", 75*time.Millisecond),\n\t\t\t\t},\n\t\t\t\tDuration: 225 * time.Millisecond,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"mixed skip and failure\",\n\t\t\tresults: []Result{\n\t\t\t\tSkip(\"cmd1\"),\n\t\t\t\tFailure(\"cmd2\", \"failed\", 100*time.Millisecond),\n\t\t\t\tSkip(\"cmd3\"),\n\t\t\t},\n\t\t\texpected: Result{\n\t\t\t\tName:   \"test-group\",\n\t\t\t\tstatus: failure,\n\t\t\t\tSub: []Result{\n\t\t\t\t\tSkip(\"cmd1\"),\n\t\t\t\t\tFailure(\"cmd2\", \"failed\", 100*time.Millisecond),\n\t\t\t\t\tSkip(\"cmd3\"),\n\t\t\t\t},\n\t\t\t\tDuration: 100 * time.Millisecond,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"all three statuses mixed\",\n\t\t\tresults: []Result{\n\t\t\t\tSuccess(\"cmd1\", 50*time.Millisecond),\n\t\t\t\tSkip(\"cmd2\"),\n\t\t\t\tFailure(\"cmd3\", \"error\", 25*time.Millisecond),\n\t\t\t\tSuccess(\"cmd4\", 125*time.Millisecond),\n\t\t\t},\n\t\t\texpected: Result{\n\t\t\t\tName:   \"test-group\",\n\t\t\t\tstatus: failure,\n\t\t\t\tSub: []Result{\n\t\t\t\t\tSuccess(\"cmd1\", 50*time.Millisecond),\n\t\t\t\t\tSkip(\"cmd2\"),\n\t\t\t\t\tFailure(\"cmd3\", \"error\", 25*time.Millisecond),\n\t\t\t\t\tSuccess(\"cmd4\", 125*time.Millisecond),\n\t\t\t\t},\n\t\t\t\tDuration: 200 * time.Millisecond,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"single success result\",\n\t\t\tresults: []Result{\n\t\t\t\tSuccess(\"single-cmd\", 300*time.Millisecond),\n\t\t\t},\n\t\t\texpected: Result{\n\t\t\t\tName:   \"test-group\",\n\t\t\t\tstatus: success,\n\t\t\t\tSub: []Result{\n\t\t\t\t\tSuccess(\"single-cmd\", 300*time.Millisecond),\n\t\t\t\t},\n\t\t\t\tDuration: 300 * time.Millisecond,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"single skip result\",\n\t\t\tresults: []Result{\n\t\t\t\tSkip(\"single-cmd\"),\n\t\t\t},\n\t\t\texpected: Result{\n\t\t\t\tName:   \"test-group\",\n\t\t\t\tstatus: skip,\n\t\t\t\tSub: []Result{\n\t\t\t\t\tSkip(\"single-cmd\"),\n\t\t\t\t},\n\t\t\t\tDuration: 0,\n\t\t\t},\n\t\t},\n\t\t{\n\t\t\tname: \"single failure result\",\n\t\t\tresults: []Result{\n\t\t\t\tFailure(\"single-cmd\", \"single error\", 150*time.Millisecond),\n\t\t\t},\n\t\t\texpected: Result{\n\t\t\t\tName:   \"test-group\",\n\t\t\t\tstatus: failure,\n\t\t\t\tSub: []Result{\n\t\t\t\t\tFailure(\"single-cmd\", \"single error\", 150*time.Millisecond),\n\t\t\t\t},\n\t\t\t\tDuration: 150 * time.Millisecond,\n\t\t\t},\n\t\t},\n\t} {\n\t\tt.Run(fmt.Sprintf(\"test %d: %s\", i, tt.name), func(t *testing.T) {\n\t\t\tassert := assert.New(t)\n\t\t\tresult := Group(\"test-group\", tt.results)\n\n\t\t\tassert.Equal(tt.expected.Name, result.Name)\n\t\t\tassert.Equal(tt.expected.status, result.status)\n\t\t\tassert.Equal(tt.expected.Duration, result.Duration)\n\t\t\tassert.EqualValues(tt.expected.Sub, result.Sub)\n\n\t\t\t// Test the status methods\n\t\t\tswitch tt.expected.status {\n\t\t\tcase success:\n\t\t\t\tassert.True(result.Success())\n\t\t\t\tassert.False(result.Failure())\n\t\t\tcase failure:\n\t\t\t\tassert.False(result.Success())\n\t\t\t\tassert.True(result.Failure())\n\t\t\tcase skip:\n\t\t\t\tassert.False(result.Success())\n\t\t\t\tassert.False(result.Failure())\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/run/run.go",
    "content": "package run\n\nimport (\n\t\"context\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/config\"\n\t\"github.com/evilmartians/lefthook/v2/internal/git\"\n\t\"github.com/evilmartians/lefthook/v2/internal/run/controller\"\n\t\"github.com/evilmartians/lefthook/v2/internal/run/result\"\n)\n\n// FailOnChangesError is a special error that fails the hook if any project file was changed.\n//\n// Exported here to be handled separately on the caller side.\ntype FailOnChangesError = controller.FailOnChangesError\n\n// Options contain hook arguments and special execution settings.\ntype Options = controller.Options\n\n// Run executes the hook.\nfunc Run(\n\tctx context.Context,\n\thook *config.Hook,\n\trepo *git.Repository,\n\topts Options,\n) ([]result.Result, error) {\n\treturn controller.NewController(repo).RunHook(ctx, opts, hook)\n}\n"
  },
  {
    "path": "internal/system/command.go",
    "content": "// Package system contains wrappers for OS interactions.\npackage system\n\nimport (\n\t\"context\"\n\t\"io\"\n\t\"os\"\n\t\"os/exec\"\n\t\"strings\"\n)\n\ntype osCmd struct {\n\texcludeEnvs []string\n}\n\nvar Cmd = osCmd{}\n\ntype Command interface {\n\tWithoutEnvs(...string) Command\n\tRun([]string, string, io.Reader, io.Writer, io.Writer) error\n}\n\ntype CommandWithContext interface {\n\tRunWithContext(context.Context, []string, string, io.Reader, io.Writer, io.Writer) error\n}\n\nfunc (c osCmd) WithoutEnvs(envs ...string) Command {\n\tc.excludeEnvs = envs\n\treturn c\n}\n\nfunc (c osCmd) Run(command []string, root string, in io.Reader, out io.Writer, errOut io.Writer) error {\n\treturn c.RunWithContext(context.Background(), command, root, in, out, errOut)\n}\n\n// RunWithContext runs system command with LEFTHOOK=0 in order to prevent calling\n// subsequent lefthook hooks.\nfunc (c osCmd) RunWithContext(\n\tctx context.Context,\n\tcommand []string,\n\troot string,\n\tin io.Reader,\n\tout io.Writer,\n\terrOut io.Writer,\n) error {\n\tcmd := exec.CommandContext(ctx, command[0], command[1:]...)\n\tif len(c.excludeEnvs) > 0 {\n\tloop:\n\t\tfor _, env := range os.Environ() {\n\t\t\tfor _, noenv := range c.excludeEnvs {\n\t\t\t\tif strings.HasPrefix(env, noenv) {\n\t\t\t\t\tcontinue loop\n\t\t\t\t}\n\t\t\t}\n\t\t\tcmd.Env = append(cmd.Env, env)\n\t\t}\n\t\tcmd.Env = append(cmd.Env, \"LEFTHOOK=0\")\n\t} else {\n\t\tcmd.Env = os.Environ()\n\t\tcmd.Env = append(cmd.Env, \"LEFTHOOK=0\")\n\t}\n\n\tif len(root) > 0 {\n\t\tcmd.Dir = root\n\t}\n\n\tcmd.Stdin = in\n\tcmd.Stdout = out\n\tcmd.Stderr = errOut\n\n\terr := cmd.Run()\n\n\treturn err\n}\n"
  },
  {
    "path": "internal/system/limits.go",
    "content": "package system\n\nimport \"runtime\"\n\nconst (\n\t// https://serverfault.com/questions/69430/what-is-the-maximum-length-of-a-command-line-in-mac-os-x\n\t// https://support.microsoft.com/en-us/help/830473/command-prompt-cmd-exe-command-line-string-limitation\n\t// https://unix.stackexchange.com/a/120652\n\tmaxCommandLengthDarwin  = 260000 // 262144\n\tmaxCommandLengthWindows = 7000   // 8191, but see issues#655\n\tmaxCommandLengthLinux   = 130000 // 131072\n)\n\nfunc MaxCmdLen() int {\n\tswitch runtime.GOOS {\n\tcase \"windows\":\n\t\treturn maxCommandLengthWindows\n\tcase \"darwin\":\n\t\treturn maxCommandLengthDarwin\n\tdefault:\n\t\treturn maxCommandLengthLinux\n\t}\n}\n"
  },
  {
    "path": "internal/system/null_reader.go",
    "content": "package system\n\nimport \"io\"\n\n// nullReader always returns `io.EOF`.\ntype nullReader struct{}\n\nvar NullReader = nullReader{}\n\n// Read implements the io.Reader interface.\nfunc (nullReader) Read(b []byte) (int, error) {\n\treturn 0, io.EOF\n}\n"
  },
  {
    "path": "internal/system/null_reader_test.go",
    "content": "package system\n\nimport (\n\t\"bytes\"\n\t\"io\"\n\t\"testing\"\n)\n\nfunc TestNullReader(t *testing.T) {\n\tres, err := io.ReadAll(NullReader)\n\tif err != nil {\n\t\tt.Errorf(\"unexpected err: %s\", err)\n\t}\n\n\tif !bytes.Equal(res, []byte{}) {\n\t\tt.Errorf(\"expected %v to be equal to %v\", res, []byte{})\n\t}\n}\n"
  },
  {
    "path": "internal/system/sh_unix.go",
    "content": "//go:build !windows\n\npackage system\n\n// Sh returns `sh` executable name.\nfunc Sh() (string, error) {\n\treturn \"sh\", nil\n}\n"
  },
  {
    "path": "internal/system/sh_windows.go",
    "content": "//go:build windows\n\npackage system\n\nimport (\n\t\"fmt\"\n\t\"os\"\n\t\"os/exec\"\n\t\"path/filepath\"\n\t\"sync\"\n)\n\nconst (\n\tsh            = \"sh\"\n\tdefaultShPath = `C:\\Program Files\\Git\\bin\\sh.exe`\n)\n\nvar fullPath = sync.OnceValues(func() (string, error) {\n\tif _, err := os.Stat(defaultShPath); err == nil {\n\t\treturn defaultShPath, nil\n\t}\n\n\tshPath, _ := exec.LookPath(\"sh\")\n\tif len(shPath) > 0 {\n\t\treturn shPath, nil\n\t}\n\n\tgitPath, err := exec.LookPath(\"git\")\n\tif err != nil {\n\t\treturn \"\", err\n\t}\n\n\tshPath = filepath.Join(gitPath, \"..\", \"..\", \"bin\", \"sh.exe\")\n\tif _, err := os.Stat(shPath); err != nil {\n\t\treturn \"\", err\n\t}\n\n\treturn shPath, nil\n})\n\n// Sh returns the path to a shell or an error if it can't find `sh` executable.\nfunc Sh() (string, error) {\n\t// In case Git runs lefthook from hooks.\n\t// Git hooks always setup GIT_INDEX env variable so here we check if we are in\n\t// a Git hook and can use `sh` without specifying the full path. This should cover most use cases.\n\tif len(os.Getenv(\"GIT_INDEX_FILE\")) != 0 {\n\t\treturn sh, nil\n\t}\n\n\t// In case you call `lefthook run ...` from the terminal\n\tshPath, err := fullPath()\n\tif err != nil {\n\t\treturn \"\", fmt.Errorf(\"`sh` lookup failed: %w\", err)\n\t}\n\n\treturn shPath, nil\n}\n"
  },
  {
    "path": "internal/templates/config.tmpl",
    "content": "# EXAMPLE USAGE:\n#\n#   Refer for explanation to following link:\n#   https://lefthook.dev/configuration/\n#\n# pre-push:\n#   jobs:\n#     - name: packages audit\n#       tags:\n#         - frontend\n#         - security\n#       run: yarn audit\n#\n#     - name: gems audit\n#       tags:\n#         - backend\n#         - security\n#       run: bundle audit\n#\n# pre-commit:\n#   parallel: true\n#   jobs:\n#     - run: yarn eslint {staged_files}\n#       glob: \"*.{js,ts,jsx,tsx}\"\n#\n#     - name: rubocop\n#       glob: \"*.rb\"\n#       exclude:\n#         - config/application.rb\n#         - config/routes.rb\n#       run: bundle exec rubocop --force-exclusion -- {all_files}\n#\n#     - name: govet\n#       files: git ls-files -m\n#       glob: \"*.go\"\n#       run: go vet -- {files}\n#\n#     - script: \"hello.js\"\n#       runner: node\n#\n#     - script: \"hello.go\"\n#       runner: go run\n"
  },
  {
    "path": "internal/templates/hook.tmpl",
    "content": "#!/bin/sh\n\nif [ \"$LEFTHOOK_VERBOSE\" = \"1\" -o \"$LEFTHOOK_VERBOSE\" = \"true\" ]; then\n  set -x\nfi\n\nif [ \"$LEFTHOOK\" = \"0\" ]; then\n  exit 0\nfi\n\n{{- if .Rc}}\n{{/* Load rc file, which may export ENV variables */}}\n[ -f {{.Rc}} ] && . {{.Rc}}\n{{- end}}\n\ncall_lefthook()\n{\n  if test -n \"$LEFTHOOK_BIN\"\n  then\n    \"$LEFTHOOK_BIN\" \"$@\"\n  {{ if .LefthookPath -}}\n  elif test -n \"{{ .LefthookPath }}\"\n  then\n    {{ .LefthookPath }} \"$@\"\n  {{ end -}}\n  elif lefthook{{.Extension}} -h >/dev/null 2>&1\n  then\n    lefthook{{.Extension}} \"$@\"\n  {{ if .Extension -}}\n  {{/* Check if lefthook.bat exists. Ruby bundler creates such a wrapper */ -}}\n  elif lefthook.bat -h >/dev/null 2>&1\n  then\n    lefthook.bat \"$@\"\n  {{ end -}}\n  {{ if .LefthookPathCurrent -}}\n  elif {{ .LefthookPathCurrent }} -h >/dev/null 2>&1\n  then\n    {{ .LefthookPathCurrent }} \"$@\"\n  {{ end -}}\n  else\n    dir=\"$(git rev-parse --show-toplevel)\"\n    osArch=$(uname | tr '[:upper:]' '[:lower:]')\n    cpuArch=$(uname -m | sed 's/aarch64/arm64/;s/x86_64/x64/')\n    if test -f \"$dir/node_modules/lefthook-${osArch}-${cpuArch}/bin/lefthook{{.Extension}}\"\n    then\n      \"$dir/node_modules/lefthook-${osArch}-${cpuArch}/bin/lefthook{{.Extension}}\" \"$@\"\n    elif test -f \"$dir/node_modules/@evilmartians/lefthook/bin/lefthook-${osArch}-${cpuArch}/lefthook{{.Extension}}\"\n    then\n      \"$dir/node_modules/@evilmartians/lefthook/bin/lefthook-${osArch}-${cpuArch}/lefthook{{.Extension}}\" \"$@\"\n    elif test -f \"$dir/node_modules/@evilmartians/lefthook-installer/bin/lefthook{{.Extension}}\"\n    then\n      \"$dir/node_modules/@evilmartians/lefthook-installer/bin/lefthook{{.Extension}}\" \"$@\"\n    elif test -f \"$dir/node_modules/lefthook/bin/index.js\"\n    then\n      \"$dir/node_modules/lefthook/bin/index.js\" \"$@\"\n    {{ $extension := .Extension -}}\n    {{ range .Roots -}}\n    elif test -f \"$dir/{{.}}/node_modules/lefthook-${osArch}-${cpuArch}/bin/lefthook{{$extension}}\"\n    then\n      \"$dir/{{.}}/node_modules/lefthook-${osArch}-${cpuArch}/bin/lefthook{{$extension}}\" \"$@\"\n    elif test -f \"$dir/{{.}}/node_modules/@evilmartians/lefthook/bin/lefthook-${osArch}-${cpuArch}/lefthook{{$extension}}\"\n    then\n      \"$dir/{{.}}/node_modules/@evilmartians/lefthook/bin/lefthook-${osArch}-${cpuArch}/lefthook{{$extension}}\" \"$@\"\n    elif test -f \"$dir/{{.}}/node_modules/@evilmartians/lefthook-installer/bin/lefthook{{$extension}}\"\n    then\n      \"$dir/{{.}}/node_modules/@evilmartians/lefthook-installer/bin/lefthook{{$extension}}\" \"$@\"\n    elif test -f \"$dir/{{.}}/node_modules/lefthook/bin/index.js\"\n    then\n      \"$dir/{{.}}/node_modules/lefthook/bin/index.js\" \"$@\"\n    {{ end -}}\n    elif go tool lefthook -h >/dev/null 2>&1\n    then\n      go tool lefthook \"$@\"\n    elif bundle exec lefthook -h >/dev/null 2>&1\n    then\n      bundle exec lefthook \"$@\"\n    elif yarn lefthook -h >/dev/null 2>&1\n    then\n      yarn lefthook \"$@\"\n    elif pnpm lefthook -h >/dev/null 2>&1\n    then\n      pnpm lefthook \"$@\"\n    elif swift package lefthook >/dev/null 2>&1\n    then\n      swift package --build-path .build/lefthook --disable-sandbox lefthook \"$@\"\n    elif command -v mint >/dev/null 2>&1\n    then\n      mint run csjones/lefthook-plugin \"$@\"\n    elif uv run lefthook -h >/dev/null 2>&1\n    then\n      uv run lefthook \"$@\"\n    elif mise exec -- lefthook -h >/dev/null 2>&1\n    then\n      mise exec -- lefthook \"$@\"\n    elif devbox run lefthook -h >/dev/null 2>&1\n    then\n      devbox run lefthook \"$@\"\n    else\n      echo \"Can't find lefthook in PATH\"\n      {{- if .AssertLefthookInstalled}}\n      echo \"ERROR: Operation is aborted due to lefthook settings.\"\n      echo \"Make sure lefthook is available in your environment and re-try.\"\n      echo \"To skip these checks use --no-verify git argument or set LEFTHOOK=0 env variable.\"\n      exit 1\n      {{- end}}\n    fi\n  fi\n}\n\ncall_lefthook run \"{{.HookName}}\" \"$@\"\n"
  },
  {
    "path": "internal/templates/templates.go",
    "content": "package templates\n\nimport (\n\t\"bytes\"\n\t\"embed\"\n\t\"fmt\"\n\t\"os\"\n\t\"runtime\"\n\t\"strings\"\n\t\"text/template\"\n)\n\nconst checksumFormat = \"%s %d %s\\n\"\n\n//go:embed *\nvar templatesFS embed.FS\n\ntype Args struct {\n\tRc                      string\n\tLefthookPath            string\n\tAssertLefthookInstalled bool\n\tRoots                   []string\n}\n\ntype hookTmplData struct {\n\tHookName                string\n\tExtension               string\n\tLefthookPath            string\n\tLefthookPathCurrent     string\n\tRc                      string\n\tRoots                   []string\n\tAssertLefthookInstalled bool\n}\n\nfunc Hook(hookName string, args Args) []byte {\n\tlefthookPathCurrent, err := os.Executable()\n\tif err != nil {\n\t\tlefthookPathCurrent = \"\"\n\t}\n\n\tbuf := &bytes.Buffer{}\n\tt := template.Must(template.ParseFS(templatesFS, \"hook.tmpl\"))\n\tif err = t.Execute(buf, hookTmplData{\n\t\tHookName:                hookName,\n\t\tExtension:               getExtension(),\n\t\tRc:                      args.Rc,\n\t\tAssertLefthookInstalled: args.AssertLefthookInstalled,\n\t\tRoots:                   args.Roots,\n\t\tLefthookPath:            strings.ReplaceAll(strings.TrimSpace(args.LefthookPath), \"\\n\", \";\"),\n\t\tLefthookPathCurrent:     lefthookPathCurrent,\n\t}); err != nil {\n\t\tpanic(err)\n\t}\n\n\treturn buf.Bytes()\n}\n\nfunc Config() []byte {\n\ttmpl, err := templatesFS.ReadFile(\"config.tmpl\")\n\tif err != nil {\n\t\tpanic(err)\n\t}\n\n\treturn tmpl\n}\n\nfunc Checksum(checksum string, timestamp int64, hooks []string) []byte {\n\treturn fmt.Appendf(nil, checksumFormat, checksum, timestamp, strings.Join(hooks, \",\"))\n}\n\nfunc getExtension() string {\n\tif runtime.GOOS == \"windows\" {\n\t\treturn \".exe\"\n\t}\n\treturn \"\"\n}\n"
  },
  {
    "path": "internal/updater/updater.go",
    "content": "// Package updater contains the self-update implementation for the lefthook executable.\npackage updater\n\nimport (\n\t\"bufio\"\n\t\"context\"\n\t\"crypto/sha256\"\n\t\"encoding/hex\"\n\t\"encoding/json\"\n\t\"errors\"\n\t\"fmt\"\n\t\"io\"\n\t\"io/fs\"\n\t\"net/http\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"strconv\"\n\t\"strings\"\n\t\"time\"\n\n\t\"github.com/schollz/progressbar/v3\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/log\"\n\t\"github.com/evilmartians/lefthook/v2/internal/version\"\n)\n\nconst (\n\ttimeout                       = 120 * time.Second\n\tlatestReleaseURL              = \"https://api.github.com/repos/evilmartians/lefthook/releases/latest\"\n\tchecksumsFilename             = \"lefthook_checksums.txt\"\n\tchecksumFields                = 2\n\tmodExecutable     os.FileMode = 0o755\n)\n\nvar (\n\terrNoAsset        = errors.New(\"couldn't find an asset to download. Please submit an issue to https://github.com/evilmartians/lefthook\")\n\terrInvalidHashsum = 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\")\n\terrUpdateFailed   = errors.New(\"update failed\")\n\n\tosNames = map[string]string{\n\t\t\"windows\": \"Windows\",\n\t\t\"darwin\":  \"MacOS\",\n\t\t\"linux\":   \"Linux\",\n\t\t\"freebsd\": \"Freebsd\",\n\t\t\"openbsd\": \"Openbsd\",\n\t}\n\n\tarchNames = map[string]string{\n\t\t\"amd64\": \"x86_64\",\n\t\t\"arm64\": \"arm64\",\n\t\t\"386\":   \"i386\",\n\t}\n)\n\ntype release struct {\n\tTagName string `json:\"tag_name\"`\n\tAssets  []asset\n}\n\ntype asset struct {\n\tName        string `json:\"name\"`\n\tDownloadURL string `json:\"browser_download_url\"`\n}\n\ntype Options struct {\n\tYes     bool\n\tForce   bool\n\tExePath string\n}\n\ntype Updater struct {\n\tclient     *http.Client\n\treleaseURL string\n}\n\nfunc New() *Updater {\n\treturn &Updater{\n\t\tclient:     &http.Client{Timeout: timeout},\n\t\treleaseURL: latestReleaseURL,\n\t}\n}\n\nfunc (u *Updater) SelfUpdate(ctx context.Context, opts Options) error {\n\trel, ferr := u.fetchLatestRelease(ctx)\n\tif ferr != nil {\n\t\treturn fmt.Errorf(\"couldn't fetch latest release: %w\", ferr)\n\t}\n\n\tlatestVersion := strings.TrimPrefix(rel.TagName, \"v\")\n\n\tif latestVersion == version.Version(false) && !opts.Force {\n\t\tlog.Infof(\"Up to date: %s\\n\", latestVersion)\n\t\treturn nil\n\t}\n\n\twantedAsset := fmt.Sprintf(\"lefthook_%s_%s_%s\", latestVersion, osNames[runtime.GOOS], archNames[runtime.GOARCH])\n\tif runtime.GOOS == \"windows\" {\n\t\twantedAsset += \".exe\"\n\t}\n\n\tlog.Debugf(\"Searching assets for %s\", wantedAsset)\n\n\tvar downloadURL string\n\tvar checksumURL string\n\tfor i := range rel.Assets {\n\t\tasset := rel.Assets[i]\n\t\tif len(downloadURL) == 0 && asset.Name == wantedAsset {\n\t\t\tdownloadURL = asset.DownloadURL\n\t\t\tif len(checksumURL) > 0 {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\n\t\tif len(checksumURL) == 0 && asset.Name == checksumsFilename {\n\t\t\tchecksumURL = asset.DownloadURL\n\t\t\tif len(downloadURL) > 0 {\n\t\t\t\tbreak\n\t\t\t}\n\t\t}\n\t}\n\n\tif len(downloadURL) == 0 {\n\t\tlog.Warnf(\"Couldn't find the right asset to download. Wanted: %s\\n\", wantedAsset)\n\t\treturn errNoAsset\n\t}\n\n\tif len(checksumURL) == 0 {\n\t\tlog.Warn(\"Couldn't find checksums\")\n\t}\n\n\tif !opts.Yes {\n\t\tlog.Infof(\"Update %s to %s? %s \", log.Cyan(\"lefthook\"), log.Yellow(latestVersion), log.Gray(\"[Y/n]\"))\n\t\tscanner := bufio.NewScanner(os.Stdin)\n\t\tscanner.Scan()\n\t\tans := scanner.Text()\n\n\t\tif len(ans) > 0 && ans[0] != 'y' && ans[0] != 'Y' {\n\t\t\tlog.Debug(\"Update rejected\")\n\t\t\treturn nil\n\t\t}\n\t}\n\n\tlefthookExePath := opts.ExePath\n\tif realPath, serr := filepath.EvalSymlinks(lefthookExePath); serr == nil {\n\t\tlefthookExePath = realPath\n\t}\n\n\tdestPath := lefthookExePath + \".\" + latestVersion\n\tdefer func() {\n\t\tif _, dErr := os.Stat(destPath); !errors.Is(dErr, fs.ErrNotExist) {\n\t\t\tif dErr = os.Remove(destPath); dErr != nil {\n\t\t\t\tlog.Warnf(\"Could not remove %s: %s\", destPath, dErr)\n\t\t\t}\n\t\t}\n\t}()\n\n\tok, err := u.download(ctx, wantedAsset, downloadURL, checksumURL, destPath)\n\tif err != nil {\n\t\treturn err\n\t}\n\tif !ok {\n\t\treturn errInvalidHashsum\n\t}\n\n\tbackupPath := lefthookExePath + \".bak\"\n\tdefer func() {\n\t\tif _, dErr := os.Stat(backupPath); !errors.Is(dErr, fs.ErrNotExist) {\n\t\t\tif dErr = os.Remove(backupPath); dErr != nil {\n\t\t\t\tlog.Warnf(\"Could not remove %s: %s\", backupPath, dErr)\n\t\t\t}\n\t\t}\n\t}()\n\n\tlog.Debugf(\"mv %s %s\", lefthookExePath, backupPath)\n\tif err = os.Rename(lefthookExePath, backupPath); err != nil {\n\t\treturn fmt.Errorf(\"failed to backup lefthook executable: %w\", err)\n\t}\n\n\tlog.Debugf(\"mv %s %s\", destPath, lefthookExePath)\n\terr = os.Rename(destPath, lefthookExePath)\n\tif err != nil {\n\t\tlog.Errorf(\"Failed to replace the lefthook executable: %s\", err)\n\t\tif err = os.Rename(backupPath, lefthookExePath); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to recover from backup: %w\", err)\n\t\t}\n\n\t\treturn errUpdateFailed\n\t}\n\n\tlog.Debugf(\"chmod +x %s\", lefthookExePath)\n\tif err = os.Chmod(lefthookExePath, modExecutable); err != nil {\n\t\tlog.Errorf(\"Failed to set executable file mode: %s\", err)\n\t\tif err = os.Rename(backupPath, lefthookExePath); err != nil {\n\t\t\treturn fmt.Errorf(\"failed to recover from backup: %w\", err)\n\t\t}\n\n\t\treturn errUpdateFailed\n\t}\n\n\treturn nil\n}\n\nfunc (u *Updater) fetchLatestRelease(ctx context.Context) (*release, error) {\n\treq, err := http.NewRequestWithContext(ctx, http.MethodGet, u.releaseURL, nil)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to initialize a request: %w\", err)\n\t}\n\treq.Header.Set(\"Accept\", \"application/vnd.github+json\")\n\treq.Header.Set(\"X-GitHub-Api-Version\", \"2022-11-28\")\n\n\tresp, err := u.client.Do(req)\n\tif err != nil {\n\t\treturn nil, fmt.Errorf(\"request failed: %w\", err)\n\t}\n\n\tif resp.StatusCode != http.StatusOK {\n\t\thttpErr := fmt.Errorf(\"response of the Github API was: %s\", resp.Status)\n\t\tms, perr := strconv.ParseInt(resp.Header.Get(\"X-RateLimit-Reset\"), 10, 64)\n\t\tif perr != nil {\n\t\t\treturn nil, httpErr\n\t\t}\n\n\t\treturn nil, errors.Join(httpErr, errors.New(time.Unix(ms, 0).Format(\"Try later on 02.01.2006, at 15:04:05\")))\n\t}\n\n\tvar rel release\n\tif err = errors.Join(json.NewDecoder(resp.Body).Decode(&rel), resp.Body.Close()); err != nil {\n\t\treturn nil, fmt.Errorf(\"failed to parse the Github response: %w\", err)\n\t}\n\n\treturn &rel, nil\n}\n\nfunc (u *Updater) download(ctx context.Context, name, fileURL, checksumURL, path string) (bool, error) {\n\tlog.Debugf(\"Downloading %s to %s\", fileURL, path)\n\n\tfilereq, err := http.NewRequestWithContext(ctx, http.MethodGet, fileURL, nil)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"failed to build download request: %w\", err)\n\t}\n\n\tsumreq, err := http.NewRequestWithContext(ctx, http.MethodGet, checksumURL, nil)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"failed to build checksum download request: %w\", err)\n\t}\n\n\tresp, err := u.client.Do(filereq)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"download request failed: %w\", err)\n\t}\n\tdefer func() {\n\t\tif cErr := resp.Body.Close(); cErr != nil {\n\t\t\tlog.Warnf(\"Could not close %s response body: %s\", resp.Request.URL, cErr)\n\t\t}\n\t}()\n\n\tchecksumResp, err := u.client.Do(sumreq)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"checksum download request failed: %w\", err)\n\t}\n\tdefer func() {\n\t\tif cErr := checksumResp.Body.Close(); cErr != nil {\n\t\t\tlog.Warnf(\"Could not close %s response body: %s\", checksumResp.Request.URL, cErr)\n\t\t}\n\t}()\n\n\tbar := progressbar.DefaultBytes(resp.ContentLength+checksumResp.ContentLength, name)\n\n\tfile, err := os.Create(path)\n\tif err != nil {\n\t\treturn false, fmt.Errorf(\"failed to create destination path (%s): %w\", path, err)\n\t}\n\n\tfileHasher := sha256.New()\n\t_, err = io.Copy(io.MultiWriter(file, fileHasher, bar), resp.Body)\n\tif err = errors.Join(err, file.Close()); err != nil {\n\t\treturn false, fmt.Errorf(\"failed to download the file: %w\", err)\n\t}\n\tlog.Debug()\n\n\thashsum := hex.EncodeToString(fileHasher.Sum(nil))\n\n\tscanner := bufio.NewScanner(checksumResp.Body)\n\tfor scanner.Scan() {\n\t\tsums := strings.Fields(scanner.Text())\n\t\tif len(sums) < checksumFields {\n\t\t\tcontinue\n\t\t}\n\n\t\tlog.Debugf(\"Checking %s %s\", sums[0], sums[1])\n\t\tif sums[1] == name {\n\t\t\tif sums[0] == hashsum {\n\t\t\t\tif err = bar.Finish(); err != nil {\n\t\t\t\t\tlog.Debugf(\"Progressbar error: %s\", err)\n\t\t\t\t}\n\n\t\t\t\tlog.Debugf(\"Match %s %s\", sums[0], sums[1])\n\n\t\t\t\treturn true, nil\n\t\t\t} else {\n\t\t\t\treturn false, nil\n\t\t\t}\n\t\t}\n\t}\n\tif err = scanner.Err(); err != nil {\n\t\treturn false, fmt.Errorf(\"scan checksum response body: %w\", err)\n\t}\n\n\tlog.Debugf(\"No matches found for %s %s\", name, hashsum)\n\n\treturn false, nil\n}\n"
  },
  {
    "path": "internal/updater/updater_test.go",
    "content": "package updater\n\nimport (\n\t\"encoding/json\"\n\t\"errors\"\n\t\"net/http\"\n\t\"net/http/httptest\"\n\t\"os\"\n\t\"path/filepath\"\n\t\"runtime\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/version\"\n)\n\nfunc TestUpdater_SelfUpdate(t *testing.T) {\n\tvar extension string\n\tif runtime.GOOS == \"windows\" {\n\t\textension = \".exe\"\n\t}\n\texePath := filepath.Join(os.TempDir(), \"lefthook\")\n\tfor name, tt := range map[string]struct {\n\t\tlatestRelease string\n\t\tassetName     string\n\t\tchecksums     string\n\t\topts          Options\n\t\tasset         []byte\n\t\terr           error\n\t}{\n\t\t\"asset not found\": {\n\t\t\tlatestRelease: \"v1.0.0\",\n\t\t\tassetName:     \"lefthook_1.0.0_darwin_arm64\",\n\t\t\topts: Options{\n\t\t\t\tYes:     true,\n\t\t\t\tForce:   false,\n\t\t\t\tExePath: exePath,\n\t\t\t},\n\t\t\terr: errNoAsset,\n\t\t},\n\t\t\"no need to update\": {\n\t\t\tlatestRelease: \"v\" + version.Version(false),\n\t\t\tassetName:     \"lefthook_1.0.0_darwin_arm64\",\n\t\t\topts: Options{\n\t\t\t\tYes:     true,\n\t\t\t\tForce:   false,\n\t\t\t\tExePath: exePath,\n\t\t\t},\n\t\t\terr: nil,\n\t\t},\n\t\t\"forced update but asset not found\": {\n\t\t\tlatestRelease: \"v\" + version.Version(false),\n\t\t\tassetName:     \"lefthook_1.0.0_darwin_arm64\",\n\t\t\topts: Options{\n\t\t\t\tYes:     true,\n\t\t\t\tForce:   true,\n\t\t\t\tExePath: exePath,\n\t\t\t},\n\t\t\terr: errNoAsset,\n\t\t},\n\t\t\"invalid hashsum\": {\n\t\t\tlatestRelease: \"v1.0.0\",\n\t\t\tassetName:     \"lefthook_1.0.0_\" + osNames[runtime.GOOS] + \"_\" + archNames[runtime.GOARCH] + extension,\n\t\t\topts: Options{\n\t\t\t\tYes:     true,\n\t\t\t\tForce:   true,\n\t\t\t\tExePath: exePath,\n\t\t\t},\n\t\t\tasset: []byte{65, 54, 24, 32, 43, 67, 21},\n\t\t\tchecksums: `\n\t\t\t\t67a5740c6c66d986c5708cddd6bd0bc240db29451646fc4c1398b988dcf7cdfe lefthook_1.0.0_MacOS_arm64\n\t\t\t\t67a5740c6c66d986c5708cddd6bd0bc240db29451646fc4c1398b988dcf7cdfe lefthook_1.0.0_MacOS_x86_64\n\t\t\t\t67a5740c6c66d986c5708cddd6bd0bc240db29451646fc4c1398b988dcf7cdfe lefthook_1.0.0_Linux_x86_64\n\t\t\t\t67a5740c6c66d986c5708cddd6bd0bc240db29451646fc4c1398b988dcf7cdfe lefthook_1.0.0_Linux_arm64\n\t\t\t\t67a5740c6c66d986c5708cddd6bd0bc240db29451646fc4c1398b988dcf7cdfe lefthook_1.0.0_Windows_x86_64.exe\n\t\t\t`,\n\t\t\terr: errInvalidHashsum,\n\t\t},\n\t\t\"success\": {\n\t\t\tlatestRelease: \"v1.0.0\",\n\t\t\tassetName:     \"lefthook_1.0.0_\" + osNames[runtime.GOOS] + \"_\" + archNames[runtime.GOARCH] + extension,\n\t\t\topts: Options{\n\t\t\t\tYes:     true,\n\t\t\t\tForce:   true,\n\t\t\t\tExePath: exePath,\n\t\t\t},\n\t\t\tasset: []byte{65, 54, 24, 32, 43, 67, 21},\n\t\t\tchecksums: `\n\t\t\t\t0e1c97246ba1bc8bde78355ae986589545d3c69bf1264d2d3c1835ec072006f6 lefthook_1.0.0_MacOS_arm64\n\t\t\t\t0e1c97246ba1bc8bde78355ae986589545d3c69bf1264d2d3c1835ec072006f6 lefthook_1.0.0_MacOS_x86_64\n\t\t\t\t0e1c97246ba1bc8bde78355ae986589545d3c69bf1264d2d3c1835ec072006f6 lefthook_1.0.0_Linux_x86_64\n\t\t\t\t0e1c97246ba1bc8bde78355ae986589545d3c69bf1264d2d3c1835ec072006f6 lefthook_1.0.0_Linux_arm64\n\t\t\t\t0e1c97246ba1bc8bde78355ae986589545d3c69bf1264d2d3c1835ec072006f6 lefthook_1.0.0_Windows_x86_64.exe\n\t\t\t`,\n\t\t\terr: nil,\n\t\t},\n\t} {\n\t\tt.Run(name, func(t *testing.T) {\n\t\t\tassert := assert.New(t)\n\t\t\tfile, err := os.Create(tt.opts.ExePath)\n\t\t\tassert.NoError(err)\n\t\t\tassert.NoError(file.Close())\n\n\t\t\tchecksumServer := httptest.NewServer(\n\t\t\t\thttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\t\tn, werr := w.Write([]byte(tt.checksums))\n\t\t\t\t\tassert.Equal(n, len(tt.checksums))\n\t\t\t\t\tassert.NoError(werr)\n\t\t\t\t}))\n\t\t\tdefer checksumServer.Close()\n\t\t\tassetServer := httptest.NewServer(\n\t\t\t\thttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\t\tn, werr := w.Write(tt.asset)\n\t\t\t\t\tassert.Equal(n, len(tt.asset))\n\t\t\t\t\tassert.NoError(werr)\n\t\t\t\t}))\n\t\t\tdefer assetServer.Close()\n\n\t\t\treleaseServer := httptest.NewServer(\n\t\t\t\thttp.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {\n\t\t\t\t\tassert.NoError(json.NewEncoder(w).Encode(map[string]any{\n\t\t\t\t\t\t\"tag_name\": tt.latestRelease,\n\t\t\t\t\t\t\"assets\": []map[string]string{\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"name\":                 tt.assetName,\n\t\t\t\t\t\t\t\t\"browser_download_url\": assetServer.URL,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t\t{\n\t\t\t\t\t\t\t\t\"name\":                 \"lefthook_checksums.txt\",\n\t\t\t\t\t\t\t\t\"browser_download_url\": checksumServer.URL,\n\t\t\t\t\t\t\t},\n\t\t\t\t\t\t},\n\t\t\t\t\t}))\n\t\t\t\t}))\n\t\t\tdefer releaseServer.Close()\n\n\t\t\tupd := Updater{\n\t\t\t\tclient:     releaseServer.Client(),\n\t\t\t\treleaseURL: releaseServer.URL,\n\t\t\t}\n\n\t\t\terr = upd.SelfUpdate(t.Context(), tt.opts)\n\n\t\t\tif tt.err != nil {\n\t\t\t\tif !errors.Is(err, tt.err) {\n\t\t\t\t\tt.Error(err)\n\t\t\t\t}\n\t\t\t} else {\n\t\t\t\tassert.NoError(err)\n\n\t\t\t\tif tt.asset != nil {\n\t\t\t\t\tcontent, err := os.ReadFile(tt.opts.ExePath)\n\t\t\t\t\tassert.NoError(err)\n\n\t\t\t\t\tassert.Equal(content, tt.asset)\n\t\t\t\t}\n\t\t\t}\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "internal/version/version.go",
    "content": "package version\n\nimport (\n\t\"errors\"\n\n\t\"golang.org/x/mod/semver\"\n)\n\nconst version = \"2.1.4\"\n\nvar (\n\t// Is set via -X github.com/evilmartians/lefthook/internal/version.commit={commit}.\n\tcommit string\n\n\tErrInvalidVersion   = errors.New(\"invalid version format\")\n\tErrUncoveredVersion = errors.New(\"version is lower than required\")\n)\n\nfunc Version(verbose bool) string {\n\tif verbose {\n\t\treturn version + \" \" + commit\n\t}\n\n\treturn version\n}\n\nfunc Check(wanted, given string) error {\n\tif wanted[0] != 'v' {\n\t\twanted = \"v\" + wanted\n\t}\n\tif given[0] != 'v' {\n\t\tgiven = \"v\" + given\n\t}\n\n\tif !semver.IsValid(wanted) {\n\t\treturn ErrInvalidVersion\n\t}\n\tif !semver.IsValid(given) {\n\t\treturn ErrInvalidVersion\n\t}\n\tif cmp := semver.Compare(wanted, given); cmp > 0 {\n\t\treturn ErrUncoveredVersion\n\t}\n\n\treturn nil\n}\n"
  },
  {
    "path": "internal/version/version_test.go",
    "content": "package version\n\nimport (\n\t\"strconv\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n)\n\nfunc TestCheck(t *testing.T) {\n\tfor i, tt := range [...]struct {\n\t\twanted, given string\n\t\terr           error\n\t}{\n\t\t{\n\t\t\twanted: \"1.0.0\",\n\t\t\tgiven:  \"1.0.1\",\n\t\t\terr:    nil,\n\t\t},\n\t\t{\n\t\t\twanted: \"v1.0.0\",\n\t\t\tgiven:  \"1.0.1\",\n\t\t\terr:    nil,\n\t\t},\n\t\t{\n\t\t\twanted: \"1.0.0\",\n\t\t\tgiven:  \"v1.0.1\",\n\t\t\terr:    nil,\n\t\t},\n\t\t{\n\t\t\twanted: \"1\",\n\t\t\tgiven:  \"1.2\",\n\t\t\terr:    nil,\n\t\t},\n\t\t{\n\t\t\twanted: \"3.0.0\",\n\t\t\tgiven:  \"1.1\",\n\t\t\terr:    ErrUncoveredVersion,\n\t\t},\n\t\t{\n\t\t\twanted: \"13\",\n\t\t\tgiven:  \"10\",\n\t\t\terr:    ErrUncoveredVersion,\n\t\t},\n\t\t{\n\t\t\twanted: \"10--.0-best\",\n\t\t\tgiven:  \"10\",\n\t\t\terr:    ErrInvalidVersion,\n\t\t},\n\t\t{\n\t\t\twanted: \"10\",\n\t\t\tgiven:  \"vv10.0.0-best\",\n\t\t\terr:    ErrInvalidVersion,\n\t\t},\n\t} {\n\t\tt.Run(strconv.Itoa(i), func(t *testing.T) {\n\t\t\tassert.Equal(t, tt.err, Check(tt.wanted, tt.given))\n\t\t})\n\t}\n}\n\nfunc TestVersion(t *testing.T) {\n\tassert.Equal(t, version, Version(false))\n\tassert.Equal(t, version+\" \"+commit, Version(true))\n}\n"
  },
  {
    "path": "main.go",
    "content": "package main\n\nimport (\n\t\"context\"\n\t\"os\"\n\n\t\"github.com/evilmartians/lefthook/v2/cmd\"\n\t\"github.com/evilmartians/lefthook/v2/internal/log\"\n)\n\nfunc main() {\n\tif err := cmd.Lefthook().Run(context.Background(), os.Args); err != nil {\n\t\tif err.Error() != \"\" {\n\t\t\tlog.Errorf(\"Error: %s\", err)\n\t\t}\n\t\tos.Exit(1)\n\t}\n}\n"
  },
  {
    "path": "packaging/.gitignore",
    "content": "# Raku precompilation\n.precomp/\n\n# Python precompilation\n__pycache__/\n/registries/pypi/lefthook.egg-info/\n\n# READMEs\n/registries/npm-*/README.md\n/registries/npm/*/README.md\n\n# schemas\n/registries/npm/*/schema.json\n/registries/npm-bundled/schema.json\n/registries/npm-installer/schema.json\n\n# binaries\nregistries/pypi/lefthook/bin/\nregistries/pypi/build/\nregistries/rubygems/pkg/\nregistries/rubygems/libexec/\nregistries/npm-bundled/bin/\nregistries/npm/*/bin/\n!registries/npm/*/package.json\n!registries/npm/lefthook/bin/index.js\n"
  },
  {
    "path": "packaging/registries/aur/lefthook/PKGBUILD",
    "content": "# Maintainer: Lefthook <lefthook@evilmartians.com>\n\npkgname=lefthook\npkgdesc=\"Git hooks manager\"\npkgver=2.1.4\npkgrel=1\narch=('x86_64' 'aarch64')\nurl=\"https://github.com/evilmartians/lefthook\"\nlicense=('MIT')\nmakedepends=('go>=1.26')\nsource=(\"https://github.com/evilmartians/lefthook/archive/v${pkgver}.tar.gz\")\nsha256sums=('{{ sha256sum }}')\n\nbuild() {\n  cd \"$pkgname-$pkgver\"\n  go build \\\n    -trimpath \\\n    -buildmode=pie \\\n    -mod=readonly \\\n    -modcacherw \\\n    -ldflags \"-linkmode external -extldflags \\\"${LDFLAGS}\\\"\" \\\n    .\n}\n\npackage() {\n  cd \"$pkgname-$pkgver\"\n  install -Dm755 $pkgname \"$pkgdir\"/usr/bin/$pkgname\n}\n"
  },
  {
    "path": "packaging/registries/aur/lefthook-bin/PKGBUILD",
    "content": "# Maintainer: Lefthook <lefthook@evilmartians.com>\n\npkgname=lefthook-bin\npkgdesc=\"Git hooks manager\"\npkgver=2.1.4\npkgrel=1\narch=('x86_64' 'aarch64')\nurl=\"https://github.com/evilmartians/lefthook\"\nlicense=('MIT')\ndepends=()\nmakedepends=()\nprovides=('lefthook')\nconflicts=('lefthook')\nsource_x86_64=(\"https://github.com/evilmartians/lefthook/releases/download/v${pkgver}/lefthook_${pkgver}_Linux_x86_64.gz\")\nsource_aarch64=(\"https://github.com/evilmartians/lefthook/releases/download/v${pkgver}/lefthook_${pkgver}_Linux_aarch64.gz\")\nsha256sums_x86_64=('{{ sha256sum_linux_x86_64 }}')\nsha256sums_aarch64=('{{ sha256sum_linux_aarch64 }}')\n\nbuild() {\n\tcd \"${srcdir}\"\n\n\tmv \"lefthook_${pkgver}_Linux_${CARCH}\" lefthook\n\tchmod +x lefthook\n\n\t./lefthook completion zsh >lefthook.zsh\n\t./lefthook completion fish >lefthook.fish\n\t./lefthook completion bash >lefthook.bash\n}\n\npackage() {\n\tcd \"${srcdir}\"\n\n\t# Install lefthook\n\tinstall -D -m0755 lefthook \\\n\t\t\"${pkgdir}/usr/bin/lefthook\"\n\n\t# Install completions\n\tinstall -Dm644 lefthook.zsh \"${pkgdir}/usr/share/zsh/site-functions/_lefthook\"\n\tinstall -Dm644 lefthook.fish \"${pkgdir}/usr/share/fish/completions/lefthook.fish\"\n\tinstall -Dm644 lefthook.bash \"${pkgdir}/usr/share/bash-completion/completions/lefthook\"\n}\n"
  },
  {
    "path": "packaging/registries/npm/lefthook/bin/index.js",
    "content": "#!/usr/bin/env node\n\nvar spawn = require('child_process').spawn;\nconst { getExePath } = require('../get-exe');\n\nvar command_args = process.argv.slice(2);\n\nvar child = spawn(\n    getExePath(),\n    command_args,\n    { stdio: \"inherit\" });\n\nchild.on('close', function (code) {\n    if (code !== 0) {\n        process.exit(1);\n    }\n});\n"
  },
  {
    "path": "packaging/registries/npm/lefthook/get-exe.js",
    "content": "const path = require(\"path\");\n\nfunction getExePath() {\n  // Detect OS\n  // https://nodejs.org/api/process.html#process_process_platform\n  let os = process.platform;\n  let extension = \"\";\n  if ([\"win32\", \"cygwin\"].includes(process.platform)) {\n    os = \"windows\";\n    extension = \".exe\";\n  }\n\n  // Detect architecture\n  // https://nodejs.org/api/process.html#process_process_arch\n  let arch = process.arch;\n\n  return require.resolve(`lefthook-${os}-${arch}/bin/lefthook${extension}`);\n}\n\nexports.getExePath = getExePath;\n"
  },
  {
    "path": "packaging/registries/npm/lefthook/package.json",
    "content": "{\n  \"name\": \"lefthook\",\n  \"version\": \"2.1.4\",\n  \"description\": \"Simple git hooks manager\",\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/evilmartians/lefthook.git\"\n  },\n  \"main\": \"bin/index.js\",\n  \"files\": [\n    \"postinstall.js\",\n    \"get-exe.js\",\n    \"schema.json\"\n  ],\n  \"bin\": {\n    \"lefthook\": \"bin/index.js\"\n  },\n  \"keywords\": [\n    \"git\",\n    \"hook\",\n    \"manager\"\n  ],\n  \"author\": \"mrexox\",\n  \"license\": \"MIT\",\n  \"bugs\": {\n    \"url\": \"https://github.com/evilmartians/lefthook/issues\",\n    \"email\": \"lefthook@evilmartians.com\"\n  },\n  \"homepage\": \"https://github.com/evilmartians/lefthook#readme\",\n  \"optionalDependencies\": {\n    \"lefthook-darwin-arm64\": \"2.1.4\",\n    \"lefthook-darwin-x64\": \"2.1.4\",\n    \"lefthook-linux-arm64\": \"2.1.4\",\n    \"lefthook-linux-x64\": \"2.1.4\",\n    \"lefthook-freebsd-arm64\": \"2.1.4\",\n    \"lefthook-freebsd-x64\": \"2.1.4\",\n    \"lefthook-openbsd-arm64\": \"2.1.4\",\n    \"lefthook-openbsd-x64\": \"2.1.4\",\n    \"lefthook-windows-arm64\": \"2.1.4\",\n    \"lefthook-windows-x64\": \"2.1.4\"\n  },\n  \"scripts\": {\n    \"postinstall\": \"node postinstall.js\"\n  }\n}\n"
  },
  {
    "path": "packaging/registries/npm/lefthook/postinstall.js",
    "content": "const { spawnSync } = require(\"child_process\");\nconst { getExePath } = require(\"./get-exe\");\n\nfunction install() {\n  const isEnabled = (value) => value && value !== \"0\" && value !== \"false\";\n  if (isEnabled(process.env.CI) && !isEnabled(process.env.LEFTHOOK)) {\n    return\n  }\n\n  spawnSync(getExePath(), [\"install\", \"-f\"], {\n    cwd: process.env.INIT_CWD || process.cwd(),\n    stdio: \"inherit\",\n  });\n}\n\ntry {\n  install();\n} catch (e) {\n  console.warn(\n    \"'lefthook install' command failed. Try running it manually.\\n\" + e,\n  );\n}\n"
  },
  {
    "path": "packaging/registries/npm/lefthook-darwin-arm64/package.json",
    "content": "{\n  \"name\": \"lefthook-darwin-arm64\",\n  \"version\": \"2.1.4\",\n  \"description\": \"The macOS ARM 64-bit binary for lefthook, git hooks manager.\",\n  \"preferUnplugged\": false,\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/evilmartians/lefthook.git\"\n  },\n  \"license\": \"MIT\",\n  \"bugs\": {\n    \"url\": \"https://github.com/evilmartians/lefthook/issues\",\n    \"email\": \"lefthook@evilmartians.com\"\n  },\n  \"homepage\": \"https://github.com/evilmartians/lefthook#readme\",\n  \"os\": [\n    \"darwin\"\n  ],\n  \"cpu\": [\n    \"arm64\"\n  ]\n}\n"
  },
  {
    "path": "packaging/registries/npm/lefthook-darwin-x64/package.json",
    "content": "{\n  \"name\": \"lefthook-darwin-x64\",\n  \"version\": \"2.1.4\",\n  \"description\": \"The macOS 64-bit binary for lefthook, git hooks manager.\",\n  \"preferUnplugged\": false,\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/evilmartians/lefthook.git\"\n  },\n  \"license\": \"MIT\",\n  \"bugs\": {\n    \"url\": \"https://github.com/evilmartians/lefthook/issues\",\n    \"email\": \"lefthook@evilmartians.com\"\n  },\n  \"homepage\": \"https://github.com/evilmartians/lefthook#readme\",\n  \"os\": [\n    \"darwin\"\n  ],\n  \"cpu\": [\n    \"x64\"\n  ]\n}\n"
  },
  {
    "path": "packaging/registries/npm/lefthook-freebsd-arm64/package.json",
    "content": "{\n  \"name\": \"lefthook-freebsd-arm64\",\n  \"version\": \"2.1.4\",\n  \"description\": \"The FreeBSD ARM 64-bit binary for lefthook, git hooks manager.\",\n  \"preferUnplugged\": false,\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/evilmartians/lefthook.git\"\n  },\n  \"license\": \"MIT\",\n  \"bugs\": {\n    \"url\": \"https://github.com/evilmartians/lefthook/issues\",\n    \"email\": \"lefthook@evilmartians.com\"\n  },\n  \"homepage\": \"https://github.com/evilmartians/lefthook#readme\",\n  \"os\": [\n    \"freebsd\"\n  ],\n  \"cpu\": [\n    \"arm64\"\n  ]\n}\n"
  },
  {
    "path": "packaging/registries/npm/lefthook-freebsd-x64/package.json",
    "content": "{\n  \"name\": \"lefthook-freebsd-x64\",\n  \"version\": \"2.1.4\",\n  \"description\": \"The FreeBSD 64-bit binary for lefthook, git hooks manager.\",\n  \"preferUnplugged\": false,\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/evilmartians/lefthook.git\"\n  },\n  \"license\": \"MIT\",\n  \"bugs\": {\n    \"url\": \"https://github.com/evilmartians/lefthook/issues\",\n    \"email\": \"lefthook@evilmartians.com\"\n  },\n  \"homepage\": \"https://github.com/evilmartians/lefthook#readme\",\n  \"os\": [\n    \"freebsd\"\n  ],\n  \"cpu\": [\n    \"x64\"\n  ]\n}\n"
  },
  {
    "path": "packaging/registries/npm/lefthook-linux-arm64/package.json",
    "content": "{\n  \"name\": \"lefthook-linux-arm64\",\n  \"version\": \"2.1.4\",\n  \"description\": \"The Linux ARM 64-bit binary for lefthook, git hooks manager.\",\n  \"preferUnplugged\": false,\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/evilmartians/lefthook.git\"\n  },\n  \"license\": \"MIT\",\n  \"bugs\": {\n    \"url\": \"https://github.com/evilmartians/lefthook/issues\",\n    \"email\": \"lefthook@evilmartians.com\"\n  },\n  \"homepage\": \"https://github.com/evilmartians/lefthook#readme\",\n  \"os\": [\n    \"linux\"\n  ],\n  \"cpu\": [\n    \"arm64\"\n  ]\n}\n"
  },
  {
    "path": "packaging/registries/npm/lefthook-linux-x64/package.json",
    "content": "{\n  \"name\": \"lefthook-linux-x64\",\n  \"version\": \"2.1.4\",\n  \"description\": \"The Linux 64-bit binary for lefthook, git hooks manager.\",\n  \"preferUnplugged\": false,\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/evilmartians/lefthook.git\"\n  },\n  \"license\": \"MIT\",\n  \"bugs\": {\n    \"url\": \"https://github.com/evilmartians/lefthook/issues\",\n    \"email\": \"lefthook@evilmartians.com\"\n  },\n  \"homepage\": \"https://github.com/evilmartians/lefthook#readme\",\n  \"os\": [\n    \"linux\"\n  ],\n  \"cpu\": [\n    \"x64\"\n  ]\n}\n"
  },
  {
    "path": "packaging/registries/npm/lefthook-openbsd-arm64/package.json",
    "content": "{\n  \"name\": \"lefthook-openbsd-arm64\",\n  \"version\": \"2.1.4\",\n  \"description\": \"The OpenBSD ARM 64-bit binary for lefthook, git hooks manager.\",\n  \"preferUnplugged\": false,\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/evilmartians/lefthook.git\"\n  },\n  \"license\": \"MIT\",\n  \"bugs\": {\n    \"url\": \"https://github.com/evilmartians/lefthook/issues\",\n    \"email\": \"lefthook@evilmartians.com\"\n  },\n  \"homepage\": \"https://github.com/evilmartians/lefthook#readme\",\n  \"os\": [\n    \"openbsd\"\n  ],\n  \"cpu\": [\n    \"arm64\"\n  ]\n}\n"
  },
  {
    "path": "packaging/registries/npm/lefthook-openbsd-x64/package.json",
    "content": "{\n  \"name\": \"lefthook-openbsd-x64\",\n  \"version\": \"2.1.4\",\n  \"description\": \"The OpenBSD 64-bit binary for lefthook, git hooks manager.\",\n  \"preferUnplugged\": false,\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/evilmartians/lefthook.git\"\n  },\n  \"license\": \"MIT\",\n  \"bugs\": {\n    \"url\": \"https://github.com/evilmartians/lefthook/issues\",\n    \"email\": \"lefthook@evilmartians.com\"\n  },\n  \"homepage\": \"https://github.com/evilmartians/lefthook#readme\",\n  \"os\": [\n    \"openbsd\"\n  ],\n  \"cpu\": [\n    \"x64\"\n  ]\n}\n"
  },
  {
    "path": "packaging/registries/npm/lefthook-windows-arm64/package.json",
    "content": "{\n  \"name\": \"lefthook-windows-arm64\",\n  \"version\": \"2.1.4\",\n  \"description\": \"The Windows ARM 64-bit binary for lefthook, git hooks manager.\",\n  \"preferUnplugged\": false,\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/evilmartians/lefthook.git\"\n  },\n  \"license\": \"MIT\",\n  \"bugs\": {\n    \"url\": \"https://github.com/evilmartians/lefthook/issues\",\n    \"email\": \"lefthook@evilmartians.com\"\n  },\n  \"homepage\": \"https://github.com/evilmartians/lefthook#readme\",\n  \"os\": [\n    \"win32\"\n  ],\n  \"cpu\": [\n    \"arm64\"\n  ]\n}\n"
  },
  {
    "path": "packaging/registries/npm/lefthook-windows-x64/package.json",
    "content": "{\n  \"name\": \"lefthook-windows-x64\",\n  \"version\": \"2.1.4\",\n  \"description\": \"The Windows 64-bit binary for lefthook, git hooks manager.\",\n  \"preferUnplugged\": false,\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/evilmartians/lefthook.git\"\n  },\n  \"license\": \"MIT\",\n  \"bugs\": {\n    \"url\": \"https://github.com/evilmartians/lefthook/issues\",\n    \"email\": \"lefthook@evilmartians.com\"\n  },\n  \"homepage\": \"https://github.com/evilmartians/lefthook#readme\",\n  \"os\": [\n    \"win32\"\n  ],\n  \"cpu\": [\n    \"x64\"\n  ]\n}\n"
  },
  {
    "path": "packaging/registries/npm-bundled/bin/index.js",
    "content": "#!/usr/bin/env node\n\nvar spawn = require('child_process').spawn;\nconst { getExePath } = require('../get-exe');\n\nvar command_args = process.argv.slice(2);\n\nvar child = spawn(\n    getExePath(),\n    command_args,\n    { stdio: \"inherit\" });\n\nchild.on('close', function (code) {\n    if (code !== 0) {\n        process.exit(1);\n    }\n});\n"
  },
  {
    "path": "packaging/registries/npm-bundled/get-exe.js",
    "content": "const path = require(\"path\")\n\nfunction getExePath() {\n  // Detect OS\n  // https://nodejs.org/api/process.html#process_process_platform\n  let goOS = process.platform;\n  let extension = '';\n  if (['win32', 'cygwin'].includes(process.platform)) {\n    goOS = 'windows';\n    extension = '.exe';\n  }\n\n  // Detect architecture\n  // https://nodejs.org/api/process.html#process_process_arch\n  let goArch = process.arch;\n  let suffix = '';\n  switch (process.arch) {\n    case 'x32':\n    case 'ia32': {\n      goArch = '386';\n      break;\n    }\n  }\n\n  const dir = path.join(__dirname, 'bin');\n  const executable = path.join(\n    dir,\n    `lefthook-${goOS}-${goArch}`,\n    `lefthook${extension}`\n  );\n  return executable;\n}\nexports.getExePath = getExePath;\n"
  },
  {
    "path": "packaging/registries/npm-bundled/package.json",
    "content": "{\n  \"name\": \"@evilmartians/lefthook\",\n  \"version\": \"2.1.4\",\n  \"description\": \"Simple git hooks manager\",\n  \"main\": \"bin/index.js\",\n  \"files\": [\n    \"postinstall.js\",\n    \"get-exe.js\",\n    \"schema.json\",\n    \"bin/**/*\"\n  ],\n  \"bin\": {\n    \"lefthook\": \"bin/index.js\"\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/evilmartians/lefthook.git\"\n  },\n  \"keywords\": [\n    \"git\",\n    \"hook\",\n    \"manager\"\n  ],\n  \"author\": \"mrexox\",\n  \"license\": \"MIT\",\n  \"bugs\": {\n    \"url\": \"https://github.com/evilmartians/lefthook/issues\",\n    \"email\": \"lefthook@evilmartians.com\"\n  },\n  \"homepage\": \"https://github.com/evilmartians/lefthook#readme\",\n  \"os\": [\n    \"darwin\",\n    \"linux\",\n    \"win32\"\n  ],\n  \"cpu\": [\n    \"x64\",\n    \"arm64\",\n    \"ia32\"\n  ],\n  \"scripts\": {\n    \"postinstall\": \"node postinstall.js\"\n  }\n}\n"
  },
  {
    "path": "packaging/registries/npm-bundled/postinstall.js",
    "content": "const isEnabled = (value) => value && value !== \"0\" && value !== \"false\";\nif (!isEnabled(process.env.CI) || isEnabled(process.env.LEFTHOOK)) {\n  const { spawnSync } = require('child_process');\n  const { getExePath } = require('./get-exe');\n\n  // run install\n  spawnSync(getExePath(), ['install', '-f'], {\n    cwd: process.env.INIT_CWD || process.cwd(),\n    stdio: 'inherit',\n  });\n}\n"
  },
  {
    "path": "packaging/registries/npm-installer/bin/index.js",
    "content": "#!/usr/bin/env node\n\nvar spawn = require('child_process').spawn;\nconst path = require(\"path\")\nconst extension = [\"win32\", \"cygwin\"].includes(process.platform) ? \".exe\" : \"\"\nconst exePath = path.join(__dirname, `lefthook${extension}`)\n\nvar command_args = process.argv.slice(2);\nvar child = spawn(\n    exePath,\n    command_args,\n    { stdio: \"inherit\" });\n\nchild.on('close', function (code) {\n    if (code !== 0) {\n        process.exit(1);\n    }\n});\n"
  },
  {
    "path": "packaging/registries/npm-installer/install.js",
    "content": "const http = require('https')\nconst fs = require('fs')\nconst path = require(\"path\")\nconst chp = require(\"child_process\")\n\nconst iswin = [\"win32\", \"cygwin\"].includes(process.platform)\n\nasync function install() {\n  const isEnabled = (value) => value && value !== \"0\" && value !== \"false\";\n  if (isEnabled(process.env.CI) && !isEnabled(process.env.LEFTHOOK)) {\n    return\n  }\n  const downloadURL = getDownloadURL()\n  const extension = iswin ? \".exe\" : \"\"\n  const fileName = `lefthook${extension}`\n  const exePath = path.join(__dirname, \"bin\", fileName)\n  await downloadBinary(downloadURL, exePath)\n  console.log('downloaded to', exePath)\n  if (!iswin) {\n    fs.chmodSync(exePath, \"755\")\n  }\n  // run install\n  chp.spawnSync(exePath, ['install',  '-f'], {\n    cwd: process.env.INIT_CWD || process.cwd(),\n    stdio: 'inherit',\n  })\n}\n\nfunction getDownloadURL() {\n  // Detect OS\n  // https://nodejs.org/api/process.html#process_process_platform\n  let goOS = process.platform\n  let extension = \"\"\n  if (iswin) {\n    goOS = \"windows\"\n    extension = \".exe\"\n  }\n\n  // Convert the goOS to the os name in the download URL\n  let downloadOS = goOS === \"darwin\" ? \"macOS\" : goOS\n  downloadOS = `${downloadOS.charAt(0).toUpperCase()}${downloadOS.slice(1)}`\n\n  // Detect architecture\n  // https://nodejs.org/api/process.html#process_process_arch\n  let arch = process.arch\n  switch (process.arch) {\n    case \"x64\": {\n      arch = \"x86_64\"\n      break\n    }\n  }\n  const version = require(\"./package.json\").version\n\n  return `https://github.com/evilmartians/lefthook/releases/download/v${version}/lefthook_${version}_${downloadOS}_${arch}${extension}`\n}\n\nasync function downloadBinary(url, dest) {\n  console.log('downloading', url)\n  const file = fs.createWriteStream(dest)\n  return new Promise((resolve, reject) => {\n    http.get(url, function(response) {\n      if (response.statusCode === 302 && response.headers.location) {\n        // If the response is a 302 redirect, follow the new location\n        downloadBinary(response.headers.location, dest)\n          .then(resolve)\n          .catch(reject)\n      } else {\n        response.pipe(file)\n\n        file.on('finish', function() {\n          file.close(() => {\n            resolve(dest)\n          })\n        })\n      }\n    }).on('error', function(err) {\n      fs.unlink(file, () => {\n        reject(err)\n      })\n    })\n  })\n}\n\n// start:\ninstall().catch((e) => {\n  throw e\n})\n"
  },
  {
    "path": "packaging/registries/npm-installer/package.json",
    "content": "{\n  \"name\": \"@evilmartians/lefthook-installer\",\n  \"version\": \"2.1.4\",\n  \"description\": \"Simple git hooks manager\",\n  \"main\": \"bin/index.js\",\n  \"files\": [\n    \"install.js\",\n    \"schema.json\"\n  ],\n  \"bin\": {\n    \"lefthook\": \"bin/index.js\"\n  },\n  \"repository\": {\n    \"type\": \"git\",\n    \"url\": \"git+https://github.com/evilmartians/lefthook.git\"\n  },\n  \"keywords\": [\n    \"git\",\n    \"hook\",\n    \"manager\"\n  ],\n  \"author\": \"mrexox\",\n  \"license\": \"MIT\",\n  \"bugs\": {\n    \"url\": \"https://github.com/evilmartians/lefthook/issues\",\n    \"email\": \"lefthook@evilmartians.com\"\n  },\n  \"homepage\": \"https://github.com/evilmartians/lefthook#readme\",\n  \"os\": [\n    \"darwin\",\n    \"linux\",\n    \"win32\"\n  ],\n  \"cpu\": [\n    \"x64\",\n    \"arm64\"\n  ],\n  \"scripts\": {\n    \"install\": \"node install.js\"\n  }\n}\n"
  },
  {
    "path": "packaging/registries/pypi/LICENSE",
    "content": "\nThe MIT License (MIT)\n\nCopyright (c) 2019 Arkweid\n\nPermission is hereby granted, free of charge, to any person obtaining a copy\nof this software and associated documentation files (the \"Software\"), to deal\nin the Software without restriction, including without limitation the rights\nto use, copy, modify, merge, publish, distribute, sublicense, and/or sell\ncopies of the Software, and to permit persons to whom the Software is\nfurnished to do so, subject to the following conditions:\n\nThe above copyright notice and this permission notice shall be included in\nall copies or substantial portions of the Software.\n\nTHE SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR\nIMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,\nFITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE\nAUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER\nLIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,\nOUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN\nTHE SOFTWARE.\n"
  },
  {
    "path": "packaging/registries/pypi/README.md",
    "content": "![Build Status](https://github.com/evilmartians/lefthook/actions/workflows/test.yml/badge.svg?branch=master)\n[![codecov](https://codecov.io/gh/evilmartians/lefthook/graph/badge.svg?token=d93ya8MfmB)](https://codecov.io/gh/evilmartians/lefthook)\n\n# Lefthook\n\n> The fastest polyglot Git hooks manager out there\n\n<img align=\"right\" width=\"147\" height=\"100\" title=\"Lefthook logo\"\n     src=\"https://raw.githubusercontent.com/evilmartians/lefthook/refs/heads/master/logo_sign.svg\">\n\nA Git hooks manager for Node.js, Ruby and many other types of projects.\n\n* **Fast.** It is written in Go. Can run commands in parallel.\n* **Powerful.** It allows to control execution and files you pass to your commands.\n* **Simple.** It is single dependency-free binary which can work in any environment.\n\n📖 [Read the introduction post](https://evilmartians.com/chronicles/lefthook-knock-your-teams-code-back-into-shape?utm_source=lefthook)\n\n<a href=\"https://evilmartians.com/?utm_source=lefthook\">\n<img src=\"https://evilmartians.com/badges/sponsored-by-evil-martians.svg\" alt=\"Sponsored by Evil Martians\" width=\"236\" height=\"54\"></a>\n\n## Install\n\n```bash\npip install lefthook\n```\n\n## Usage\n\nConfigure your hooks, install them once and forget about it: rely on the magic underneath.\n\n#### TL;DR\n\n```bash\n# Configure your hooks\nvim lefthook.yml\n\n# Install them to the git project\nlefthook install\n\n# Enjoy your work with git\ngit add -A && git commit -m '...'\n```\n\n#### More details\n\n- [**Configuration**](https://github.com/evilmartians/lefthook/blob/master/docs/configuration.md) for `lefthook.yml` config options.\n- [**Usage**](https://github.com/evilmartians/lefthook/blob/master/docs/usage.md) for **lefthook** CLI options, supported ENVs, and usage tips.\n- [**Discussions**](https://github.com/evilmartians/lefthook/discussions) for questions, ideas, suggestions.\n<!-- - [**Wiki**](https://github.com/evilmartians/lefthook/wiki) for guides, examples, and benchmarks. -->\n"
  },
  {
    "path": "packaging/registries/pypi/hatch_build.py",
    "content": "import atexit\nimport os\nimport platform\nimport shutil\nimport sys\nimport tempfile\nfrom pathlib import Path\n\nfrom hatchling.builders.hooks.plugin.interface import BuildHookInterface\n\n\nPLATFORM_MAPPING = {\n    'linux': 'linux',\n    'linux2': 'linux',\n    'darwin': 'darwin',\n    'win32': 'windows',\n    'windows': 'windows',\n    'freebsd': 'freebsd',\n    'openbsd': 'openbsd',\n}\n\nARCH_MAPPING = {\n    'x86_64': 'x86_64',\n    'amd64': 'x86_64',\n    'arm64': 'arm64',\n    'aarch64': 'arm64',\n}\n\n\nPEP425_TAGS = {\n    (\"linux\", \"x86_64\"): \"py3-none-manylinux_2_17_x86_64\",\n    (\"linux\", \"arm64\"): \"py3-none-manylinux_2_17_aarch64\",\n    (\"darwin\", \"x86_64\"): \"py3-none-macosx_10_15_x86_64\",\n    (\"darwin\", \"arm64\"): \"py3-none-macosx_11_0_arm64\",\n    (\"windows\", \"x86_64\"): \"py3-none-win_amd64\",\n    (\"windows\", \"arm64\"): \"py3-none-win_arm64\",\n}\n\n\ndef normalize_platform(value: str) -> str:\n    if not value:\n        return value\n    return PLATFORM_MAPPING.get(value.lower(), value.lower())\n\n\ndef normalize_arch(value: str) -> str:\n    if not value:\n        return value\n    return ARCH_MAPPING.get(value.lower(), value.lower())\n\n\ndef get_platform_info():\n    target_platform = os.environ.get('LEFTHOOK_TARGET_PLATFORM')\n    target_arch = os.environ.get('LEFTHOOK_TARGET_ARCH')\n\n    if target_platform and target_arch:\n        normalized_platform = normalize_platform(target_platform)\n        normalized_arch = normalize_arch(target_arch)\n        print(f\"[HOOK] Using target: {normalized_platform}-{normalized_arch}\")\n        return normalized_platform, normalized_arch\n\n    system = normalize_platform(sys.platform) or normalize_platform(platform.system())\n    machine = normalize_arch(platform.machine())\n    result = system, machine\n    print(f\"[HOOK] Auto-detected: {result[0]}-{result[1]}\")\n    return result\n\n\nclass CustomBuildHook(BuildHookInterface):\n    PLUGIN_NAME = \"custom\"\n\n    def __init__(self, *args, **kwargs) -> None:\n        super().__init__(*args, **kwargs)\n        self.target_platform = None\n        self.target_arch = None\n        self._temp_dir = None\n        self._moved_entries = []\n        self._restore_registered = False\n\n    def initialize(self, version, build_data):\n        target_platform, target_arch = get_platform_info()\n        self.target_platform = target_platform\n        self.target_arch = target_arch\n\n        tag = PEP425_TAGS.get((target_platform, target_arch))\n        if tag:\n            build_data[\"tag\"] = tag\n            self._prune_binaries()\n            if not self._restore_registered:\n                atexit.register(self._restore_binaries)\n                self._restore_registered = True\n            print(f\"[HOOK] Building platform wheel {tag}\")\n        else:\n            print(\n                \"[HOOK] No PEP425 tag for \"\n                f\"{target_platform}-{target_arch}; building universal wheel.\"\n            )\n\n        print(f\"[HOOK] Initialized for {target_platform}-{target_arch}\")\n\n    def finalize(self, version, build_data, artifact_path) -> None:\n        print(f\"[HOOK] Built artifact: {artifact_path}\")\n        self._restore_binaries()\n\n    def _prune_binaries(self):\n        if not self.target_platform or not self.target_arch:\n            raise RuntimeError(\"Target platform is not set before pruning binaries.\")\n\n        bin_dir = Path(self.root) / \"lefthook\" / \"bin\"\n        if not bin_dir.is_dir():\n            raise RuntimeError(f\"Bin directory not found: {bin_dir}\")\n\n        target_dir_name = f\"lefthook-{self.target_platform}-{self.target_arch}\"\n        target_dir = bin_dir / target_dir_name\n        if not target_dir.exists():\n            available = \", \".join(sorted(p.name for p in bin_dir.iterdir() if p.is_dir()))\n            raise FileNotFoundError(\n                f\"Binary folder '{target_dir_name}' is missing. Available: {available or 'none'}\"\n            )\n\n        binaries = list(target_dir.glob(\"lefthook*\"))\n        if not binaries:\n            raise FileNotFoundError(\n                f\"No lefthook binary found under {target_dir}.\"\n            )\n\n        self._temp_dir = Path(tempfile.mkdtemp(prefix=\"lefthook-bin-backup-\"))\n        preserved = {target_dir_name, \".keep\"}\n\n        for entry in bin_dir.iterdir():\n            if entry.name in preserved:\n                continue\n            destination = self._temp_dir / entry.name\n            shutil.move(str(entry), str(destination))\n            self._moved_entries.append((destination, entry))\n\n        print(f\"[HOOK] Shipped binaries: {target_dir_name}\")\n\n    def _restore_binaries(self):\n        while self._moved_entries:\n            backup_path, original_path = self._moved_entries.pop()\n            if backup_path.exists():\n                shutil.move(str(backup_path), str(original_path))\n        if self._temp_dir and self._temp_dir.exists():\n            shutil.rmtree(self._temp_dir, ignore_errors=True)\n        self._temp_dir = None\n"
  },
  {
    "path": "packaging/registries/pypi/lefthook/__init__.py",
    "content": ""
  },
  {
    "path": "packaging/registries/pypi/lefthook/__main__.py",
    "content": "import sys\n\nfrom .main import main\n\nif __name__ == \"__main__\":\n    sys.exit(main())\n"
  },
  {
    "path": "packaging/registries/pypi/lefthook/bin/.keep",
    "content": ""
  },
  {
    "path": "packaging/registries/pypi/lefthook/main.py",
    "content": "import os\nimport sys\nimport platform\nimport subprocess\n\nISSUE_URL = \"https://github.com/evilmartians/lefthook/issues/new/choose\"\nARCH_MAPPING = {\n    'amd64': 'x86_64',\n    'aarch64': 'arm64',\n}\n\ndef main():\n    os_name = platform.system().lower()\n    arch = platform.machine().lower()\n    arch = ARCH_MAPPING.get(arch, arch)\n    ext = os_name == \"windows\" and \".exe\" or \"\"\n    subfolder = f\"lefthook-{os_name}-{arch}\"\n    executable = os.path.join(os.path.dirname(__file__), \"bin\", subfolder, \"lefthook\"+ext)\n    if not os.path.isfile(executable):\n        print(f\"Couldn't find binary {executable}. Please create an issue: {ISSUE_URL}\")\n        return 1\n\n    result = subprocess.run([executable] + sys.argv[1:])\n    return result.returncode\n"
  },
  {
    "path": "packaging/registries/pypi/pyproject.toml",
    "content": "[project]\nname = \"lefthook\"\nversion = \"2.1.4\"\ndescription = \"Git hooks manager. Fast, powerful, simple.\"\nreadme = \"README.md\"\nlicense = \"MIT\"\nlicense-files = [\"LICENSE\"]\nauthors = [\n    {name = \"Evil Martians\", email = \"lefthook@evilmartians.com\"}\n]\nrequires-python = \">=3.6\"\nclassifiers = [\n    \"Operating System :: OS Independent\",\n    \"Topic :: Software Development :: Version Control :: Git\",\n]\n\n[project.urls]\nHomepage = \"https://github.com/evilmartians/lefthook\"\n\n[project.scripts]\nlefthook = \"lefthook.main:main\"\n\n[build-system]\nrequires = [\"hatchling\"]\nbuild-backend = \"hatchling.build\"\n\n[tool.hatch.build.targets.wheel]\npackages = [\"lefthook\"]\nartifacts = [\"lefthook\"]\n\n[tool.hatch.build.hooks.custom]\npath = \"hatch_build.py\"\n\n[tool.hatch.build.targets.sdist]\ninclude = [\n    \"lefthook/\",\n]\n\n[[tool.uv.index]]\nname = \"pypi\"\nurl = \"https://pypi.org/simple/\"\ndefault = true\n"
  },
  {
    "path": "packaging/registries/rubygems/Gemfile",
    "content": "source \"https://rubygems.org\"\n\n# Specify your gem's dependencies in lefthook.gemspec\ngemspec\n"
  },
  {
    "path": "packaging/registries/rubygems/README.md",
    "content": "# Lefthook\n\nRuby wrapper around [lefthook](https://github.com/evilmartians/lefthook)\n"
  },
  {
    "path": "packaging/registries/rubygems/Rakefile",
    "content": "require \"bundler/gem_tasks\"\ntask :default => :spec\n"
  },
  {
    "path": "packaging/registries/rubygems/bin/lefthook",
    "content": "#!/usr/bin/env ruby\n\nrequire \"rubygems\"\n\nplatform = Gem::Platform.new(RUBY_PLATFORM)\narch =\n  case platform.cpu.sub(/\\Auniversal\\./, '')\n  when /\\Aarm64/ then \"arm64\" # Apple reports arm64e on M1 macs\n  when /aarch64/ then \"arm64\"\n  when \"x86_64\"  then \"x64\"\n  when \"x64\"     then \"x64\" # Windows with MINGW64 reports RUBY_PLATFORM as \"x64-mingw32\"\n  else raise \"Unknown architecture: #{platform.cpu}\"\n  end\n\nos =\n  case platform.os\n  when \"linux\"   then \"linux\"\n  when \"darwin\"  then \"darwin\"  # MacOS\n  when \"windows\" then \"windows\"\n  when \"mingw32\" then \"windows\" # Windows with MINGW64 reports RUBY_PLATFORM as \"x64-mingw32\"\n  when \"mingw\"   then \"windows\"\n  when \"freebsd\" then \"freebsd\"\n  when \"openbsd\" then \"openbsd\"\n  else raise \"Unknown OS: #{platform.os}\"\n  end\n\nbinary = \"lefthook-#{os}-#{arch}/lefthook\"\nbinary = \"#{binary}.exe\" if os == \"windows\"\n\nargs = $*.map { |x| x.include?(' ') ? \"'\" + x + \"'\" : x }\ncmd = File.expand_path \"#{File.dirname(__FILE__)}/../libexec/#{binary}\"\n\nunless File.exist?(cmd)\n  raise \"Invalid platform. Lefthook wasn't build for #{RUBY_PLATFORM}\"\nend\n\npid = spawn(\"#{cmd} #{args.join(' ')}\")\nProcess.wait(pid)\nexit($?.exitstatus)\n"
  },
  {
    "path": "packaging/registries/rubygems/lefthook.gemspec",
    "content": "Gem::Specification.new do |spec|\n  spec.name          = \"lefthook\"\n  spec.version       = \"2.1.4\"\n  spec.authors       = [\"A.A.Abroskin\", \"Evil Martians\"]\n  spec.email         = [\"lefthook@evilmartians.com\"]\n\n  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.\"\n  spec.homepage      = \"https://github.com/evilmartians/lefthook\"\n  spec.post_install_message = \"Lefthook installed! Run command in your project root directory 'lefthook install -f' to complete installation.\"\n\n  spec.bindir        = \"bin\"\n  spec.executables   << \"lefthook\"\n  spec.require_paths = [\"lib\"]\n\n  spec.files = %w(\n    lib/lefthook.rb\n    bin/lefthook\n  ) + `find libexec/ -executable -type f -print0`.split(\"\\x0\")\n\n  spec.licenses = ['MIT']\nend\n"
  },
  {
    "path": "packaging/registries/rubygems/lib/lefthook.rb",
    "content": ""
  },
  {
    "path": "packaging/registries/rubygems/libexec/.keep",
    "content": ""
  },
  {
    "path": "packaging/scripts/META6.json",
    "content": "{\n    \"name\": \"Lefthook-Packager\",\n    \"test-depends\": [\n        \"File::Temp\"\n    ]\n}\n"
  },
  {
    "path": "packaging/scripts/clean.raku",
    "content": "#! /usr/bin/env raku\n\nuse v6;\n\nuse lib $?FILE.IO.parent.child(\"lib\");\nuse Packager;\nuse Registry :Target;\n\nsub MAIN(\n  Registry::Target :$target = all-registries,\n  Bool :$dry-run = False,\n) {\n  Packager.new(\n    target  => $target,\n    dry-run => $dry-run,\n  ).clean;\n}\n"
  },
  {
    "path": "packaging/scripts/lib/Constants.rakumod",
    "content": "# Current lefthook version.\nconstant VERSION = \"2.1.4\";\n\n# Git root.\nconstant REPO-ROOT = $?FILE.IO.parent(4);\n\n# /packages/registries/\nconstant PKG-ROOT  = $?FILE.IO.parent(3).child(\"registries\");\n\nmy constant DIST-ROOT = REPO-ROOT.child(\"dist\");\n\n# Supported platforms and architectures.\nconstant %DISTS = (\n  amd64-linux   => \"{DIST-ROOT}/no_self_update_linux_amd64_v1/lefthook\",\n  amd64-windows => \"{DIST-ROOT}/no_self_update_windows_amd64_v1/lefthook.exe\",\n  amd64-darwin  => \"{DIST-ROOT}/no_self_update_darwin_amd64_v1/lefthook\",\n  amd64-freebsd => \"{DIST-ROOT}/no_self_update_freebsd_amd64_v1/lefthook\",\n  amd64-openbsd => \"{DIST-ROOT}/no_self_update_openbsd_amd64_v1/lefthook\",\n\n  arm64-linux   => \"{DIST-ROOT}/no_self_update_linux_arm64_v8.0/lefthook\",\n  arm64-windows => \"{DIST-ROOT}/no_self_update_windows_arm64_v8.0/lefthook.exe\",\n  arm64-darwin  => \"{DIST-ROOT}/no_self_update_darwin_arm64_v8.0/lefthook\",\n  arm64-freebsd => \"{DIST-ROOT}/no_self_update_freebsd_arm64_v8.0/lefthook\",\n  arm64-openbsd => \"{DIST-ROOT}/no_self_update_openbsd_arm64_v8.0/lefthook\",\n);\n"
  },
  {
    "path": "packaging/scripts/lib/Packager.rakumod",
    "content": "# Software development should not just bring profit, it also has to be fun.\n# Of course Raku isn't the perfect tool for scripting, but it is quite expressive,\n# it has types, and it feels like real magic.\n#\n# I hope that reading through these scripts will show you\n# a lot of interesting concepts... and definitely fun!\n\nunit class Packager;\n\nuse System;\nuse Registry;\n\nuse Registries::NPM;\nuse Registries::RubyGems;\nuse Registries::PyPI;\nuse Registries::AUR;\nuse Registries::AUR-Bin;\n\nmy constant @PACKAGE-TYPES = (\n  Registries::NPM,\n  Registries::RubyGems,\n  Registries::PyPI,\n  Registries::AUR,\n  Registries::AUR-Bin,\n);\n\nhas Bool             $.dry-run is required;\nhas Registry::Target $.target  is required;\n\nmethod clean(--> Nil)       { .clean       for self!packages }\nmethod set-version(--> Nil) { .set-version for self!packages }\nmethod prepare(--> Nil)     { .prepare     for self!packages }\nmethod publish(--> Nil)     { .publish     for self!packages }\n\nmethod !packages(--> Seq) {\n  my $sys = System.new(dry-run => $!dry-run);\n  my @packages = @PACKAGE-TYPES.map({ .new(sys => $sys) });\n\n  return @packages.Seq if $!target == Registry::Target::all-registries;\n\n  @packages.grep(*.target == $!target);\n}\n"
  },
  {
    "path": "packaging/scripts/lib/Registries/AUR/Publishing.rakumod",
    "content": "use Constants;\nuse SystemAPI;\n\nunit module Registries::AUR::Publishing;\n\n# Updates the AUR Git repo with the new version.\nsub publish-aur-package(\n  Str:D :$name!,\n  :%sha256-urls!,\n  IO() :$path-to-pkgbuild!,\n  SystemAPI :$sys!,\n  --> Nil\n) is export {\n  my $clone-to = PKG-ROOT.child(\"{$name}-aur\");\n  my $dest-pkgbuild = $clone-to.child(\"PKGBUILD\");\n\n  $sys.in-dir(PKG-ROOT, {\n    clone-aur-repo($sys, $name, $clone-to);\n    copy-pkgbuild($sys, $path-to-pkgbuild, $dest-pkgbuild);\n    fill-sha256-sums($sys, $dest-pkgbuild, %sha256-urls);\n  });\n\n  $sys.in-dir($clone-to, {\n    $sys.run(\"sh\", \"-c\", \"makepkg --printsrcinfo > .SRCINFO\");\n    $sys.run(\"makepkg\", \"--noconfirm\");\n    $sys.run(\"makepkg\", \"--install\", \"--noconfirm\");\n\n    $sys.run(\"git\", \"config\", \"user.name\", \"github-actions[bot]\");\n    $sys.run(\"git\", \"config\", \"user.email\", \"github-actions[bot]@users.noreply.github.com\");\n    $sys.run(\"git\", \"add\", \"PKGBUILD\", \".SRCINFO\");\n    $sys.run(\"git\", \"commit\", \"-m\", \"release v{VERSION}\");\n    $sys.run(\"git\", \"push\", \"origin\", \"master\");\n  });\n}\n\nsub clone-aur-repo(SystemAPI $sys, Str:D $name, IO() $clone-to --> Nil) {\n  $sys.run(\"git\", \"clone\", \"ssh://aur@aur.archlinux.org/{$name}.git\", $clone-to);\n}\n\nsub copy-pkgbuild(SystemAPI $sys, IO() $from, IO() $to --> Nil) {\n  $sys.cp($from, $to);\n}\n\nsub fill-sha256-sums(\n  SystemAPI $sys,\n  IO() $pkgbuild,\n  %sha256-urls,\n  --> Nil\n) {\n  for %sha256-urls.kv -> $template-name, $url {\n    my $sha256sum = fetch-sha256($url);\n\n    $sys.replace(\n      file => $pkgbuild,\n      regex => /'{{ ' $template-name ' }}'/,\n      replacement => $sha256sum,\n    );\n  }\n}\n\n# Fetches the binary data by $url and returns SHA256 on it.\nsub fetch-sha256(Str:D $url --> Str:D) {\n  say \"Fetching SHA256 for $url\";\n\n  my $curl = run(\"curl\", \"-fsSL\", $url, :out, :bin);\n  my $sha256sum = run(\"sha256sum\", \"-\", :in($curl.out), :out);\n\n  $sha256sum.out.slurp(:close).words.head;\n}\n"
  },
  {
    "path": "packaging/scripts/lib/Registries/AUR-Bin.rakumod",
    "content": "use Registry;\n\nunit class Registries::AUR-Bin does Registry::Package;\n\nuse Constants;\nuse SystemAPI;\nuse Registries::AUR::Publishing;\n\nmy constant PKGBUILD = PKG-ROOT.child(\"aur\").child(\"lefthook-bin\").child(\"PKGBUILD\");\n\nhas SystemAPI $.sys is required;\n\nmethod target(--> Registry::Target:D) { Registry::Target::aur-bin }\n\nmethod clean {}\n\nmethod set-version {\n  $!sys.replace(\n    file => PKGBUILD,\n    regex => /pkgver\\s*'='.*$/,\n    replacement => \"pkgver={VERSION}\",\n  );\n}\n\nmethod prepare {}\n\nmethod publish {\n  publish-aur-package(\n    name => \"lefthook-bin\",\n    sha256-urls => {\n      sha256sum_linux_x86_64 => \"https://github.com/evilmartians/lefthook/releases/download/v{VERSION}/lefthook_{VERSION}_Linux_x86_64.gz\",\n      sha256sum_linux_aarch64 => \"https://github.com/evilmartians/lefthook/releases/download/v{VERSION}/lefthook_{VERSION}_Linux_aarch64.gz\"\n    },\n    path-to-pkgbuild => PKGBUILD,\n    sys => $!sys,\n  );\n}\n"
  },
  {
    "path": "packaging/scripts/lib/Registries/AUR.rakumod",
    "content": "use Registry;\n\nunit class Registries::AUR does Registry::Package;\n\nuse Constants;\nuse SystemAPI;\nuse Registries::AUR::Publishing;\n\nmy constant PKGBUILD = PKG-ROOT.child(\"aur\").child(\"lefthook\").child(\"PKGBUILD\");\n\nhas SystemAPI $.sys is required;\n\nmethod target(--> Registry::Target:D) { Registry::Target::aur }\n\nmethod clean {}\n\nmethod set-version {\n  $!sys.replace(\n    file => PKGBUILD,\n    regex => /pkgver\\s*'='.*$/,\n    replacement => \"pkgver={VERSION}\",\n  );\n}\n\nmethod prepare {}\n\nmethod publish {\n  publish-aur-package(\n    name => \"lefthook\",\n    sha256-urls => {\n      sha256sum => \"https://github.com/evilmartians/lefthook/archive/v{VERSION}.tar.gz\",\n    },\n    path-to-pkgbuild => PKGBUILD,\n    sys => $!sys,\n  );\n}\n"
  },
  {
    "path": "packaging/scripts/lib/Registries/NPM.rakumod",
    "content": "use Registry;\n\nunit class Registries::NPM does Registry::Package;\n\nuse Constants;\nuse SystemAPI;\n\nmy constant NPM           = PKG-ROOT.child(\"npm\");\nmy constant NPM-BUNDLED   = PKG-ROOT.child(\"npm-bundled\");\nmy constant NPM-INSTALLER = PKG-ROOT.child(\"npm-installer\");\n\nmy constant @READMES = qq:to/END/.lines.map(*.trim);\n  {NPM}/lefthook/README.md\n  {NPM}/lefthook-darwin-arm64/README.md\n  {NPM}/lefthook-darwin-x64/README.md\n  {NPM}/lefthook-linux-arm64/README.md\n  {NPM}/lefthook-linux-x64/README.md\n  {NPM}/lefthook-windows-arm64/README.md\n  {NPM}/lefthook-windows-x64/README.md\n  {NPM}/lefthook-freebsd-arm64/README.md\n  {NPM}/lefthook-freebsd-x64/README.md\n  {NPM}/lefthook-openbsd-arm64/README.md\n  {NPM}/lefthook-openbsd-x64/README.md\n  {NPM-BUNDLED}/README.md\n  {NPM-INSTALLER}/README.md\nEND\nmy constant @PACKAGES = qq:to/END/.lines.map(*.trim);\n  {NPM}/lefthook-darwin-arm64/\n  {NPM}/lefthook-darwin-x64/\n  {NPM}/lefthook-linux-arm64/\n  {NPM}/lefthook-linux-x64/\n  {NPM}/lefthook-windows-arm64/\n  {NPM}/lefthook-windows-x64/\n  {NPM}/lefthook-freebsd-arm64/\n  {NPM}/lefthook-freebsd-x64/\n  {NPM}/lefthook-openbsd-arm64/\n  {NPM}/lefthook-openbsd-x64/\n  {NPM}/lefthook/\n  {NPM-BUNDLED}\n  {NPM-INSTALLER}\nEND\nmy constant @PACKAGE-JSONS = @PACKAGES.map(*.IO.child(\"package.json\"));\nmy constant @SCHEMAS = qq:to/END/.lines.map(*.trim);\n  {NPM}/lefthook/schema.json\n  {NPM-BUNDLED}/schema.json\n  {NPM-INSTALLER}/schema.json\nEND\n\nhas SystemAPI $.sys is required;\n\nmy constant %NPM-DISTS = (\n  amd64-linux   => \"{NPM}/lefthook-linux-x64/bin/lefthook\",\n  amd64-windows => \"{NPM}/lefthook-windows-x64/bin/lefthook.exe\",\n  amd64-darwin  => \"{NPM}/lefthook-darwin-x64/bin/lefthook\",\n  amd64-freebsd => \"{NPM}/lefthook-freebsd-x64/bin/lefthook\",\n  amd64-openbsd => \"{NPM}/lefthook-openbsd-x64/bin/lefthook\",\n\n  arm64-linux   => \"{NPM}/lefthook-linux-arm64/bin/lefthook\",\n  arm64-windows => \"{NPM}/lefthook-windows-arm64/bin/lefthook.exe\",\n  arm64-darwin  => \"{NPM}/lefthook-darwin-arm64/bin/lefthook\",\n  arm64-freebsd => \"{NPM}/lefthook-freebsd-arm64/bin/lefthook\",\n  arm64-openbsd => \"{NPM}/lefthook-openbsd-arm64/bin/lefthook\",\n);\nmy constant %NPM-BUNDLED-DISTS = (\n  amd64-linux   => \"{NPM-BUNDLED}/bin/lefthook-linux-x64/lefthook\",\n  amd64-windows => \"{NPM-BUNDLED}/bin/lefthook-windows-x64/lefthook.exe\",\n  amd64-darwin  => \"{NPM-BUNDLED}/bin/lefthook-darwin-x64/lefthook\",\n  amd64-freebsd => \"{NPM-BUNDLED}/bin/lefthook-freebsd-x64/lefthook\",\n  amd64-openbsd => \"{NPM-BUNDLED}/bin/lefthook-openbsd-x64/lefthook\",\n\n  arm64-linux   => \"{NPM-BUNDLED}/bin/lefthook-linux-arm64/lefthook\",\n  arm64-windows => \"{NPM-BUNDLED}/bin/lefthook-windows-arm64/lefthook.exe\",\n  arm64-darwin  => \"{NPM-BUNDLED}/bin/lefthook-darwin-arm64/lefthook\",\n  arm64-freebsd => \"{NPM-BUNDLED}/bin/lefthook-freebsd-arm64/lefthook\",\n  arm64-openbsd => \"{NPM-BUNDLED}/bin/lefthook-openbsd-arm64/lefthook\",\n);\n\nmethod target(--> Registry::Target:D) { Registry::Target::npm }\n\nmethod clean {\n  $!sys.rm(\n    |@READMES,\n    |@SCHEMAS,\n    |%NPM-DISTS.values,\n    |%NPM-BUNDLED-DISTS.values,\n  )\n}\n\nmethod set-version {\n  for @PACKAGE-JSONS -> $path {\n    $!sys.replace(\n      file => $path,\n      regex => /'\"version\":' \\s* '\"' <[\\d\\w.]>+ '\"'/,\n      replacement => qq[\"version\": \"{VERSION}\"],\n    );\n  }\n\n  # Update optional dependencies for the main lefthook package\n  $!sys.replace(\n    file => \"{NPM}/lefthook/package.json\",\n    regex => /'\"' $<package>=(lefthook '-' <[\\d\\w-]>+) '\":' \\s* '\"' <[\\d\\w.]>+ '\"'/,\n    replacement => -> $/ { qq[\"$<package>\": \"{VERSION}\"] },\n  );\n}\n\nmethod prepare {\n  $!sys.cp(\"{REPO-ROOT}/README.md\", $_) for @READMES;\n  $!sys.cp(\"{REPO-ROOT}/schema.json\", $_) for @SCHEMAS;\n\n  die \"npm/ setup is not complete\" unless %DISTS.keys.Set == %NPM-DISTS.keys.Set;\n  die \"NPM-BUNDLED/ setup is not complete\" unless %DISTS.keys.Set == %NPM-BUNDLED-DISTS.keys.Set;\n\n  for %DISTS.kv -> $platform, $source {\n    $!sys.cp($source, %NPM-DISTS{$platform});\n    $!sys.cp($source, %NPM-BUNDLED-DISTS{$platform});\n  }\n}\n\nmethod publish {\n  for @PACKAGES -> $package {\n    say \"Publish {$package.IO.basename}\";\n\n    $!sys.in-dir($package, {\n      $!sys.run(\"npm\", \"publish\", \"--access\", \"public\");\n    });\n  }\n}\n"
  },
  {
    "path": "packaging/scripts/lib/Registries/PyPI.rakumod",
    "content": "use Registry;\n\nunit class Registries::PyPI does Registry::Package;\n\nuse Constants;\nuse SystemAPI;\n\nmy constant PYPI = PKG-ROOT.child(\"pypi\");\nmy constant %PYPI-DISTS = {\n  amd64-linux   => \"{PYPI}/lefthook/bin/lefthook-linux-x86_64/lefthook\",\n  amd64-windows => \"{PYPI}/lefthook/bin/lefthook-windows-x86_64/lefthook.exe\",\n  amd64-darwin  => \"{PYPI}/lefthook/bin/lefthook-darwin-x86_64/lefthook\",\n  amd64-freebsd => \"{PYPI}/lefthook/bin/lefthook-freebsd-x86_64/lefthook\",\n  amd64-openbsd => \"{PYPI}/lefthook/bin/lefthook-openbsd-x86_64/lefthook\",\n\n  arm64-linux   => \"{PYPI}/lefthook/bin/lefthook-linux-arm64/lefthook\",\n  arm64-windows => \"{PYPI}/lefthook/bin/lefthook-windows-arm64/lefthook.exe\",\n  arm64-darwin  => \"{PYPI}/lefthook/bin/lefthook-darwin-arm64/lefthook\",\n  arm64-freebsd => \"{PYPI}/lefthook/bin/lefthook-freebsd-arm64/lefthook\",\n  arm64-openbsd => \"{PYPI}/lefthook/bin/lefthook-openbsd-arm64/lefthook\",\n};\nmy constant @PLATFORMS = (\n  (\"linux\",   \"x86_64\"),\n  (\"windows\", \"x86_64\"),\n  (\"darwin\",  \"x86_64\"),\n  (\"linux\",   \"arm64\"),\n  (\"windows\", \"arm64\"),\n  (\"darwin\",  \"arm64\"),\n);\n\nhas SystemAPI $.sys is required;\n\nmethod target(--> Registry::Target:D) { Registry::Target::pypi }\n\nmethod clean {\n  $!sys.rm(\n    \"{PYPI}/lefthook/__pycache__/\",\n    \"{PYPI}/lefthook/bin/\".IO.dir.grep(*.basename ne \".keep\"),\n    \"{PYPI}/lefthook.egg-info/\",\n    \"{PYPI}/build/\",\n  )\n}\n\nmethod set-version {\n  $!sys.replace(\n    file => \"{PYPI}/pyproject.toml\",\n    regex => /^ \\s* version \\s* '=' .+ $/,\n    replacement => qq[version = \"{VERSION}\"],\n  );\n}\n\nmethod prepare {\n  die \"PYPI/ setup is not complete\" unless %PYPI-DISTS.keys.Set == %DISTS.keys.Set;\n\n  for %DISTS.kv -> $platform, $source {\n    $!sys.cp($source, %PYPI-DISTS{$platform});\n  }\n}\n\nmethod publish {\n  $!sys.in-dir(PYPI, {\n    for @PLATFORMS {\n      my ($os, $arch) = $_;\n\n      say \"Build wheel for $os-$arch\";\n      %*ENV<LEFTHOOK_TARGET_PLATFORM> = $os;\n      %*ENV<LEFTHOOK_TARGET_ARCH> = $arch;\n      $!sys.run(\"uv\", \"build\", \"--wheel\");\n    }\n\n    $!sys.run(\"uv\", \"publish\");\n  });\n}\n"
  },
  {
    "path": "packaging/scripts/lib/Registries/RubyGems.rakumod",
    "content": "use Registry;\n\nunit class Registries::RubyGems does Registry::Package;\n\nuse Constants;\nuse SystemAPI;\n\nmy constant RUBYGEMS = PKG-ROOT.child(\"rubygems\");\nmy constant %RUBYGEM-DISTS = (\n  amd64-linux   => \"{RUBYGEMS}/libexec/lefthook-linux-x64/lefthook\",\n  amd64-windows => \"{RUBYGEMS}/libexec/lefthook-windows-x64/lefthook.exe\",\n  amd64-darwin  => \"{RUBYGEMS}/libexec/lefthook-darwin-x64/lefthook\",\n  amd64-freebsd => \"{RUBYGEMS}/libexec/lefthook-freebsd-x64/lefthook\",\n  amd64-openbsd => \"{RUBYGEMS}/libexec/lefthook-openbsd-x64/lefthook\",\n\n  arm64-linux   => \"{RUBYGEMS}/libexec/lefthook-linux-arm64/lefthook\",\n  arm64-windows => \"{RUBYGEMS}/libexec/lefthook-windows-arm64/lefthook.exe\",\n  arm64-darwin  => \"{RUBYGEMS}/libexec/lefthook-darwin-arm64/lefthook\",\n  arm64-freebsd => \"{RUBYGEMS}/libexec/lefthook-freebsd-arm64/lefthook\",\n  arm64-openbsd => \"{RUBYGEMS}/libexec/lefthook-openbsd-arm64/lefthook\",\n);\n\nhas SystemAPI $.sys is required;\n\nmethod target(--> Registry::Target:D) { Registry::Target::rubygems }\n\nmethod clean {\n  $!sys.rm(\"{RUBYGEMS}/libexec/\".IO.dir.grep(*.d));\n  $!sys.rm(\"{RUBYGEMS}/pkg/\".IO);\n}\n\nmethod set-version {\n  $!sys.replace(\n    file => \"{RUBYGEMS}/lefthook.gemspec\",\n    regex => /$<spec-version>=(spec '.' version \\s* '=') .* $/,\n    replacement => -> $/ { qq[$<spec-version> \"{VERSION}\"] },\n  );\n}\n\nmethod prepare {\n  die \"rubygems/ setup is not complete\" unless %RUBYGEM-DISTS.keys.Set == %DISTS.keys.Set;\n\n  for %DISTS.kv -> $platform, $source {\n    $!sys.cp($source, %RUBYGEM-DISTS{$platform});\n  }\n}\n\nmethod publish {\n  say \"Publish lefthook gem\";\n\n  $!sys.in-dir(RUBYGEMS, {\n    $!sys.run(\"rake\", \"build\");\n  });\n\n  my $pkg-dir = RUBYGEMS.child(\"pkg\");\n  my $last-pkg = $pkg-dir.IO.dir.sort(*.basename).tail\n      // die \"no gem found in $pkg-dir\";\n\n  $!sys.run(\"gem\", \"push\", $last-pkg);\n}\n"
  },
  {
    "path": "packaging/scripts/lib/Registry.rakumod",
    "content": "unit module Registry;\n\n# Supported regitstries.\nenum Target is export(:Target) <\n  all-registries\n\n  npm\n  rubygems\n  pypi\n  aur\n  aur-bin\n>;\n\n# Abstract interface for a registry class to implement.\nrole Package {\n  method target(--> Target:D)     { ... }\n  method clean(--> Nil)       { ... }\n  method set-version(--> Nil) { ... }\n  method prepare(--> Nil)     { ... }\n  method publish(--> Nil)     { ... }\n}\n"
  },
  {
    "path": "packaging/scripts/lib/System.rakumod",
    "content": "use SystemAPI;\n\n# Provides wrappers for interaction with file system.\nclass System does SystemAPI {\n  has Bool $.dry-run is required;\n\n  # Removes file or dir recursively.\n  multi method rm(@paths --> Nil) {\n    for @paths -> $path {\n      next unless $path.IO.e;\n\n      say \"rm \" ~ $path;\n      next if $!dry-run;\n\n      self!rm-r($path);\n    };\n  }\n\n  # Changes current dir and execute the &block.\n  method in-dir(IO() $path, &block --> Nil) {\n    my $old = $*CWD;\n\n    say \"cd $path\";\n\n    chdir $path unless $!dry-run;\n    LEAVE { say \"cd $old\"; chdir $old unless $!dry-run; } # like defer in Go\n\n    block();\n  }\n\n  # Copies a file. Creates parent dirs for $dest if needed.\n  method cp(IO() $source, IO() $dest --> Nil) {\n    say \"cp $source -> $dest\";\n    return if $!dry-run;\n\n    mkdir $dest.dirname unless $dest.IO.parent.e;\n    $source.IO.copy($dest) unless $!dry-run;\n  }\n\n  # Replaces text in a $file line-by-line.\n  method replace(IO() :$file, Regex :$regex, :$replacement --> Nil) {\n    say \"replace in $file\\n\\t{$regex.gist} -> {$replacement.gist}\";\n    return if $!dry-run;\n\n    die \"$file does not exist\" unless $file.f;\n\n    spurt $file, $file.slurp.lines.map({ .subst($regex, $replacement) }).join(\"\\n\") ~ \"\\n\";\n  }\n\n  # Runs the command.\n  method run(*@argv --> Nil) {\n    say \"run {@argv.join(' ')}\";\n    return if $!dry-run;\n\n    my $proc = run(|@argv, :out, :err);\n    my $out = $proc.out.slurp(:close);\n    my $err = $proc.err.slurp(:close);\n\n    print $out if $out.chars;\n    note $err if $err.chars;\n\n    die \"failed: {@argv.join(' ')} --> {$proc.exitcode}\" if $proc.exitcode != 0;\n  }\n\n  method !rm-r(IO() $path --> Nil) {\n    return unless $path.e;\n\n    if $path.f {\n      $path.unlink;\n      return;\n    }\n\n    die \"not a file/dir: $path\" unless $path.d;\n\n    for $path.dir -> $entry {\n      self!rm-r($entry);\n    }\n\n    $path.rmdir;\n  }\n}\n"
  },
  {
    "path": "packaging/scripts/lib/SystemAPI.rakumod",
    "content": "unit role SystemAPI;\n\nmulti method rm(*@paths --> Nil) { self.rm(@paths) }\nmulti method rm(@paths --> Nil) { ... }\n\nmethod in-dir(IO() $path, &block --> Nil) { ... }\n\nmethod cp(IO() $source, IO() $dest --> Nil) { ... }\n\nmethod replace(IO() :$file, Regex :$regex, :$replacement --> Nil) { ... }\n\nmethod run(*@argv --> Nil) {... }\n"
  },
  {
    "path": "packaging/scripts/prepare.raku",
    "content": "#! /usr/bin/env raku\n\nuse v6;\n\nuse lib $?FILE.IO.parent.child(\"lib\");\nuse Packager;\nuse Registry :Target;\n\nsub MAIN(\n  Registry::Target :$target = all-registries,\n  Bool :$dry-run = False,\n) {\n  Packager.new(\n    target  => $target,\n    dry-run => $dry-run,\n  ).prepare;\n}\n"
  },
  {
    "path": "packaging/scripts/publish.raku",
    "content": "#! /usr/bin/env raku\n\nuse v6;\n\nuse lib $?FILE.IO.parent.child(\"lib\");\nuse Packager;\nuse Registry :Target;\n\nsub MAIN(\n  Registry::Target :$target = all-registries,\n  Bool :$dry-run = False,\n) {\n  Packager.new(\n    target  => $target,\n    dry-run => $dry-run,\n  ).publish;\n}\n"
  },
  {
    "path": "packaging/scripts/set-version.raku",
    "content": "#! /usr/bin/env raku\n\nuse v6;\n\nuse lib $?FILE.IO.parent.child(\"lib\");\nuse Packager;\nuse Registry :Target;\n\nsub MAIN(\n  Registry::Target :$target = all-registries,\n  Bool :$dry-run = False,\n) {\n  Packager.new(\n    target  => $target,\n    dry-run => $dry-run,\n  ).set-version;\n}\n"
  },
  {
    "path": "packaging/scripts/t/01-system.rakutest",
    "content": "use Test;\nuse File::Temp;\n\nuse System;\n\nmy $sys = System.new(dry-run => False);\n\nsubtest \"rm(*@paths)\", {\n  my ($tmp-one) = tempfile;\n  my ($tmp-two) = tempfile;\n  my $tmp-dir = tempdir;\n\n  my $sub1-dir = $tmp-dir.IO.child(\"sub1\");\n  my $sub2-dir = $sub1-dir.child(\"sub2\");\n  mkdir $sub2-dir;\n\n  ok $tmp-one.IO.e, \"exists\";\n  ok $tmp-two.IO.e, \"exists\";\n  ok $sub1-dir.IO.e, \"exists\";\n  ok $sub2-dir.IO.e, \"exists\";\n\n  $sys.rm($tmp-one, $tmp-two, $sub1-dir);\n\n  nok $tmp-one.IO.e, \"removes $tmp-one\";\n  nok $tmp-two.IO.e, \"removes $tmp-two\";\n  nok $sub1-dir.IO.e, \"removes $sub1-dir\";\n  nok $sub2-dir.IO.e, \"removes $sub2-dir\";\n  ok $tmp-dir.IO.e, \"keeps parent $tmp-dir\";\n}\n\nsubtest \"rm(@paths)\", {\n  my ($tmp-one) = tempfile;\n  my ($tmp-two) = tempfile;\n\n  ok $tmp-one.IO.e, \"exists\";\n  ok $tmp-two.IO.e, \"exists\";\n\n  $sys.rm([$tmp-one, $tmp-two]);\n\n  nok $tmp-one.IO.e, \"removes $tmp-one\";\n  nok $tmp-two.IO.e, \"removes $tmp-two\";\n}\n\nsubtest \"in-dir\", {\n  my $dir = tempdir;\n\n  isnt $*CWD, $dir, \"not in a temp dir\";\n\n  $sys.in-dir($dir, {\n    is $*CWD, $dir, \"in a temp dir\";\n  });\n\n  isnt $*CWD, $dir, \"not in a temp dir\";\n}\n\nsubtest \"cp\", {\n  my ($tmp-file) = tempfile;\n  my $tmp-dir = tempdir;\n\n  my $dest = $tmp-dir.IO.child('subdir').child($tmp-file.IO.basename);\n\n  nok $dest.e, \"not copied\";\n  nok $dest.parent.e, \"parent doesn't exist\";\n\n  $sys.cp($tmp-file, $dest);\n\n  ok $dest.e, \"copied\";\n  ok $dest.parent.e, \"parent exists\";\n}\n\nsubtest \"replace\", {\n  my ($tmp-file) = tempfile;\n\n  spurt $tmp-file.IO, qq:to/END/;\n  version = \"1.0.0\"\n  description = \"lefthook is a Git hooks manager\"\n  END\n\n  $sys.replace(\n    file => $tmp-file,\n    regex => /version \\s* '=' .*$/,\n    replacement => 'version = \"2.0.0\"',\n  );\n\n  my $result = $tmp-file.IO.slurp;\n\n  is $result, qq:to/END/, \"replaces successfully\";\n  version = \"2.0.0\"\n  description = \"lefthook is a Git hooks manager\"\n  END\n\n  $sys.replace(\n    file => $tmp-file,\n    regex => /description \\s* '=' \\s* '\"' $<name>=(<[\\w]>+).*/,\n    replacement => -> $/ { qq[description = \"$<name> is cool\"] },\n  );\n\n  $result = $tmp-file.IO.slurp;\n\n  is $result, qq:to/END/, \"replaces successfully\";\n  version = \"2.0.0\"\n  description = \"lefthook is cool\"\n  END\n}\n\nsubtest \"run\", {\n  my Str $said;\n  temp $*OUT = class { method print(*@s) { $said ~= @s.join } }\n  $sys.run(\"echo\", \"'Hello'\");\n\n  is $said, q:to/END/, \"called echo\";\n  run echo 'Hello'\n  'Hello'\n  END\n}\n\ndone-testing;\n"
  },
  {
    "path": "packaging/scripts/t/02-npm.rakutest",
    "content": "use Test;\n\nuse Constants;\nuse Registries::NPM;\n\nuse lib $?FILE.IO.parent.child(\"lib\");\nuse TestRegistry;\n\nsubtest \"clean\", {\n  my ($sys, $npm) = new-registry(Registries::NPM);\n\n  $npm.clean;\n\n  is-deeply $sys.removed.Set, (\n    \"{PKG-ROOT}/npm/lefthook/schema.json\",\n    \"{PKG-ROOT}/npm-bundled/schema.json\",\n    \"{PKG-ROOT}/npm-installer/schema.json\",\n    \"{PKG-ROOT}/npm/lefthook/README.md\",\n    \"{PKG-ROOT}/npm/lefthook-darwin-arm64/README.md\",\n    \"{PKG-ROOT}/npm/lefthook-darwin-x64/README.md\",\n    \"{PKG-ROOT}/npm/lefthook-linux-arm64/README.md\",\n    \"{PKG-ROOT}/npm/lefthook-linux-x64/README.md\",\n    \"{PKG-ROOT}/npm/lefthook-windows-arm64/README.md\",\n    \"{PKG-ROOT}/npm/lefthook-windows-x64/README.md\",\n    \"{PKG-ROOT}/npm/lefthook-freebsd-arm64/README.md\",\n    \"{PKG-ROOT}/npm/lefthook-freebsd-x64/README.md\",\n    \"{PKG-ROOT}/npm/lefthook-openbsd-arm64/README.md\",\n    \"{PKG-ROOT}/npm/lefthook-openbsd-x64/README.md\",\n    \"{PKG-ROOT}/npm-bundled/README.md\",\n    \"{PKG-ROOT}/npm-installer/README.md\",\n    \"{PKG-ROOT}/npm/lefthook-linux-x64/bin/lefthook\",\n    \"{PKG-ROOT}/npm/lefthook-windows-x64/bin/lefthook.exe\",\n    \"{PKG-ROOT}/npm/lefthook-darwin-x64/bin/lefthook\",\n    \"{PKG-ROOT}/npm/lefthook-freebsd-x64/bin/lefthook\",\n    \"{PKG-ROOT}/npm/lefthook-openbsd-x64/bin/lefthook\",\n    \"{PKG-ROOT}/npm/lefthook-linux-arm64/bin/lefthook\",\n    \"{PKG-ROOT}/npm/lefthook-windows-arm64/bin/lefthook.exe\",\n    \"{PKG-ROOT}/npm/lefthook-darwin-arm64/bin/lefthook\",\n    \"{PKG-ROOT}/npm/lefthook-freebsd-arm64/bin/lefthook\",\n    \"{PKG-ROOT}/npm/lefthook-openbsd-arm64/bin/lefthook\",\n    \"{PKG-ROOT}/npm-bundled/bin/lefthook-linux-x64/lefthook\",\n    \"{PKG-ROOT}/npm-bundled/bin/lefthook-windows-x64/lefthook.exe\",\n    \"{PKG-ROOT}/npm-bundled/bin/lefthook-darwin-x64/lefthook\",\n    \"{PKG-ROOT}/npm-bundled/bin/lefthook-freebsd-x64/lefthook\",\n    \"{PKG-ROOT}/npm-bundled/bin/lefthook-openbsd-x64/lefthook\",\n    \"{PKG-ROOT}/npm-bundled/bin/lefthook-linux-arm64/lefthook\",\n    \"{PKG-ROOT}/npm-bundled/bin/lefthook-windows-arm64/lefthook.exe\",\n    \"{PKG-ROOT}/npm-bundled/bin/lefthook-darwin-arm64/lefthook\",\n    \"{PKG-ROOT}/npm-bundled/bin/lefthook-freebsd-arm64/lefthook\",\n    \"{PKG-ROOT}/npm-bundled/bin/lefthook-openbsd-arm64/lefthook\",\n  ).Set;\n}\n\nsubtest \"prepare\", {\n  my ($sys, $npm) = new-registry(Registries::NPM);\n\n  $npm.prepare;\n\n  is-deeply $sys.copied, {\n    \"{REPO-ROOT}/README.md\" => (\n      \"{PKG-ROOT}/npm/lefthook/README.md\",\n      \"{PKG-ROOT}/npm/lefthook-darwin-arm64/README.md\",\n      \"{PKG-ROOT}/npm/lefthook-darwin-x64/README.md\",\n      \"{PKG-ROOT}/npm/lefthook-linux-arm64/README.md\",\n      \"{PKG-ROOT}/npm/lefthook-linux-x64/README.md\",\n      \"{PKG-ROOT}/npm/lefthook-windows-arm64/README.md\",\n      \"{PKG-ROOT}/npm/lefthook-windows-x64/README.md\",\n      \"{PKG-ROOT}/npm/lefthook-freebsd-arm64/README.md\",\n      \"{PKG-ROOT}/npm/lefthook-freebsd-x64/README.md\",\n      \"{PKG-ROOT}/npm/lefthook-openbsd-arm64/README.md\",\n      \"{PKG-ROOT}/npm/lefthook-openbsd-x64/README.md\",\n      \"{PKG-ROOT}/npm-bundled/README.md\",\n      \"{PKG-ROOT}/npm-installer/README.md\",\n    ).SetHash,\n    \"{REPO-ROOT}/schema.json\" => (\n      \"{PKG-ROOT}/npm/lefthook/schema.json\",\n      \"{PKG-ROOT}/npm-bundled/schema.json\",\n      \"{PKG-ROOT}/npm-installer/schema.json\",\n    ).SetHash,\n    \"{REPO-ROOT}/dist/no_self_update_linux_amd64_v1/lefthook\" => (\n      \"{PKG-ROOT}/npm/lefthook-linux-x64/bin/lefthook\",\n      \"{PKG-ROOT}/npm-bundled/bin/lefthook-linux-x64/lefthook\",\n    ).SetHash,\n    \"{REPO-ROOT}/dist/no_self_update_windows_amd64_v1/lefthook.exe\" => (\n      \"{PKG-ROOT}/npm/lefthook-windows-x64/bin/lefthook.exe\",\n      \"{PKG-ROOT}/npm-bundled/bin/lefthook-windows-x64/lefthook.exe\",\n    ).SetHash,\n    \"{REPO-ROOT}/dist/no_self_update_darwin_amd64_v1/lefthook\" => (\n      \"{PKG-ROOT}/npm/lefthook-darwin-x64/bin/lefthook\",\n      \"{PKG-ROOT}/npm-bundled/bin/lefthook-darwin-x64/lefthook\",\n    ).SetHash,\n    \"{REPO-ROOT}/dist/no_self_update_freebsd_amd64_v1/lefthook\" => (\n      \"{PKG-ROOT}/npm/lefthook-freebsd-x64/bin/lefthook\",\n      \"{PKG-ROOT}/npm-bundled/bin/lefthook-freebsd-x64/lefthook\",\n    ).SetHash,\n    \"{REPO-ROOT}/dist/no_self_update_openbsd_amd64_v1/lefthook\" => (\n      \"{PKG-ROOT}/npm/lefthook-openbsd-x64/bin/lefthook\",\n      \"{PKG-ROOT}/npm-bundled/bin/lefthook-openbsd-x64/lefthook\",\n    ).SetHash,\n    \"{REPO-ROOT}/dist/no_self_update_linux_arm64_v8.0/lefthook\" => (\n      \"{PKG-ROOT}/npm/lefthook-linux-arm64/bin/lefthook\",\n      \"{PKG-ROOT}/npm-bundled/bin/lefthook-linux-arm64/lefthook\",\n    ).SetHash,\n    \"{REPO-ROOT}/dist/no_self_update_windows_arm64_v8.0/lefthook.exe\" => (\n      \"{PKG-ROOT}/npm/lefthook-windows-arm64/bin/lefthook.exe\",\n      \"{PKG-ROOT}/npm-bundled/bin/lefthook-windows-arm64/lefthook.exe\",\n    ).SetHash,\n    \"{REPO-ROOT}/dist/no_self_update_darwin_arm64_v8.0/lefthook\" => (\n      \"{PKG-ROOT}/npm/lefthook-darwin-arm64/bin/lefthook\",\n      \"{PKG-ROOT}/npm-bundled/bin/lefthook-darwin-arm64/lefthook\",\n    ).SetHash,\n    \"{REPO-ROOT}/dist/no_self_update_freebsd_arm64_v8.0/lefthook\" => (\n      \"{PKG-ROOT}/npm/lefthook-freebsd-arm64/bin/lefthook\",\n      \"{PKG-ROOT}/npm-bundled/bin/lefthook-freebsd-arm64/lefthook\",\n    ).SetHash,\n    \"{REPO-ROOT}/dist/no_self_update_openbsd_arm64_v8.0/lefthook\" => (\n      \"{PKG-ROOT}/npm/lefthook-openbsd-arm64/bin/lefthook\",\n      \"{PKG-ROOT}/npm-bundled/bin/lefthook-openbsd-arm64/lefthook\",\n    ).SetHash,\n  };\n}\n\nsubtest \"publish\", {\n  my ($sys, $npm) = new-registry(Registries::NPM);\n\n  $npm.publish;\n\n  is-deeply $sys.run-calls, [\n    (\"npm publish --access public\", \"{PKG-ROOT}/npm/lefthook-darwin-arm64/\".IO),\n    (\"npm publish --access public\", \"{PKG-ROOT}/npm/lefthook-darwin-x64/\".IO),\n    (\"npm publish --access public\", \"{PKG-ROOT}/npm/lefthook-linux-arm64/\".IO),\n    (\"npm publish --access public\", \"{PKG-ROOT}/npm/lefthook-linux-x64/\".IO),\n    (\"npm publish --access public\", \"{PKG-ROOT}/npm/lefthook-windows-arm64/\".IO),\n    (\"npm publish --access public\", \"{PKG-ROOT}/npm/lefthook-windows-x64/\".IO),\n    (\"npm publish --access public\", \"{PKG-ROOT}/npm/lefthook-freebsd-arm64/\".IO),\n    (\"npm publish --access public\", \"{PKG-ROOT}/npm/lefthook-freebsd-x64/\".IO),\n    (\"npm publish --access public\", \"{PKG-ROOT}/npm/lefthook-openbsd-arm64/\".IO),\n    (\"npm publish --access public\", \"{PKG-ROOT}/npm/lefthook-openbsd-x64/\".IO),\n    (\"npm publish --access public\", \"{PKG-ROOT}/npm/lefthook/\".IO),\n    (\"npm publish --access public\", \"{PKG-ROOT}/npm-bundled\".IO),\n    (\"npm publish --access public\", \"{PKG-ROOT}/npm-installer\".IO),\n  ], \"calls publishing in the right order\";\n}\n\ndone-testing;\n"
  },
  {
    "path": "packaging/scripts/t/03-rubygems.rakutest",
    "content": "use Test;\n\nuse Constants;\nuse Registries::RubyGems;\n\nuse lib $?FILE.IO.parent.child(\"lib\");\nuse TestRegistry;\n\nsubtest \"clean\", {\n  my ($sys, $gem) = new-registry(Registries::RubyGems);\n\n  $gem.clean;\n\n  is-deeply $sys.removed.Set, (\n    \"{PKG-ROOT}/rubygems/pkg/\",\n  ).Set;\n}\n\nsubtest \"prepare\", {\n  my ($sys, $gem) = new-registry(Registries::RubyGems);\n\n  $gem.prepare;\n\n  is-deeply $sys.copied, {\n    \"{REPO-ROOT}/dist/no_self_update_linux_amd64_v1/lefthook\" => (\n      \"{PKG-ROOT}/rubygems/libexec/lefthook-linux-x64/lefthook\",\n    ).SetHash,\n    \"{REPO-ROOT}/dist/no_self_update_windows_amd64_v1/lefthook.exe\" => (\n      \"{PKG-ROOT}/rubygems/libexec/lefthook-windows-x64/lefthook.exe\",\n    ).SetHash,\n    \"{REPO-ROOT}/dist/no_self_update_darwin_amd64_v1/lefthook\" => (\n      \"{PKG-ROOT}/rubygems/libexec/lefthook-darwin-x64/lefthook\",\n    ).SetHash,\n    \"{REPO-ROOT}/dist/no_self_update_freebsd_amd64_v1/lefthook\" => (\n      \"{PKG-ROOT}/rubygems/libexec/lefthook-freebsd-x64/lefthook\",\n    ).SetHash,\n    \"{REPO-ROOT}/dist/no_self_update_openbsd_amd64_v1/lefthook\" => (\n      \"{PKG-ROOT}/rubygems/libexec/lefthook-openbsd-x64/lefthook\",\n    ).SetHash,\n    \"{REPO-ROOT}/dist/no_self_update_linux_arm64_v8.0/lefthook\" => (\n      \"{PKG-ROOT}/rubygems/libexec/lefthook-linux-arm64/lefthook\",\n    ).SetHash,\n    \"{REPO-ROOT}/dist/no_self_update_windows_arm64_v8.0/lefthook.exe\" => (\n      \"{PKG-ROOT}/rubygems/libexec/lefthook-windows-arm64/lefthook.exe\",\n    ).SetHash,\n    \"{REPO-ROOT}/dist/no_self_update_darwin_arm64_v8.0/lefthook\" => (\n      \"{PKG-ROOT}/rubygems/libexec/lefthook-darwin-arm64/lefthook\",\n    ).SetHash,\n    \"{REPO-ROOT}/dist/no_self_update_freebsd_arm64_v8.0/lefthook\" => (\n      \"{PKG-ROOT}/rubygems/libexec/lefthook-freebsd-arm64/lefthook\",\n    ).SetHash,\n    \"{REPO-ROOT}/dist/no_self_update_openbsd_arm64_v8.0/lefthook\" => (\n      \"{PKG-ROOT}/rubygems/libexec/lefthook-openbsd-arm64/lefthook\",\n    ).SetHash,\n  };\n}\n\nsubtest \"publish\", {\n  my ($sys, $gem) = new-registry(Registries::RubyGems);\n\n  my $tmp-gem1-file = \"{PKG-ROOT}/rubygems/pkg/lefthook-0.0.1.gem\";\n  my $tmp-gem2-file = \"{PKG-ROOT}/rubygems/pkg/lefthook-0.0.2.gem\";\n  spurt $tmp-gem1-file, \"<TEST>\";\n  spurt $tmp-gem2-file, \"<TEST>\";\n  LEAVE { .IO.unlink for ($tmp-gem1-file, $tmp-gem2-file) }\n\n  $gem.publish;\n\n  is-deeply $sys.run-calls, [\n    (\"rake build\", \"{PKG-ROOT}/rubygems\".IO),\n    (\"gem push {PKG-ROOT}/rubygems/pkg/lefthook-0.0.2.gem\", \"{PKG-ROOT}/rubygems\".IO),\n  ];\n}\n"
  },
  {
    "path": "packaging/scripts/t/04-pypi.rakutest",
    "content": "use Test;\n\nuse Constants;\nuse Registries::PyPI;\n\nuse lib $?FILE.IO.parent.child(\"lib\");\nuse TestRegistry;\n\nsubtest \"clean\", {\n  my ($sys, $pypi) = new-registry(Registries::PyPI);\n\n  $pypi.clean;\n\n  is-deeply $sys.removed.Set, (\n    \"{PKG-ROOT}/pypi/lefthook/__pycache__/\",\n    \"{PKG-ROOT}/pypi/lefthook.egg-info/\",\n    \"{PKG-ROOT}/pypi/build/\",\n  ).Set;\n}\n\nsubtest \"prepare\", {\n  my ($sys, $pypi) = new-registry(Registries::PyPI);\n\n  $pypi.prepare;\n\n  is-deeply $sys.copied, {\n    \"{REPO-ROOT}/dist/no_self_update_linux_amd64_v1/lefthook\" => (\n      \"{PKG-ROOT}/pypi/lefthook/bin/lefthook-linux-x86_64/lefthook\",\n    ).SetHash,\n    \"{REPO-ROOT}/dist/no_self_update_windows_amd64_v1/lefthook.exe\" => (\n      \"{PKG-ROOT}/pypi/lefthook/bin/lefthook-windows-x86_64/lefthook.exe\",\n    ).SetHash,\n    \"{REPO-ROOT}/dist/no_self_update_darwin_amd64_v1/lefthook\" => (\n      \"{PKG-ROOT}/pypi/lefthook/bin/lefthook-darwin-x86_64/lefthook\",\n    ).SetHash,\n    \"{REPO-ROOT}/dist/no_self_update_freebsd_amd64_v1/lefthook\" => (\n      \"{PKG-ROOT}/pypi/lefthook/bin/lefthook-freebsd-x86_64/lefthook\",\n    ).SetHash,\n    \"{REPO-ROOT}/dist/no_self_update_openbsd_amd64_v1/lefthook\" => (\n      \"{PKG-ROOT}/pypi/lefthook/bin/lefthook-openbsd-x86_64/lefthook\",\n    ).SetHash,\n    \"{REPO-ROOT}/dist/no_self_update_linux_arm64_v8.0/lefthook\" => (\n      \"{PKG-ROOT}/pypi/lefthook/bin/lefthook-linux-arm64/lefthook\",\n    ).SetHash,\n    \"{REPO-ROOT}/dist/no_self_update_windows_arm64_v8.0/lefthook.exe\" => (\n      \"{PKG-ROOT}/pypi/lefthook/bin/lefthook-windows-arm64/lefthook.exe\",\n    ).SetHash,\n    \"{REPO-ROOT}/dist/no_self_update_darwin_arm64_v8.0/lefthook\" => (\n      \"{PKG-ROOT}/pypi/lefthook/bin/lefthook-darwin-arm64/lefthook\",\n    ).SetHash,\n    \"{REPO-ROOT}/dist/no_self_update_freebsd_arm64_v8.0/lefthook\" => (\n      \"{PKG-ROOT}/pypi/lefthook/bin/lefthook-freebsd-arm64/lefthook\",\n    ).SetHash,\n    \"{REPO-ROOT}/dist/no_self_update_openbsd_arm64_v8.0/lefthook\" => (\n      \"{PKG-ROOT}/pypi/lefthook/bin/lefthook-openbsd-arm64/lefthook\",\n    ).SetHash,\n  };\n}\n\nsubtest \"publish\", {\n  my ($sys, $pypi) = new-registry(Registries::PyPI);\n\n  $pypi.publish;\n\n  is-deeply $sys.run-calls, [\n    (\"uv build --wheel\", \"{PKG-ROOT}/pypi\".IO),\n    (\"uv build --wheel\", \"{PKG-ROOT}/pypi\".IO),\n    (\"uv build --wheel\", \"{PKG-ROOT}/pypi\".IO),\n    (\"uv build --wheel\", \"{PKG-ROOT}/pypi\".IO),\n    (\"uv build --wheel\", \"{PKG-ROOT}/pypi\".IO),\n    (\"uv build --wheel\", \"{PKG-ROOT}/pypi\".IO),\n    (\"uv publish\", \"{PKG-ROOT}/pypi\".IO),\n  ];\n}\n"
  },
  {
    "path": "packaging/scripts/t/lib/FakeSystem.rakumod",
    "content": "use SystemAPI;\n\n# Mocks work with filesystem.\nclass FakeSystem does SystemAPI {\n  has Str @.removed;\n  has %.copied;\n  has @.run-calls = ();\n  has $!cwd;\n\n  multi method rm(@paths --> Nil) {\n    @.removed.append(@paths.map(*.Str));\n  }\n\n  method in-dir(IO() $path, &block --> Nil) {\n    $!cwd = $path;\n    block();\n  }\n\n  method cp(IO() $source, IO() $dest --> Nil) {\n    %.copied{$source} //= SetHash.new;\n    %.copied{$source}.set($dest.Str);\n  }\n\n  method replace(IO() :$file, Regex :$regex, :$replacement --> Nil) {\n    ...\n  }\n\n  method run(*@argv --> Nil) {\n    @.run-calls.push((@argv.join(' '), $!cwd.clone));\n  }\n}\n"
  },
  {
    "path": "packaging/scripts/t/lib/TestRegistry.rakumod",
    "content": "unit module TestRegistry;\n\nuse FakeSystem;\n\nuse Registries::NPM;\nuse Registries::RubyGems;\nuse Registries::PyPI;\nuse Registries::AUR;\nuse Registries::AUR-Bin;\n\nsubset RegistryClass where * ~~ (\n  | Registries::NPM\n  | Registries::RubyGems\n  | Registries::PyPI\n  | Registries::AUR\n  | Registries::AUR-Bin\n);\n\nsub new-registry(RegistryClass $class --> List) is export {\n  my $sys = FakeSystem.new;\n  my $npm = $class.new(:$sys);\n\n  ($sys, $npm);\n}\n"
  },
  {
    "path": "schema.json",
    "content": "{\n  \"$schema\": \"https://json-schema.org/draft/2020-12/schema\",\n  \"$id\": \"https://json.schemastore.org/lefthook.json\",\n  \"$defs\": {\n    \"Command\": {\n      \"properties\": {\n        \"run\": {\n          \"type\": \"string\"\n        },\n        \"files\": {\n          \"type\": \"string\"\n        },\n        \"root\": {\n          \"type\": \"string\"\n        },\n        \"fail_text\": {\n          \"type\": \"string\"\n        },\n        \"timeout\": {\n          \"type\": \"string\",\n          \"examples\": [\n            \"15s\"\n          ]\n        },\n        \"skip\": {\n          \"oneOf\": [\n            {\n              \"type\": \"boolean\"\n            },\n            {\n              \"type\": \"array\"\n            }\n          ]\n        },\n        \"only\": {\n          \"oneOf\": [\n            {\n              \"type\": \"boolean\"\n            },\n            {\n              \"type\": \"array\"\n            }\n          ]\n        },\n        \"tags\": {\n          \"oneOf\": [\n            {\n              \"type\": \"string\"\n            },\n            {\n              \"type\": \"array\"\n            }\n          ],\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"file_types\": {\n          \"oneOf\": [\n            {\n              \"type\": \"string\"\n            },\n            {\n              \"type\": \"array\"\n            }\n          ],\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"glob\": {\n          \"oneOf\": [\n            {\n              \"type\": \"string\"\n            },\n            {\n              \"type\": \"array\"\n            }\n          ],\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"exclude\": {\n          \"oneOf\": [\n            {\n              \"type\": \"string\"\n            },\n            {\n              \"type\": \"array\"\n            }\n          ],\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"env\": {\n          \"additionalProperties\": {\n            \"type\": \"string\"\n          },\n          \"type\": \"object\"\n        },\n        \"priority\": {\n          \"type\": \"integer\"\n        },\n        \"interactive\": {\n          \"type\": \"boolean\"\n        },\n        \"use_stdin\": {\n          \"type\": \"boolean\"\n        },\n        \"stage_fixed\": {\n          \"type\": \"boolean\"\n        }\n      },\n      \"additionalProperties\": false,\n      \"type\": \"object\",\n      \"required\": [\n        \"run\"\n      ]\n    },\n    \"Group\": {\n      \"properties\": {\n        \"root\": {\n          \"type\": \"string\"\n        },\n        \"parallel\": {\n          \"type\": \"boolean\"\n        },\n        \"piped\": {\n          \"type\": \"boolean\"\n        },\n        \"jobs\": {\n          \"items\": {\n            \"$ref\": \"#/$defs/Job\"\n          },\n          \"type\": \"array\"\n        }\n      },\n      \"additionalProperties\": false,\n      \"type\": \"object\",\n      \"required\": [\n        \"jobs\"\n      ]\n    },\n    \"Hook\": {\n      \"properties\": {\n        \"parallel\": {\n          \"type\": \"boolean\"\n        },\n        \"piped\": {\n          \"type\": \"boolean\"\n        },\n        \"follow\": {\n          \"type\": \"boolean\"\n        },\n        \"fail_on_changes\": {\n          \"type\": \"string\",\n          \"enum\": [\n            \"true\",\n            \"1\",\n            \"0\",\n            \"false\",\n            \"never\",\n            \"always\",\n            \"ci\",\n            \"non-ci\"\n          ]\n        },\n        \"fail_on_changes_diff\": {\n          \"type\": \"boolean\"\n        },\n        \"files\": {\n          \"type\": \"string\"\n        },\n        \"exclude_tags\": {\n          \"items\": {\n            \"type\": \"string\"\n          },\n          \"type\": \"array\"\n        },\n        \"exclude\": {\n          \"items\": {\n            \"type\": \"string\"\n          },\n          \"type\": \"array\"\n        },\n        \"skip\": {\n          \"oneOf\": [\n            {\n              \"type\": \"boolean\"\n            },\n            {\n              \"type\": \"array\"\n            }\n          ]\n        },\n        \"only\": {\n          \"oneOf\": [\n            {\n              \"type\": \"boolean\"\n            },\n            {\n              \"type\": \"array\"\n            }\n          ]\n        },\n        \"setup\": {\n          \"items\": {\n            \"$ref\": \"#/$defs/SetupInstruction\"\n          },\n          \"type\": \"array\"\n        },\n        \"jobs\": {\n          \"items\": {\n            \"$ref\": \"#/$defs/Job\"\n          },\n          \"type\": \"array\"\n        },\n        \"commands\": {\n          \"additionalProperties\": {\n            \"$ref\": \"#/$defs/Command\"\n          },\n          \"type\": \"object\"\n        },\n        \"scripts\": {\n          \"additionalProperties\": {\n            \"$ref\": \"#/$defs/Script\"\n          },\n          \"type\": \"object\"\n        }\n      },\n      \"additionalProperties\": false,\n      \"type\": \"object\"\n    },\n    \"Job\": {\n      \"oneOf\": [\n        {\n          \"required\": [\n            \"run\"\n          ],\n          \"title\": \"Run a command\"\n        },\n        {\n          \"required\": [\n            \"script\"\n          ],\n          \"title\": \"Run a script\"\n        },\n        {\n          \"required\": [\n            \"group\"\n          ],\n          \"title\": \"Run a group\"\n        }\n      ],\n      \"properties\": {\n        \"name\": {\n          \"type\": \"string\"\n        },\n        \"run\": {\n          \"type\": \"string\"\n        },\n        \"script\": {\n          \"type\": \"string\"\n        },\n        \"runner\": {\n          \"type\": \"string\"\n        },\n        \"args\": {\n          \"type\": \"string\"\n        },\n        \"root\": {\n          \"type\": \"string\"\n        },\n        \"files\": {\n          \"type\": \"string\"\n        },\n        \"fail_text\": {\n          \"type\": \"string\"\n        },\n        \"timeout\": {\n          \"type\": \"string\",\n          \"examples\": [\n            \"15s\"\n          ]\n        },\n        \"glob\": {\n          \"oneOf\": [\n            {\n              \"type\": \"string\"\n            },\n            {\n              \"type\": \"array\"\n            }\n          ],\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"exclude\": {\n          \"oneOf\": [\n            {\n              \"type\": \"string\"\n            },\n            {\n              \"type\": \"array\"\n            }\n          ],\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"tags\": {\n          \"items\": {\n            \"type\": \"string\"\n          },\n          \"type\": \"array\"\n        },\n        \"file_types\": {\n          \"oneOf\": [\n            {\n              \"type\": \"string\"\n            },\n            {\n              \"type\": \"array\"\n            }\n          ],\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"env\": {\n          \"additionalProperties\": {\n            \"type\": \"string\"\n          },\n          \"type\": \"object\"\n        },\n        \"interactive\": {\n          \"type\": \"boolean\"\n        },\n        \"use_stdin\": {\n          \"type\": \"boolean\"\n        },\n        \"stage_fixed\": {\n          \"type\": \"boolean\"\n        },\n        \"skip\": {\n          \"oneOf\": [\n            {\n              \"type\": \"boolean\"\n            },\n            {\n              \"type\": \"array\"\n            }\n          ]\n        },\n        \"only\": {\n          \"oneOf\": [\n            {\n              \"type\": \"boolean\"\n            },\n            {\n              \"type\": \"array\"\n            }\n          ]\n        },\n        \"group\": {\n          \"$ref\": \"#/$defs/Group\"\n        }\n      },\n      \"additionalProperties\": false,\n      \"type\": \"object\"\n    },\n    \"Remote\": {\n      \"properties\": {\n        \"git_url\": {\n          \"type\": \"string\",\n          \"description\": \"A URL to Git repository. It will be accessed with privileges of the machine lefthook runs on.\"\n        },\n        \"ref\": {\n          \"type\": \"string\",\n          \"description\": \"An optional *branch* or *tag* name\"\n        },\n        \"configs\": {\n          \"items\": {\n            \"type\": \"string\"\n          },\n          \"type\": \"array\",\n          \"description\": \"An optional array of config paths from remote's root\",\n          \"default\": [\n            \"lefthook.yml\"\n          ]\n        },\n        \"refetch\": {\n          \"type\": \"boolean\",\n          \"description\": \"Set to true if you want to always refetch the remote\"\n        },\n        \"refetch_frequency\": {\n          \"type\": \"string\",\n          \"description\": \"Provide a frequency for the remotes refetches\",\n          \"examples\": [\n            \"24h\"\n          ]\n        }\n      },\n      \"additionalProperties\": false,\n      \"type\": \"object\"\n    },\n    \"Script\": {\n      \"properties\": {\n        \"runner\": {\n          \"type\": \"string\"\n        },\n        \"args\": {\n          \"type\": \"string\"\n        },\n        \"skip\": {\n          \"oneOf\": [\n            {\n              \"type\": \"boolean\"\n            },\n            {\n              \"type\": \"array\"\n            }\n          ]\n        },\n        \"only\": {\n          \"oneOf\": [\n            {\n              \"type\": \"boolean\"\n            },\n            {\n              \"type\": \"array\"\n            }\n          ]\n        },\n        \"tags\": {\n          \"oneOf\": [\n            {\n              \"type\": \"string\"\n            },\n            {\n              \"type\": \"array\"\n            }\n          ],\n          \"items\": {\n            \"type\": \"string\"\n          }\n        },\n        \"env\": {\n          \"additionalProperties\": {\n            \"type\": \"string\"\n          },\n          \"type\": \"object\"\n        },\n        \"priority\": {\n          \"type\": \"integer\"\n        },\n        \"fail_text\": {\n          \"type\": \"string\"\n        },\n        \"timeout\": {\n          \"type\": \"string\",\n          \"examples\": [\n            \"15s\"\n          ]\n        },\n        \"interactive\": {\n          \"type\": \"boolean\"\n        },\n        \"use_stdin\": {\n          \"type\": \"boolean\"\n        },\n        \"stage_fixed\": {\n          \"type\": \"boolean\"\n        }\n      },\n      \"additionalProperties\": false,\n      \"type\": \"object\"\n    },\n    \"SetupInstruction\": {\n      \"oneOf\": [\n        {\n          \"required\": [\n            \"run\"\n          ],\n          \"title\": \"Run a command\"\n        }\n      ],\n      \"properties\": {\n        \"run\": {\n          \"type\": \"string\"\n        }\n      },\n      \"additionalProperties\": false,\n      \"type\": \"object\"\n    }\n  },\n  \"$comment\": \"Last updated on 2026.02.28.\",\n  \"properties\": {\n    \"min_version\": {\n      \"type\": \"string\",\n      \"description\": \"Specify a minimum version for the lefthook binary\"\n    },\n    \"lefthook\": {\n      \"type\": \"string\",\n      \"description\": \"Lefthook executable path or command\"\n    },\n    \"source_dir\": {\n      \"type\": \"string\",\n      \"description\": \"Change a directory for script files. Directory for script files contains folders with git hook names which contain script files.\",\n      \"default\": \".lefthook/\"\n    },\n    \"source_dir_local\": {\n      \"type\": \"string\",\n      \"description\": \"Change a directory for local script files (not stored in VCS)\",\n      \"default\": \".lefthook-local/\"\n    },\n    \"rc\": {\n      \"type\": \"string\",\n      \"description\": \"Provide an rc file - a simple sh script\"\n    },\n    \"output\": {\n      \"oneOf\": [\n        {\n          \"type\": \"boolean\"\n        },\n        {\n          \"type\": \"array\"\n        }\n      ],\n      \"description\": \"Manage verbosity by skipping the printing of output of some steps\"\n    },\n    \"colors\": {\n      \"oneOf\": [\n        {\n          \"type\": \"boolean\"\n        },\n        {\n          \"type\": \"object\"\n        }\n      ],\n      \"description\": \"Enable disable or set your own colors for lefthook output\"\n    },\n    \"extends\": {\n      \"items\": {\n        \"type\": \"string\"\n      },\n      \"type\": \"array\",\n      \"description\": \"Specify files to extend config with\"\n    },\n    \"no_tty\": {\n      \"type\": \"boolean\",\n      \"description\": \"Whether hide spinner and other interactive things\"\n    },\n    \"assert_lefthook_installed\": {\n      \"type\": \"boolean\"\n    },\n    \"skip_lfs\": {\n      \"type\": \"boolean\",\n      \"description\": \"Skip running Git LFS hooks (enabled by default)\"\n    },\n    \"no_auto_install\": {\n      \"type\": \"boolean\",\n      \"description\": \"Do not automatically install hooks when running lefthook\"\n    },\n    \"install_non_git_hooks\": {\n      \"type\": \"boolean\",\n      \"description\": \"Install non-Git hooks to .git/hooks\"\n    },\n    \"glob_matcher\": {\n      \"type\": \"string\",\n      \"enum\": [\n        \"gobwas\",\n        \"doublestar\"\n      ],\n      \"description\": \"Choose the glob matching engine: 'gobwas' (default) or 'doublestar' (standard ** behavior)\",\n      \"default\": \"gobwas\"\n    },\n    \"remotes\": {\n      \"items\": {\n        \"$ref\": \"#/$defs/Remote\"\n      },\n      \"type\": \"array\",\n      \"description\": \"Provide multiple remote configs to use lefthook configurations shared across projects. Lefthook will automatically download and merge configurations into main config.\"\n    },\n    \"templates\": {\n      \"additionalProperties\": {\n        \"type\": \"string\"\n      },\n      \"type\": \"object\",\n      \"description\": \"Custom templates for replacements in run commands.\"\n    },\n    \"$schema\": {\n      \"type\": \"string\"\n    },\n    \"pre-commit\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"applypatch-msg\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"pre-applypatch\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"post-applypatch\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"pre-merge-commit\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"prepare-commit-msg\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"commit-msg\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"post-commit\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"pre-rebase\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"post-checkout\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"post-merge\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"pre-push\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"pre-receive\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"update\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"proc-receive\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"post-receive\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"post-update\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"reference-transaction\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"push-to-checkout\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"pre-auto-gc\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"post-rewrite\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"sendemail-validate\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"fsmonitor-watchman\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"p4-changelist\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"p4-prepare-changelist\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"p4-post-changelist\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"p4-pre-submit\": {\n      \"$ref\": \"#/$defs/Hook\"\n    },\n    \"post-index-change\": {\n      \"$ref\": \"#/$defs/Hook\"\n    }\n  },\n  \"additionalProperties\": {\n    \"properties\": {\n      \"parallel\": {\n        \"type\": \"boolean\"\n      },\n      \"piped\": {\n        \"type\": \"boolean\"\n      },\n      \"follow\": {\n        \"type\": \"boolean\"\n      },\n      \"fail_on_changes\": {\n        \"type\": \"string\",\n        \"enum\": [\n          \"true\",\n          \"1\",\n          \"0\",\n          \"false\",\n          \"never\",\n          \"always\",\n          \"ci\",\n          \"non-ci\"\n        ]\n      },\n      \"fail_on_changes_diff\": {\n        \"type\": \"boolean\"\n      },\n      \"files\": {\n        \"type\": \"string\"\n      },\n      \"exclude_tags\": {\n        \"items\": {\n          \"type\": \"string\"\n        },\n        \"type\": \"array\"\n      },\n      \"exclude\": {\n        \"items\": {\n          \"type\": \"string\"\n        },\n        \"type\": \"array\"\n      },\n      \"skip\": {\n        \"oneOf\": [\n          {\n            \"type\": \"boolean\"\n          },\n          {\n            \"type\": \"array\"\n          }\n        ]\n      },\n      \"only\": {\n        \"oneOf\": [\n          {\n            \"type\": \"boolean\"\n          },\n          {\n            \"type\": \"array\"\n          }\n        ]\n      },\n      \"setup\": {\n        \"items\": {\n          \"$ref\": \"#/$defs/SetupInstruction\"\n        },\n        \"type\": \"array\"\n      },\n      \"jobs\": {\n        \"items\": {\n          \"$ref\": \"#/$defs/Job\"\n        },\n        \"type\": \"array\"\n      },\n      \"commands\": {\n        \"additionalProperties\": {\n          \"$ref\": \"#/$defs/Command\"\n        },\n        \"type\": \"object\"\n      },\n      \"scripts\": {\n        \"additionalProperties\": {\n          \"$ref\": \"#/$defs/Script\"\n        },\n        \"type\": \"object\"\n      }\n    },\n    \"additionalProperties\": false,\n    \"type\": \"object\"\n  },\n  \"type\": \"object\"\n}"
  },
  {
    "path": "tea.yaml",
    "content": "# https://tea.xyz/what-is-this-file\n---\nversion: 1.0.0\ncodeOwners:\n  - '0xb1a25Fe215747E1093901282dc2Ea68cE8c290D8'\n  - '0x4d9B3A6207B48E31147327f8efaF31D5EFC3784e'\nquorum: 1\n"
  },
  {
    "path": "tests/helpers/cmdtest/cmdtest.go",
    "content": "package cmdtest\n\nimport (\n\t\"io\"\n\t\"testing\"\n)\n\n// NewOrdered returns executor that have the order defined in `outs`.\nfunc NewOrdered(t testing.TB, outs []Out) *OrderedCmd {\n\treturn &OrderedCmd{t: t, outs: outs}\n}\n\n// NewTracking returns executor that collects the called commands.\nfunc NewTracking(cb func(string, string, io.Writer) error) *TrackingCmd {\n\treturn &TrackingCmd{\n\t\tCommands: make([]string, 0),\n\t\tcallback: cb,\n\t}\n}\n\n// NewDumb returns executor that does simply nothing.\nfunc NewDumb() *DumbCmd {\n\treturn &DumbCmd{}\n}\n"
  },
  {
    "path": "tests/helpers/cmdtest/dumb.go",
    "content": "package cmdtest\n\nimport (\n\t\"io\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/system\"\n)\n\ntype DumbCmd struct{}\n\n// WithoutEnvs does nothing.\nfunc (c *DumbCmd) WithoutEnvs(_ ...string) system.Command {\n\treturn c\n}\n\n// Run does nothing.\nfunc (c *DumbCmd) Run(_ []string, _ string, _ io.Reader, _ io.Writer, _ io.Writer) error {\n\treturn nil\n}\n"
  },
  {
    "path": "tests/helpers/cmdtest/ordered.go",
    "content": "package cmdtest\n\nimport (\n\t\"io\"\n\t\"strings\"\n\t\"testing\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/system\"\n)\n\ntype Out struct {\n\tCommand string\n\tOutput  string\n}\n\n// OrderedCmd contains predefined list of commands and makes sure actual calls are the same.\ntype OrderedCmd struct {\n\tt    testing.TB\n\touts []Out\n\tcnt  int\n}\n\n// WithoutEnvs simply does nothing.\nfunc (c *OrderedCmd) WithoutEnvs(envs ...string) system.Command {\n\treturn c\n}\n\n// Run makes sure command is executed correctly.\nfunc (c *OrderedCmd) Run(command []string, root string, in io.Reader, out io.Writer, err io.Writer) error {\n\tc.t.Helper()\n\tdefer func() { c.cnt += 1 }()\n\n\tcmd := strings.Join(command, \" \")\n\tif len(c.outs) == 0 {\n\t\tc.t.Errorf(\"expected: no command, called: %s\", cmd)\n\t\treturn nil\n\t}\n\n\tcheckCmd := c.outs[0]\n\n\tif checkCmd.Command != cmd {\n\t\tc.t.Errorf(\"%d) expected: '%s', called: '%s'\", c.cnt, checkCmd.Command, cmd)\n\t}\n\n\t_, _ = out.Write([]byte(checkCmd.Output))\n\tc.outs = c.outs[1:]\n\n\treturn nil\n}\n"
  },
  {
    "path": "tests/helpers/cmdtest/ordered_test.go",
    "content": "package cmdtest\n\nimport (\n\t\"io\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/system\"\n)\n\nfunc TestOrderedCmd(t *testing.T) {\n\tvar _ system.Command = (*OrderedCmd)(nil)\n\n\tcmd := NewOrdered(\n\t\tt,\n\t\t[]Out{\n\t\t\t{\"A 1\", \"\"},\n\t\t\t{\"B 2\", \"\"},\n\t\t\t{\"C 3\", \"\"},\n\t\t},\n\t)\n\t_ = cmd.WithoutEnvs(\"OK\")\n\n\tassert.NoError(t, cmd.Run([]string{\"A\", \"1\"}, \"\", system.NullReader, io.Discard, io.Discard))\n\tassert.NoError(t, cmd.Run([]string{\"B\", \"2\"}, \"\", system.NullReader, io.Discard, io.Discard))\n\tassert.NoError(t, cmd.Run([]string{\"C\", \"3\"}, \"\", system.NullReader, io.Discard, io.Discard))\n}\n"
  },
  {
    "path": "tests/helpers/cmdtest/tracking.go",
    "content": "package cmdtest\n\nimport (\n\t\"context\"\n\t\"io\"\n\t\"strings\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/system\"\n)\n\ntype TrackingCmd struct {\n\tCommands []string\n\tcallback func(cmd string, root string, out io.Writer) error\n}\n\n// WithoutEnvs simply does nothing.\nfunc (c *TrackingCmd) WithoutEnvs(envs ...string) system.Command {\n\treturn c\n}\n\n// Run makes sure command is executed correctly.\nfunc (c *TrackingCmd) Run(command []string, root string, in io.Reader, out io.Writer, err io.Writer) error {\n\tcmd := strings.Join(command, \" \")\n\tc.Commands = append(c.Commands, cmd)\n\n\tif c.callback != nil {\n\t\treturn c.callback(cmd, root, out)\n\t}\n\n\treturn nil\n}\n\nfunc (c *TrackingCmd) RunWithContext(_ context.Context, command []string, root string, in io.Reader, out io.Writer, err io.Writer) error {\n\treturn c.Run(command, root, in, out, err)\n}\n\nfunc (c *TrackingCmd) Reset() {\n\tc.Commands = []string{}\n}\n"
  },
  {
    "path": "tests/helpers/cmdtest/tracking_test.go",
    "content": "package cmdtest\n\nimport (\n\t\"io\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/system\"\n)\n\nfunc TestTrackingCmd(t *testing.T) {\n\tvar _ system.Command = (*TrackingCmd)(nil)\n\tvar _ system.CommandWithContext = (*TrackingCmd)(nil)\n\n\tcommands := make([]string, 0, 3)\n\tcb := func(command string, root string, _ io.Writer) error {\n\t\tcommands = append(commands, command)\n\t\treturn nil\n\t}\n\tcmd := NewTracking(cb)\n\n\tassert.NoError(t, cmd.Run([]string{\"A\", \"1\"}, \"\", system.NullReader, io.Discard, io.Discard))\n\tassert.NoError(t, cmd.Run([]string{\"B\", \"2\"}, \"\", system.NullReader, io.Discard, io.Discard))\n\tassert.NoError(t, cmd.RunWithContext(t.Context(), []string{\"C\", \"3\"}, \"\", system.NullReader, io.Discard, io.Discard))\n\n\tassert.Equal(t, []string{\"A 1\", \"B 2\", \"C 3\"}, cmd.Commands)\n\tassert.Equal(t, []string{\"A 1\", \"B 2\", \"C 3\"}, commands)\n\n\t_ = cmd.WithoutEnvs(\"OK\")\n}\n"
  },
  {
    "path": "tests/helpers/configtest/config.go",
    "content": "package configtest\n\nimport (\n\t\"bytes\"\n\t\"strings\"\n\n\t\"github.com/goccy/go-yaml\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/config\"\n)\n\n// ParseHook simplifies config.Hook definition with YAML string.\nfunc ParseHook(str string) *config.Hook {\n\thook := config.Hook{}\n\terr := yaml.Unmarshal(stripPadding(str), &hook)\n\tif err != nil {\n\t\tpanic(\"Failed to parse hook: \" + err.Error())\n\t}\n\treturn &hook\n}\n\n// ParseJob simplifies config.Job definition with YAML string.\nfunc ParseJob(str string) *config.Job {\n\tjob := config.Job{}\n\terr := yaml.Unmarshal(stripPadding(str), &job)\n\tif err != nil {\n\t\tpanic(\"Failed to parse job: \" + err.Error())\n\t}\n\treturn &job\n}\n\nfunc stripPadding(str string) []byte {\n\tstr = strings.TrimRight(strings.Trim(str, \"\\n\"), \" \\t\")\n\tcleanBuffer := new(bytes.Buffer)\n\tvar padding int\n\tvar paddingSet bool\n\tfor line := range strings.Lines(str) {\n\t\tvar cleanLine string\n\t\tif !paddingSet {\n\t\t\tcleanLine = strings.TrimLeft(line, \" \\t\")\n\t\t\tpadding = len(line) - len(cleanLine)\n\t\t\tpaddingSet = true\n\t\t} else {\n\t\t\tcleanLine = line[padding:]\n\t\t}\n\t\tcleanBuffer.WriteString(cleanLine)\n\t}\n\n\treturn cleanBuffer.Bytes()\n}\n"
  },
  {
    "path": "tests/helpers/configtest/config_test.go",
    "content": "package configtest\n\nimport (\n\t\"strconv\"\n\t\"testing\"\n\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/config\"\n)\n\nfunc TestParseHook(t *testing.T) {\n\tfor i, tt := range [...]struct {\n\t\traw  string\n\t\thook *config.Hook\n\t}{\n\t\t{\n\t\t\traw: `\n        parallel: true\n        exclude_tags:\n          - tag1\n          - tag2\n        jobs:\n          - run: echo\n        commands:\n          simple:\n            run: echo\n        scripts:\n          \"dummy.sh\":\n            runner: bash\n      `,\n\t\t\thook: &config.Hook{\n\t\t\t\tParallel:    true,\n\t\t\t\tExcludeTags: []string{\"tag1\", \"tag2\"},\n\t\t\t\tJobs: []*config.Job{\n\t\t\t\t\t{\n\t\t\t\t\t\tRun: \"echo\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tCommands: map[string]*config.Command{\n\t\t\t\t\t\"simple\": {\n\t\t\t\t\t\tRun: \"echo\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t\tScripts: map[string]*config.Script{\n\t\t\t\t\t\"dummy.sh\": {\n\t\t\t\t\t\tRunner: \"bash\",\n\t\t\t\t\t},\n\t\t\t\t},\n\t\t\t},\n\t\t},\n\t} {\n\t\tt.Run(strconv.Itoa(i), func(t *testing.T) {\n\t\t\tparsed := ParseHook(tt.raw)\n\t\t\tassert.New(t).Equal(tt.hook, parsed)\n\t\t})\n\t}\n}\n\nfunc TestParseJob(t *testing.T) {\n\tfor i, tt := range [...]struct {\n\t\traw string\n\t\tjob *config.Job\n\t}{\n\t\t{\n\t\t\traw: `\n        name: test\n        run: echo\n        glob:\n          - \"*.sh\"\n          - \"*.md\"\n        exclude:\n          - \"install.sh\"\n          - \"README.md\"\n        root: docs/\n        use_stdin: true\n        stage_fixed: true\n      `,\n\t\t\tjob: &config.Job{\n\t\t\t\tName:       \"test\",\n\t\t\t\tRun:        \"echo\",\n\t\t\t\tGlob:       []string{\"*.sh\", \"*.md\"},\n\t\t\t\tExclude:    []string{\"install.sh\", \"README.md\"},\n\t\t\t\tRoot:       \"docs/\",\n\t\t\t\tUseStdin:   true,\n\t\t\t\tStageFixed: true,\n\t\t\t},\n\t\t},\n\t} {\n\t\tt.Run(strconv.Itoa(i), func(t *testing.T) {\n\t\t\tparsed := ParseJob(tt.raw)\n\t\t\tassert.New(t).Equal(tt.job, parsed)\n\t\t})\n\t}\n}\n"
  },
  {
    "path": "tests/helpers/gittest/gittest.go",
    "content": "package gittest\n\nimport (\n\t\"path/filepath\"\n\n\t\"github.com/spf13/afero\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/git\"\n\t\"github.com/evilmartians/lefthook/v2/internal/system\"\n)\n\ntype RepositoryBuilder struct {\n\troot string\n\tcmd  system.Command\n\tfs   afero.Fs\n}\n\nfunc NewRepositoryBuilder() *RepositoryBuilder {\n\treturn &RepositoryBuilder{}\n}\n\nfunc (b *RepositoryBuilder) Root(root string) *RepositoryBuilder {\n\tb.root = root\n\treturn b\n}\n\nfunc (b *RepositoryBuilder) Cmd(cmd system.Command) *RepositoryBuilder {\n\tb.cmd = cmd\n\treturn b\n}\n\nfunc (b *RepositoryBuilder) Fs(fs afero.Fs) *RepositoryBuilder {\n\tb.fs = fs\n\treturn b\n}\n\nfunc (b *RepositoryBuilder) Build() *git.Repository {\n\treturn &git.Repository{\n\t\tFs:        b.fs,\n\t\tGit:       git.NewExecutor(b.cmd),\n\t\tRootPath:  b.root,\n\t\tGitPath:   GitPath(b.root),\n\t\tHooksPath: filepath.Join(GitPath(b.root), \"hooks\"),\n\t\tInfoPath:  filepath.Join(GitPath(b.root), \"info\"),\n\t}\n}\n\nfunc GitPath(root string) string {\n\treturn filepath.Join(root, \".git\")\n}\n"
  },
  {
    "path": "tests/helpers/gittest/gittest_test.go",
    "content": "package gittest\n\nimport (\n\t\"path/filepath\"\n\t\"testing\"\n\n\t\"github.com/spf13/afero\"\n\t\"github.com/stretchr/testify/assert\"\n\n\t\"github.com/evilmartians/lefthook/v2/internal/git\"\n\t\"github.com/evilmartians/lefthook/v2/internal/system\"\n)\n\nfunc TestBuilder(t *testing.T) {\n\tfs := afero.NewMemMapFs()\n\tcmd := system.Cmd\n\trepo := NewRepositoryBuilder().Root(\"root\").Fs(fs).Cmd(cmd).Build()\n\n\tassert := assert.New(t)\n\tassert.Equal(\"root\", repo.RootPath)\n\tassert.Equal(filepath.Join(\"root\", \".git\"), repo.GitPath)\n\tassert.Equal(filepath.Join(\"root\", \".git\", \"info\"), repo.InfoPath)\n\tassert.Equal(filepath.Join(\"root\", \".git\", \"hooks\"), repo.HooksPath)\n\tassert.Equal(git.NewExecutor(cmd), repo.Git)\n\tassert.Equal(fs, repo.Fs)\n}\n\nfunc TestGitPath(t *testing.T) {\n\tassert.Equal(t, filepath.Join(\"root\", \".git\"), GitPath(\"root\"))\n}\n"
  },
  {
    "path": "tests/integration/add.txt",
    "content": "[windows] skip\n\nexec git init\nexec lefthook add pre-commit\n! stderr .\nexists .git/hooks/pre-commit\n! exists .lefthook/pre-commit\n\nexec lefthook add pre-push --dirs\n! stderr .\nexists .git/hooks/pre-push\nexists .lefthook/pre-push\nexists .lefthook-local/pre-push\n"
  },
  {
    "path": "tests/integration/check_install.txt",
    "content": "! exec lefthook check-install\nexec git init\nexec git config user.email \"you@example.com\"\nexec git config user.name \"Your Name\"\nexec git add -A\n! exec lefthook check-install\nexec lefthook install\nexec lefthook check-install\n\n-- lefthook.yml --\npre-commit:\n  jobs:\n    - run: echo hello, test\n"
  },
  {
    "path": "tests/integration/cli_run_only.txt",
    "content": "exec git init\nexec git config user.email \"you@example.com\"\nexec git config user.name \"Your Name\"\nexec git add -A\nexec lefthook install\nexec lefthook run hook --job a --job c --job db --job lint\nstdout '\\s*a\\s*ca\\s*cb\\s*db\\s*lint\\s*'\nexec lefthook run hook --tag red\nstdout '\\s*a\\s*ca\\s*cb\\s*db\\s*lint\\s*'\n\n-- lefthook.yml --\noutput:\n  - execution_out\nhook:\n  jobs:\n    - name: a\n      tags: [red]\n      run: echo a\n    - name: b\n      tags: [blue]\n      run: echo b\n    - name: c\n      tags: [red]\n      group:\n        jobs:\n          - run: echo ca\n            tags: [blue]\n          - run: echo cb\n    - name: d\n      group:\n        jobs:\n          - name: da\n            run: echo da\n          - name: db\n            run: echo db\n            tags: [red]\n  commands:\n    lint:\n      run: echo lint\n      tags: [red, blue]\n    test:\n      run: echo test\n      tags: [blue]\n"
  },
  {
    "path": "tests/integration/dump.txt",
    "content": "[windows] skip\n\nexec git init\nexec lefthook dump\ncmp stdout lefthook-dumped.yml\n! stderr .\n\nexec lefthook dump --format=json\ncmp stdout lefthook-dumped.json\n! stderr .\n\nexec lefthook dump -f toml\ncmp stdout lefthook-dumped.toml\n! stderr .\n\n-- lefthook.yml --\ncolors:\n  cyan: 14\n  gray: 244\n  green: '#32CD32'\n  red: '#FF1493'\n  yellow: '#F0E68C'\npre-commit:\n  follow: true\n  commands:\n    lint:\n      interactive: true\n      skip:\n        - merge\n        - rebase\n        - ref: main\n      run: yarn lint {staged_files}\n    test:\n      skip: merge\n      glob: \"*.js\"\n      run: yarn test\n  scripts:\n    \"my-script.sh\":\n      runner: bash\n      use_stdin: true\n      stage_fixed: true\n      env:\n        FOO: bar\n-- lefthook-dumped.yml --\ncolors:\n  cyan: 14\n  gray: 244\n  green: '#32CD32'\n  red: '#FF1493'\n  yellow: '#F0E68C'\npre-commit:\n  follow: true\n  commands:\n    lint:\n      run: yarn lint {staged_files}\n      skip:\n        - merge\n        - rebase\n        - ref: main\n      interactive: true\n    test:\n      run: yarn test\n      skip: merge\n      glob:\n        - '*.js'\n  scripts:\n    my-script.sh:\n      runner: bash\n      env:\n        FOO: bar\n      use_stdin: true\n      stage_fixed: true\n-- lefthook-dumped.json --\n{\n  \"colors\": {\n    \"cyan\": 14,\n    \"gray\": 244,\n    \"green\": \"#32CD32\",\n    \"red\": \"#FF1493\",\n    \"yellow\": \"#F0E68C\"\n  },\n  \"pre-commit\": {\n    \"follow\": true,\n    \"commands\": {\n      \"lint\": {\n        \"run\": \"yarn lint {staged_files}\",\n        \"skip\": [\n          \"merge\",\n          \"rebase\",\n          {\n            \"ref\": \"main\"\n          }\n        ],\n        \"interactive\": true\n      },\n      \"test\": {\n        \"run\": \"yarn test\",\n        \"skip\": \"merge\",\n        \"glob\": [\n          \"*.js\"\n        ]\n      }\n    },\n    \"scripts\": {\n      \"my-script.sh\": {\n        \"runner\": \"bash\",\n        \"env\": {\n          \"FOO\": \"bar\"\n        },\n        \"use_stdin\": true,\n        \"stage_fixed\": true\n      }\n    }\n  }\n}\n-- lefthook-dumped.toml --\n[colors]\ncyan = 14\ngray = 244\ngreen = '#32CD32'\nred = '#FF1493'\nyellow = '#F0E68C'\n\n[pre-commit]\nfollow = true\n\n[pre-commit.commands]\n[pre-commit.commands.lint]\nrun = 'yarn lint {staged_files}'\nskip = ['merge', 'rebase', {ref = 'main'}]\ninteractive = true\n\n[pre-commit.commands.test]\nrun = 'yarn test'\nskip = 'merge'\nglob = ['*.js']\n\n[pre-commit.scripts]\n[pre-commit.scripts.'my-script.sh']\nrunner = 'bash'\nuse_stdin = true\nstage_fixed = true\n\n[pre-commit.scripts.'my-script.sh'.env]\nFOO = 'bar'\n"
  },
  {
    "path": "tests/integration/env_overwrite_issue_1137.txt",
    "content": "exec git init\nexec git config user.email \"you@example.com\"\nexec git config user.name \"Your Name\"\nexec git add -A\nexec lefthook install\nexec lefthook run test\n\n-- lefthook.yml --\noutput:\n  - execution_out\ntest:\n  parallel: true\n  jobs:\n    - run: echo $E1\n      env:\n        E1: e1\n    - run: echo $E2\n      env:\n        E2: e2\n    - env:\n        E1: e1\n        E2: e2\n      group:\n        parallel: true\n        jobs:\n          - run: echo $E1\n          - run: echo $E2\n            env:\n              E2: new-e2\n\n"
  },
  {
    "path": "tests/integration/exclude.txt",
    "content": "exec git init\nexec lefthook install\nexec git config user.email \"you@example.com\"\nexec git config user.name \"Your Name\"\nexec git add -A\nexec lefthook run -f all\nstdout '^a.txt b.txt dir/a.txt dir/b.txt lefthook.yml\\s*$'\nexec lefthook run -f oneline\nstdout '^lefthook.yml\\s*$'\nexec lefthook run -f array\nstdout '^dir/a.txt dir/b.txt\\s*$'\nexec lefthook run -f nested\nstdout '^lefthook.yml\\s+dir/b.txt lefthook.yml\\s+b.txt dir/b.txt lefthook.yml\\s+b.txt dir/b.txt\\s*$'\n\n-- lefthook.yml --\noutput:\n  - execution_out\n\nall:\n  commands:\n    echo:\n      run: echo {staged_files}\n\noneline:\n  commands:\n    echo:\n      run: echo {staged_files}\n      exclude: '*.txt'\n\narray:\n  jobs:\n    - run: echo {staged_files}\n      exclude:\n        - a.txt\n        - b.txt\n        - '*.yml'\n\nnested:\n  jobs:\n    - exclude:\n        - '*.txt'\n      run: echo {staged_files}\n    - exclude:\n        - a.txt\n        - dir/a.txt\n      group:\n        jobs:\n          - exclude:\n              - b.txt\n            run: echo {staged_files}\n          - group:\n              jobs:\n                - run: echo {staged_files}\n          - group:\n              jobs:\n                - exclude:\n                    - '*.yml'\n                  run: echo {staged_files}\n-- a.txt --\na\n\n-- b.txt --\nb\n\n-- dir/a.txt --\ndir-a\n\n-- dir/b.txt --\ndir-b\n\n\n"
  },
  {
    "path": "tests/integration/exclude_arg.txt",
    "content": "exec git init\nexec git config user.email \"you@example.com\"\nexec git config user.name \"Your Name\"\nexec git add -A\nexec lefthook install\nexec lefthook run test --exclude file1.txt\nstdout '\\s*file2.txt\\s*file2.txt\\s*'\nexec lefthook run test --exclude file2.txt\nstdout '\\s*file1.txt\\s*file1.txt\\s*'\n\n-- lefthook.yml --\noutput:\n  - execution_out\ntest:\n  commands:\n    list:\n      run: echo {all_files}\n      exclude:\n        - lefthook.yml\n  jobs:\n    - run: echo {all_files}\n      exclude:\n        - lefthook.yml\n\n-- file1.txt --\nHello\n\n-- file2.txt --\nHi\n"
  },
  {
    "path": "tests/integration/fail_on_changes.txt",
    "content": "exec git init\nexec lefthook install\nexec git config user.email \"you@example.com\"\nexec git config user.name \"Your Name\"\nexec git add file.txt\n\n! exec lefthook run pre-commit --fail-on-changes\nstdout '│  Error: files were modified by a hook, and fail_on_changes is enabled'\n\n! exec lefthook run hook-setting\nstdout '│  Error: files were modified by a hook, and fail_on_changes is enabled'\n\nexec lefthook run hook-setting --fail-on-changes=false\n\n-- lefthook.yml --\npre-commit:\n  commands:\n    edit_file:\n      run: echo newline >> file.txt\n      stage_fixed: true\n\nhook-setting:\n  fail_on_changes: true\n  jobs:\n    - name: edit_file\n      run: echo newline >> file.txt\n      stage_fixed: true\n\n-- file.txt --\n1\n"
  },
  {
    "path": "tests/integration/fail_on_changes_issue_1125.txt",
    "content": "exec git init\nexec git config user.email \"you@example.com\"\nexec git config user.name \"Your Name\"\nexec git add .\nexec git commit -m \"firstcommit\"\nexec lefthook install\n\n# This should fail because README.md is modified\n! exec lefthook run pre-commit --all-files\nstdout '│  Error: files were modified by a hook, and fail_on_changes is enabled'\n\n-- README.md --\nThis is a readme.\n\n-- lefthook.yml --\npre-commit:\n  fail_on_changes: true\n  jobs:\n    - name: test-job\n      run: echo 123 >> README.md\n"
  },
  {
    "path": "tests/integration/fail_on_changes_recover_previous_change.txt",
    "content": "exec git init\nexec git config user.email \"you@example.com\"\nexec git config user.name \"Your Name\"\nexec git add .\nexec git commit -m \"firstcommit\"\nexec lefthook install\n\n# same as echo \"The good\" > file.txt\ncp file.txt.changed file.txt\nexec cat file.txt\ncmp stdout file.txt.expected\n\nexec echo \"The bad\" > file.txt\nexec cat file.txt\ncmp stdout file.txt.expected\n\n! exec git commit -m 'test'\nexec cat file.txt\ncmp stdout file.txt.expected\n\n-- file.txt --\nGuess the film\n\n-- lefthook.yml --\npre-commit:\n  fail_on_changes: \"always\"\n  fail_on_changes_diff: true\n  jobs:\n    - run: echo \"The evil\" > file.txt\n\n-- file.txt.changed --\nThe good\n\n-- file.txt.expected --\nThe good\n\n"
  },
  {
    "path": "tests/integration/fail_text.txt",
    "content": "exec git init\nexec git config user.email \"you@example.com\"\nexec git config user.name \"Your Name\"\nexec git add -A\nexec lefthook install\n! exec git commit -m 'test'\nstderr '\\s*fails: no such command\\s*'\n\n-- lefthook.yml --\noutput:\n  - failure\npre-commit:\n  commands:\n    fails:\n      run: oops-no-such-command\n      fail_text: no such command\n"
  },
  {
    "path": "tests/integration/files_override.txt",
    "content": "exec git init\nexec lefthook install\nexec git add -A\n\nexec lefthook run echo\nstdout 'a-file\\.js'\n\nexec lefthook run echo --all-files\nstdout 'a-file\\.js b_file\\.go c,file\\.rb'\n\nexec lefthook run echo --file a-file.js --file ghost.file\nstdout 'a-file\\.js ghost\\.file'\n\n-- lefthook.yml --\noutput:\n  - execution_out\n\necho:\n  commands:\n    echo:\n      files: echo a-file.js\n      run: echo \"{files}\"\n\n-- a-file.js --\na-file.js\n\n-- b_file.go --\nb_file.go\n\n-- c,file.rb --\nc,file.rb\n"
  },
  {
    "path": "tests/integration/files_skip_if_empty.txt",
    "content": "# https://github.com/evilmartians/lefthook/issues/1232\nexec git init\nexec git config user.email \"you@example.com\"\nexec git config user.name \"Your Name\"\nexec lefthook run test\n! stdout 'FILES DETECTED'\n\n-- lefthook.yml --\noutput:\n  - execution_out\n\ntest:\n  jobs:\n    - run: echo FILES DETECTED {files}\n      files: echo\n\n    - run: echo FILES DETECTED\n      files: echo\n\n"
  },
  {
    "path": "tests/integration/filter_by_file_type.txt",
    "content": "[windows] skip\n\nexec git init\nexec git config user.email \"you@example.com\"\nexec git config user.name \"Your Name\"\nexec lefthook install\nchmod 777 executable\nsymlink symlink -> results\nexec git add -A\nexec git commit -m 'test'\nexec lefthook run filters\nstdout '.*all ❯\\s+executable lefthook.yml results symlink\\s+┃.*'\nstdout '.*filter_text ❯\\s+executable lefthook.yml results\\s+┃.*'\nstdout '.*filter_executable ❯\\s+executable\\s+┃.*'\nstdout '.*filter_symlink ❯\\s+symlink\\s+┃.*'\nstdout '.*filter_not_symlink ❯\\s+executable lefthook.yml results\\s+┃.*'\nstdout '.*filter_not_executable ❯\\s+lefthook.yml results symlink\\s*'\n\n-- lefthook.yml --\noutput:\n  - execution\n  - skips\nfilters:\n  piped: true\n  commands:\n    all:\n      run: echo {all_files}\n      priority: 1\n    filter_text:\n      run: echo {all_files}\n      file_types: text\n      priority: 2\n    filter_executable:\n      run: echo {all_files}\n      file_types: executable\n      priority: 3\n    filter_symlink:\n      run: echo {all_files}\n      file_types: symlink\n      priority: 4\n    filter_not_symlink:\n      run: echo {all_files}\n      file_types: not symlink\n      priority: 5\n    filter_not_executable:\n      run: echo {all_files}\n      priority: 6\n      file_types:\n        - not executable\n\n-- results --\nsome text\n\n-- executable --\n#!/bin/sh\n\necho 'Executable'\n"
  },
  {
    "path": "tests/integration/filter_by_mime_type.txt",
    "content": "[windows] skip\n\nexec git init\nexec git config user.email \"you@example.com\"\nexec git config user.name \"Your Name\"\nexec lefthook install\nexec git add -A\nexec git commit -m 'test'\nexec lefthook run filters\nstdout '.*all ❯\\s+html lefthook.yml lua-script perl-script python-script shell-script\\s+┃.*'\nstdout '.*shell ❯\\s+shell-script\\s+┃.*'\nstdout '.*perl ❯\\s+perl-script\\s+┃.*'\nstdout '.*python ❯\\s+python-script\\s+┃.*'\nstdout '.*html ❯\\s+html\\s+┃.*'\nstdout '.*scripts ❯\\s+lua-script perl-script shell-script\\s*'\n\n-- lefthook.yml --\noutput:\n  - execution\n  - skips\nfilters:\n  piped: true\n  jobs:\n    - name: all\n      run: echo {all_files}\n    - name: shell\n      run: echo {all_files}\n      file_types: text/x-sh\n    - name: perl\n      run: echo {all_files}\n      file_types: text/x-perl\n    - name: python\n      run: echo {all_files}\n      file_types: text/x-python\n    - name: html\n      run: echo {all_files}\n      file_types: text/html\n    - name: scripts\n      run: echo {all_files}\n      file_types:\n        - text/x-shellscript\n        - text/x-perl\n        - text/x-lua\n\n-- shell-script --\n#!/bin/sh\n\necho 'Hello'\n\n-- perl-script --\n#!/usr/bin/env perl\n\nsay 'Hello'\n\n-- python-script --\n#!/usr/bin/env python\n\nif __name__ == '__main__':\n  print(\"Hello\")\n\n-- html --\n<html>\n<header></header>\n<body>\n  <h1>Hello</h1>\n</body>\n</html>\n\n-- lua-script --\n#!/usr/bin/env lua\n\nprint(\"Hello\")\n"
  },
  {
    "path": "tests/integration/group_envs.txt",
    "content": "exec git init\nexec git config user.email \"you@example.com\"\nexec git config user.name \"Your Name\"\nexec git add -A\nexec lefthook install\nexec lefthook run test\nstdout '\\s*1\\s*3\\s*'\n\n-- lefthook.yml --\noutput:\n  - execution_out\ntest:\n  jobs:\n    - env:\n        E1: 1\n        E2: 2\n      group:\n        jobs:\n          - run: echo $E1\n          - run: echo $E2\n            env:\n              E2: 3\n"
  },
  {
    "path": "tests/integration/hide_unstaged.txt",
    "content": "[windows] skip\n\nexec git init\nexec lefthook install\nexec git config user.email \"you@example.com\"\nexec git config user.name \"Your Name\"\nexec git add -A\nexec git commit -m 'initial commit'\n\nexec lefthook run edit_file\nexec git add -A\nexec lefthook run edit_file\nexec git status --short\nstdout 'AM newfile.txt'\n\nexec git commit -m 'test hide unstaged changes'\nexec git status --short\nstdout 'M newfile.txt'\n\n-- lefthook.yml --\nmin_version: 1.1.1\npre-commit:\n  commands:\n    edit_file:\n      run: echo newline >> file.txt\n      stage_fixed: true\n\nedit_file:\n  commands:\n    echo:\n      run: echo newline >> newfile.txt\n\n-- file.txt --\nfirstline\n"
  },
  {
    "path": "tests/integration/install.txt",
    "content": "exec git init\nexec lefthook install\nexists lefthook.yml\n! stderr .\n"
  },
  {
    "path": "tests/integration/install_specific.txt",
    "content": "exec git init\nexec lefthook install pre-commit post-commit\n! stderr .\n\nexists lefthook.yml\nexists .git/hooks/pre-commit\nexists .git/hooks/post-commit\n! exists .git/hooks/pre-push\n\n-- lefthook.yml --\npre-commit:\n  jobs:\n    - run: echo\n\npost-commit:\n  jobs:\n    - run: echo\n\npre-push:\n  jobs:\n    - run: echo\n\n"
  },
  {
    "path": "tests/integration/job_fail_text.txt",
    "content": "exec git init\nexec git config user.email \"you@example.com\"\nexec git config user.name \"Your Name\"\nexec git add -A\nexec lefthook install\n! exec git commit -m 'test'\nstderr '\\s*fails: no such command\\s*'\n\n-- lefthook.yml --\noutput:\n  - failure\npre-commit:\n  jobs:\n    - name: fails\n      run: oops-no-such-command\n      fail_text: no such command\n"
  },
  {
    "path": "tests/integration/job_filter_by_file_type.txt",
    "content": "[windows] skip\n\nexec git init\nexec git config user.email \"you@example.com\"\nexec git config user.name \"Your Name\"\nexec lefthook install\nchmod 777 executable\nsymlink symlink -> results\nexec git add -A\nexec git commit -m 'test'\nexec lefthook run filters\nstdout '.*all ❯\\s+executable lefthook.yml results symlink\\s+┃.*'\nstdout '.*filter_text ❯\\s+executable lefthook.yml results\\s+┃.*'\nstdout '.*filter_executable ❯\\s+executable\\s+┃.*'\nstdout '.*filter_symlink ❯\\s+symlink\\s+┃.*'\nstdout '.*filter_not_symlink ❯\\s+executable lefthook.yml results\\s+┃.*'\nstdout '.*filter_not_executable ❯\\s+lefthook.yml results symlink\\s*'\n\n-- lefthook.yml --\noutput:\n  - execution\n  - skips\nfilters:\n  piped: true\n  jobs:\n    - name: all\n      run: echo {all_files}\n\n    - name: filter_text\n      run: echo {all_files}\n      file_types: text\n\n    - name: filter_executable\n      run: echo {all_files}\n      file_types: executable\n\n    - name: filter_symlink\n      run: echo {all_files}\n      file_types: symlink\n\n    - name: filter_not_symlink\n      run: echo {all_files}\n      file_types: not symlink\n\n    - name: filter_not_executable\n      run: echo {all_files}\n      file_types:\n        - not executable\n\n-- results --\nsome text\n\n-- executable --\n#!/bin/sh\n\necho 'Executable'\n\n"
  },
  {
    "path": "tests/integration/job_merging.txt",
    "content": "[windows] skip\n\nexec git init\nexec lefthook dump\ncmp stdout dump.yml\n! stderr .\n\n-- lefthook.yml --\nextends:\n  - extends/e1.yml\n\npre-commit:\n  jobs:\n    - name: group\n      group:\n        jobs:\n          - name: child\n            run: named\n          - run: 0 no-name\n    - name: echo\n      run: echo 0\n    - run: lefthook.yml\n\n-- extends/e1.yml --\nextends:\n  - extends/e2.yml\n\npre-commit:\n  jobs:\n    - name: group\n      group:\n        jobs:\n          - name: child\n            run: child named\n          - run: 1 no-name\n    - name: echo\n      run: echo 1\n      skip: true\n    - run: e1\n\ne1:\n  jobs:\n    - name: echo\n      run: e1\n\n-- extends/e2.yml --\nextends:\n  - extends/e3.yml\n\npre-commit:\n  jobs:\n    - name: group\n      glob: \"*.rb\"\n      group:\n        jobs:\n          - name: child\n            run: child named with glob\n          - run: 2 no-name\n    - name: echo\n      run: echo 2\n      tags: [\"backend\"]\n    - run: e2\n\ne2:\n  jobs:\n    - name: echo\n      run: e2\n\n-- extends/e3.yml --\npre-commit:\n  jobs:\n    - name: group\n      glob: \"*.rb\"\n      group:\n        jobs:\n          - name: child\n            stage_fixed: true\n          - run: 3 no-name\n    - name: echo\n      glob: 3\n    - run: e3\n\ne3:\n  jobs:\n    - name: echo\n      run: e3\n\n-- dump.yml --\ne1:\n  jobs:\n    - name: echo\n      run: e1\ne2:\n  jobs:\n    - name: echo\n      run: e2\ne3:\n  jobs:\n    - name: echo\n      run: e3\nextends:\n  - extends/e1.yml\npre-commit:\n  jobs:\n    - name: group\n      glob:\n        - '*.rb'\n      group:\n        jobs:\n          - name: child\n            run: child named with glob\n            stage_fixed: true\n          - run: 0 no-name\n          - run: 1 no-name\n          - run: 2 no-name\n          - run: 3 no-name\n    - name: echo\n      run: echo 2\n      glob:\n        - \"3\"\n      tags:\n        - backend\n      skip: true\n    - run: lefthook.yml\n    - run: e1\n    - run: e2\n    - run: e3\n"
  },
  {
    "path": "tests/integration/job_stage_fixed.txt",
    "content": "exec git init\nexec lefthook install\nexec git config user.email \"you@example.com\"\nexec git config user.name \"Your Name\"\nexec git add -A\nexec git status --short\nexec git commit -m 'test stage_fixed'\nexec git status --short\n! stdout .\n\n-- lefthook.yml --\nmin_version: 1.1.1\npre-commit:\n  jobs:\n    - stage_fixed: true\n      run: |\n        echo newline >> \"[file].js\"\n        echo newline >> file.txt\n\n-- file.txt --\nsometext\n\n-- [file].js --\nsomecode\n"
  },
  {
    "path": "tests/integration/lefthook_job_name_issue_1345.txt",
    "content": "exec git init\nexec lefthook install\nexec git config user.email \"you@example.com\"\nexec git config user.name \"Your Name\"\nexec git add -A\nexec git commit -m 'test'\nstderr 'Macbeth'\n\n-- lefthook.yml --\noutput:\n  - execution_out\n\npre-commit:\n  jobs:\n    - name: 'Macbeth'\n      run: echo {lefthook_job_name}\n\n"
  },
  {
    "path": "tests/integration/lefthook_option.txt",
    "content": "exec git init\nexec lefthook install\nexec git config user.email \"you@example.com\"\nexec git config user.name \"Your Name\"\nexec git add -A\nexec git commit -m 'must show debug logs'\nstderr 'injected'\nstdout '[lefthook]'\n\n-- lefthook.yml --\nlefthook: |\n  echo 'injected'\n  lefthook\n\noutput:\n  - execution_out\n\npre-commit:\n  jobs:\n    - run: echo {all_files}\n      glob: \"*.txt\"\n\n-- file.txt --\nsometext\n\n-- file.js --\nsomecode\n"
  },
  {
    "path": "tests/integration/many_extends_levels.txt",
    "content": "[windows] skip\n\nexec git init\nexec lefthook dump\ncmp stdout dump.yml\n! stderr .\n\n-- lefthook.yml --\nextends:\n  - extends/e1.yml\n\npre-commit:\n  commands:\n    echo:\n      run: echo 0\n\n-- extends/e1.yml --\nextends:\n  - extends/e2.yml\n\npre-commit:\n  commands:\n    echo:\n      run: echo 1\n      skip: true\n\ne1:\n  commands:\n    echo:\n      run: e1\n\n-- extends/e2.yml --\nextends:\n  - extends/e3.yml\n\npre-commit:\n  commands:\n    echo:\n      run: echo 2\n      tags: [\"backend\"]\n\ne2:\n  commands:\n    echo:\n      run: e2\n\n-- extends/e3.yml --\npre-commit:\n  commands:\n    echo:\n      glob: 3\n\ne3:\n  commands:\n    echo:\n      run: e3\n\n-- dump.yml --\ne1:\n  commands:\n    echo:\n      run: e1\ne2:\n  commands:\n    echo:\n      run: e2\ne3:\n  commands:\n    echo:\n      run: e3\nextends:\n  - extends/e1.yml\npre-commit:\n  commands:\n    echo:\n      run: echo 2\n      skip: true\n      tags:\n        - backend\n      glob:\n        - \"3\"\n"
  },
  {
    "path": "tests/integration/min_version.txt",
    "content": "exec git init\n! exec lefthook run pre-commit\nstdout 'required lefthook version \\(v13.1.1\\) is higher than current'\n\n-- lefthook.yml --\nmin_version: v13.1.1\npre-commit:\n  commands:\n    echo:\n      run: echo\n"
  },
  {
    "path": "tests/integration/pre-commit_issue_919.txt",
    "content": "exec git init\nexec git add -A\nexec git config user.email \"you@example.com\"\nexec git config user.name \"Your Name\"\nexec git add -A\nexec git commit -m 'first commit'\nrm file.txt\nexec git add -A\nexec lefthook run pre-commit\nstdout '^\\s*must be printed\\s*$'\n\n-- lefthook.yml --\noutput:\n  - execution_out\npre-commit:\n  jobs:\n    - run: echo 'must be printed'\n    - run: echo 'excluded txt'\n      exclude:\n        - '*.txt'\n    - run: echo 'excluded by' {staged_files}\n\n-- file.txt --\nwill be deleted\n"
  },
  {
    "path": "tests/integration/remotes.txt",
    "content": "[windows] skip\n\nexec git init\nexec lefthook install\n\nexec lefthook dump\ncmp stdout lefthook-dump.yml\n\n-- lefthook.yml --\nremotes:\n  - git_url: https://github.com/evilmartians/lefthook\n    configs:\n      - examples/with_scripts/lefthook.yml\n    ref: v1.4.0\n  - git_url: https://github.com/evilmartians/lefthook\n    configs:\n      - examples/verbose/lefthook.yml\n      - examples/remote/ping.yml\n\n-- lefthook-dump.yml --\npre-commit:\n  parallel: true\n  commands:\n    js-lint:\n      run: npx eslint --fix -- {staged_files} && git add -- {staged_files}\n      glob:\n        - '*.{js,ts}'\n    ping:\n      run: echo pong\n    ruby-lint:\n      run: bundle exec rubocop --force-exclusion --parallel -- '{files}'\n      files: git diff-tree -r --name-only --diff-filter=CDMR HEAD origin/master\n      glob:\n        - '*.rb'\n    ruby-test:\n      run: bundle exec rspec\n      fail_text: Run bundle install\n      skip:\n        - merge\n        - rebase\n  scripts:\n    good_job.js:\n      runner: node\npre-push:\n  commands:\n    spelling:\n      run: npx yaspeller -- {files}\n      files: git diff --name-only HEAD @{push}\n      glob:\n        - '*.md'\nremotes:\n  - git_url: https://github.com/evilmartians/lefthook\n    ref: v1.4.0\n    configs:\n      - examples/with_scripts/lefthook.yml\n  - git_url: https://github.com/evilmartians/lefthook\n    configs:\n      - examples/verbose/lefthook.yml\n      - examples/remote/ping.yml\n"
  },
  {
    "path": "tests/integration/run_deleted_only.txt",
    "content": "exec git init\nexec git config user.email \"you@example.com\"\nexec git config user.name \"Your Name\"\nexec git add -A\nexec git commit -m 'initial'\nexec lefthook install\nexec rm A.txt\nexec git add -A\nexec git commit -m 'test'\nstderr 'no files for inspection'\n\n-- lefthook.yml --\npre-commit:\n  jobs:\n    - run: echo FILES DETECTED {staged_files}\n\n-- A.txt --\nwill be deleted\n"
  },
  {
    "path": "tests/integration/run_interrupt.txt",
    "content": "[windows] skip\n\nchmod 0700 hook.sh\nchmod 0700 commit-with-interrupt.sh\nexec git init\nexec git config user.email \"you@example.com\"\nexec git config user.name \"Your Name\"\nexec lefthook install\nexec git add -A\n\nexec git commit -m 'init'\nstderr 'hook-done'\n\nexec ./commit-with-interrupt.sh\nstderr 'script-done'\n! stderr 'hook-done'\nstderr 'signal: killed'\nstderr 'Error: Interrupted'\ngrep unstaged newfile.txt\nexec git stash list\n! stdout 'lefthook auto backup'\n\n-- lefthook.yml --\npre-commit:\n  commands:\n    slow_job:\n      run: ./hook.sh\n\n-- hook.sh --\n#!/usr/bin/env bash\n\nsleep 2\n>&2 echo hook-done\n\n-- newfile.txt --\nstaged\n\n-- commit-with-interrupt.sh --\n#!/usr/bin/env bash\n\necho staged >> newfile.txt\ngit add newfile.txt\necho unstaged >> newfile.txt\n\n# ctrl-c is emulated by sending SIGINT to a process group\n# so we first need to emulate being a terminal and enable\n# job monitoring so that new PGIDs are assigned.\nset -m\nnohup git commit -m test &\npgid=$!\nsleep 1\nkill -SIGINT -$pgid\nwait\n>&2 echo 'script-done'\n"
  },
  {
    "path": "tests/integration/run_json.txt",
    "content": "exec git init\nexec git config user.email \"you@example.com\"\nexec git config user.name \"Your Name\"\nexec git add -A\nexec lefthook install\nexec git commit -m 'test'\nstderr '\\s*Hi there from Lefthook\\s*'\n\n-- lefthook.json --\n{\n  \"output\": [\n    \"execution\"\n  ],\n  \"pre-commit\": {\n    \"commands\": {\n      \"echo\": {\n        \"run\": \"echo Hi there from Lefthook\"\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "tests/integration/run_jsonc.txt",
    "content": "exec git init\nexec git config user.email \"you@example.com\"\nexec git config user.name \"Your Name\"\nexec git add -A\nexec lefthook install\nexec git commit -m 'test'\nstderr '\\s*Hi there from Lefthook\\s*'\n\n-- lefthook.jsonc --\n{\n  /* Prints only what's being executed */\n  \"output\": [\n    \"execution\"\n  ],\n  \"pre-commit\": {\n    \"commands\": {\n      \"echo\": {\n        \"run\": \"echo Hi there from Lefthook\" // echoes Hi there from Lefthook\n      }\n    }\n  }\n}\n"
  },
  {
    "path": "tests/integration/run_non_existing.txt",
    "content": "exec git init\nexec lefthook run pre-commit\n! stdout 'Error.*'\n! exec lefthook run no-a-hook\nstdout 'Error.*'\n\n-- lefthook.yml --\n# empty\n"
  },
  {
    "path": "tests/integration/run_script.txt",
    "content": "exec git init\nexec git config user.email \"you@example.com\"\nexec git config user.name \"Your Name\"\nexec git add -A\nexec lefthook install\nexec git commit -m 'test'\nstderr '\\s*Hi there from script\\s*'\n\n-- lefthook.yml --\noutput:\n  - execution_out\n\npre-commit:\n  scripts:\n    \"file.sh\":\n      runner: sh\n\n-- .lefthook/pre-commit/file.sh --\n#!/usr/bin/env sh\n\necho Hi there from scripts\n"
  },
  {
    "path": "tests/integration/run_script_with_args.txt",
    "content": "exec git init\nexec git config user.email \"you@example.com\"\nexec git config user.name \"Your Name\"\nexec git add -A\nexec lefthook install\nexec git commit -m 'test'\nstderr '\\s*Args: lefthook.yml\\s*'\n\nexec lefthook run pre-commit --file 'non-existent'\nstdout 'no files for inspection'\n\n-- lefthook.yml --\noutput:\n  - execution_out\n  - skips\n\npre-commit:\n  jobs:\n    - script: echo.sh\n      runner: sh\n      args: \"{files}\"\n      files: echo lefthook.yml\n      glob: \"*.yml\"\n\n-- .lefthook/pre-commit/echo.sh --\n#!/usr/bin/env sh\n\necho \"Args: $@\"\n"
  },
  {
    "path": "tests/integration/run_toml.txt",
    "content": "exec git init\nexec git config user.email \"you@example.com\"\nexec git config user.name \"Your Name\"\nexec git add -A\nexec lefthook install\nexec git commit -m 'test'\nstderr '\\s*Hi there from Lefthook\\s*'\n\n-- lefthook.toml --\noutput = [\n  'execution'\n]\n\n[pre-commit.commands.echo]\nrun = \"echo Hi there from Lefthook\"\n"
  },
  {
    "path": "tests/integration/run_yml.txt",
    "content": "exec git init\nexec git config user.email \"you@example.com\"\nexec git config user.name \"Your Name\"\nexec git add -A\nexec lefthook install\nexec git commit -m 'test'\nstderr '\\s*Hi there from Lefthook\\s*'\n\n-- lefthook.yml --\noutput:\n  - execution_out\n\npre-commit:\n  commands:\n    echo:\n      run: echo Hi there from Lefthook\n"
  },
  {
    "path": "tests/integration/setup_instructions.txt",
    "content": "exec git init\nexec git config user.email \"you@example.com\"\nexec git config user.name \"Your Name\"\nexec git add -A\nexec lefthook install\nexec git commit -m 'test'\nstderr '\\s*Setup 1\\s*Setup 2\\s*Hi there from Lefthook\\s*'\n\n-- lefthook.yml --\noutput:\n  - setup\n  - execution_out\n\npre-commit:\n  setup:\n    - run: echo 'Setup 1'\n    - run: echo 'Setup 2'\n  commands:\n    echo:\n      run: echo Hi there from Lefthook\n\n"
  },
  {
    "path": "tests/integration/sh_syntax_in_files.txt",
    "content": "[windows] skip\n\nexec git init\nexec lefthook install\nexec git config user.email \"you@example.com\"\nexec git config user.name \"Your Name\"\n\nexec lefthook run echo_files\n\nstdout '1.txt 10.txt'\n\n-- lefthook.yml --\noutput:\n  - execution_out\n\necho_files:\n  commands:\n    echo:\n      files: ls | grep 1\n      run: echo {files}\n\n-- 1.txt --\n1.txt\n\n-- 10.txt --\n10.txt\n\n-- 20.txt --\n20.txt\n"
  },
  {
    "path": "tests/integration/skip_group_issue_1083.txt",
    "content": "exec git init\nexec git config user.email \"you@example.com\"\nexec git config user.name \"Your Name\"\nexec git add -A\nexec lefthook install\nexec lefthook run hook\nstdout '\\s*1\\s*2\\s*'\n\n-- lefthook.yml --\noutput:\n  - execution_out\nhook:\n  jobs:\n    - run: echo 1\n    - skip: true\n      group:\n        jobs:\n          - run: echo must not be printed\n          - run: echo must not be printed\n    - group:\n        jobs:\n          - run: echo 2\n          - run: echo must not be printed\n            skip: true\n          - skip: true\n            group:\n              jobs:\n                - run: echo must not be printed\n"
  },
  {
    "path": "tests/integration/skip_merge_commit.txt",
    "content": "exec git init\nexec git config user.email \"you@example.com\"\nexec git config user.name \"Your Name\"\nexec git add -A\nexec git commit -m 'commit 1'\nexec lefthook install\n\nexec git checkout -b merge-me\nexec touch file.A\nexec git add -A\nexec git commit -m 'commit A'\n\nexec lefthook run pre-commit --force\nstdout 'skip-merge-commit'\n\nexec git checkout -\nexec touch file.B\nexec git add -A\nexec git commit -m 'commit B'\n\nexec git merge merge-me\n\nexec lefthook run pre-commit --force\n! stdout 'skip-merge-commit'\n\n-- lefthook.yml --\noutput:\n  - execution_out\n\npre-commit:\n  commands:\n    skip-merge-commit:\n      skip:\n        - merge-commit\n      run: echo 'skip-merge-commit'\n"
  },
  {
    "path": "tests/integration/skip_run.txt",
    "content": "exec git init\nexec git add -A\nexec lefthook run skip\n! stdout 'Ha-ha!'\nexec lefthook run no-skip\nstdout 'Ha-ha!'\n\nexec lefthook run skip-var\n! stdout 'Ha-ha!'\n\nenv VAR=1\nexec lefthook run skip-var\nstdout 'Ha-ha!'\n\n-- lefthook.yml --\noutput:\n  - execution_out\nskip:\n  skip:\n    - run: test 1 -eq 1\n  commands:\n    echo:\n      run: echo 'Ha-ha!'\n\nno-skip:\n  skip:\n    - run: \"[ 1 -eq 0 ]\"\n  commands:\n    echo:\n      run: echo 'Ha-ha!'\n\nskip-var:\n  skip:\n    - run: test -z $VAR\n  commands:\n    echo:\n      run: echo 'Ha-ha!'\n"
  },
  {
    "path": "tests/integration/stage_fixed.txt",
    "content": "exec git init\nexec lefthook install\nexec git config user.email \"you@example.com\"\nexec git config user.name \"Your Name\"\nexec git add -A\nexec git status --short\nexec git commit -m 'test stage_fixed'\nexec git status --short\n! stdout .\n\nexec lefthook run pre-commit --force --no-stage-fixed\nexec git status --short\nstdout ' M \\[file\\].js'\nstdout ' M file.txt'\n\n-- lefthook.yml --\nmin_version: 1.1.1\npre-commit:\n  commands:\n    edit_file:\n      run: |\n        echo newline >> \"[file].js\"\n        echo newline >> file.txt\n      stage_fixed: true\n\n-- file.txt --\nsometext\n\n-- [file].js --\nsomecode\n"
  },
  {
    "path": "tests/integration/stage_fixed_505.txt",
    "content": "exec git init\nexec lefthook install\nexec git config user.email \"you@example.com\"\nexec git config user.name \"Your Name\"\nexec git add -A\nexec git status --short\nexec git commit -m 'test stage_fixed'\nexec git status --short\n! stdout .\n\n-- lefthook.yml --\npre-commit:\n  commands:\n    edit_file:\n      run: echo \"{staged_files}\" && echo newline >> \"[file].js\"\n      stage_fixed: true\n\n-- [file].js --\nsomecode\n"
  },
  {
    "path": "tests/integration/templates.txt",
    "content": "exec git init\nexec lefthook run test\nstdout '^\\s*hello\\s*$'\n\n-- lefthook.yml --\ntemplates:\n  message: hello\n\noutput:\n  - execution_out\n\ntest:\n  jobs:\n    - run: echo {message}\n"
  },
  {
    "path": "tests/integration/timeout.txt",
    "content": "[windows] skip\n\nexec git init\nexec git config user.email \"you@example.com\"\nexec git config user.name \"Your Name\"\nexec git add -A\nexec lefthook install\n! exec git commit -m 'test'\nstderr 'timeout \\(100ms\\)'\n\n-- lefthook.yml --\noutput:\n  - failure\npre-commit:\n  commands:\n    slow:\n      timeout: 100ms\n      run: sleep 10\n"
  },
  {
    "path": "tests/integration/timeout_success.txt",
    "content": "[windows] skip\n\nexec git init\nexec git config user.email \"you@example.com\"\nexec git config user.name \"Your Name\"\nexec git add -A\nexec lefthook install\nexec git commit -m 'test' --allow-empty\n! stderr 'timeout'\n\n-- lefthook.yml --\noutput:\n  - failure\n  - success\npre-commit:\n  commands:\n    fast:\n      timeout: 10s\n      run: echo \"done\"\n"
  },
  {
    "path": "tests/integration/uninstall.txt",
    "content": "exec git init\nexec lefthook install\nexists .git/hooks/pre-push\nexec lefthook uninstall\n! exists .git/hooks-pre-push\nexists lefthook.yml\nexists .lefthook-local.toml\n\nexec lefthook install\nexists .git/hooks/pre-push\nexec lefthook uninstall --remove-configs\n! exists .git/hooks-pre-push\n! exists lefthook.yml\n! exists .lefthook-local.toml\n\n-- lefthook.yml --\npre-push:\n  commands:\n    echo:\n      run: echo pre-push\n\n\n-- .lefthook-local.toml --\n[pre-commit.commands.echo]\nrun = \"echo pre-commit\"\n"
  },
  {
    "path": "tests/integration/validate.txt",
    "content": "exec git init\nexec lefthook validate\n\n-- lefthook.yml --\npre-commit:\n  jobs:\n    - run: echo test\n      tags:\n        - echo\n        - test\n        - integration\n"
  },
  {
    "path": "tests/integration/validate_fail.txt",
    "content": "exec git init\n! exec lefthook validate\n\n-- lefthook.yml --\npre-commit:\n  jobs:\n    - run: echo test\n      wait_what: emm\n"
  },
  {
    "path": "tests/integration/version.txt",
    "content": "exec lefthook version\nstdout \\d+\\.\\d+\\.\\d+\n! stderr .\n"
  }
]