├── .npmrc ├── .husky ├── post-checkout ├── post-commit ├── post-merge ├── post-rewrite └── pre-commit ├── _config.yml ├── .gitignore ├── assets └── css │ └── style.scss ├── src ├── gitChangedFilesSinceLastHead.js ├── configLoad.js ├── resolveMatchingPatterns.js └── runCommands.js ├── eslint.config.js ├── _includes └── head-custom-google-analytics.html ├── renovate.json ├── .editorconfig ├── run-if-changed.js ├── .github └── workflows │ ├── js_test.yml │ └── publish.yml ├── LICENSE ├── package.json └── README.md /.npmrc: -------------------------------------------------------------------------------- 1 | tag-version-prefix="" 2 | -------------------------------------------------------------------------------- /.husky/post-checkout: -------------------------------------------------------------------------------- 1 | npx run-if-changed 2 | -------------------------------------------------------------------------------- /.husky/post-commit: -------------------------------------------------------------------------------- 1 | npx run-if-changed 2 | -------------------------------------------------------------------------------- /.husky/post-merge: -------------------------------------------------------------------------------- 1 | npx run-if-changed 2 | -------------------------------------------------------------------------------- /.husky/post-rewrite: -------------------------------------------------------------------------------- 1 | npx run-if-changed 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | lint-staged --quiet && npm test 2 | -------------------------------------------------------------------------------- /_config.yml: -------------------------------------------------------------------------------- 1 | theme: jekyll-theme-cayman 2 | show_downloads: false 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Dependency directories 2 | node_modules/ 3 | 4 | # Optional eslint cache 5 | .eslintcache 6 | -------------------------------------------------------------------------------- /assets/css/style.scss: -------------------------------------------------------------------------------- 1 | --- 2 | --- 3 | 4 | @import "{{ site.theme }}"; 5 | 6 | summary { 7 | display: list-item !important; 8 | } 9 | -------------------------------------------------------------------------------- /src/gitChangedFilesSinceLastHead.js: -------------------------------------------------------------------------------- 1 | import { execaSync } from 'execa'; 2 | 3 | // Get the list of changed files since the previous HEAD from a git hook 4 | export default () => execaSync({ lines: true })`git diff-tree --name-only --no-commit-id --diff-filter=d --ignore-submodules -r HEAD@{1} HEAD` 5 | .stdout 6 | .filter((file) => !!file); 7 | -------------------------------------------------------------------------------- /eslint.config.js: -------------------------------------------------------------------------------- 1 | import js from '@eslint/js'; 2 | import globals from 'globals'; 3 | 4 | export default [ 5 | js.configs.recommended, 6 | { 7 | languageOptions: { 8 | globals: { 9 | ...globals.node, 10 | }, 11 | parserOptions: { 12 | ecmaVersion: 'latest', 13 | }, 14 | }, 15 | }, 16 | ]; 17 | -------------------------------------------------------------------------------- /_includes/head-custom-google-analytics.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 10 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": [ 3 | "config:recommended" 4 | ], 5 | "packageRules": [ 6 | { 7 | "matchPackageNames": [ 8 | "/eslint/" 9 | ], 10 | "groupName": "eslint" 11 | }, 12 | { 13 | "matchUpdateTypes": ["patch"], 14 | "matchCurrentVersion": "!/^0/", 15 | "automerge": true 16 | } 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /src/configLoad.js: -------------------------------------------------------------------------------- 1 | import { cosmiconfigSync } from 'cosmiconfig'; 2 | 3 | export default () => { 4 | const configResult = cosmiconfigSync('run-if-changed', { 5 | searchStrategy: 'project', 6 | }).search(); 7 | 8 | if (!configResult || configResult.isEmpty) { 9 | process.exit(0); 10 | } 11 | 12 | const { config } = configResult; 13 | 14 | return config; 15 | }; 16 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # EditorConfig is awesome: https://EditorConfig.org 2 | 3 | # top-most EditorConfig file 4 | root = true 5 | 6 | # Unix-style newlines with a newline ending every file 7 | [*] 8 | # Set default charset 9 | charset = utf-8 10 | # Unix-style newlines 11 | end_of_line = lf 12 | # newline ending every file 13 | insert_final_newline = true 14 | # 4 space indentation 15 | indent_style = space 16 | indent_size = 2 17 | -------------------------------------------------------------------------------- /src/resolveMatchingPatterns.js: -------------------------------------------------------------------------------- 1 | import micromatch from 'micromatch'; 2 | 3 | export default (changedFiles, config) => { 4 | const commandsToRun = Object.entries(config) 5 | .filter(([pattern]) => micromatch.some(changedFiles, [pattern])) 6 | .flatMap(([, commands]) => commands); 7 | 8 | // unique commands, do not run them multiple times if they are the same 9 | // for multiple patterns or they are repeated in a list for a single pattern 10 | return [...new Set(commandsToRun)]; 11 | }; 12 | -------------------------------------------------------------------------------- /run-if-changed.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | 3 | import resolveMatchingPatterns from './src/resolveMatchingPatterns.js'; 4 | import runCommands from './src/runCommands.js'; 5 | import configLoad from './src/configLoad.js'; 6 | import gitChangedFilesSinceLastHead from './src/gitChangedFilesSinceLastHead.js'; 7 | 8 | const changedFiles = gitChangedFilesSinceLastHead(); 9 | 10 | if (changedFiles.length === 0) { 11 | process.exit(0); 12 | } 13 | 14 | runCommands(resolveMatchingPatterns(changedFiles, configLoad())); 15 | -------------------------------------------------------------------------------- /.github/workflows/js_test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ main ] 6 | pull_request: 7 | 8 | jobs: 9 | test: 10 | runs-on: ubuntu-latest 11 | strategy: 12 | matrix: 13 | node-version: [ 18, 20, 22, 24 ] 14 | steps: 15 | - uses: actions/checkout@v5 16 | - name: Use Node.js ${{ matrix.node-version }} 17 | uses: actions/setup-node@v4 18 | with: 19 | node-version: ${{ matrix.node-version }} 20 | cache: 'npm' 21 | - name: Install Dependencies 22 | run: npm ci 23 | - name: npm test 24 | run: npm test 25 | -------------------------------------------------------------------------------- /src/runCommands.js: -------------------------------------------------------------------------------- 1 | import { execaCommandSync } from 'execa'; 2 | import { Listr } from 'listr2'; 3 | 4 | const runCommand = (command) => { 5 | execaCommandSync({ 6 | preferLocal: true, 7 | stdout: 'inherit', 8 | stderr: 'inherit', 9 | })(command); 10 | }; 11 | 12 | export default (commands) => { 13 | const tasks = new Listr( 14 | (Array.isArray(commands) ? commands : [commands]) 15 | .filter((x) => !!x) 16 | .map((command) => ({ 17 | title: command, 18 | task: () => runCommand(command), 19 | })), 20 | { 21 | concurrent: false, 22 | renderer: 'simple', 23 | } 24 | ); 25 | 26 | return tasks.run(); 27 | }; 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) Haralan Dobrev 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@hkdobrev/run-if-changed", 3 | "version": "0.6.3", 4 | "description": "Run a command if a file changes via Git hooks", 5 | "bin": "run-if-changed.js", 6 | "exports": "./run-if-changed.js", 7 | "repository": "https://github.com/hkdobrev/run-if-changed", 8 | "author": "Haralan Dobrev ", 9 | "license": "MIT", 10 | "private": false, 11 | "type": "module", 12 | "engines": { 13 | "node": ">=18 <25" 14 | }, 15 | "dependencies": { 16 | "cosmiconfig": "^9.0.0", 17 | "execa": "^9.5.2", 18 | "listr2": "^8.2.5", 19 | "micromatch": "^4.0.8", 20 | "string-argv": "^0.3.2" 21 | }, 22 | "devDependencies": { 23 | "eslint": "^9.17.0", 24 | "eslint-plugin-json": "^4.0.1", 25 | "globals": "^16.0.0", 26 | "husky": "^9.1.7", 27 | "lint-staged": "^16.0.0" 28 | }, 29 | "scripts": { 30 | "test": "eslint .", 31 | "preversion": "npm run test", 32 | "postversion": "git push --atomic && gh release create --generate-notes $npm_package_version", 33 | "run-if-changed": "./run-if-changed.js", 34 | "prepare": "husky" 35 | }, 36 | "husky": { 37 | "hooks": { 38 | "pre-commit": "lint-staged", 39 | "post-commit": "npm run run-if-changed", 40 | "post-checkout": "npm run run-if-changed", 41 | "post-merge": "npm run run-if-changed", 42 | "post-rewrite": "npm run run-if-changed" 43 | } 44 | }, 45 | "run-if-changed": { 46 | "package-lock.json": [ 47 | "npm install --prefer-offline --no-audit --no-fund" 48 | ] 49 | }, 50 | "lint-staged": { 51 | "*.{js,json}": "eslint --fix" 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /.github/workflows/publish.yml: -------------------------------------------------------------------------------- 1 | name: Node.js Package 2 | 3 | on: 4 | release: 5 | types: [created] 6 | 7 | jobs: 8 | build: 9 | runs-on: ubuntu-latest 10 | strategy: 11 | matrix: 12 | node-version: [ 18, 20, 22, 24 ] 13 | steps: 14 | - uses: actions/checkout@v5 15 | - name: Use Node.js ${{ matrix.node-version }} 16 | uses: actions/setup-node@v4 17 | with: 18 | node-version: ${{ matrix.node-version }} 19 | cache: 'npm' 20 | - name: npm install, build, and test 21 | run: | 22 | npm ci 23 | npm run build --if-present 24 | npm test 25 | 26 | publish-npm: 27 | needs: build 28 | runs-on: ubuntu-latest 29 | permissions: 30 | contents: read 31 | id-token: write 32 | steps: 33 | - uses: actions/checkout@v5 34 | - uses: actions/setup-node@v4 35 | with: 36 | cache: 'npm' 37 | registry-url: https://registry.npmjs.org/ 38 | - run: npm ci 39 | - run: npm publish --provenance --access public 40 | env: 41 | NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} 42 | 43 | publish-gpr: 44 | needs: build 45 | runs-on: ubuntu-latest 46 | permissions: 47 | contents: read 48 | id-token: write 49 | packages: write 50 | steps: 51 | - uses: actions/checkout@v5 52 | - uses: actions/setup-node@v4 53 | with: 54 | cache: 'npm' 55 | registry-url: https://npm.pkg.github.com/ 56 | - run: npm ci 57 | - run: npm publish --provenance --access public 58 | env: 59 | NODE_AUTH_TOKEN: ${{secrets.GITHUB_TOKEN}} 60 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🔃 run-if-changed [![npm version](https://img.shields.io/npm/v/@hkdobrev/run-if-changed.svg)](https://www.npmjs.com/package/@hkdobrev/run-if-changed) 2 | 3 | Run a command if a file changes via Git hooks. 4 | Useful for lock files or build systems to keep dependencies and generated files up to date when changing branches, pulling or commiting. 5 | 6 | Inspired by [`lint-staged`](https://github.com/okonet/lint-staged) and recommended to be used with [`husky`](https://github.com/typicode/husky). 7 | 8 | #### State of the project 9 | 10 | run-if-changed is functional as-is, but it's still quite basic and rough as it has just been published. So issues, feature requests and pull requests are most welcome! 11 | 12 | ## Installation and setup 13 | 14 |
15 | Install with npm 16 | 17 |

 18 | npm install --save-dev husky @hkdobrev/run-if-changed
 19 | 
20 | 21 |
Recommended setup
22 | 23 |

 24 | "run-if-changed": {
 25 |   "package-lock.json": "npm install --prefer-offline --no-audit"
 26 | }
 27 | 
28 |
29 | 30 |
31 | Install with Yarn 32 | 33 |

 34 | yarn add --dev husky @hkdobrev/run-if-changed
 35 | 
36 | 37 |
Recommended setup
38 | 39 |

 40 | "run-if-changed": {
 41 |   "yarn.lock": "yarn install --prefer-offline --pure-lockfile --color=always"
 42 | }
 43 | 
44 |
45 | 46 | ### Set up Git hooks 47 | 48 |
49 | Using husky 50 | 51 |

 52 | echo "npx run-if-changed" > .husky/post-commit
 53 | echo "npx run-if-changed" > .husky/post-checkout
 54 | echo "npx run-if-changed" > .husky/post-merge
 55 | echo "npx run-if-changed" > .husky/post-rewrite
 56 | 
57 | 58 |
59 | 60 |
61 | Just git hooks 62 | 63 |

 64 | echo "npx run-if-changed" >> .git/hooks/post-commit && chmod +x .git/hooks/post-commit
 65 | echo "npx run-if-changed" >> .git/hooks/post-checkout && chmod +x .git/hooks/post-checkout
 66 | echo "npx run-if-changed" >> .git/hooks/post-merge && chmod +x .git/hooks/post-merge
 67 | echo "npx run-if-changed" >> .git/hooks/post-rewrite && chmod +x .git/hooks/post-rewrite
 68 | 
69 | 70 |
71 | 72 | ## Why 73 | 74 | The use case for `run-if-changed` is mostly for a team working on a project and push and pull code in different branches. When you share dependencies, database migrations or compilable code in the shared Git repository often some commands need to be run when a file or folder gets updated. 75 | 76 | Check out the [common use cases](#use-cases). 77 | 78 | ## Configuration 79 | 80 | - `run-if-changed` object in your `package.json` 81 | - `.run-if-changedrc` file in JSON or YML format 82 | - `run-if-changed.config.js` file in JS format 83 | 84 | See [cosmiconfig](https://github.com/davidtheclark/cosmiconfig) for more details on what formats are supported. 85 | 86 | Configuration should be an object where each key is a file or directory match pattern and the value is either a single command or an array of commands to run if the file have changed since the last Git operation. 87 | 88 | ## What commands are supported? 89 | 90 | Supported are any executables installed locally or globally via `npm` or Yarn as well as any executable from your `$PATH`. 91 | 92 | > Using globally installed scripts is discouraged, since run-if-changed may not work for someone who doesn't have it installed. 93 | 94 | `run-if-changed` is using [`execa`](https://github.com/sindresorhus/execa) to locate locally installed scripts and run them. So in your `.run-if-changedrc` you can just write and it would use the local version: 95 | 96 |

 97 | {
 98 |   "src": "webpack"
 99 | }
100 | 
101 | 102 | Sequences of commands are supported. Pass an array of commands instead of a single one and they will run sequentially. 103 | 104 | ## Use cases 105 | 106 | #### Install or update dependencies when lock file changes 107 | 108 | If you use a dependency manager with a lock file like npm, Yarn, Composer, Bundler or others, you would usually add a dependency and the dependency manager would install it and add it to the lock file in a single run. However, when someone else has updated a dependency and you pull new code or checkout their branch you need to manually run the install command of your dependency manager. 109 | 110 | Here's example configuration of `run-if-changed`: 111 | 112 |
113 | npm 114 | 115 | package.json 116 | 117 |

118 | {
119 |   "run-if-changed": {
120 |     "package-lock.json": "npm install --prefer-offline --no-audit --no-fund"
121 |   }
122 | }
123 | 
124 | 125 | .run-if-changedrc 126 | 127 |

128 | {
129 |   "package-lock.json": "npm install --prefer-offline --no-audit --no-fund"
130 | }
131 | 
132 | 133 |
134 | 135 |
136 | Yarn 137 | 138 | package.json 139 | 140 |

141 | {
142 |   "run-if-changed": {
143 |     "yarn.lock": "yarn install --prefer-offline --pure-lockfile --color=always --non-interactive"
144 |   }
145 | }
146 | 
147 | 148 | .run-if-changedrc 149 | 150 |

151 | {
152 |   "yarn.lock": "yarn install --prefer-offline --pure-lockfile --color=always --non-interactive"
153 | }
154 | 
155 | 156 |
157 | 158 |
159 | Composer 160 | 161 | package.json 162 | 163 |

164 | {
165 |   "run-if-changed": {
166 |     "composer.lock": "composer install --ignore-platform-reqs --ansi"
167 |   }
168 | }
169 | 
170 | 171 |
172 | 173 |
174 | Bundler 175 | 176 | package.json 177 | 178 |

179 | {
180 |   "run-if-changed": {
181 |     "Gemfile.lock": "bundle install --prefer-local"
182 |   }
183 | }
184 | 
185 | 186 |
187 | 188 | #### Run database migrations if there are new migrations 189 | 190 | If you keep database migrations in your repository, you'd usually want to run them when you check out a branch or pull from master. 191 | 192 |
193 | Example of running Doctrine migrations when pulling or changing branches 194 | 195 | package.json 196 | 197 |

198 | {
199 |   "run-if-changed": {
200 |     "migrations": "./console db:migrate --allow-no-migration --no-interaction"
201 |   }
202 | }
203 | 
204 | 205 |
206 | 207 | #### Compile sources in a build folder after pulling new code. 208 | 209 |
210 | Example for running build on changing src folder when pulling or changing branches 211 | 212 | package.json 213 | 214 |

215 | {
216 |   "run-if-changed": {
217 |     "src": "npm run build"
218 |   }
219 | }
220 | 
221 | 222 |
223 | --------------------------------------------------------------------------------