← All runbooks in Git, CI/CD & GitOps
Runbook: Resolve an Infrastructure Merge Conflict
1 · Prerequisites
Confirm every item is in place before any state change.
- Fast-forward merges — when a merge is just a pointer move
- The conflict markers — reading <<<<<<<, =======, and >>>>>>>
- Working clone with the conflicting branches both present
- For Terraform:
terraformCLI configured against the relevant state bucket - For Ansible:
ansible-playbook --syntax-checkavailable - For Kubernetes:
kubectlandkubeconformavailable
2 · Pre-checks
Read-only diagnostic commands. If any of these don't match expected output, stop and investigate further.
- · Capture which branches are conflicting:
git statusshowsboth modified:for the affected files;git rev-parse --abbrev-ref HEADis the branch you are merging into - · Identify the merge tool:
git config --get merge.tool(defaultvimdiff); setgit config --global merge.tool nvimdiff3or similar before continuing if unset - · For Terraform: confirm state lock is healthy before the conflict blocks other agents:
terraform force-unlock <LOCK_ID>is wrong here — instead,terraform plan -lock=false -refresh-onlyto confirm state has not drifted underneath you - · For Ansible: confirm the inventory is current:
ansible-inventory -i inventories/prod/hosts.yml --graph - · For Kubernetes manifests: confirm the cluster context matches the branch (
prodoverlay vsstagingoverlay):kubectl config current-context - · Identify the file format: YAML/JSON for K8s and Ansible, HCL for Terraform — conflict markers look different, the resolution strategy differs accordingly
3 · Procedure
Execute each step in order. Verify the expected output of a step before moving to the next.
- 1List the conflicting files:
git diff --name-only --diff-filter=Uandgit status --porcelain | grep "^UU\\|^AA\\|^DD" - 2For each conflicting file, identify which side is "ours" (current branch) and "theirs" (the branch being merged):
git log --oneline HEAD -- <file> -n 5andgit log --oneline MERGE_HEAD -- <file> -n 5 - 3Open the conflict in a three-way merge tool:
git mergetool -- <file>(configured tool) or use a CLI alternative for HCL/YAML - 4Resolve by re-running the parser, not by reading conflict markers: for YAML,
yq --version && yq eval-all ". as $item ireduce ({}; . *+ $item)" <file> > /tmp/resolved.yamlproduces a merged object only if neither side changed the same key - 5For Terraform HCL: do not hand-edit HCL if the conflict is structural (
resource "aws_security_group" "x" { ... }added on both sides with different arguments). Re-author the block once, then runterraform fmt - 6For Ansible YAML: resolve the conflict, then run
ansible-playbook --syntax-check -i inventories/prod/hosts.yml <playbook>to confirm the YAML is still valid; the parser will reject what your eyes accept - 7For Kubernetes manifests: validate with
kubeconform -strict -summary <file>(uses the upstream CRDs) andkubectl apply --dry-run=server -f <file>against the same cluster context the branch deploys to - 8Stage the resolution:
git add <file>for each resolved file. Do not usegit add -A— you want to review what is staged before committing - 9Run the semantic check for the affected IaC tool (
terraform plan,ansible-playbook --check,kubectl diff -f -R) to confirm the resolved file produces the expected plan: zero changes for non-functional merges (whitespace, comment), an additive plan for one-sided changes - 10For Terraform: confirm state has not drifted by running
terraform plan -lock=falseand comparing the resource addresses to the expected merge — a changed address means the resolution is wrong - 11For Ansible: confirm the resulting variable scope is what you expect by running
ansible-inventory -i inventories/prod/hosts.yml --listand diffing against the pre-conflict version - 12For Kubernetes: confirm the resolved manifest applies cleanly against a staging cluster with
kubectl apply --dry-run=server -f <file>before committing to the conflict-resolving branch - 13Commit the resolution with a message that names both sides and the chosen strategy:
git commit --no-edit -m "merge $BRANCH into $TARGET: resolve <files> (kept <side>, applied <change>)" - 14Push only after the CI pipeline validates the resolved branch:
git push origin "$TARGET_BRANCH"
4 · Verification
Confirm the procedure actually fixed the problem.
- ✓No conflict markers remain in the working tree:
git grep -nE "^(<{7}|={7}|>{7})( |$)" -- <file>returns empty for every previously-conflicting file - ✓For Terraform:
terraform validatereturns success andterraform planproduces no unexpected changes outside the merged-in additions - ✓For Ansible:
ansible-playbook --syntax-checkandansible-lint <playbook>both pass against the resolved file - ✓For Kubernetes:
kubeconform -strict -summary <file>reports0 failuresandkubectl apply --dry-run=server -f <file>reports no errors - ✓
git diff HEAD~1 --statshows only the expected additions and no unexpected modifications - ✓The merge commit has both parents:
git log -1 --format="%P"shows two SHAs (the merge of both sides)
5 · Rollback
If verification fails, undo the procedure in reverse order.
- ↶If the resolution produced a syntactically invalid file, abort the merge:
git merge --abort(only works if no resolution has been committed yet) - ↶If the resolution was committed and pushed and CI rejects it, push the revert:
git revert -m 1 <merge-commit-sha>records the inverse of the merge as a new commit on the target branch - ↶If Terraform plan shows drift or unexpected changes after the resolution, the conflict resolution lost context — re-merge with
--strategy-option=theirsfor the lost file is wrong; instead, reset to the pre-merge state and resolve again with a three-way tool that shows both sides - ↶If Ansible syntax-check fails, the YAML is invalid; undo the resolution with
git checkout --merge <file>and resolve with a YAML-aware tool (yq,ruamel.yaml) instead of a text editor - ↶If Kubernetes manifests apply cleanly but produce unexpected drift, the conflict resolution chose the wrong overlay; revert and re-merge with the right base branch
6 · Escalation
When the runbook isn't enough, contact:
- · The conflict is in a CRD that no validator knows about (custom resource, no schema published): escalate to the CRD owner before merging, the schema check cannot catch the mistake
- · Terraform state lock cannot be acquired because another agent holds it: wait for the lock to expire (default 30 minutes) or coordinate with the holder, do not force-unlock
- · The merge is between two long-lived branches with conflicts in many files: do not try to resolve in one pass, merge one commit at a time with
git merge -s recursive -X theirsfor the lower-priority side, then resolve the high-priority side intentionally - · A dependency (provider, helm chart, kustomize base) was updated on both sides with incompatible versions: escalate to platform ownership, this is a manifest-library bug not a merge bug
A merge conflict in an infrastructure repository is not a text problem.
It is a state problem. The two branches disagreed about what the
infrastructure looks like; the resolution is the new contract. Reading
the conflict markers and picking “ours” or “theirs” without re-running the
tool’s parser is how broken clusters ship — the YAML parses, the
Terraform plan passes syntax check, and kubectl apply accepts the
manifest, but the resulting state is not what either branch intended.
The runbook is: read the markers to find the disagreement, resolve with a parser-aware tool, then validate with the tool that interprets the file (Terraform plan, Ansible syntax check, kubeconform + kubectl dry-run).
1. Identify the conflicting files and which side is which
$ git diff --name-only --diff-filter=U
echo '--- our side (HEAD) ---'
git log --oneline HEAD -- "$(git diff --name-only --diff-filter=U | head -1)" -n 5
echo '--- their side (MERGE_HEAD) ---'
git log --oneline MERGE_HEAD -- "$(git diff --name-only --diff-filter=U | head -1)" -n 5
echo '--- conflict marker locations ---'
git grep -nE '^(<{7}|={7}|>{7})( |$)' -- '*.tf' '*.yml' '*.yaml' '*.json' || echo no-markers-in-fmtFor a single conflict the answer is usually obvious: one side adds a resource block, the other side edits an unrelated resource. For a structural conflict (the same resource edited differently), the resolution requires re-authoring the block, not picking a side.
2. Resolve with a parser-aware tool
$ # YAML — use yq for additive merges
yq eval-all '. as $item ireduce ({}; . *+ $item)' main.tfvars prod.tfvars > /tmp/merged.tfvars
yq eval-all '. as $item ireduce ({}; . *+ $item)' values-staging.yaml values-prod.yaml > /tmp/merged-values.yaml
# JSON — use jq for additive merges
jq -s 'reduce .[] as $item ({}; . * $item)' side-a.json side-b.json > /tmp/merged.json
# HCL — re-author once, then terraform fmt
terraform fmt main.tfyq eval-all ... ireduce merges documents additively when neither side
touches the same key. If both sides edited the same key (e.g. replicas
in a Deployment), the tool picks the second argument’s value
silently — that is the case you must resolve by hand.
3. Validate with the tool that owns the file
$ # Terraform
terraform init -backend=false
terraform validate
terraform plan -lock=false -refresh-only | tee /tmp/refresh-plan.txt
terraform plan -lock=false -out=/tmp/plan.tfplan
terraform show -no-color /tmp/plan.tfplan | head -80
# Ansible
ansible-playbook --syntax-check -i inventories/prod/hosts.yml site.yml
ansible-lint site.yml
ansible-inventory -i inventories/prod/hosts.yml --list > /tmp/post-merge-inv.json
# Kubernetes
kubeconform -strict -summary overlays/prod/kustomization.yaml
kubectl kustomize overlays/prod/ > /tmp/manifests.yaml
kubeconform -strict -summary /tmp/manifests.yaml
kubectl apply --dry-run=server -k overlays/prod/If terraform plan shows resource drift that is not in the merge
diff, the resolution lost context — the HCL parses, but the resource
address changed. Stop and re-merge the block, do not apply the plan.
For Ansible, syntax-check catches structural mistakes; ansible-lint
catches deprecated modules and unsafe loops. For K8s, kubeconform
validates against upstream CRDs and kubectl apply --dry-run=server
catches admission-policy rejections that static validation misses.
4. Stage, commit, and verify
$ git status --porcelain
git diff --staged --stat
git add REPLACE_WITH_FILE # for each resolved file, individually
echo '--- no markers remain ---'
git grep -nE '^(<{7}|={7}|>{7})( |$)' || echo clean
git commit --no-edit -m "merge $BRANCH into $TARGET: resolve REPLACE_WITH_FILES (kept REPLACE_WITH_SIDE, applied REPLACE_WITH_CHANGE)"
git log -1 --format='%H %P %s'
echo '--- the commit has two parents ---'
git log -1 --format='%P' | wc -wThe commit message must name both sides and the resolution strategy.
“merge origin/main” is not enough; the next agent who reads the merge
needs to know why neither --strategy-option=ours nor --theirs was
used.
Verification
git grep -nE "^(<{7}|={7}|>{7})( |$)" returns empty across the working
tree. terraform validate (or ansible-playbook --syntax-check, or
kubeconform -strict) reports success. terraform plan (or
ansible-playbook --check, or kubectl diff -k overlays/prod/) shows
only the expected changes from the merge — no unintended drift, no
unintended resource replacements. The merge commit has two parents.
If CI is configured, the resolved branch passes the plan/apply pipeline
before the merge is merged into the protected branch.
Rollback
If the resolution was syntactically valid but semantically wrong (plan
shows drift the merge did not intend), the merge has not been pushed
yet: git merge --abort returns to the pre-merge state and the
resolution can be retried. If the merge was pushed, push the inverse
with git revert -m 1 <merge-sha> — that records the inverse as a new
commit on the target branch and leaves the original merge in the audit
trail. Never git reset --hard and git push --force on a merge that
already reached the protected branch; revert is the correct tool.