mv_git puts MultiValue accounts under real version control. It is git for hash-file records — your records, your dictionaries and your VOC, committed, branched, diffed and pushed — and it runs inside your session on Rocket UniData and Rocket UniVerse. You type GIT ADD -A and GIT COMMIT at TCL, in the account, against the system that is actually running.
What makes it different from the usual approach is that there is no export step and no working copy to keep in sync. The records in your files are the working tree — commit reads them out into git objects, checkout writes them back, and there is never a second copy of the truth sitting in a directory somewhere disagreeing with the account. It is open source under GPL-2.0; releases, source and issues are at github.com/mvx-lang/mv_git, and there is a demo account to clone and try it against at github.com/mvx-lang/demo.
Nothing about the approach is tied to one platform. The engine that turns records into git objects sits on a deliberately narrow set of record primitives — open, read, write, select, delete — so bringing it to another MultiValue system means implementing that handful of calls, not porting the tool. UniData and UniVerse are done; jBASE is in development — Rocket’s as well, and the one other MV BASIC that compiles, which makes it the comparison I most wanted. It commits from a live account, clones a repository into a new one, and answers from inside a session; it is benchmarked further down. It did not quite make the 2.0 release — rather than hold the release for it, I would sooner ship what is finished — and it should land in 2.1. The remaining work is in the open if you want to see how close that is. The intention is to keep going through as many MV systems as I can. The honest constraint is access: each platform has to be licensed and running somewhere I can build and test against, and that, rather than the code, is what sets the pace.
Thanks
To Rocket Software, for making trial versions of UniData, UniVerse and jBASE available — and, since D3 is theirs too, for most of the ground this project still has to cover. Everything above that concerns those platforms — the CallC engine, the background process on UniVerse, the dictionary translation, the numbers in this article — was built and measured against real systems because of it. Without that I could have speculated about how MultiValue platforms behave; I could not have found out, and I would not have got anywhere near this far.
Which leads to a request, and I would rather ask plainly than hint. D3 is the one I cannot get to. There is no trial version, so unlike the other three I have no way to develop against it — and it is the platform where the port would be most interesting, because D3 spells things differently enough to test whether this is really portable or merely portable between cousins. The key item is ID rather than @ID, which the code already anticipates and has never once been able to run. If you are at Rocket, or a partner, or anywhere else that could put a D3 licence in front of me, I would like to hear from you — I will do the work.
And the same goes wider. If you build or maintain a MultiValue system and you would like mv_git on it, please get in touch. The engine sits on a deliberately narrow set of record primitives — open, read, write, select, delete — so a port is a few hundred lines against your own API rather than a rewrite of the tool: jBASE went from nothing to committing and cloning real accounts inside a day, once I could get at it. The constraint has never been the code. It is access to a running system, and that is a problem somebody reading this can probably solve.
Where I come from
I came to MultiValue straight out of school, as a systems administrator at Taranaki Energy — now Powerco — looking after a General Automation 600 running Gentrack 1, the billing system. We later moved that onto a Motorola stack running Gentrack 2 on UniData, which is where UniData and I were first introduced.
From there I joined General Automation itself — later acquired by Sanderson Computers — as a Gentrack 2 developer, still keeping a hand in Gentrack 1. Then Canberra, for the electricity conversion at ACT Electricity and Water (ACTEW, now ActewAGL), and then Melbourne as a senior Gentrack developer. Later came warehousing systems — Masterpack and others of that family — and I finished up doing external integrations against UniData and UniVerse with Redback.
That was my first professional life, twenty-odd years ago now. I spent the years since somewhere else entirely, building and migrating large Drupal systems, which is what Heydon Consulting does today.
What brought me back was clang. When Apple put LLVM at the centre of its development platforms, I looked at it and thought: that is how you would build Pick BASIC properly — a real compiler, straight to machine-executable programs. I have always loved the language, and I had never seen it compiled the way everything else gets compiled. I have been playing with that idea for the last couple of years.
How this came about
Once the compiler worked, I started building MVX around it — my own Pick/MV system. BASIC source to object code through LLVM: no transpilation to C, which is how this has usually been attempted, and which costs you the debugger and the build times.
Because it emits DWARF debug information against the BASIC line numbers, you also get a real debugger. Not a BASIC-level trace facility bolted on top — gdb and lldb step through the BASIC you wrote, breakpoints on your line numbers, because as far as the debugger is concerned it is just another compiled language.
The same thing that buys the debugger buys everything else, and that turned out to matter more than I expected. A compiled BASIC program is an ordinary executable carrying ordinary debug information, so a profiler does not need to know what Pick is, or that MVX exists. Point one at a running program and it answers in your own source:
2377 mvx_main (in sieve-banked) + 628 sieve-banked.b:53
37 mvx_main (in sieve-banked) + 584 sieve-banked.b:53
9 mvx_main (in sieve-banked) + 404 sieve-banked.b:34That is sample, which ships with macOS and was told nothing except a process id. perf on Linux does the same, and so do Instruments, valgrind, and anything else that reads DWARF — there is no MVX-specific profiler and there does not need to be. Line 53 of that program is BANK(B)<1,P> = 0, and that is a worked example rather than a demonstration: the dynamic-array problem further down was found exactly this way, by a stock profiler naming a line of Pick BASIC, not by reading the runtime and hoping to spot something.
The same door opens onto security work, and this is the part I think matters most in the long run. The compiler emits real LLVM IR — intermediate representation, the typed, assembly-like language a compiler works in between your source and the machine code, and the form every LLVM tool actually reads. mvx-basic --emit-llvm writes it out, with your BASIC variable names still attached to it — so the analysis machinery that security tools are built from reads a Pick program with nothing adapted for the occasion:
$ opt -passes='print<memoryssa>' sieve-banked.b.ll
MemorySSA for function: mvx_main
define void @mvx_main(ptr %ctx) local_unnamed_addr !dbg !4 {
entry:
%VM = alloca %mv_value, align 8, !dbg !8That is stock LLVM reading a program written in Pick BASIC. Data-flow and taint analysis, symbolic execution, fuzzing a compiled program's inputs, running the whole thing under Valgrind — none of these need to know the source language. The runtime underneath gets the same treatment from the C side, where the sanitizers and static analysers already live, and a memory bug caught once there is caught for every BASIC program that links it.
I should be careful about what that is and is not. It is not a security scanner for MV BASIC: nobody has written one, and compiling properly does not conjure it. What it is is the substrate such a thing would need, finally present — which for a language still running billing, banking and patient records is later than it should be, and better than not at all.
This is the half of compiling properly that never shows up in a benchmark. You inherit forty years of other people's tooling for nothing, because to a debugger, a profiler, a core-dump reader or a coverage tool, a compiled BASIC program is just a program that happens to have unfamiliar function names.
And it is fast, which was the point of doing it properly. I wanted a benchmark nobody could accuse me of choosing, so I used the Primes project — Dave Plummer's prime sieve, the one from his Dave's Garage channel, where implementations in every language get measured against each other on the same terms. I wrote a Pick BASIC implementation of it, which is bench/sieve.b in the MVX repository.
On a 1M sieve over five seconds, MVX managed 13,660 passes against 14,221 for the equivalent C byte-array sieve — about 96% of C, from Pick BASIC. Both are the best of eight interleaved runs, which matters more than it sounds: run-to-run variance on the same machine is several per cent, so a single pair of numbers can flatter either side by more than the gap being measured. That number is the whole argument for the approach: a string-typed MV language, compiled properly, has no business being an order of magnitude off C, and it is not.
The interesting part is what the same program does elsewhere. Same source, same five seconds, same rules, and a C baseline measured on each machine so nothing turns on whose hardware is faster. On Rocket UniData 8.3, where C manages 13,967 passes, the BASIC manages 66. That is not a typo and it is not a criticism of anyone's engineering — an interpreter is doing what an interpreter does. It is simply the gap, and it is why compiling MV BASIC properly seemed worth the trouble.
Then there is jBASE, which is the comparison I most wanted, because jBASE compiles too. Its BASIC goes through C to a native binary — the route MVX deliberately does not take — which makes it the fairest test of whether any of this is really about compilation. On a machine whose C baseline is 13,708, jBASE 6.2.1 manages 145 passes, and 216 with its optimiser turned up (jcompile -O4). Three times the interpreter, and still around 1.5% of C. The source needed exactly one change to get there, which is its own small lesson in portability: jBASE reserves COUNT as a keyword, so the variable holding the prime count had to be called something else.
That is the number that settled the argument for me, because it says the difference is not compiled-versus-interpreted at all. It is the value representation. If every variable stays a boxed string that has to be inspected and converted before you can add to it, you can compile it as carefully as you like and you will still pay for the box on every operation — the C compiler underneath cannot see through it. The win comes from proving in the frontend that a variable is numeric and emitting an i64 or a double, and that is a decision you make in your own compiler. It is not one you can hand downstream.
UniVerse could not run it at all, which turned out to be more interesting than a slow number. Its BASIC caps a compile-time array at 64,000 elements — Compile time ARRAY dimension may not exceed 64000 elements — and a two-dimensional DIM counts against the same total. A runtime dimension is not allowed either: ARRAY dimension must be constant. In the sieve itself even 64,000 failed, with Not enough aggregate name space to allocate compile time ARRAY; 48,000 compiles. The benchmark wants 500,000 flags in one array, so the standard shape of it cannot be expressed on UniVerse — not slowly, but at all. You can bank the flags across smaller arrays instead — and since that is the only shape UniVerse will take, it seemed worth doing properly rather than leaving it as a defeat.
So there is a second version, bench/sieve-banked.b. It holds the same 500,000 flags sixteen to a DIM element, each element a dynamic array — a shape every MV BASIC can express. Same algorithm, same five seconds, same validated count of 78,498. UniVerse runs the full 1,000,000 sieve: 35 passes. Not a fast number, but a real one, from the machine that could not dimension the array at all a paragraph ago.
Running that banked version on everything is where it stopped being a workaround and became the most interesting measurement here. UniData also manages 35, which is what you would expect of two systems with a common ancestor. jBASE manages 7. And MVX managed 31 — the fastest system in the flat test came third of four, behind both interpreters.
The banking cost UniData 1.9× and MVX 440×, and the asymmetry is what gave it away. UniData barely notices, because its interpreter overhead already dwarfs everything else. MVX had nothing to hide behind: the numeric fast path that wins the flat test does nothing once every access has to scan a string for value marks. A dynamic array is not a corner of MV BASIC — it is how the language reaches its data — so this was not a curiosity, it was most of what real programs do, running slowly.
So I went and looked, and it was worse than a missing optimisation. Every dynamic-array operation allocated, and replacing one element allocated twice: a temporary buffer to rebuild the whole value in, then the destination string, with the entire thing copied through both to change a single byte. Profiling a replace loop put 45% of the time in malloc and free. Reading an element allocated too — one heap allocation and one free to hand back a single character.
Three changes fixed it. A value now reuses its own buffer when it owns it outright, with copy-on-write keeping shared strings honest. A subscripted assignment patches the string where it stands instead of rebuilding it — a byte copy when the lengths match, a shift of the tail when they do not. And long ranges get an offset index built on demand, so walking to element N stops being linear and the cost stops growing with the size of the array. Writing an element — line 53 of the sieve, the line the profiler had been pointing at all along — went from 170ns to 64ns, and reading one from 86ns to 31ns. On a longer array, where the index rather than the allocator is what changes, a write went from 1083ns to 60ns and a read from 828ns to 23ns: the cost stopped tracking the length of the array at all. The allocator's share of that replace loop fell from 45% to 1.3%.
MVX now manages 76 on the banked sieve — ahead of both interpreters rather than behind them — and the flat sieve is unchanged, because none of this touched the numeric path. That is the outcome I would keep if I could keep only one thing from writing this article. The benchmark I built to show the compiler off found a real weakness in the runtime, in the half of the language the compiler had done least for, and it was only visible because a limitation on somebody else's system forced the program into a shape I would never have written by choice.
That is enough about speed. It was what I set out to prove, and it is why the value representation looks the way it does — but the part that turned out to matter most was not speed at all. It was the pluggable storage backend. An MV file is bound to a driver, and the driver can be LMDB, PostgreSQL or MongoDB — mixed per file inside one account if you want. With that came a mapping system that builds real SQL tables from your Pick dictionaries: the data is read and written as SQL, and still used by the MV system, completely transparently. Not the old interoperability layers that sat beside your data trying to interpret it into an SQL-shaped view — actual tables, from the dictionary definitions you already have, working both ways at once.
Which invites the obvious question: why write another database at all? There are excellent ones already, and a MultiValue system that ships its own storage engine is signing up to maintain one for ever.
I never wanted to build one. I wanted to build a compiler — and everything else here exists because a compiler needs something to compile. The verbs, the shell, the file handling, the query language: each of them started as a test case, a program real enough to prove the compiler could carry it. The storage arrived the same way, which is precisely why it ended up as a driver interface rather than a database.
So MVX does not have one. It has drivers. The MultiValue half — the language, the dictionaries, the verbs, the record model — is what this is actually for; durability, replication, backup and monitoring are solved problems that Postgres and its neighbours have spent decades solving properly. Binding a file to one of them is not a compromise, it is the point: you inherit their operational tooling, and the people who keep your systems running get a database they already know how to run.
It also turns the interoperability problem inside out. The usual answer to how do we get at the MV data from everything else is an export, a nightly ETL, or a layer that reads the records and presents them as rows. Here the rows are the records. Anything that speaks SQL — a reporting tool, a dashboard, another application — reads the live data directly, through the dictionary definitions you already wrote, while the MV programs go on reading and writing the same data as records. No copy, no window, no second version of the truth to reconcile.
That is also what lets it come apart. MVX ships as containers and is built to run as several processes rather than one: a session is a process, and a file is bound to a driver that may be a local database, a networked LMDB daemon, or an SQL or Mongo server on another machine. So an account can keep its VOC and its source local while its data files live on a shared daemon, and two applications can read and write the same records without sharing a host. The classic answer to needing more MultiValue is a bigger machine, because the system and its data are one installation; this one spreads across as many as you care to give it, and the application does not know the difference — the driver does.
Why git cannot just do this
And then, inevitably, I needed to keep the BASIC under source control — and this is where MV stops resembling anything git was built for.
The BASIC itself is the easy half. Programs usually live in directory files, which are real directories of real OS files, so plain git can see them and you could be forgiven for thinking the problem is solved. It is not — because on an MV system a great deal of what is genuinely source does not live in a directory file at all. It lives in a hash file.
Object code is not the issue; you would no more commit that than you would commit a .o in any other language. The issue is everything else. If you are running an application development environment — SB+, now SB/XA and Rocket's, or CueBic from Apscore International — your screen and process definitions are records in hash files, and so is the environment's own record of your dictionaries. Those are not artifacts. They are the application — hand-authored, reviewed, changed deliberately, and exactly the sort of thing you want a history of. Alongside them sit the VOC pointers that make a program callable by name, the dictionaries that describe your data, and the control records the application reads at startup.
Both of those tools have more Australian history than most people realise. SystemBuilder started in a garden shed in South Africa in 1982, but it and RedBack were developed in Sydney until 2005, when the work was folded into the U2 team in Denver; CueBic came out of Apscore in St Leonards, a few suburbs away, and is still going. I spent a good part of my career on the output of both.
Version the BASIC and nothing else, and what you have in git is a repository you cannot restore a working account from. Worse, you have a history that quietly omits the changes most likely to break things: nobody edits a screen definition and expects it to be untracked.
So source control for MV means being able to manage the contents of hash files — and it means both halves of every file, because the dictionary is a hash file in its own right. A dictionary is source in the plainest sense: it is the definition of what your data means, and changing one changes the behaviour of every report and every program that reads through it. A version history that covers the data file and not its dictionary is not a version history of anything you could rebuild.
Read a record out, hash it, write it back, tell whether it changed — for the data level and the dictionary level alike. I used libgit2 to do exactly that inside MVX.
That is where the eureka moment came. Nothing I had written was really about MVX. It was about records, and libgit2, and a small number of calls between them — and UniData has the CallC interface, which meant the same engine could be called from a UniData session. So I did that, and it worked.
UniVerse was harder, and more interesting. There is no usable CallC equivalent there, and no InterCall to reach for either, so a session simply cannot call libgit2 in-process. I rewrote that side completely: the git work runs in a background process, and the BASIC session talks to it over a FIFO with a length-framed protocol. The session keeps the records — it is the only thing that can read them — and the background process does the git objects. Same engine, different way of reaching it.
What it actually does
The idea it turns on is small, and everything else follows from it:
The working tree is the live records.
There is no export step. No "check out the source to a directory, edit it, check it back in". No staging copy that drifts from what the account is really running. When you commit, mv_git reads the records out of your files and writes them into git objects. When you check out, it writes them back. The thing under version control is the account itself.
In practice, at TCL:
:GIT INIT
initialised empty git repository
:GIT ADD -A
180 record(s) staged across 8 file(s)
:GIT COMMIT -m "the demo account, under version control"
[26178b3] the demo account, under version control
:GIT STATUS
nothing to commit, working tree cleanThat is real output from a UniData session, against the demo account at github.com/mvx-lang/demo — a small trading company: clients, an inventory, orders with multivalued line items, staff. 180 records across 8 files, in one commit. That repository is the account: you clone it and what you get is a working one, on UniData, UniVerse or MVX.
The verbs do what their names suggest: STATUS, DIFF, LOG, SHOW, BRANCH, CHECKOUT, MERGE, CHERRY-PICK, TAG, RESTORE, CLONE, FETCH, PULL, PUSH. If git is new to you, the short version is that each COMMIT is a permanent, named snapshot you can return to, compare against, or branch from — and none of them is ever overwritten by the next one.
A record is a file, and it reads like one
Each record becomes one git object, at <FILE>/<id>, with attribute marks written as newlines. So a CLIENTS record lands in git looking like this:
Alice Nguyen
Harbour Freight Co
14 Wharf Rd
Sydney
NSW
2000
02 9550 1100ý0412 300 991
officeýmobile
18699One attribute per line. Value marks stay inline — that ý separates the client's two phone numbers, and the line below it separates their types, because the two are associated. The last attribute is a date: MV keeps it as a day number, and the dictionary is what turns it back into one — a large part of why dictionaries have to travel too. Which means a diff is readable by a human:
:GIT STATUS
M CLIENTS/C1001
:GIT DIFF CLIENTS
diff CLIENTS/C1001
-Sydney
+NewcastleAttribute 4 changed. Not "the file changed" — attribute 4, of record C1001, of file CLIENTS.
Dictionaries travel too, at <FILE>.DICT/<id>, because a D-item is a record like any other and a dictionary change is exactly the kind of thing you want a history of.
What else travels
A commit is not much use if restoring it gives you the records but not a working account. So the commit also carries:
- The file's geometry — its type, and for a hash file its modulo and whether it is static or dynamic. A clone creates the file the size it was, rather than at whatever the platform defaults to.
- VOC pointers and Q-pointers, so an account cloned somewhere else can actually find its files.
- The account descriptor — its name, and on UniVerse its VOC flavour, which is fixed when an account is created and is recorded nowhere you can read back. If it does not travel with the repository, a clone comes back looking right and behaving differently.
What deliberately does not travel: compiled objects, &SAVEDLISTS&, _HOLD_, and on UniVerse the several hundred stock VOC records every account is born with. Committing the furniture makes a repository where most of the content is things nobody wrote, and buries your actual change in the diff.
Branches, and a program that changes under you
Because the records are the working tree, a branch is not a copy of the account — it is the account, at a different commit. Switch, and the records change under you:
:GIT BRANCH by-value
created branch by-value
:GIT CHECKOUT by-value
switched to by-value
110 record(s) restored, 0 file(s) createdThat counts records, not files, and it is the whole idea in one line. Here is what it means for source. A BASIC program is a record too, so it moves with the branch:
:CT BP STOCK.RPT
* STOCK.RPT — what is on hand, by category, with what it is worth.
PRINT "Stock on hand and its value"
EXECUTE "LIST INVENTORY CATEGORY QTY_ON_HAND STOCK_VALUE BY CATEGORY"
:GIT CHECKOUT main
switched to main
110 record(s) restored, 0 file(s) created
:CT BP STOCK.RPT
* STOCK.RPT — what is on hand, by category.
PRINT "Stock on hand"
EXECUTE "LIST INVENTORY CATEGORY QTY_ON_HAND BY CATEGORY"
:GIT CHECKOUT by-value
switched to by-value
110 record(s) restored, 0 file(s) createdSame verb, same file, different source — because the program in BP is a record under version control, and checking out a branch rewrote it. No export, no copy of the account per branch, and nothing to remember to put back.
And then it is just git
Once the records are git objects, everything built on top of git works, and none of it had to be taught about MultiValue. Push a branch and open a pull request, and the review is a review of records:
Nobody had to build any of that. The diff is a diff, so review comments attach to a line of a record; the branch is a branch, so it can be protected, and a change to a dictionary can be made to require an approval before it reaches main. Continuous integration runs on the pull request like it would anywhere else — and what it can check is the account: compile the BASIC, run the test suite, refuse the merge if a dictionary lost a conversion or a file lost its geometry.
That is the part I did not expect to matter as much as it does. MultiValue shops have had change control for decades, and it is usually a written procedure, a spreadsheet, and someone senior who remembers what the screen used to look like. Here the safety net is the same one every other kind of development already has: nothing reaches production without a second pair of eyes, every change has an author and a reason attached, and anything that turns out to be wrong can be reverted to the character.
And the account browses like any other repository:
Which means the tooling reaches further than review. A dependency scanner, a secret scanner, a policy check, an AI reading the diff before you do — none of them know what a MultiValue account is, and none of them need to.
One repository, any platform
There are two ways to keep a repository, and the difference matters.
By default it is a native repository, and nothing is converted at all. What goes into git is what your platform actually has: its own file controls, its own account descriptor, its own pointers. That is the simplest thing that can work, it is exact, and it is the right answer if the repository is only ever going to serve the system it came from. The catch is that those pointers mean nothing anywhere else — a UniData control means nothing to UniVerse, and neither means anything to MVX. A native repository is a UniData repository, not an MV one.
Turn on the open account format and you get the other behaviour. It is opt-in, per account, one setting, and it works like core.autocrlf: mv_git converts on the way into git and converts back on the way out. Your account on disk stays entirely native — you are not working in some neutral interchange format, you are working in exactly what you were working in before. It is only what gets stored that changes.
mvx-git config mvx.openaccount true
# or, from the other end:
mvx-git clone --open-account <url>What changes in the conversion is only the platform-specific parts:
| in git (open form) | on disk (native) |
|---|---|
file control = DIR or hash | FILE⊽lmdb, FILE⊽dir, a UniData hash file… |
account descriptor .mv-account | .mvx, or the platform's own |
| records and dictionaries as legible blobs | records in the backend store |
The result is a single repository you can check out onto UniData, UniVerse or MVX without asking it which one it came from. There is no flag at checkout and no conversion step you have to remember: git reads the portable description, and each platform builds the account its own way from it. Files are created with the geometry recorded for them rather than whatever the local default happens to be. The account descriptor is rebuilt in the native form. VOC and Q-pointers are restored so the account can find its own files. And the dictionary definitions travel with the data they describe, so what you get out is a file that reports and programs can actually read through — not a pile of records with no meaning attached.
Where a platform has no place to keep something, it makes one up honestly. UniData has no on-disk control record for a file's class or its index list, so udt-git generates those virtually rather than littering the account with files UniData never asked for. Index structures are never committed at all — they are derived, so a clone rebuilds them rather than carrying someone else's B-trees around.
That portability is the whole point. One history, three platforms, and no branch per flavour. It is also the seam MVX rides in on — but you do not need MVX to use any of this.
Getting it
Grab the latest release for your platform, unpack, install — 2.0.0 is current:
tar xzf mv_git-2.0.0-udt-linux-x86_64-le.tar.gz
cd mv_git
./install.shThat turns the unpacked directory into an account of its own, builds the CallC library the verb calls into, and catalogs GIT — plus a udt-git command for driving it from the shell. UniVerse is the same shape with the -uv- tarball. On Rocky/RHEL 8 you also need libgit2 from EPEL — dnf install libgit2_1.7; the base-OS one is 0.26 and far too old.
Then each account that wants GIT points at that install and runs its setup — the same dance every other package here uses, so if you have installed one before you already know it:
ED VOC GIT.BP
001: DIR
002: /home/rocky/mv_git/BP
003: /home/rocky/mv_git/D_BP
:RUN GIT.BP GIT.SETUP
mv_git BP: /home/rocky/mv_git/BP
cataloging:
GIT.FILECLASS
GIT.BRANCH
…
55 program(s) cataloged.
Done. This account can now: GIT INITA pointer and one command. It catalogs the verb and its handlers local to the account, which is what lets one account use the install you are testing while the rest of the system carries on with the one it had — several installs can sit side by side, each registered under its own name.
Or skip the pointer entirely. If you drive mv_git from the shell, the CLI does this part for you: udt-git -a <account> init sets the verb up in the account as it initialises the repository, and uv-git does the same when it clones one — so an account you cloned into can run GIT the moment the clone finishes. The pointer and GIT.SETUP are for the other direction: an account that already exists, wiring itself to the install you choose.
After that it is the account's own:
:GIT INIT
:GIT ADD -A
:GIT COMMIT -m "first"Remotes work as you would expect. Pushing to a private GitHub repository from inside a UniData session works today over https with a personal access token.
What this means if you sell an application
Everything above is about one account. The reason I care about it is what happens when there are two hundred.
If you sell MultiValue software you know the shape of this without my describing it. You have a product, and every site running it has changed something: a report, a validation rule, a screen, an interface nobody told you about. When you ship a new version somebody has to work out which of those changes to preserve, and the record of what they are is a listing in a folder, a comment in a program header, or the memory of a contractor who left in 2014. Upgrades become projects. Sites fall behind. Some stop upgrading altogether, which is how a customer ends up eleven years back and unsupportable.
I have been at both ends of that. I looked after a Gentrack site as its systems administrator and then went and wrote Gentrack for a living, and the awkwardness runs both ways: as the vendor you cannot see what the customer changed, and as the customer you cannot tell what the upgrade is about to do to you.
With the account under version control the shape changes. The master repository is the product; you TAG a release the way you would in any other language. A customer site is a clone of that repository with its own commits on top — so the vendor's history is genuinely their history, not a starting point they diverged from years ago and lost track of.
An upgrade is then a merge. FETCH the vendor's release, merge the tag, and git says precisely where the customer's changes and the vendor's changes touch the same records. That is worth being exact about, because it is the entire benefit: what you get is a conflict, in the handful of records that genuinely collide, instead of an archaeology exercise across a whole account. Those conflicts still need a person. There are simply far fewer of them than the current method has to assume, and each one arrives with both versions and the history behind each.
And the question no MultiValue vendor can currently answer — what has this site actually changed? — becomes a DIFF between your tag and their branch.
There is a quieter benefit that turns up the first time you cut a release. Once a commit is a real commit it can carry a reference — a Jira key, a GitHub issue number, whatever your team already uses — and release notes stop being a document somebody writes from memory on the last afternoon. They are generated from the commits between one tag and the next, and every line in them points back at the issue that caused it.
That changes the conversation with a customer more than it sounds like it should. Is the thing I reported in March in this release? stops being a question answered from recollection and becomes one anybody can check for themselves. A site can read the notes for a version before they take it, follow a line back to the issue, and see the discussion, the change, and the records it touched — not a summary of the change, the change.
mv_git is built this way, so I can say what it costs: nothing, once the habit is there. The release that came out while I was writing this went issue, to a branch named after it, to commits that quote it, to a merge that closes it, and the tag says what changed — including what is still broken and where the evidence is, which is the half most release notes leave out.
What this means if you run one
From the customer's side it is the same machinery pointed the other way, and it makes local work respectable.
A change starts as a branch. It is tested on that branch, because a branch here is a working account rather than a copy of one. It is reviewed as a pull request — which means somebody other than the author reads the Pick BASIC before it goes anywhere near production, on a platform where arranging that has been unreasonably hard. Then it merges into your development branch and moves to staging and to production as controlled merges: each one a request that somebody approved, in the open, with a name and a date on it. For a site whose software handles money or patient records, who changed this and who signed it off stops being a question you answer from memory.
None of that is novel. It is how the rest of the industry has worked for fifteen years. It has been out of reach here for the mundane reason that there was nothing to branch: the account was the account, and the only way to have two versions of it was to have two of them.
The part I find genuinely interesting is what becomes of a good local change. Today a customisation is a permanent tax — you pay it at every upgrade, for as long as you run the software. In this model it can go home. A fix that everyone would benefit from becomes a pull request against the vendor's master repository, ships in the next release, and comes back down to every site — including yours, where it arrives as part of the product and stops being a local modification at all. The tax stops.
That last step asks the vendor to ship a repository rather than an account save, which is a change in how MV software is delivered and not only in the tooling around it. But the tooling was the part nobody could work around, and it is no longer missing.
The problem COBOL has, arriving
COBOL's difficulty was never the language. COBOL is fine. The difficulty is that the people who understood those systems retired faster than anyone replaced them, and what was left was a large amount of working software that very few people could safely change. MultiValue is on the same road, a little further back.
I am part of the evidence for that. I left this world for twenty years and I came back for a compiler, not for MultiValue — and if I am honest, most programmers will not pick up MV unless something makes them. There are newer languages and far more attractive environments, ones you can be productive in on the first afternoon, with tutorials and a package manager and a community that answers at three in the morning. That is not a criticism of MV. It is just what the choice looks like to somebody who has one.
So the people maintaining these applications will increasingly be people who did not grow up in them: a developer moved across from somewhere else, a contractor, someone who has been handed a system nobody else is left to hand it to. And until now the whole safety net for that person was a written procedure, a spreadsheet, and whoever remembers what the screen used to look like.
Half of this problem was solved long ago, and it is worth saying so plainly: UniData and UniVerse have carried these applications forward. Software written for hardware that has not existed for decades runs on current processors, current operating systems, virtual machines and containers, usually without the application being told. That is a real engineering achievement, and most of the industry has nothing like it — try running a 1985 Windows application today.
What has not come forward with it is how the software is developed. The runtime moved; the working practices largely did not. A system can be running on a machine built this year and still be changed the way it was changed in 1990 — edit in place, tell somebody, hope. Modern hardware arrived without modern development, and the second half is the one that is now missing.
Version control changes what that costs, and not because git is clever. Every change gets an author, a date and a reason. What the system looked like last Tuesday is a question with an answer. A newcomer can make a change on a branch, and someone who does know the application can look at exactly what it touches before it goes anywhere near production. If it is wrong it comes back out — not "restore from the backup and lose the day's work", but this record, this attribute, back to what it was.
And then everything built around git arrives with it, without any of it having to learn what MultiValue is. Work tracked in Jira or GitHub issues, tied to the commit that closed it. Review comments on the line of the record that changed. Continuous integration that compiles the BASIC and runs the tests before a merge is allowed. Even the AI tools: one cannot read your account, but a diff is a diff — it will tell a newcomer what a change actually touched, and ask the question a reviewer would have asked if a reviewer had been available.
None of that makes MultiValue fashionable, and it is not meant to. What it does is stop the knowledge living only in people's heads, and put the same guard rails around a forty-year-old application that a three-week-old one gets by default. That seems to me the difference between a system somebody inherits and a system nobody dares touch.
Taking it, and what open source actually means
MultiValue has spent forty years buying software from vendors, for good reasons, and one side effect is that "open source" tends to land somewhere between shareware and unsupported. It is neither, and since this is the first thing most people will have to decide about, it is worth being plain.
mv_git is licensed under the GPL, version 2. In practice: you can download it, read every line of it, change it, and run it in production, commercially, for as long as you like, without paying anybody and without asking anybody — me included. There is no licence key, no user count, no expiry and nothing that phones home. It is not a trial of something that costs money later. What is on GitHub is the whole thing.
The obligation is narrower than people expect, and it is probably not yours. If you distribute a modified version to someone else, you have to give them your modifications under the same licence. If you modify it and run it yourself, however heavily, that never arises. Most sites will never go near it.
There is a second thing you get, and here it matters more than in most places: I cannot take it away. If I lose interest, get hit by a bus, or turn unreasonable about money, the code is already out and anyone can pick it up and carry on. MultiValue has watched enough vendors get acquired and enough products get end-of-lifed to know what that is worth.
So the first and most likely thing you will do is take it and go. Clone it, build it, point it at a test account, and never speak to me. That is not rudeness — that is the design working.
The second is to raise an issue. If it breaks on your platform, or your dictionaries do not survive the crossing, or something is simply wrong, say so at github.com/mvx-lang/mv_git/issues. It costs nothing, it is public, and it makes the thing better for everyone else running it. Every bug named in this article was found by somebody running the software and watching it fail; yours will be found the same way, and I would rather hear about it than not.
The third is to hire me, which is why it is last and short. The software is free. What is not free is my time and my being answerable for the outcome — so if you want somebody to work out whether this is viable for your site before you commit to it, to get an account under version control properly, or simply to be on the hook when it matters, that is what Heydon Consulting is for. But try it first. You may not need me, and that is a perfectly good result.
One more thing, because being wary of new software pointed at the system your business runs on is an entirely reasonable position. mv_git is new. What it stands on is not. Git is twenty years old, and libgit2 — the library doing the actual work of hashing objects, storing them and merging them — has been in production use for over a decade inside tools you already depend on. The hard parts are not mine and never were. What I wrote is the adapter between MultiValue records and git objects, which is the piece that did not exist.
The shape of it is conservative by construction, too. Putting an account under version control does not change the account. INIT, ADD and COMMIT read your records and write git objects somewhere else entirely: nothing is reorganised, nothing is migrated, no program is rewritten, and the application behaves on Tuesday exactly as it did on Monday. The operations with teeth are the ones that write records back — CHECKOUT, RESTORE, MERGE — and you have to ask for those by name. So the worst realistic outcome of trying it against a copy of an account is that you end up with a repository you decide you do not want.
I have also been running this whole project on it, which is the least I can offer. The demo account in this article is committed with it. So is MVX's own package source. So, for that matter, is mv_git itself: the verb, its handlers and the rest of its BASIC live in an account that is version-controlled with mv_git. If it were going to eat somebody's source, it would have had mine first.
Which leaves the warranty, the part the licence is most emphatic about: there isn't one. Open source has a saying for this and it is not really a joke — if it breaks, you get to keep both pieces. Test it against an account you can afford to lose, the way you would with anything else that writes to your files.
And if the honest answer is that you have no machine you can afford to lose — which for a lot of sites it is — you do not need one of your own. Rocket offers trial versions of UniData, UniVerse and jBASE, and they are not crippled demonstrations. Everything in this article that touches those three platforms was built and measured on them: the CallC engine, the background process on UniVerse, the dictionary translation, every number in the benchmark. Install one on a virtual machine or an old laptop, somewhere well away from anything that matters — not your production box, and not your test box either, since a test system is usually a production system that has not been noticed yet. Restore a copy of an account onto it and break it there as often as you like.
That is all this took: three virtual machines on one small server, an afternoon each to set up and nothing at all to run. It is a low enough bar that having a look is no longer a project you have to justify to anybody.
Then decide — having watched it run against your own records and your own dictionaries, which is the only way that question was ever going to be answered.
Releases, source and issues: github.com/mvx-lang/mv_git. The demo account is at github.com/mvx-lang/demo.