Automating recurring tasks after every merge
Forgetting to update Composer after every pull costs time chasing cryptic error messages. The post-merge hook handles routine tasks like that automatically, without anyone having to remember.
Table of Contents
- 1. What the post-merge hook is
- 2. Typical use cases
- 3. Basic structure and the script's parameter
- 4. Distinguishing fast forward merges from real ones
- 5. Distributing hooks across a team
- 6. Keeping security and performance in mind
- 7. Interaction with post-checkout and post-rewrite
- 8. A practical example for a Magento project
- 9. Debugging and disabling hooks
- 10. Summary
- 11. FAQ
1. What the post-merge hook is
The post-merge hook is a client side script that Git runs automatically once a merge finishes successfully. That includes both explicit git merge calls and git pull, since pull internally consists of fetch plus merge.
Unlike server side hooks, post-merge only ever runs locally, on the machine of the developer who performed the merge. There is no way to enforce it centrally for everyone involved from the server.
The script lives at .git/hooks/post-merge by default and has to be executable for Git to invoke it at all. Without execute permission, the hook is silently skipped, with no error message.
# Create the hook file and make it executable
touch .git/hooks/post-merge
chmod +x .git/hooks/post-merge
# Minimal test hook
echo '#!/bin/sh
echo "Merge finished, running follow up tasks"' > .git/hooks/post-merge
2. Typical use cases
The most common use for post-merge is automatically updating dependencies. If a merge changes composer.lock or package-lock.json, a matching install command runs right away in the background, without the developer having to remember it manually.
A second common use case is clearing local caches. In Magento projects, a merge often touches layout XML, configuration, or class structure, so an automatic hint or a direct cache clean call avoids many small follow up bugs.
Triggering database migrations, or at least a clear notice that new migrations exist, can also be automated through the hook, even though actually running them should usually still require manual confirmation for safety reasons.
#!/bin/sh
# Only run when the Composer lock file actually changed
if git diff --name-only ORIG_HEAD HEAD | grep -q "composer.lock"; then
echo "composer.lock changed, running composer install"
composer install
fi
3. Basic structure and the script's parameter
The post-merge hook receives exactly one parameter: a 1 if the merge ran in squash mode, otherwise a 0. That lets you define different behavior for regular merges and squash merges, if that distinction matters to the team.
Git does not evaluate the script's exit code to undo the merge itself, the merge has already fully completed by that point. A failing hook can no longer prevent the preceding merge, it can only emit a warning.
Because the hook runs after every merge, even very small ones, it should decide quickly whether any action is needed at all before triggering expensive commands like a full dependency install.
#!/bin/sh
SQUASH_FLAG="$1"
if [ "$SQUASH_FLAG" = "1" ]; then
echo "Squash merge detected, skipping automated actions"
exit 0
fi
echo "Regular merge, checking for relevant changes"
4. Distinguishing fast forward merges from real ones
Not every merge creates its own merge commit. In a fast forward merge, the pointer of the current branch simply moves ahead, without a new commit being created. The post-merge hook runs in both cases, regardless of whether an actual merge commit was created.
To detect changes introduced by the merge, comparing the environment variable ORIG_HEAD, which Git sets before the merge, against the current HEAD works well. That approach works equally for fast forward and real merges.
A common mistake is assuming there is always a second parent to compare against. In a fast forward merge, no second merge parent exists, which is why comparing against HEAD^2 fails in that case, and ORIG_HEAD should be used instead.
#!/bin/sh
# Robust comparison, works for fast forward and real merges alike
CHANGED_FILES=$(git diff --name-only ORIG_HEAD HEAD)
if echo "$CHANGED_FILES" | grep -q "^composer.lock$"; then
composer install
fi
5. Distributing hooks across a team
Files inside .git/hooks/ are never versioned by Git and therefore never shared automatically with the repository. Every developer would have to manually copy the hook into their own local copy, something that gets forgotten quickly in practice.
The cleanest solution is the core.hooksPath configuration option, which lets you point Git to an alternative, versioned directory for hooks. If that directory lives inside the repository itself, for example under .githooks/, the hook ships automatically with every clone and every pull.
Alternative tools like Husky solve the same problem for Node based projects by setting core.hooksPath automatically while installing dependencies. For pure PHP or Magento projects, manual configuration through a setup script is usually entirely sufficient.
# Create a versioned hook directory and activate it
mkdir -p .githooks
git mv .git/hooks/post-merge .githooks/post-merge
git config core.hooksPath .githooks
# Document it in the README or setup script so everyone runs it
echo "git config core.hooksPath .githooks" >> setup.sh
6. Keeping security and performance in mind
Because post-merge scripts run with the full privileges of whoever triggers them, the content of a shared hook directory should be treated as seriously in code review as any other change to the repository. A tampered hook could execute arbitrary commands.
Long running actions inside the hook, for example a full reinstall of every dependency on every merge regardless of what actually changed, frustrate a team quickly. A hook should only become active when the relevant files actually changed.
A hook should never fail in a blocking way when an action fails for a non critical reason, for example missing internet access. A clear warning message is usually better than an aborted merge that slows the developer down for no good reason.
#!/bin/sh
if ! composer install 2>/tmp/composer-error.log; then
echo "Warning: composer install failed, please check manually"
cat /tmp/composer-error.log
fi
7. Interaction with post-checkout and post-rewrite
The post-merge hook does not exist in isolation. post-checkout runs after a branch switch and is the right place for actions relevant when switching between branches with different dependencies, independent of any merge.
post-rewrite fires on operations that rewrite commits, such as git rebase or git commit --amend. Anyone who wants the same follow up work after both a merge and a rebase should move the actual logic into a shared script called from both hooks.
That separation avoids duplicated code and ensures the logic only ever needs updating in one place, regardless of which Git operation ultimately triggered the change.
#!/bin/sh
# post-merge and post-rewrite both call the same shared script
DIR="$(git rev-parse --show-toplevel)"
sh "$DIR/.githooks/lib/sync-dependencies.sh"
8. A practical example for a Magento project
In a Magento project, a merge frequently changes composer.lock, module configuration files, or db_schema.xml definitions. A hook can emit a clear, specific recommendation for each of these changes instead of silently running everything automatically.
Fully automatic setup:upgrade execution directly inside the hook is risky in most projects, since the command can run for a while and can conflict with other people using the database at the same time. A clear notice in the terminal is the safer approach here.
Combined with core.hooksPath, a hook like this ensures no team member forgets necessary follow up work after a merge, without executing critical commands in an uncontrolled way.
#!/bin/sh
CHANGED=$(git diff --name-only ORIG_HEAD HEAD)
echo "$CHANGED" | grep -q "composer.lock" && echo "Note: run composer install"
echo "$CHANGED" | grep -q "db_schema.xml" && echo "Note: run bin/magento setup:upgrade"
9. Debugging and disabling hooks
Unlike pre-commit or pre-push, there is no way to bypass post-merge using --no-verify, since that option is only meant for hooks that can prevent an operation. A merge has already completed by the time this hook runs.
For targeted testing, the hook can be invoked directly without performing an actual merge. That makes it quick to find logic errors without actually changing the repository state.
For permanent deactivation, it is enough to make the file non executable or point core.hooksPath to an empty directory. Detailed tracing of hook execution can additionally be enabled through standard shell debugging options right inside the script itself.
# Invoke the hook manually, without an actual merge
sh .git/hooks/post-merge 0
# Enable shell built in tracing inside the hook
#!/bin/sh
set -x
| Hook | Timing | Blocking | Typical action |
|---|---|---|---|
| post-merge | After a completed merge or pull | No | Update dependencies, clear cache |
| post-checkout | After a branch switch | No | Adjust the environment to the new branch |
| post-rewrite | After rebase or amend | No | Same follow up work as post-merge on rewritten commits |
| pre-push | Before pushing to the remote | Yes | Enforce tests and linting before the push |
Mironsoft
Git workflows, branching strategies, and CI hooks
Chaotic Git history and unclear branching rules across the team?
We set up clean Git workflows, clarify branching strategies for the team, and automate quality checks via Git hooks and CI pipelines so the history stays traceable.
Workflow Audit
Review the existing branching strategy and merge practice for weak spots.
Hook Automation
Set up pre-commit and pre-push hooks for linting, tests, and commit conventions.
Team Training
Teach rebase, cherry-pick, and conflict resolution hands-on across the team.
10. Summary
Post-Merge Hooks
Location
.git/hooks/post-merge, must be executable
Parameter
1 on a squash merge, otherwise 0
Distribution
core.hooksPath for a versioned hook directory
Comparison
ORIG_HEAD against HEAD covers fast forward and real merges