Keeping Long-Running Survey Branches in Sync with Main
A survey branch stays open for as long as the fieldwork takes, and every day it is open the trunk moves under it β so the merge at the end is large not because the survey was, but because nobody integrated. This page is a focused companion to feature branching for GIS development teams.
Concept & Context
Software branches are usually measured in days. Survey branches are measured in the duration of the fieldwork β six weeks is ordinary, a season is not unusual β and during that time corrections, imports and other surveys land on the trunk.
What makes this tractable is that spatial divergence is bounded by geography. A trunk change in a different county cannot conflict with this branch, no matter how large it is. The number that predicts merge pain is not commits behind trunk; it is changed features inside the branchβs own extent. A branch 400 commits behind trunk with no overlapping edits merges cleanly; one 12 commits behind with 200 overlapping edits does not.
That gives a much better signal than a schedule. Measure divergence in the extent, integrate when it crosses a threshold, and the branch that never overlaps anything is never disturbed.
Core Algorithmic Pipeline
- Record the branchβs extent at cut time, as part of the branch metadata.
- Measure divergence as the count of trunk-changed features intersecting that extent since the merge base.
- Warn at half the budget, block at the budget, so the team gets notice rather than a surprise.
- Integrate by merging trunk into the branch, never by rebasing.
- Re-validate after each integration, because the merge base moved.
Working Implementation
"""Measure and enforce divergence for long-running spatial branches."""
from __future__ import annotations
import json
import subprocess
from dataclasses import dataclass
import geopandas as gpd
from shapely.geometry import box
@dataclass
class Divergence:
branch: str
commits_behind: int
changed_in_extent: int
budget: int
@property
def state(self) -> str:
if self.changed_in_extent >= self.budget:
return "blocked"
if self.changed_in_extent >= self.budget // 2:
return "warn"
return "ok"
def merge_base(branch: str, trunk: str = "origin/main") -> str:
return subprocess.check_output(
["git", "merge-base", branch, trunk], text=True
).strip()
def branch_extent(branch: str) -> box:
"""The ground this branch was assigned, recorded when it was cut."""
meta = json.loads(subprocess.check_output(
["git", "show", f"{branch}:.branch-meta.json"], text=True
))
return box(*meta["extent"])
def changed_features_in_extent(layer: str, base: str, trunk: str,
extent) -> int:
"""Trunk features changed since the merge base that fall inside the extent.
This is the number that predicts how hard the merge will be β commits behind
trunk says nothing, because most trunk work is somewhere else entirely.
"""
with_base = materialise(layer, base)
with_trunk = materialise(layer, trunk)
a = gpd.read_file(with_base)
b = gpd.read_file(with_trunk)
from spatial_diff import diff_layers
d = diff_layers(with_base, with_trunk, key="parcel_uid")
touched = set(d.geometry_only) | {e["id"] for e in d.both} | set(d.added) \
| set(d.removed) | {e["id"] for e in d.attribute_only}
if not touched:
return 0
subset = b[b["parcel_uid"].isin(touched)]
return int(subset.intersects(extent).sum())
def assess(branch: str, layer: str = "data/parcels.gpkg",
budget: int = 150) -> Divergence:
base = merge_base(branch)
behind = int(subprocess.check_output(
["git", "rev-list", "--count", f"{base}..origin/main"], text=True
).strip())
changed = changed_features_in_extent(layer, base, "origin/main",
branch_extent(branch))
return Divergence(branch, behind, changed, budget)
def integrate(branch: str) -> None:
"""Merge trunk into the branch. Never rebase β offline packages and other
clones hold references to these commits."""
subprocess.run(["git", "switch", branch], check=True)
subprocess.run(["git", "merge", "--no-ff", "origin/main",
"-m", f"Integrate trunk into {branch}"], check=True)
subprocess.run(["dvc", "pull", "--quiet"], check=True)
subprocess.run(["python", "scripts/validate_spatial.py", "data/parcels.gpkg",
"--crs", "EPSG:3035", "--topology"], check=True)
The scheduled job reports rather than acts, so a crew in the field is never surprised by a branch that changed under them:
# .github/workflows/branch-divergence.yml
name: Survey branch divergence
on:
schedule: [{cron: "0 6 * * 1-5"}]
jobs:
measure:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
with: {fetch-depth: 0}
- run: pip install --require-hashes -r requirements.lock
- name: Measure every open survey branch
run: python scripts/divergence_report.py --prefix survey/ --budget 150
# A branch over budget gets an issue comment, not a forced merge:
# integrating while a crew is mid-package is worse than the divergence.
Validation & Output Verification
# The measure must respond to overlapping change and ignore distant change
python - <<'PY'
from divergence import assess
d = assess("survey/east-2026")
print(f"{d.branch}: {d.commits_behind} commits behind, "
f"{d.changed_in_extent} changed features in extent β {d.state}")
PY
# A trunk edit outside the extent must not move the number
git switch -q main
python scripts/edit_fixture.py --region west --features 250
python -c "
from divergence import assess
print('after 250 edits in WEST:', assess('survey/east-2026').changed_in_extent)
" # expected: unchanged
# A trunk edit inside the extent must move it
python scripts/edit_fixture.py --region east --features 10
python -c "
from divergence import assess
print('after 10 edits in EAST:', assess('survey/east-2026').changed_in_extent)
" # expected: +10
# After integration, the branch must still pass the gates against the new base
git switch survey/east-2026
python scripts/validate_spatial.py data/parcels.gpkg --crs EPSG:3035 --topology
python scripts/check_size_budget.py origin/main
The second validation run is the one teams skip. A branch that passed validation last week passed it against last weekβs trunk, and an integration can introduce a topology error at the boundary between the branchβs work and somebody elseβs.
Failure Modes
-
The final merge takes days β symptom: a six-week branch produces a week of reconciliation. Root cause: no integration during the survey. Fix: measure divergence in the extent and integrate on a threshold rather than at the end.
-
A rebase orphaned an offline package β symptom: a returned field package cannot find its base revision. Root cause: the branch was rebased. Fix: merge instead; the same reasoning applies to offline field collection packages, which record a base commit that must remain reachable.
-
Divergence looks fine and the merge is still painful β symptom: a clean measure, a hard merge. Root cause: the branch extent was recorded too small, or was never recorded. Fix: require
.branch-meta.jsonat branch creation and fail the measure loudly when it is absent. -
Integration breaks validation on the branch β symptom: topology errors appear after a merge nobody edited. Root cause: a boundary between branch work and trunk work. Fix: re-validate after each integration and repair on the branch, per resolving topology errors during branch merges.
Related
- Feature Branching for GIS Development Teams β the parent guide and the branch lifecycle this extends
- Automated Conflict Detection in Merge Requests β what the merge runs into when divergence is allowed to grow
- Offline Field Collection and Sync Reconciliation β the packages that depend on branch commits staying reachable
- Diffing Attribute-Only Changes Without Geometry Noise β the diff the divergence measure is built on