4. Mesh File Revision Tracking with Git LFS

Why Mesh Files Need LFS, Not Plain Git

A solver mesh is a binary artifact. A mid-size CFD volume mesh in CGNS or Nastran BDF form easily reaches 200–800 MB, and every re-mesh produces a completely new binary blob. If you commit those blobs directly, Git stores a full copy per revision inside .git/objects, and the repository grows without bound — clones become slow, and the history is unusable for anyone who only needs the deck. Git LFS solves this by replacing the binary content in the commit with a small text pointer file and storing the actual bytes in a separate LFS object store. The commit history then contains only pointers, so revisions are cheap to diff, tag, and review.

This case walks through the full loop: register a mesh under LFS tracking, commit a baseline, produce a revised mesh, commit it, and then compare the two revisions using both the pointer metadata and the file-level metadata that LFS records.

Repository and LFS Setup

Start from a clean working directory. The commands below assume Git ≥ 2.40 and Git LFS ≥ 3.4; the exact version is not critical, but git lfs track and git lfs ls-files must be available.

mkdir mesh-rev-demo && cd mesh-rev-demo
git init
git lfs install --local

# Declare which paths LFS should intercept
git lfs track "meshes/**/*.cgns"
git lfs track "meshes/**/*.bdf"

# The tracking rules live in .gitattributes and must be committed
git add .gitattributes
git commit -m "chore(lfs): track CGNS and BDF mesh artifacts"

The generated .gitattributes should contain lines of the form meshes/**/*.cgns filter=lfs diff=lfs merge=lfs -text. The -text flag is important: it prevents Git from attempting line-ending normalization on binary content. Verify the rules took effect before adding any mesh:

git check-attr filter -- meshes/wing/rev_a/wing_v1.cgns
# expected: meshes/wing/rev_a/wing_v1.cgns: filter: lfs
If git check-attr returns filter: unspecified, the path does not match any pattern in .gitattributes. Fix the glob before committing — once a binary is committed without LFS, it stays in history and requires a history rewrite to remove.

Committing the Baseline Mesh

Place the baseline mesh in a revision-named directory so the path itself carries provenance. A practical layout keeps the mesh identity stable and the revision explicit:

meshes/
  wing/
    rev_a/
      wing_v1.cgns      # baseline, 412 MB
      mesh_manifest.json

The manifest is a small JSON sidecar that records the parameters used to generate the mesh. It is plain text, so it diffs cleanly and gives reviewers context that the binary cannot:

{
  "mesh_id": "wing",
  "revision": "rev_a",
  "generator": "pointwise-18.4",
  "cells": 18420000,
  "nodes": 19240000,
  "y_plus_target": 0.8,
  "boundary_layers": 32,
  "notes": "baseline RANS mesh, coarse TE resolution"
}

Commit the baseline and tag it so later comparisons have a stable reference:

git add meshes/wing/rev_a/
git commit -m "feat(mesh): add baseline wing mesh rev_a (18.4M cells)"
git tag -a mesh-wing-rev_a -m "Baseline wing mesh, 18.4M cells, y+ target 0.8"

Confirm the binary went to LFS rather than the object database:

git lfs ls-files
# 3f9a1c2e7b * meshes/wing/rev_a/wing_v1.cgns

The leading * means the LFS object is present locally. A - would mean the pointer is committed but the object has not been fetched — a common state after a fresh clone before git lfs pull.

Producing and Committing the Revised Mesh

After refining the trailing edge, the mesher emits wing_v2.cgns at 21.7M cells. Place it under a new revision directory rather than overwriting rev_a, so both revisions remain addressable by path:

meshes/wing/rev_b/
  wing_v2.cgns      # 487 MB
  mesh_manifest.json

Update the manifest to reflect the change, then commit:

{
  "mesh_id": "wing",
  "revision": "rev_b",
  "generator": "pointwise-18.4",
  "cells": 21700000,
  "nodes": 22680000,
  "y_plus_target": 0.8,
  "boundary_layers": 36,
  "notes": "refined TE, +4 boundary layers, +18% cells"
}
git add meshes/wing/rev_b/
git commit -m "feat(mesh): add wing rev_b, refined TE (+18% cells)"
git tag -a mesh-wing-rev_b -m "Revised wing mesh, 21.7M cells"

Comparing the Two Revisions

There are three complementary comparison layers, and each answers a different question.

1. Pointer diff — did the content change at all? Because LFS replaces the binary with a pointer, git diff between tags shows the pointer's OID and size fields changing. This is the cheapest possible check that a revision is genuinely new content and not a re-commit of the same bytes:

git diff mesh-wing-rev_a mesh-wing-rev_b -- meshes/wing/rev_a/wing_v1.cgns

A pointer file has the form version https://git-lfs.github.com/spec/v1, followed by oid sha256:<64 hex> and size <bytes>. If the OID is identical across two tags, the mesh bytes are identical regardless of filename or directory.

2. Manifest diff — what changed in the generation parameters? The JSON sidecar diffs as readable text, so reviewers see cell count, boundary-layer count, and notes without downloading hundreds of megabytes:

git diff mesh-wing-rev_a mesh-wing-rev_b -- '**/mesh_manifest.json'

3. LFS metadata — how large is each revision on disk and in the store? git lfs ls-files --long reports the OID, size, and path for every tracked object at the current checkout. To compare sizes across tags without checking out both, query the pointer directly:

git show mesh-wing-rev_a:meshes/wing/rev_a/wing_v1.cgns | head -3
git show mesh-wing-rev_b:meshes/wing/rev_b/wing_v2.cgns | head -3

The size line in each pointer gives the exact byte count of the stored blob, which is what actually consumes LFS quota. A useful sanity check is that the pointer size matches the manifest's implied scale — a 21.7M-cell mesh should not report a pointer size smaller than the 18.4M-cell baseline.

Working Tree Git History (pointers) LFS Object Store rev_a / wing_v1.cgns 412 MB binary 18.4M cells · 32 BL mesh_manifest.json revision: rev_a rev_b / wing_v2.cgns 487 MB binary 21.7M cells · 36 BL mesh_manifest.json revision: rev_b git add git add commit · tag mesh-wing-rev_a version .../spec/v1 oid sha256:3f9a1c2e7b… size 431,982,592 pointer only · ~130 B commit · tag mesh-wing-rev_b version .../spec/v1 oid sha256:a71d90f4c2… size 510,656,512 pointer only · ~130 B push push LFS objects 3f9a1c2e7b… wing_v1.cgns · 412 MB rev_a blob a71d90f4c2… wing_v2.cgns · 487 MB rev_b blob distinct OIDs ⇒ distinct content total store ≈ 899 MB repo .git ≈ few KB Comparison layers 1 · pointer diff → OID + size change (content identity) 2 · manifest diff → cells, BL, notes (generation parameters) 3 · git lfs ls-files --long → local presence + byte size 4 · git show <tag>:<path> → pointer without checkout

Verification and Failure Symptoms

After the two commits, run a short verification pass. Each check maps to a specific failure mode:

CheckCommandPass conditionFailure symptom
Tracking rule active git check-attr filter -- <path> returns filter: lfs unspecified → binary will enter .git/objects
Object in LFS git lfs ls-files * prefix on both meshes - prefix → object not fetched; run git lfs pull
Distinct revisions git show <tag>:<path> | grep oid different SHA-256 per tag identical OID → same bytes committed twice
Repo stays light du -sh .git kilobytes to low megabytes hundreds of MB → LFS filter bypassed
If du -sh .git already shows hundreds of megabytes, the mesh was committed before the .gitattributes rule existed. Adding the rule afterwards does not retroactively convert existing blobs. The correction requires git lfs migrate import --include="meshes/**/*.cgns" followed by a force-push, which rewrites every commit hash — coordinate with all collaborators before doing this.

One further habit pays off in review: keep the manifest and the mesh in the same commit. A commit that updates wing_v2.cgns but leaves the manifest at rev_a values produces a misleading diff, and reviewers comparing tags will see a cell-count mismatch that has no physical meaning. Treat the pair as an atomic unit, and the pointer diff plus manifest diff together give a complete, low-bandwidth record of what changed between mesh revisions.