The query --range feature worked perfectly — right up until someone squash-merged a branch.
That’s the kind of bug that doesn’t show up in your happy-path tests. You build a commit-range filter, you test it against normal merges, everything resolves beautifully. Then a squash merge comes along and rewrites history into a single commit with a brand new SHA, and suddenly your carefully anchored entries point at commits that no longer exist on main. The query returns nothing. Not an error, not a warning — just silence.
This one stung a little because the --range flag itself was straightforward work. The export and draft commands already had range filtering via a shared getEntriesByRange function in entry_filter.go. The query command was just… missing it. A parity gap. Wiring it up was the kind of satisfying plumbing where you reuse existing infrastructure, add a flag, update validation, and everything clicks. Two rounds of commits to get the integration clean, but nothing conceptually difficult.
The squash-merge problem, though — that was the real puzzle. Every entry in Timbers carries an anchor_commit, the SHA it was created against. Range filtering works by walking Git’s commit ancestry: “show me entries whose anchors fall between commit A and commit B.” Elegant when history is linear or merge-committed. Completely broken when the merge strategy replaces the original commits with a synthetic one. The feature branch SHAs that entries reference simply aren’t in main’s ancestry graph anymore. They’re ghosts.
The fix was a file-based fallback. When anchor matching comes back empty, the system now shells out to git diff --name-only A..B -- .timbers/ to discover which entry files actually changed in that range. It doesn’t care about ancestry — it just asks Git “what .timbers/ files are different between these two points?” This required a new DiffNameOnly method on the GitOps interface and a corresponding EntryPathsInRange in the storage layer. Not a huge amount of code, but it touches the boundary between “entries as data” and “entries as files in a repo,” which is one of those seams you want to get right.
The deeper pattern here: any system that references Git SHAs as stable identifiers is making an assumption about merge strategy. Squash merges, rebases,
filter-branch— they all break SHA-based references. If your tool is Git-native, it needs to survive Git being Git.
The fallback is honest about what it trades away. Anchor-based matching is precise — it knows which entry was created at which commit. File-based matching is coarser — it knows an entry file appeared in a diff range, but it’s inferring relevance from presence rather than proving it from lineage. For the query --range use case, that’s the right tradeoff. Empty results are worse than slightly broad results.
The rest of the session was housekeeping that doesn’t make for great narrative but matters: a Go version bump to 1.25.8 to clear two govulncheck findings (IPv6 parsing in net/url and a root-escape issue in os.ReadDir), plus a dependency update on the go-sdk to patch a null Unicode JSON parsing vulnerability in segmentio/encoding. The kind of things where the work is a one-line version bump and the value is not waking up to a CVE.
There was also a significant rework of the devblog template itself — moving from a Carmack .plan-style stream-of-consciousness format to a structured three-voice essay approach. The old style produced flat recaps. The new structure (Storyteller/Conceptualist/Practitioner voices, Hook→Work→Insight→Landing scaffolding) was tested by regenerating existing posts and comparing output quality. Meta, sure — but the template is the product for this part of the system.
The session left things in solid shape. Range queries now survive the merge strategies people actually use, the dependency chain is clean, and the tool that tells its own story got better at storytelling. Sometimes the most important fix is the one for the failure mode you didn’t design for.
This post was drafted with AI assistance from development log entries.