Skip to content
← Blog Blog

In the jungle

Part of my onboarding was a task with my name on it: get every codebase running locally. Seventy-one repositories, a decade of technology choices, and two weeks of finding out which of them could still start on a fresh machine — including the one whose local database could not be built at all, and what it took to make one command build it anyway.

A golf ball lost in tall rough, the green far beyond

I started at GolfNext at the beginning of August. Somewhere in the onboarding, between the accounts and the hardware, was a task with my name on it: get every codebase running locally.

Not read them. Run them. Clone the repository, install whatever it wants, start the thing, and watch it come up on your own machine.

It reads like something you hand a new person to keep them busy for a day. It ran through most of my first two weeks, it produced the most useful map of this company I have, and it turned into a fair amount of repair work nobody had asked for, because several of those repositories could not start at all.

In my first week alone I worked in a Vue admin panel, an Android app that runs on a self-service kiosk, a Java backend, and a desktop point-of-sale application that ships as a Windows executable.

That’s the shape of the company. GolfNext builds the software golf clubs run on — booking and paying for a round, the till in the pro shop, the unattended machines out on the practice range — and after more than a decade of doing it there are seventy-one repositories in our GitHub organisation. Mostly TypeScript, JavaScript and Java, with PHP behind them, and then a few one-offs: Kotlin, HCL for the infrastructure, shell.

None of that is unusual for a company this age, and none of it is a complaint. It exists because people shipped a decade of working product under real constraints, and the tidying up I’m about to describe is only possible because the product already works. It does mean every repository is a small patch of jungle, though. Two things were genuinely hard, and neither of them was the actual programming: working out which of seventy-one repositories the thing I needed even lived in, and then working out how to start it. The second one was the assignment.

What the jungle charges

Big obstacles are interesting, and you remember them. The jungle charges you in small identical amounts instead, over and over.

You clone something and the build fails because your JDK is one major version off. You fix that and the build tool wants a different one. You move to a web project and the version file says one Node while the lockfile was built against another. Somewhere there’s an environment file to fill in, and the values live with a colleague. Each of those is fifteen or twenty minutes, none of them teaches you anything, and you pay them again on the next machine and again for the next person who joins.

None of that was any one repository’s fault. It was the absence of a shared answer: every Java application had settled on its own JVM version, and every web project on its own Node version, each pinned in practice by whatever happened to be current the last time somebody set it up, with nothing ever going back to reconcile them. The dependencies underneath drifted the same way — the same libraries sitting at different versions in different repositories — so what you learned getting one repository to build didn’t transfer to the next one.

And getting any of them to run locally was its own tax, payable in a different currency each time. The clearest case: two of our Java desktop applications are the same kind of program, built for the same job — one builds with Gradle, the other with Maven. Neither repository records a reason. At some point I stopped taking notes and asked the question out loud, which is the question this whole post is really about: where is the standardisation? Not as a complaint. As a genuine question about how an estate ends up holding two answers to a problem that only has one.

One person, one application

There’s a structural reason for this, and I think it explains most of what I found. For most of this company’s life the software was built by remote developers, working at a distance from other countries and, as far as I can tell, largely on their own: one person, one application, start to finish. If you are the only one who will ever build a thing, declaring its toolchain in the repository buys you nothing. You already have the toolchain — it’s on your machine, and it’s correct because it’s yours. Conventions converge when people have to work inside each other’s repositories, and for years almost nobody did.

I’d add a softer theory, and I want to be clear that it is one. Distance isn’t only geographic. Working across countries, time zones and second languages makes the cheap conversations expensive — the ones where two developers notice in passing that they’ve solved the same problem differently and settle on one way of doing it. Nobody has to disagree for an estate to diverge. It’s enough that the conversation is never quite convenient. None of which is a criticism of anyone who worked here before me; it’s roughly what I’d expect that arrangement to produce, and I’d probably have built the same jungle in their position.

All of that has a name, and it’s the oldest joke in the industry: works on my machine. Nobody here says it with a shrug, and nobody was defending it — but it’s what the estate had quietly organised itself around. If every machine that builds a given repository is already configured, then the configuration doesn’t live in the repository. It lives in a few people’s heads and in their shell history. Onboarding a new person means reading it back out of them, and it is never complete, because the parts they do automatically are exactly the parts they forget to mention.

The repository that cannot start

The worse case is the repository that cannot start at all.

In one of our backends the local development profile — the configuration that points the application at a database on your own machine — had been deleted months earlier, and nobody noticed, because nobody who was already set up ever needed it. Clone it fresh and you get a data-source error that tells you nothing about why. Underneath that sat something worse: a database migration written years ago that won’t let the schema finish until it has called out to a third-party service, one this codebase can no longer reach. So a genuinely fresh local database could not be built at all, on any profile.

Neither of those was in a tracker. They aren’t bugs in the ordinary sense, and nobody did anything wrong to create them. They’re what a repository drifts into when no fresh machine is ever the one being tested — and you only find out on the day somebody joins, or somebody’s laptop is replaced, when the whole bill arrives at once.

Two hundred and fifty-one migrations

The hardest one to start was our GolfMore admin panel — the back office club staff log into, which serves the public API out of the same codebase. PHP and Symfony, MySQL underneath, and a decade of schema history sitting on disk: a 114 KB base dump to import first, then 251 migration files to run on top of it.

The documented way to get a local database was exactly that. Import the dump, run the migration chain, get a working schema. It does not work, and hasn’t for years, because four migrations from 2022 and 2023 cannot run against a database built that way:

  • One drops the bookkeeping table left over from the migration tool the project used before Doctrine, which a fresh dump import has never had.
  • One throws unconditionally in its postUp(), because it calls addSql() on a migration that is already frozen.
  • One references a table several months before the migration that creates it.
  • One declares AUTO_INCREMENT on a column with no key, which MySQL refuses, so it has never been able to run at all.

Nobody is at fault here in an interesting way. The combined effect is the problem: the one path a new developer is told to take is the one path that’s broken, and everybody already set up has a database that predates it, so nobody else ever hits it.

How a chain rots

How it got this way I can only guess at, and I’d rather say that than pretend otherwise. My best guess is that at some point the schema in production was changed by hand. The fourth of those defects is nearly proof of it: MySQL has never accepted an auto-increment column that isn’t a key, so that migration cannot have run anywhere, ever — and yet the table it describes exists everywhere the application runs. Something other than the migration chain put it there. The same explanation covers the forward reference: if that table already existed on the machine where the migration was written, referencing it worked fine there, and only a database built strictly from the chain would ever notice.

Two other mechanisms need no villain at all. One is the base dump. It’s a snapshot, and if it was refreshed at some point later than the migrations that follow it, then importing it and replaying the chain stops being the history that actually happened — you’re now asking old migrations to redo what the dump already contains, or to drop what it never had, which is exactly the leftover bookkeeping table. The other is dependency upgrades. The migration that throws in its post-run hook was presumably legal the day it was written, and a later version of the migrations library made that call illegal. Nobody re-runs the old end of a chain, so a change like that lands silently and waits for whoever builds a database next.

Which is the general shape of the thing. A migration chain only ever gets tested at its head. The tail is exercised by exactly one kind of event — somebody building an environment from nothing — and if that hasn’t happened for a few years, nothing anywhere tells you it has rotted.

Was it me or the code?

What made this slow wasn’t any one of those four. It was not knowing, for a good while, whether the problem was the code or me. A new person with a failing command has no way to tell a broken repository from their own missing knowledge, and the default assumption — the polite one, and usually the correct one — is that it’s you. So you re-read the README, check your versions, throw it away and start from a clean clone, and lose an afternoon proving something that was never true in the first place.

Once I accepted it was the chain, the method was dull and it worked. Run the migrations up to the version where they stop. Work out what that one needed and couldn’t get. Write the patch by hand, mark that version as done without running it, and carry on to the next dead end. I did that a handful of times, each round a little faster than the last, until the whole chain ran end to end without breaking anything.

Then I wrapped the resulting list of hacks in a single mise task, which is the only part anybody else should ever have to see — and the reason starting this thing locally now feels like a breeze instead of an archaeology project.

What one command hides

What I did about it is not elegant. One task, mise run db, starts the container and then asks the database a single question: does the migrations table exist? Everything after that hangs off the answer.

If the table is there, this is an existing database: migrate it forward, assert it ended up current, done. If it isn’t, the task imports the dump and walks the chain in stages, stopping at each broken migration to do by hand the one thing that migration can’t do for itself. Create the leftover bookkeeping table so the migration that drops it succeeds. Add the column the throwing migration would have added, then mark that migration applied without running it. Stand up a stub of the table that doesn’t exist yet, just long enough for the forward reference to resolve, and drop it again afterwards. Create outright the one table whose migration can never execute. Then run the remainder normally, verify the schema is up to date, and print the test login.

I’d rather have fixed the four migrations. That’s a bigger job than it looks, because they’re part of a chain every deployed environment has already run, so touching them means being certain what each of those environments actually has. So the workaround is what shipped — with the root causes written up, and a note above the block saying the whole thing can be deleted once they’re fixed. Ugly and documented beats elegant and imaginary. The failure mode I care about is the next person spending two days rediscovering all four.

The result is the part I’d defend. On a machine with nothing but mise and Docker, one command brings up the panel, the API, a schema at the right version, and an account to log in as. Re-running it is safe. And mise run db:reset throws the database away, so you can prove the whole thing works from empty — which is the real test, and the one nobody had been in a position to run.

What the count multiplies

Two costs don’t just repeat with the repository count. They scale with it.

The first is dependency bumps. In that same week I bumped an HTTP client by hand in two frontends, a JSON library in a third repository and a PDF library in a fourth: four branches, four pull requests, four review cycles, and nothing learned by anybody. The usual case for automating that away is security. At seventy-one repositories it’s arithmetic.

The second is branch topology. Some repositories still carry a branch each for development, staging and production, with changes travelling between them by merge. From that week I have a commit of my own whose entire message is “Fixed merge conflict error”. Every long-lived branch is another place the same change exists in a slightly different state. We’re moving to one, where a merge deploys to staging and a published release deploys to production, and nothing is left to reconcile.

The machete

Two tools do most of the cutting, and neither of them was my idea — our tech lead brought both in during his own first weeks. mise declares a repository’s toolchain — and the commands you run against it — in a file in the repository, and fnox does the same for the references to its secrets. The repository commits pointers; the values live in a managed store that grants access per person, rather than in a file somebody pastes you.

The Android app got its Java and Gradle versions pinned. The backends got Java and Maven pinned. Several repositories got a README that was true again, and a CI workflow where there hadn’t been one.

The one I liked most was the desktop application. It ships as a Windows executable, and it now cross-builds from Linux through a task defined in the same file that pins its toolchain — hiding a toolchain I’d otherwise have to learn before touching a one-line change.

To be honest about the coverage: that’s a handful of repositories out of seventy-one. It’s less a programme than a habit a few of us keep picking up.

What the machete doesn’t cut

All of that shortens the distance from clone to running. It does nothing whatever to the distance from running to understood.

My most recent feature is a super-admin screen — the one our own staff use to manage the internal accounts that get into the back office. List them, create one, and since this week, edit one. A form, a table, a couple of calls.

Nothing in the repository documented what those calls expected. The contract did exist, as a published API document sitting outside the codebase, so the types came out of that rather than out of guesswork. A slightly undignified way to obtain a contract, and a much better one than reading the existing client code and hoping whoever wrote it had got it right.

Then the document contradicted itself. One endpoint is described as returning every account. The response it declares is a single account. Both statements sit in the same file, and I couldn’t settle which was true by calling the thing, because I didn’t have credentials for that environment.

So the client code I wrote accepts a list, or a single object, or an empty body, and always hands back an array. Three tests, one per shape, and one manual pass through the screen itself. The alternative was to pick the shape I thought most likely and fail loudly on the other, which would have converted a documentation ambiguity into an outage. The shape of that code is an ambiguity I couldn’t resolve, and the only way anyone will know that later is that I wrote it down beside it.

The edit form produced three more. A password field the API doesn’t return as empty, so the form has to open blank and send nothing unless someone types a new one — round-tripping whatever came back would write nonsense over a perfectly good credential. Roles matched on their stored identifier, so a change has to carry that identifier; send the new role without it and the account quietly ends up with two. And a save that treats a row left out of the payload as unchanged rather than deleted, so there’s no way to remove one through it at all, which is why that delete control is disabled instead of sitting there pretending to work.

Each of those is one comment at the site, explaining why the code is the shape it is.

Where the agents fit

I’m generally enthusiastic about coding agents, so here’s another commit from that same week. Its entire message is “revering claude slop” — my typo, my commit, reverting what an agent had produced.

That commit doesn’t dent the enthusiasm so much as locate it. An agent is only as good as the ground it stands on. Point one at a repository with a clear structure and a pinned toolchain and tests that actually run, and the output is usually something you can work with. Point one at a repository where none of that is true and it will guess, fluently and confidently, and you’ll spend your afternoon working out which parts were guesses.

Which means the tooling work and the AI work aren’t two projects. Every pinned version and every honest README raises the floor under everything an agent does in that repository afterwards, and the repositories where I trust an agent most are, without exception, the ones somebody has already tidied.

Two distances

The distance from clone to running is a tooling problem, and it compresses beautifully. It goes from an afternoon to one command that’s spelled the same everywhere, and it compresses again for the next person.

The distance from running to understood does not compress. No config file will tell you that an endpoint’s summary and its schema disagree, or that a migration from years ago is holding your database hostage over a service it can’t reach. What helps there is slower and less satisfying: read the API’s own description of itself rather than the code somebody wrote against it, and when you end up writing defensive code, write down what you were defending against.

That second distance is most of the job. The value of streamlined tooling isn’t that it makes the job smaller. It’s that you arrive at the real work with your twenty minutes and your patience intact — and the way in is starting to be the same in every repository.

As for the task: everything I pointed myself at did eventually come up, and several of them only after being repaired first. That’s the part I hadn’t expected from an onboarding chore. It looks like it exists to teach the new person the estate, and it does do that. What it actually produces is a list of everything that had quietly stopped working while nobody was looking.

Which is, as I understand it, the job. Johann, our tech lead, and I arrived within a day of each other at a company whose product works and whose technical foundation grew one application at a time, because that’s what the arrangement that built it asked for. Bringing structure to that, and making it scale past the size we are now, is what the two of us are here to do — not as a cleanup project with an end date, but as the standing work. Everything above is week one of it.

And the ambition behind it isn’t tidiness. We want to build the engine that can carry this product worldwide — the software golf clubs run on, in as many countries as will have us — and the only thing that ever earns a position like that is customers who are amazed by what they get. Structure and scale are how you stay able to deliver that when there are ten times as many of them. A foundation that can’t scale doesn’t fail loudly. It just quietly caps how good the product is allowed to become.