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.

Commits behind trunk predicts nothing; changed features in the extent predicts everything A grid of four branch situations against commits behind trunk, changed features inside the branch's extent, and how hard the merge actually was β€” showing that the two measures are unrelated. Commits behind Changed in extent Merge difficulty survey/east 412 3 trivial survey/west 12 214 a steward and a day survey/north 180 47 an hour of review snapshot/q3 301 0 none The first column is what every tool reports. The second is the one worth putting a budget on.

Core Algorithmic Pipeline

  1. Record the branch’s extent at cut time, as part of the branch metadata.
  2. Measure divergence as the count of trunk-changed features intersecting that extent since the merge base.
  3. Warn at half the budget, block at the budget, so the team gets notice rather than a surprise.
  4. Integrate by merging trunk into the branch, never by rebasing.
  5. Re-validate after each integration, because the merge base moved.
The scheduled measure, and what it does at each level Four steps of the daily divergence job: read the branch's recorded extent, count trunk-changed features intersecting it, compare against the budget, and report β€” warning at half, blocking at the budget, and never integrating unasked. 1 Read the recorded extent from branch metadata at cut time 2 Count changes inside it since the merge base, on the trunk 3 Compare against the budget warn at half, block at full 4 Report β€” do not integrate a crew mid-package must not be surprised Reporting rather than acting is deliberate: an automatic merge during fieldwork is worse than the divergence.

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.json at 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.

Merge, never rebase, on a branch other artifacts point at Two panels. Rebasing rewrites the branch's commits, orphaning the base revisions recorded in offline packages and in other clones; merging keeps every commit reachable at the cost of a noisier history. REBASE Rewrites every commit on the branch Offline packages lose their recorded base revision Other clones need a forced reset to continue Tidier history, at the cost of reachability MERGE Every existing commit stays reachable Offline packages reconcile as normal Other clones fast-forward without intervention Noisier history, and nothing breaks The tidiness argument loses the moment anything outside the repository names a commit. Field packages record the revision they were cut from β€” that is the artifact a rebase destroys.

Back to Feature Branching for GIS Development Teams