Automated Conflict Detection in Merge Requests
Automated conflict detection intercepts spatial inconsistencies in merge requests before they reach production β covering CRS mismatches, topology violations, and attribute schema drift that line-based diff tools cannot see. This page is part of Branching & Merge Strategies for Spatial Datasets.
When multiple contributors modify vector layers or attribute schemas concurrently, standard version control treats .gpkg and .geojson files as opaque blobs. A coordinate reference system shift, an overlapping polygon, or a dropped required column produces no visible warning at merge time β the bad data silently enters the main branch and corrupts downstream analytical pipelines. Building a geometry-aware detection stage into every merge request closes that gap.
Prerequisites & Environment Setup
Before deploying automated spatial conflict detection, establish a consistent baseline environment. The following components are required:
Core Algorithmic Patterns
1. Geometry Overlay Conflict Detection
The foundational algorithm computes Boolean set operations between the base branch geometry and the head branch geometry β the same family of operations that underpins spatial diff algorithms for polygon data. Three overlay results matter for conflict classification:
- Intersection (
A β© B): Unchanged regions. Any unexpected intersection between new features and existing features signals an overlap conflict. - Symmetric difference (
A β³ B): The full edit footprint β all regions changed in either direction. A symmetric difference that covers more area than the declared edit scope indicates unintended side effects. - Difference (
A \ BandB \ A): Deletions and additions. Large deletions from the base layer that are not recorded in the MR description trigger a warning.
Spatial complexity: O(n log n) with an R-tree index pre-filter; degrades to O(nΒ²) without spatial indexing on large datasets.
2. CRS Drift Detection
CRS mismatch is the most common source of silent spatial corruption. The detection algorithm extracts the CRS from both branches using pyproj.CRS.from_user_input(), compares authority codes, and flags any divergence as a critical conflict before any overlay operation runs. Reprojecting to a mismatched CRS before comparison is not a safe fallback β it silently moves features by tens of metres in some national grid systems.
3. Attribute Schema Drift Analysis
Schema drift β columns added, removed, or retyped between branches β breaks downstream joins and analytical pipelines even when the geometry is valid. The detector performs a symmetric difference on column name sets and a dtype comparison on shared columns. Missing required columns (defined in the validation config) are critical; unexpected new columns are warning.
Production Workflow Implementation
Step 1: Identify Changed Spatial Files
# Extract only spatial files touched by this branch
git diff --name-only origin/${BASE_BRANCH} HEAD \
| grep -E '\.(gpkg|geojson|shp)$' \
> spatial_changes.txt
Only process files that appear in spatial_changes.txt. Running the full overlay on unchanged files wastes runner time and generates noise.
Step 2: CRS Validation and Harmonization
import geopandas as gpd
from pyproj import CRS
from shapely.validation import make_valid
def load_and_validate(path: str, layer: str | None = None) -> gpd.GeoDataFrame:
"""Load spatial data, repair geometries, and assert CRS is defined."""
gdf = gpd.read_file(path, layer=layer)
if gdf.crs is None:
raise ValueError(f"No CRS found in {path}. Define a projection before committing.")
# Shapely 2.0 vectorised repair β avoids Python-level loop
gdf["geometry"] = gdf.geometry.apply(
lambda g: make_valid(g) if g is not None else g
)
return gdf
def harmonize_crs(base: gpd.GeoDataFrame, head: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
"""Reproject head to base CRS only when authority codes differ."""
base_crs = CRS.from_user_input(base.crs)
head_crs = CRS.from_user_input(head.crs)
if base_crs != head_crs:
head = head.to_crs(base.crs)
return head
Step 3: Geometry Overlay and Conflict Classification
import json
from typing import Any
def detect_spatial_conflicts(
base_path: str,
head_path: str,
layer: str | None = None,
precision: float = 1e-6,
) -> dict[str, Any]:
"""
Compare two spatial layers and return a structured conflict report.
precision: coordinate rounding tolerance applied after CRS harmonization
to suppress sub-millimetre floating-point noise.
"""
base_gdf = load_and_validate(base_path, layer)
head_gdf = load_and_validate(head_path, layer)
head_gdf = harmonize_crs(base_gdf, head_gdf)
# Snap coordinates to precision grid to prevent sliver false positives
base_gdf["geometry"] = base_gdf.geometry.apply(
lambda g: g.__class__([(round(x, 6), round(y, 6)) for x, y in g.exterior.coords]
if hasattr(g, "exterior") else g)
)
conflicts: list[dict] = []
# --- Geometry overlap check ---
try:
intersection = gpd.overlay(base_gdf, head_gdf, how="intersection", keep_geom_type=True)
if not intersection.empty:
conflicts.append({
"type": "geometry_overlap",
"count": len(intersection),
"severity": "critical",
"message": (
f"Head branch introduces {len(intersection)} overlapping features "
"with the target baseline."
),
})
except Exception as exc:
conflicts.append({
"type": "overlay_error",
"severity": "critical",
"message": f"Overlay operation failed: {exc}",
})
# --- Symmetric difference for edit-footprint audit ---
try:
sym_diff = gpd.overlay(base_gdf, head_gdf, how="symmetric_difference")
except Exception:
sym_diff = gpd.GeoDataFrame()
# --- Attribute schema drift ---
base_cols = set(base_gdf.columns) - {"geometry"}
head_cols = set(head_gdf.columns) - {"geometry"}
missing = base_cols - head_cols
added = head_cols - base_cols
if missing:
conflicts.append({
"type": "schema_drift_missing",
"columns": sorted(missing),
"severity": "critical",
"message": f"Required columns removed in head branch: {sorted(missing)}",
})
if added:
conflicts.append({
"type": "schema_drift_added",
"columns": sorted(added),
"severity": "warning",
"message": f"New columns introduced in head branch: {sorted(added)}",
})
status = "blocked" if any(c["severity"] == "critical" for c in conflicts) else "passed"
return {
"status": status,
"conflicts": conflicts,
"diff_summary": {
"base_feature_count": len(base_gdf),
"head_feature_count": len(head_gdf),
"changed_area_features": len(sym_diff),
},
}
if __name__ == "__main__":
import sys
report = detect_spatial_conflicts(sys.argv[1], sys.argv[2])
print(json.dumps(report, indent=2))
sys.exit(1 if report["status"] == "blocked" else 0)
When the pipeline flags structural inconsistencies, teams follow the topology error resolution workflow for branch merges β isolating conflicting features, reconciling boundaries with shapely.union or shapely.difference, and committing the cleaned geometry with a descriptive audit trail.
Step 4: CI/CD Pipeline Integration
name: Spatial Conflict Detection
on:
pull_request:
branches: [main, develop]
paths:
- "**/*.gpkg"
- "**/*.geojson"
- "spatial_checks/**"
jobs:
spatial-diff:
runs-on: ubuntu-latest
steps:
- name: Checkout repository (full history)
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.10"
cache: "pip"
- name: Install spatial dependencies
run: pip install -r requirements.txt
- name: Identify changed spatial files
id: changes
run: |
git diff --name-only origin/$ HEAD \
| grep -E '\.(gpkg|geojson)$' > spatial_changes.txt || true
echo "files=$(tr '\n' ' ' < spatial_changes.txt)" >> "$GITHUB_OUTPUT"
- name: Run conflict detection
if: steps.changes.outputs.files != ''
run: |
for file in $; do
echo "Checking: $file"
python spatial_checks/detect_conflicts.py \
"origin/$:${file}" \
"HEAD:${file}"
done
- name: Upload conflict report
if: always()
uses: actions/upload-artifact@v4
with:
name: spatial-conflict-report
path: conflict_report.json
Aligning this gate with your release tagging strategy for spatial basemaps ensures only topology-verified datasets reach production endpoints, maintaining version traceability across analytical environments.
Code Reliability Patterns
Defensive programming prevents pipeline flakiness on real-world data irregularities:
Tolerance snapping before overlay: Always snap coordinates to a consistent precision grid (6 decimal places for EPSG:4326, 3 for metre-based projected CRS) after reprojection. Without snapping, floating-point residuals produce hundreds of false sliver conflicts on datasets with long shared boundaries.
Geometry repair before comparison: Call shapely.validation.make_valid() on every geometry before any Boolean operation. Unrepaired self-intersections cause gpd.overlay() to raise TopologicalError exceptions that abort the pipeline without any diagnostic output.
Explicit CRS authority comparison: Use pyproj.CRS.equals() with ignore_axis_order=True rather than comparing EPSG integers directly. Two CRS objects can share an EPSG code but differ in axis ordering, which produces transposed coordinates in some GDAL versions.
Rollback on critical failure: If the detection script exits non-zero, the merge should be blocked immediately. Do not allow a continue-on-error: true flag on the detection step β partial conflict reports are worse than no report, because they give a false sense that the check ran cleanly.
Multi-layer GeoPackage iteration: Use fiona.listlayers(path) to enumerate all layers in a .gpkg file and run detection on each independently. A conflict in a boundary layer that is ignored because only the attribute layer was checked will propagate silently.
import fiona
def detect_all_layers(base_path: str, head_path: str) -> list[dict]:
"""Run conflict detection across every layer in a GeoPackage."""
base_layers = set(fiona.listlayers(base_path))
head_layers = set(fiona.listlayers(head_path))
all_layers = base_layers | head_layers
results = []
for layer in sorted(all_layers):
if layer not in base_layers:
results.append({"layer": layer, "status": "added", "conflicts": []})
continue
if layer not in head_layers:
results.append({"layer": layer, "status": "deleted", "conflicts": []})
continue
report = detect_spatial_conflicts(base_path, head_path, layer=layer)
report["layer"] = layer
results.append(report)
return results
Performance & Scale Considerations
Bounding box pre-filter: Before running the full overlay, compute the bounding box intersection of both datasets. If the bounding boxes do not overlap at all, skip the overlay entirely β there can be no geometry conflicts in non-overlapping extents. This alone eliminates expensive operations on regional datasets where branches modify different geographic areas.
Chunked processing for large files: Datasets above 500 MB should be tiled by bounding box grid before diffing. Use a 1-degree or 10 km grid to partition features, diff each tile independently, and aggregate results. This keeps per-tile memory usage below 1 GB even for national-scale vector datasets.
Spatial indexing: geopandas uses a libspatialindex R-tree by default. Verify the index is being used by checking that gdf.sindex is not empty before calling overlay(). On datasets where features have highly variable sizes (e.g., mixed point/polygon layers), a Quadtree index may outperform the R-tree.
Parallel tile processing: Use concurrent.futures.ProcessPoolExecutor to run tile-level diffs in parallel. Spatial operations release the GIL via GEOS, so multi-processing provides near-linear speedup up to the number of physical cores on the runner.
Benchmark reference: On a 4-core runner with 8 GB RAM, the full overlay pipeline processes a 50 MB GeoPackage (β80,000 polygon features) in under 90 seconds. Datasets above 200 MB require chunking to stay within a 10-minute CI timeout.
Troubleshooting & Failure Modes
| Symptom | Root Cause | Fix |
|---|---|---|
TopologicalError during overlay |
Invalid input geometries (self-intersections, degenerate rings) | Run make_valid() on both GeoDataFrames before calling gpd.overlay() |
| Hundreds of sliver conflicts on unchanged boundaries | Floating-point residuals after CRS reprojection | Apply coordinate rounding to 1e-6 precision after to_crs() |
| Pipeline passes but overlapping features reach production | continue-on-error: true set on the detection step |
Remove the flag; the step must fail the job on non-zero exit |
CRS not found error on GeoJSON from third-party source |
Missing or non-standard CRS declaration in the file | Add "crs": {"type": "name", "properties": {"name": "EPSG:4326"}} to the GeoJSON before committing |
| Detection runs on every push, not just spatial changes | paths filter missing from the CI trigger |
Add paths: ["**/*.gpkg", "**/*.geojson"] to the on.pull_request block |
Schema drift false positive on geometry column |
Column set comparison includes the geometry column | Explicitly subtract {"geometry"} from both column sets before comparing |
FAQ
Why does standard Git diff fail for spatial data? Gitβs line-based diff detects byte-level changes. Spatial datasets encode geometry, CRS, and topology as binary or structured binary (GeoPackage, Shapefile). A coordinate shift or topology violation produces an opaque binary diff β no indication of the spatial significance of the change. Geometry-aware overlay operations are required to classify what actually changed.
How do I avoid false positives from floating-point CRS transformations?
Snap coordinates to a consistent precision grid after reprojection β typically 1e-6 degrees for geographic CRS or 0.001 metres for projected CRS. Round after reprojecting, not before, and define the target EPSG code explicitly in your validation config.
What severity levels should spatial conflicts carry?
Use three tiers: critical (overlapping geometries, missing CRS, invalid topology) blocks the merge immediately; warning (attribute schema drift, added or removed columns) requires human acknowledgement but can be bypassed with a review label; info (minor area delta within tolerance, cosmetic rename) is logged only. This prevents alert fatigue while ensuring real topology breaks are never silently merged.
Can the pipeline handle multi-layer GeoPackage files?
Yes. Use fiona.listlayers() to enumerate layers and iterate the detection function over each independently. Report conflicts per-layer so reviewers can pinpoint which feature class is affected.
How do I integrate conflict reports with the GitHub PR review interface?
Serialize the conflict report to SARIF format and upload it via actions/upload-sarif. GitHub renders SARIF findings inline in the pull request diff view, giving reviewers clickable annotations without parsing terminal logs.
Related
- Resolving Topology Errors During Branch Merges β step-by-step remediation for conflicts surfaced by the detection pipeline
- Spatial Diff Algorithms for Polygon Data β the overlay and symmetric-difference mechanics that power conflict classification
- Feature Branching for GIS Development Teams β isolation strategies that reduce the conflict surface area per merge request
- Release Tagging Strategies for Spatial Basemaps β version anchoring that gives the detection pipeline a stable baseline to diff against
- Back to Branching & Merge Strategies for Spatial Datasets