Recovering from a Broken Pointer After a Force Push
The rewrite succeeded, the branch looks clean, and dvc pull reports that half a terabyte of imagery cannot be found โ because the pointers naming it are no longer in any reachable commit. This page is a focused companion to pointer synchronization for raster datasets.
Concept & Context
Pointer-based versioning splits a dataset into two halves that are kept in step by reference: Git holds small text files naming content hashes, and an object store holds the bytes those hashes address. Rewriting history changes the first half only. The blobs are untouched โ but the record of which blob belonged to which version is what was rewritten, and without it a hash in the object store is an anonymous object nobody can attribute.
The saving grace is content addressing. A blob is identified by what it contains, so any surviving copy of the pointer โ in a reflog, in a colleagueโs un-fetched clone, in a CI cache, in a release manifest โ is sufficient to reattach it. Recovery is therefore a search for a pointer, not for the data.
The clock matters. Object stores are pruned on a schedule, and a prune run after a rewrite will conclude that the orphaned blobs are unreferenced and delete them. The first action in a recovery is not to investigate; it is to stop the prune.
Core Algorithmic Pipeline
- Suspend garbage collection on the object store and on any DVC cache prune job, before anything else.
- Enumerate the discarded commits from the reflog on any machine that had the old history, including CI runners.
- Extract every pointer those commits contained, and the hashes they name.
- Locate each hash in the remote, in a local cache, or in a colleagueโs cache.
- Reattach by committing a pointer that names the recovered hash, then verify the artifact against it.
Working Implementation
#!/usr/bin/env bash
# recover_pointers.sh โ reattach artifacts orphaned by a history rewrite.
set -euo pipefail
echo "==> 1. Stop anything that prunes. Do this first, investigate second."
# (Disable the scheduled `dvc gc` job and any object-store lifecycle rule now.)
echo "==> 2. Commits the rewrite discarded, from every reflog we can reach"
git reflog --date=iso --all \
| awk '{print $1}' | sort -u > /tmp/reflog-commits.txt
git fsck --lost-found --no-progress 2>/dev/null \
| awk '/dangling commit/ {print $3}' >> /tmp/reflog-commits.txt
sort -u -o /tmp/reflog-commits.txt /tmp/reflog-commits.txt
wc -l < /tmp/reflog-commits.txt
#!/usr/bin/env python3
"""Enumerate orphaned artifact hashes and report where each one can be found."""
from __future__ import annotations
import json
import subprocess
from pathlib import Path
import yaml
def pointers_in(commit: str) -> dict[str, dict]:
"""Every .dvc pointer in a commit, mapped path -> {md5, size}."""
try:
listing = subprocess.check_output(
["git", "ls-tree", "-r", "--name-only", commit],
text=True, stderr=subprocess.DEVNULL,
).split()
except subprocess.CalledProcessError:
return {}
found = {}
for path in (p for p in listing if p.endswith(".dvc")):
blob = subprocess.check_output(["git", "show", f"{commit}:{path}"], text=True)
for out in yaml.safe_load(blob).get("outs", []):
if "md5" in out:
found[path] = {"md5": out["md5"], "size": out.get("size")}
return found
def reachable_hashes() -> set[str]:
"""Hashes referenced by history that still exists."""
live = set()
commits = subprocess.check_output(["git", "rev-list", "--all"], text=True).split()
for commit in commits:
for info in pointers_in(commit).values():
live.add(info["md5"])
return live
def locate(md5: str, cache_dir: Path) -> str:
"""Where a given hash can be found, if anywhere."""
local = cache_dir / md5[:2] / md5[2:]
if local.exists():
return f"local cache {local}"
probe = subprocess.run(
["dvc", "get", ".", "--show-url", md5], capture_output=True, text=True
)
if probe.returncode == 0:
return "remote"
return "MISSING"
def main() -> int:
cache_dir = Path(".dvc/cache/files/md5")
live = reachable_hashes()
orphaned: dict[str, dict] = {}
for commit in Path("/tmp/reflog-commits.txt").read_text().split():
for path, info in pointers_in(commit).items():
if info["md5"] not in live:
orphaned.setdefault(info["md5"], {"paths": set(), **info})
orphaned[info["md5"]]["paths"].add(path)
report = []
for md5, info in sorted(orphaned.items()):
where = locate(md5, cache_dir)
report.append({"md5": md5, "paths": sorted(info["paths"]),
"size": info["size"], "found_in": where})
marker = "!" if where == "MISSING" else " "
print(f"{marker} {md5[:12]} {where:<28} {sorted(info['paths'])[0]}")
Path("orphaned-artifacts.json").write_text(json.dumps(report, indent=2))
missing = sum(1 for r in report if r["found_in"] == "MISSING")
print(f"\n{len(report)} orphaned artifact(s); {missing} not found anywhere")
return 1 if missing else 0
if __name__ == "__main__":
raise SystemExit(main())
Reattaching restores the pointer rather than the bytes, which is the distinction that preserves reproducibility:
# Restore the pointer exactly as the discarded commit held it
git show "${LOST_COMMIT}:data/raw/orthomosaic_2025.tif.dvc" \
> data/raw/orthomosaic_2025.tif.dvc
# Pull the artifact the restored pointer names, and verify it
dvc pull data/raw/orthomosaic_2025.tif.dvc
dvc status data/raw/orthomosaic_2025.tif.dvc # expected: up to date
git add data/raw/orthomosaic_2025.tif.dvc
git commit -m "Restore pointer for orthomosaic_2025 orphaned by the 2026-08-04 rewrite"
Validation & Output Verification
# Every pointer in every reachable commit must resolve on the remote
git rev-list --all | while read -r sha; do
git ls-tree -r --name-only "$sha" | grep '\.dvc$' | while read -r ptr; do
md5=$(git show "$sha:$ptr" | awk '/md5:/ {print $2}')
dvc get . --show-url "$md5" >/dev/null 2>&1 \
|| echo "UNRESOLVABLE $sha $ptr $md5"
done
done | sort -u
# Release tags are the ones that must never break โ check them explicitly
git tag -l 'v*' | while read -r tag; do
git checkout -q "$tag" 2>/dev/null || continue
dvc status --cloud >/dev/null 2>&1 \
&& echo "OK $tag" || echo "BROKEN $tag"
done
git checkout -q main
Run the tag check on a schedule rather than only after an incident. It is a few minutes of object-store queries and it turns โa release is unreproducibleโ from something a user discovers into something a job reports.
Failure Modes
-
The blobs were pruned during the investigation โ symptom: hashes that existed an hour ago are gone. Root cause: a scheduled prune ran against rewritten history. Fix: suspend pruning as the first action; restore from backup if it already ran.
-
The recovered artifact has a different hash โ symptom:
dvc statusreports a modification after recovery. Root cause: the artifact was re-added from the working tree instead of the pointer being restored. Fix: restore the pointer from the discarded commit and pull the bytes it names. -
Reflog is empty on every machine โ symptom: nothing knows the old commits. Root cause: the rewrite happened long ago, or reflog expiry ran. Fix: recover hashes from release manifests, which is the reason to keep an out-of-band inventory of hashes per tag.
-
The rewrite is repeated a month later โ symptom: the same incident. Root cause: force pushes are still permitted on the default branch. Fix: protect the branch, and add the pointer-resolution check to the pipeline so a rewrite that orphans anything fails before it is merged.
Related
- Pointer Synchronization for Raster Datasets โ the parent guide and the manifest model this repairs
- Understanding Pointer Files in GeoGit vs DVC โ what a pointer contains, and what recovery therefore needs
- Migrating a Shapefile Repo from Git LFS to DVC โ planning a rewrite that does not produce this incident
- Release Tagging Strategies for Spatial Basemaps โ the manifests that make recovery possible when reflogs are gone