Case Study 2: The Merge Conflict Marathon
Overview
Company: HealthTrack, a health technology company Team size: 8 developers across 2 time zones (US East, India) Product: A patient management system with HIPAA compliance requirements Stack: Python (Django) backend, Vue.js frontend, PostgreSQL database AI Tools: Claude Code, GitHub Copilot Duration: A single catastrophic merge week, and the recovery process
The Setup
HealthTrack was in the final sprint before a major release: version 3.0 of their patient management system. The release included three large features that had been developed in parallel on separate branches over the past four weeks:
-
feature/patient-portal— A new patient-facing portal (led by the US team, 3 developers). 2,400 lines of new code across 45 files. Extensive use of Claude Code for generating Django views and Vue components. -
feature/billing-overhaul— A complete rewrite of the billing module (led by the India team, 3 developers). 3,100 lines of changes across 38 files. AI tools generated the new billing calculation engine. -
feature/audit-logging— A comprehensive audit logging system for HIPAA compliance (led by 2 developers, one from each team). 1,800 lines of changes across 52 files, touching nearly every model and view.
The problem was architectural: all three features needed to modify the same core files:
models/patient.py— All three features added fields and methods.views/api.py— All three features added or modified API endpoints.middleware/auth.py— The portal needed new auth flows; billing needed payment auth; audit logging needed to intercept all auth events.settings.py— Each feature added configuration variables.urls.py— Each feature added new URL patterns.tests/test_patient.py— Each feature added test cases for the patient model.
The team had been so focused on delivering features with AI assistance that they had not rebased their branches in three weeks. Each branch had diverged significantly from main and from each other.
The Merge Attempt
On Monday morning, the tech lead, Deepak, began the merge process. His plan was straightforward:
- Merge
feature/patient-portalintomain. - Merge
feature/billing-overhaulinto the updatedmain. - Merge
feature/audit-logginginto the finalmain.
Merge 1: Patient Portal
The first merge went smoothly. There were no conflicts because main had not changed significantly since the branch was created. Deepak merged with a merge commit:
git checkout main
git merge --no-ff feature/patient-portal
# Clean merge, no conflicts
Merge 2: Billing Overhaul (The First Crisis)
The second merge was where things went wrong. Git reported conflicts in 14 files.
git checkout main
git merge --no-ff feature/billing-overhaul
# CONFLICT (content): Merge conflict in models/patient.py
# CONFLICT (content): Merge conflict in views/api.py
# CONFLICT (content): Merge conflict in middleware/auth.py
# CONFLICT (content): Merge conflict in settings.py
# CONFLICT (content): Merge conflict in urls.py
# ... 9 more conflicts
# Automatic merge failed; fix conflicts and then commit the result.
Deepak spent 3 hours manually resolving these conflicts. The most painful was models/patient.py, where both branches had added new fields to the Patient model class:
<<<<<<< HEAD
class Patient(models.Model):
# ... existing fields ...
# Patient portal fields (from feature/patient-portal)
portal_username = models.CharField(max_length=150, unique=True, null=True)
portal_password_hash = models.CharField(max_length=255, null=True)
portal_last_login = models.DateTimeField(null=True)
portal_preferences = models.JSONField(default=dict)
email_verified = models.BooleanField(default=False)
two_factor_enabled = models.BooleanField(default=False)
def get_portal_display_name(self) -> str:
"""Return the patient's display name for the portal."""
return f"{self.first_name} {self.last_name}"
def has_portal_access(self) -> bool:
"""Check if the patient has active portal access."""
return self.portal_username is not None and self.email_verified
=======
class Patient(models.Model):
# ... existing fields ...
# Billing fields (from feature/billing-overhaul)
billing_account_id = models.CharField(max_length=50, unique=True, null=True)
insurance_provider = models.ForeignKey('InsuranceProvider', null=True,
on_delete=models.SET_NULL)
copay_amount = models.DecimalField(max_digits=10, decimal_places=2, default=0)
billing_address = models.JSONField(default=dict)
payment_method_token = models.CharField(max_length=255, null=True)
def get_outstanding_balance(self) -> Decimal:
"""Calculate the patient's outstanding balance."""
return self.invoices.filter(
status='unpaid'
).aggregate(
total=models.Sum('amount')
)['total'] or Decimal('0.00')
def has_valid_insurance(self) -> bool:
"""Check if the patient has valid insurance."""
return (self.insurance_provider is not None and
self.insurance_provider.is_active)
>>>>>>> feature/billing-overhaul
Deepak resolved this by including both sets of fields and methods. But the conflict in views/api.py was much more complex. Both branches had restructured the API view module, reorganizing imports, adding new viewsets, and modifying the existing PatientViewSet:
<<<<<<< HEAD
class PatientViewSet(viewsets.ModelViewSet):
serializer_class = PatientSerializer
permission_classes = [IsAuthenticated, IsStaffOrPatient]
filterset_fields = ['status', 'portal_username']
def get_queryset(self):
user = self.request.user
if hasattr(user, 'patient_profile'):
return Patient.objects.filter(id=user.patient_profile.id)
return Patient.objects.all()
@action(detail=True, methods=['post'])
def send_portal_invite(self, request, pk=None):
patient = self.get_object()
PortalService.send_invite(patient)
return Response({'status': 'invite sent'})
=======
class PatientViewSet(viewsets.ModelViewSet):
serializer_class = PatientSerializer
permission_classes = [IsAuthenticated, IsStaff]
filterset_fields = ['status', 'insurance_provider']
def get_queryset(self):
return Patient.objects.select_related(
'insurance_provider'
).prefetch_related(
'invoices'
).all()
@action(detail=True, methods=['get'])
def billing_summary(self, request, pk=None):
patient = self.get_object()
return Response(BillingService.get_summary(patient))
>>>>>>> feature/billing-overhaul
This conflict was not just about adding lines together. The two branches had fundamentally different permission_classes, different filterset_fields, and different get_queryset implementations. A naive merge (concatenating both) would produce broken code.
The AI-Assisted Resolution
After struggling with manual resolution for an hour, Deepak decided to use Claude Code to help. He provided the AI with:
- The full conflict markers for each file.
- The purpose of each feature branch.
- The project's coding standards.
- The requirement that both features must work simultaneously.
His prompt:
I have a merge conflict in a Django PatientViewSet. The HEAD branch
(patient-portal) changed permissions to allow patients to access their
own records, added portal-related filter fields, and added a portal
invite action. The feature/billing-overhaul branch changed the queryset
to include billing-related prefetching, added billing filter fields,
and added a billing summary action.
I need a merged version that:
1. Allows both staff and patients to access the viewset (portal requirement)
2. Includes billing-related query optimization (billing requirement)
3. Keeps both filter fields
4. Keeps both action methods
5. Has a get_queryset that handles both patient self-access AND billing prefetching
Here are both versions:
[pasted conflict]
The AI generated a merged resolution:
class PatientViewSet(viewsets.ModelViewSet):
serializer_class = PatientSerializer
permission_classes = [IsAuthenticated, IsStaffOrPatient]
filterset_fields = ['status', 'portal_username', 'insurance_provider']
def get_queryset(self):
queryset = Patient.objects.select_related(
'insurance_provider'
).prefetch_related(
'invoices'
)
user = self.request.user
if hasattr(user, 'patient_profile'):
return queryset.filter(id=user.patient_profile.id)
return queryset.all()
@action(detail=True, methods=['post'])
def send_portal_invite(self, request, pk=None):
patient = self.get_object()
PortalService.send_invite(patient)
return Response({'status': 'invite sent'})
@action(detail=True, methods=['get'])
def billing_summary(self, request, pk=None):
patient = self.get_object()
return Response(BillingService.get_summary(patient))
This resolution correctly combined the intent of both branches: the portal's patient access control with the billing module's query optimization. Deepak reviewed it carefully, verified the logic, and accepted it.
Merge 3: Audit Logging (The Full Marathon)
With main now containing both the patient portal and billing overhaul, Deepak attempted to merge the audit logging feature. This branch had been designed to touch nearly every model and view to add audit trail tracking. Git reported conflicts in 23 files.
git merge --no-ff feature/audit-logging
# CONFLICT in 23 files
The audit logging branch had been written before either of the other two features existed on main. It had added audit mixins and decorators to the original versions of models and views, not the versions that now included portal and billing functionality.
Deepak realized this would take an entire day of manual conflict resolution. He called a team meeting.
The Team Resolution Strategy
The team decided on a systematic approach using AI assistance:
Step 1: Categorize Conflicts by Severity
They sorted the 23 conflicting files into three categories:
- Simple (8 files): Both sides added non-overlapping content (e.g.,
settings.py,urls.py). Resolution was straightforward concatenation. - Moderate (10 files): Both sides modified existing code, but the modifications were in different sections of the file. Resolution required understanding both changes.
- Complex (5 files): Both sides fundamentally restructured the same code. Resolution required design decisions.
Step 2: Resolve Simple Conflicts Manually (1 hour)
Two developers handled the simple conflicts. These were cases where the audit logging branch added new settings or URL patterns that did not overlap with the portal or billing additions.
Step 3: Resolve Moderate Conflicts with AI Assistance (3 hours)
For each moderate conflict, a developer:
- Read both sides of the conflict to understand the intent.
- Provided the conflict and context to Claude Code.
- Reviewed the AI's resolution for correctness.
- Ran the relevant tests after each file was resolved.
The AI was particularly helpful for model files, where the audit logging mixin needed to be applied to models that now had additional fields from the portal and billing features:
# AI-resolved version of models/patient.py
class Patient(AuditMixin, models.Model):
"""Patient model with portal access, billing, and audit logging."""
# Core fields
first_name = models.CharField(max_length=100)
last_name = models.CharField(max_length=100)
date_of_birth = models.DateField()
# ... other existing fields ...
# Patient portal fields
portal_username = models.CharField(max_length=150, unique=True, null=True)
portal_password_hash = models.CharField(max_length=255, null=True)
portal_last_login = models.DateTimeField(null=True)
portal_preferences = models.JSONField(default=dict)
email_verified = models.BooleanField(default=False)
two_factor_enabled = models.BooleanField(default=False)
# Billing fields
billing_account_id = models.CharField(max_length=50, unique=True, null=True)
insurance_provider = models.ForeignKey(
'InsuranceProvider', null=True, on_delete=models.SET_NULL
)
copay_amount = models.DecimalField(max_digits=10, decimal_places=2, default=0)
billing_address = models.JSONField(default=dict)
payment_method_token = models.CharField(max_length=255, null=True)
# Audit configuration
audit_fields = [
'portal_username', 'email_verified', 'two_factor_enabled',
'billing_account_id', 'insurance_provider', 'copay_amount',
'payment_method_token',
]
class Meta:
ordering = ['last_name', 'first_name']
# Portal methods
def get_portal_display_name(self) -> str:
"""Return the patient's display name for the portal."""
return f"{self.first_name} {self.last_name}"
def has_portal_access(self) -> bool:
"""Check if the patient has active portal access."""
return self.portal_username is not None and self.email_verified
# Billing methods
def get_outstanding_balance(self) -> Decimal:
"""Calculate the patient's outstanding balance."""
return self.invoices.filter(
status='unpaid'
).aggregate(
total=models.Sum('amount')
)['total'] or Decimal('0.00')
def has_valid_insurance(self) -> bool:
"""Check if the patient has valid insurance."""
return (
self.insurance_provider is not None
and self.insurance_provider.is_active
)
Step 4: Resolve Complex Conflicts with Pair Programming (4 hours)
The five complex conflicts required design decisions that AI alone could not make. For these, two senior developers worked together, using AI as an advisor:
The middleware/auth.py conflict was the most challenging. The original file had 150 lines. The portal branch had expanded it to 280 lines (adding patient authentication). The billing branch had expanded it to 240 lines (adding payment authentication). The audit logging branch had added decorators and middleware hooks to every authentication function.
The merged file needed to be 400+ lines and incorporate all three sets of changes. The team decided to refactor the middleware into three separate files during the merge:
middleware/
├── auth.py # Core authentication (original)
├── portal_auth.py # Patient portal authentication
├── billing_auth.py # Payment authentication
└── audit.py # Audit logging middleware
This refactoring resolved the conflict by separating concerns. Each feature's code lived in its own file, with the audit middleware wrapping the others. The AI helped generate the refactored structure, and the developers verified that the behavior was preserved.
Step 5: Comprehensive Testing (2 hours)
After all conflicts were resolved, the team ran the full test suite:
# Run all tests
python manage.py test --verbosity=2
# 847 tests total
# 12 failures
The 12 failures fell into three categories:
- Import errors (4 tests): The middleware refactoring changed import paths. AI quickly generated the corrected imports.
- Logic errors (5 tests): The merged
get_querysetmethod had a subtle bug where patient self-access was checked before the billing prefetch was applied to the base queryset. The fix was a two-line change. - Missing audit assertions (3 tests): The audit logging tests expected to find audit entries for operations that now had different code paths. The team updated the tests to match the new architecture.
After fixing all failures:
python manage.py test --verbosity=2
# 847 tests, 0 failures, 0 errors
Post-Mortem and Prevention
The team held a post-mortem to prevent this from happening again. Total time spent on the merge process: 11 hours across 5 developers, or approximately 25 person-hours.
Root Causes Identified
- Long-lived branches. Three branches running for four weeks without rebasing guaranteed massive conflicts.
- Overlapping modifications. Three features all modifying the same core files without coordination.
- No integration testing during development. Each branch was tested in isolation but never against the others.
- AI-generated code amplified the problem. AI tools generated large volumes of code quickly, and the features were finished weeks before the team was ready to merge. Those weeks of additional divergence were unnecessary.
Preventive Measures Adopted
1. Weekly integration branches. Every Friday, the team creates a temporary integration/week-N branch that merges all active feature branches. This catches conflicts early.
# Weekly integration check
git checkout -b integration/week-12 main
git merge --no-commit feature/patient-portal || echo "Conflict with portal"
git merge --no-commit feature/billing-overhaul || echo "Conflict with billing"
git merge --no-commit feature/audit-logging || echo "Conflict with audit"
# Review conflicts, then discard the integration branch
git checkout main
git branch -D integration/week-12
2. Shared file ownership. Files that multiple features need to modify are assigned an owner who coordinates changes. The owner reviews all modifications to that file before they are committed.
3. Continuous rebase. Feature branches must rebase on main at least every two days. A GitHub Action posts a warning when a branch is more than three days behind main.
4. AI-assisted conflict detection. The team wrote a script that uses AI to predict potential conflicts by analyzing the files modified on active branches:
# List files modified on each active branch
for branch in $(git branch -r --no-merged main | grep feature/); do
echo "=== $branch ==="
git diff main...$branch --name-only
done
# Pipe to AI: "Identify files modified by multiple branches and assess conflict risk"
5. Architectural guidelines for AI prompts. When asking AI to generate code that modifies core files, the prompt now includes: "Add new functionality in a separate module/file. Only modify existing files to add imports and registrations, not to restructure existing code."
Lessons Learned
1. AI Accelerates Code Generation but Not Conflict Resolution
The three features were completed quickly thanks to AI tools. But the merge conflicts still required human understanding of intent, architecture, and design trade-offs. AI assisted in generating merged code, but the critical decisions were made by humans.
2. Frequent Integration Prevents Catastrophic Merges
The 23-file conflict was entirely preventable. If the team had merged to main weekly (or even bi-weekly), each merge would have had 2-3 conflicts instead of 23.
3. AI Is Excellent at Mechanical Conflict Resolution
For conflicts where both sides added non-overlapping code (simple concatenation with correct ordering), AI resolved them perfectly. For conflicts requiring design decisions, AI provided useful options but could not choose between them.
4. Refactoring During Merge Can Be the Right Call
The decision to refactor middleware/auth.py into four files during the merge was initially controversial (it added scope to an already painful process). But it resolved the conflict cleanly and left the codebase in a better state than any of the three branches had individually.
5. The Cost of Delayed Integration Is Non-Linear
One week of divergence might cause 2 conflicts. Two weeks might cause 8. Four weeks caused 23. The cost grows faster than linearly because more code means more opportunity for overlapping changes.
Discussion Questions
- How could the team have structured their AI prompts to reduce the likelihood of conflicting changes across branches?
- What role should automated conflict detection play in a CI/CD pipeline?
- Is there a team size or project complexity threshold where long-lived feature branches become unavoidable? How should such teams manage merge conflicts?
- How does the HIPAA compliance requirement affect the merge and review process? What additional safeguards should be in place?
- If you were Deepak, would you have merged all three features at once or released them incrementally? What are the trade-offs?