Copies, not dependencies
Some code kept getting copied from one of my repos to the next: a bootstrapper, a connector, the core of a small chat bot. Copies drift. When I compared the copies of that bot, one of its files differed by 103 lines, and a safety guard was in some copies and missing from others. Nothing tied a copy to where it came from.
0-libraries is where those pieces live now, as fourteen packages. A package is a directory the library declares in one file, packages.toml, and its version is the last commit that touched that directory. A project that uses them, a consumer, carries a single folder, 0-lib/, holding the packages it asked for. Each package is either vendored, a copy inside 0-lib/, or linked, a path into the library's own checkout. One rule picks between them: link where the library is on disk, vendor where it is not. A vendored consumer runs on a machine that has never seen the library.
The wrapper knows which copy it holds
The one package every consumer carries is the wrapper, its access point to the rest. When the wrapper installs a package, it records a hash of the copy and the commit it came from, in a file committed with the consumer. That record answers the question loose copies never could: is this copy only behind the library, or did someone change it by hand? A stale copy can be replaced. An edited one is refused unless I pass --force, because replacing it would throw someone's work away. Each consumer chooses whether a stale copy is only reported, offered once, or synced on its own; an edited copy is never synced on its own.
The record did not always travel. It used to live in a file each machine kept for itself, so a fresh clone had none, and the wrapper could not tell stale from edited. Its advice in that case was --force, the one thing its guard exists to prevent. Since decision 0008 the record is committed with the consumer, and a clone knows what it holds.
A swap that survives a crash
Replacing a folder is not one atomic step, so the wrapper never deletes first. It stages the new copy inside the container, renames the old copy aside, moves the new one in, and deletes the aside only when the swap is complete. If the move fails, the aside goes back. A crash leaves the aside on disk, and the container's own README says how to recover from each place the swap can stop. A test forces the move to fail and checks that the old copy is still intact.
The wrapper updates itself the same way, so a sync can replace the very code that is running it. Setting the old copy aside is what makes that survivable.
The library never writes into a consumer
The first version pushed: hooks in the library wrote updated files into consumers on their own. Writing into consumer repositories unattended needed a defence against a bad push, signed commits, and that defence was never switched on, because no commit was signed. Push was retired on 2026-08-20, nine days after the first commit. Now a consumer pulls, and only when it asks.
A pull does not sync either. "Stale" has no direction: it says a copy differs from the library's checkout, not that the checkout is newer. A hook that synced on every pull could move a current consumer backwards to a checkout that lags. So the library keeps a report instead, a script that checks every consumer on a machine and fails unless all their copies are current, and the decision to sync stays with the consumer.
Even the container's name was measured. Across 16 of my repos, the name config was already in use in five, four of them the projects' own folders, so a flat layout would sooner or later have read a project's own settings as the library's. Everything the library owns sits inside one folder with a fixed name.
Decisions, written down
Every choice above has a numbered decision record: 28 so far, each with its context, and most with the options it turned down and why. A record is not rewritten: a later record supersedes it, or a dated amendment is added to it. A few worth reading:
- 0001, a delivery mechanism per consumer. Where the 103 drifted lines were found, and how link, vendor and bundle came to be decided per consumer.
- 0004, pull replaces push. The library deleted its own propagation tool, with its tests.
- 0005, one container inside a consumer. The folder's name, picked from a table of rejected names and a count of collisions.
- 0008, the baseline travels with the consumer. How the guard's own advice defeated it.
- 0023, the https connect has no knob to map. A connect timeout that bounds an ssh remote has no https counterpart in git. Measured, an unreachable ssh remote gave up in 10.04 s and an https one took 136 s, and the record keeps that gap on purpose, with the numbers.
- 0025, a pull does not sync. The automation I chose not to build, and why.
Tests stay home
The library has 766 test functions: 237 for the wrapper, the setup and the reports, 469 for the Python packages and 60 for the Python parts of the shell packages, beside shell suites of their own. None of them travel. A vendored copy is checked by its hash, so a suite shipped inside it would prove nothing the hash does not, and would be dead weight in every consumer.
On the shelf
- Pinned tools: shellcheck, age and zellij, upstream binaries verified before they can run; uvpython and pywebview, a pinned Python toolchain and a desktop-window runtime on top of it; and fetchkit, a small download library.
- Pane host: pywebview-ui, a desktop window of panes, with themes.
- AI session tools: ai-transcripts, which turns AI coding sessions into readable transcripts and numbers, and ai, one prompt in and one answer out.
- The rest: the wrapper itself; git, sweeps that pull or push every repository a machine lists, log a failure and carry on, plus the setup of a machine's git; and botcore, the part of a small chat bot that is the same on every device.
- Two more packages handle access to my machines. Like every access detail, they stay off this site.