Branch warehouses¶
A warehouse per git branch. With branch warehouses on, checking out a git
branch also switches to that branch's data: havn transform, havn query,
the web UI and every other command work against a warehouse file of the
branch's own. It starts empty and costs nothing to create, because every
model the branch has not built is read from the base warehouse
(production, usually) through defer. Only what the
branch changes is ever materialized in it, and the base is only ever opened
read-only.
A pull request then shows the row-level data diff next to the code diff, in CI and in the web UI.
Turning it on¶
# project.yml
environments:
dev:
database:
path: dev.duckdb
prod:
database:
path: prod.duckdb
branches:
enabled: true
base: prod # environment whose warehouse is the base
# main: [main] # branches that keep the normal resolution
# path: .havn/branches/{branch}.duckdb
| Key | Default | Meaning |
|---|---|---|
enabled |
false |
Resolve the warehouse from the checked-out branch. |
base |
top-level database.path |
Environment whose warehouse unbuilt models are read from. Must be a defined environment. Leave it out in a project without environments. |
main |
main, or master when there is no main |
Branch name, or list of names, that never get a branch warehouse. |
path |
.havn/branches/{branch}.duckdb |
Where branch warehouses go. Must contain {branch}. |
havn init already ignores .havn/branches/ (and *.duckdb) in git.
Which warehouse is used¶
First match wins:
havn deploy, andhavn serve --auth: never a branch warehouse (see below).--name <branch>on ahavn branchcommand, or theHAVN_BRANCHenvironment variable: that branch's warehouse, whatever is checked out.--env <name>or.havn-env(fromhavn env use): that environment. An explicit choice wins over the checkout;havn env resetgoes back to following git.- No git repository, or a detached HEAD: the normal resolution.
- One of the
mainbranches: the normal resolution. - Any other branch:
.havn/branches/<branch>.duckdb, deferring to the base.
havn env show and havn branch status print the result and, when no branch
warehouse is used, why not.
Branch names are made filename-safe. A plain lowercase name (fix-orders)
is used as it is; anything else (feature/x, Fix, a Windows device name,
a very long name) is flattened and given 8 hex characters of the name's hash,
so feature/x becomes feature-x-217d2bf5.duckdb and never collides with a
branch called feature-x. A small <file>.branch.json beside each warehouse
records the real branch name.
Working on a branch¶
git checkout -b feature/exclude-refunds
# edit transform/silver/orders_enriched.sql
havn branch build # build what changed, read everything else from prod
havn branch diff # rows and schema of every branch model vs prod
havn branch status # what is built here, what is read from prod, what is stale
havn branch build builds state:modified+ judged against the base: every
model whose SQL or upstream differs from what the base last built, plus
everything downstream of those. Everything else is read from the base. Two
details keep the branch honest:
- A model whose branch copy matches the base again (you reverted the change)
is pruned: dropped from the branch warehouse so it reads through to the
base instead of shadowing it with old data.
--no-prunekeeps it. - A view that reads the base is built as a table on the branch. The base is attached only while a build runs, so a view pointing at it would fail on every later query.
--plan shows what would be built and pruned without changing anything;
--force rebuilds every planned model even if the branch copy is current;
--defer-snapshot reads the base through a consistent copy when a job holds
it open for writing.
Plain havn transform works too and also defers to the base, but it builds
what you ask for: with no selector, that is every model.
Status and staleness¶
havn branch status lists the models built on the branch, the ones read
from the base, the planned models that still need a build, the prunable ones
and the stale ones. A branch model is stale when the base rebuilt one of
the upstreams it reads from the base after the branch model was built, or
loaded new source data after it. havn branch build brings it up to date.
--json gives the same as data.
Diff¶
havn branch diff compares every model the branch built or changed with the
same model in the base, using the diff engine: schema changes
(added, removed and retyped columns), and added, removed and modified rows.
Modified rows need a key: -- havn:primary_key = id, models.<name>.primary_key
in project.yml, or the model's unique_key. Statuses:
| Status | Meaning |
|---|---|
changed |
Rows or schema differ from the base. |
added |
New on the branch; the base has no such model. |
removed |
The base built it; the branch no longer defines it. |
not_built |
Planned for the branch but not built yet. |
unchanged |
Rebuilt on the branch with identical data. |
--markdown prints the pull-request comment (--output file.md writes it);
--json the full report; --full every changed row instead of samples;
--exit-nonzero-on-change exits 2 when anything differs.
Cleaning up¶
havn branch list # warehouses on disk, and whether their branch still exists
havn branch clean # delete warehouses of branches merged into main or deleted
havn branch clean --dry-run
havn branch reset # delete this branch's warehouse; it reads everything from the base again
clean never removes the current branch's warehouse, and every delete
refuses a file that is the base, an environment's warehouse, or does not
match branches.path. A branch with no commits of its own counts as merged;
its warehouse only held main's code anyway.
The web UI¶
havn serve follows checkouts. It checks .git/HEAD at most once a second
and moves to the new branch's warehouse when nothing is using the current
one: no pipeline, deploy or change build running and no other request open.
Until then the top bar shows the branch in amber ("switching…") and the
switch happens on a later request. A checkout from the Git panel switches at
once.
The top bar names the checked-out branch: in the accent colour on a branch
warehouse, next to a defer: <base> badge whose dot says whether the base
can be read right now. When the warehouse moves, the tables and files reload
and the Output panel says where the data is now. The Home page calls models
the branch has not built "from base".
Ship → Data changes on this branch shows the same as havn branch status
and havn branch diff: what is built locally, what needs a build, a Build
branch button, and the per-model diff with schema changes and sample rows.
Copy as markdown copies the pull-request comment.
The API: GET /api/branch, GET /api/branch/status, POST /api/branch/build,
POST /api/branch/diff, GET /api/branch/list; GET /api/environment
carries the branch too.
Pull requests in CI¶
havn ci generate
writes two GitHub Actions workflows:
havn-base.ymlruns on every push to main. It builds the base warehouse (havn jobs run full-refresh --env prod, orhavn transformwhen there is no full-refresh job) and uploads it as thehavn-baseartifact. Change the build step to match how production is built, or to copy a recent backup of it.havn-ci.ymlruns on every pull request. It downloads the newest base artifact, runshavn branch build --base <artifact>, rendershavn branch diff --markdownand posts it withhavn ci comment, which updates its earlier comment on later pushes instead of adding one each time. The PR checkout is a detached merge commit, so the workflow names the branch withHAVN_BRANCH: ${{ github.head_ref }}.
The same two commands work anywhere, given a base file:
havn branch build --name feature/x --base /backups/prod.duckdb
havn branch diff --name feature/x --base /backups/prod.duckdb --markdown > diff.md
The in-app change build (Ship → Build) keeps diffing against main's warehouse even while the server is on a branch warehouse.
Deploys¶
Deploying is unchanged: havn deploy prod merges into production as before.
A branch warehouse is never a deploy target; deploy resolves environments as
if no branch were checked out.
Limits¶
- DuckDB backend only. A DuckLake project never resolves to a branch
warehouse (
havn branch statussays so). DuckLake's snapshots allow read-only time travel but not a writable fork of a catalog, and defer cannot attach a DuckLake base yet; branch warehouses on DuckLake would need both. - No read-through for ad-hoc queries. Queries in the web UI and
havn querysee what the branch warehouse holds. Models the branch has not built are read from the base during builds, not by ad-hoc queries, because masking policies live in each warehouse and a read-through would show base data without the base's policies. Query the base with--env prod. havn serve --authdoes not follow branches. Users, tokens and masking policies live in the warehouse, and a new branch warehouse has none: following a checkout would sign everyone out and offer first-run admin setup to the next visitor.- The base must be readable. DuckDB refuses a read-only attach while
another process writes the file;
havn branch build --defer-snapshotreads a consistent copy instead. - Scheduled jobs follow the checkout.
havn serve --schedulewithout--envruns jobs against whatever the checkout resolves to. Start a production scheduler with--env prod.