About Henriette Harmse

I am a software architect with 20 years experience as software developer, architect and consultant in a variety of industries (i.e. financial, healthcare, media, mining, etc). I have a PhD in Artificial Intelligence/Data Science. Currently I am working at EMBL-EBI where I am leading the development of their suite of Ontology Tools.

End-to-End Testing across Git Worktrees

As many developers, I am running several AI coding agents (Claude Code sessions in my case) in parallel on the same repository, each in its own git worktree. Git handles the code isolation beautifully: every worktree has its own files, its own branch, its own uncommitted changes. The breakdown comes at test time. My project’s end-to-end tests spin up a complete stack — a Solr index, a Spring Boot backend, a React frontend and a Nextflow data-load pipeline — and the moment two worktrees tried to test at the same time, they collided over ports, data directories and indexes. One agent’s test run wiped the index another agent was querying.

For those of you who have not solved this problem yet, I in this post give a step-by-step guide to fixing that: making every worktree able to run a complete, isolated end-to-end test stack on demand. The worked example is OxO2, the SSSOM-compliant ontology mapping service I work on at EMBL-EBI, but the recipe transfers to any repo — my Solr could be your Postgres, Neo4j or Redis.

The problem: git isolates code, not runtime

git worktree gives each checkout its own files. Ports, data directories, PID files and log directories, however, live on the machine, shared by everything running on it. Two Solr instances started with default settings both want port 8983, the same home directory and the same index files. This is not misuse — it is the designed behaviour. Git solves the code half; the runtime half is yours to solve.

The fix rests on one idea:

Derive every port and every state directory from a single per-worktree number.

Give the main checkout index 0 and each worktree the next free integer. Everything else — ports, data directories, config choice, service URLs — is computed from that index by one shared script. No registry, no negotiation, no remembering. Any terminal, and crucially any AI agent session, can compute the whole environment from the index.

The rest of this post builds that up in eight steps.

Step 1 — Inventory what your stack shares

Before writing any scripts, list every machine-global resource your stack and your tests touch. For OxO2 the list looks like this:

ResourceDefaultCollision symptom
Frontend dev server port8080“address already in use”
Backend port8081“address already in use”
Solr port8983second instance fails to start
Solr stop port7983 (port − 1000)mysterious failures with “different” ports
Test Solr port8984e2e suites collide
Data directory$OXO2_DATAone run overwrites another’s pipeline output
Solr home (cores + index)$SOLR_HOMEparallel end-to-end tests corrupts index
Test data / test Solr home*_TEST dirsintegration tests trample each other
Pipeline (Nextflow) work dir$NEXTFLOW_DIRinterleaved intermediate files
Solr logs directoryinstall defaultinterleaved, undebuggable logs

Two entries deserve emphasis, because they are the ones that have bitten me:

  • A service can claim more ports than you configured. Every Solr instance also binds a stop port at SOLR_PORT − 1000 and, with JMX enabled, SOLR_PORT + 10000. Two Solrs on “different” ports 8983 and 7983 still collide: the first’s stop port is the second’s listen port.
  • A service’s state is several directories, not one. For Solr that is its home (cores, configs, index), its PID directory (which bin/solr status and stop consult) and its logs directory. Share any one of them between two instances and they are entangled.

Step 2 — Make every resource configurable through the environment

Every item on the inventory must be settable per checkout, with the old value as the default so existing setups keep working. In practice this means touching each service once, in whichever way that service supports:

  • Native environment variable. Solr’s bin/solr reads SOLR_PORT and SOLR_HOME natively — nothing to change.
  • Build-tool config reads the environment. OxO2’s vite.config.ts gained one line: port: Number(process.env.OXO_FRONTEND_PORT) || 8080,
  • Launcher script forwards the variable. OxO2’s startBackend.sh forwards OXO2_BACKEND_PORT into Spring Boot as -Dserver.port=…, only when the variable is set, so the default stays untouched.
  • Pipelines key off a URL. The data-load pipeline reads SOLR_URL and derives the port to start/stop its managed Solr from it, so pointing the URL at the right port is enough.

This step is the actual work of the whole exercise, and it is also where an AI agent can help you: ask it to find every hard-coded port and path in the repo and thread an environment variable through each one.

Step 3 — Choose a directory layout

I keep the main checkout, the worktrees and their runtime state as siblings under one project root, with the state outside the worktrees:

oxo2/ # project root (not itself a git repo)
├── oxo2/ # main checkout — index 0
├── worktrees/
│ ├── fix-ranking/ # worktree — claims index 1
│ └── cleanup-facets/ # worktree — claims index 2
├── worktree-state/
│ ├── fix-ranking/ # that worktree's private runtime state
│ │ ├── data/ test-data/
│ │ ├── solr-data/ test-solr-data/
│ │ ├── nextflow/ solr-logs/
│ └── cleanup-facets/ …
├── data/ solr-data/ … # full-corpus state, main checkout only
└── oxo2-env.sh # the one shared environment script

Why keep state outside the worktree? Index and pipeline state can be large, it slows git’s untracked-file scanning, and — most importantly — git worktree remove refuses a dirty tree. Keeping state in a sibling directory keyed by the worktree’s name makes removing the code and removing the state two separate, deliberate acts.

Note the asymmetry: the full production corpus (for OxO2: 134 GB of data plus a 38 GB Solr index) exists once, in the main checkout’s shared directories. Worktree loads are test-scale by design — that is Step 6.

Step 4 — One environment script, one number

Now the heart of it: a single script at the project root, outside the repo, that every checkout sources. It does four things — identify the checkout, claim an index, derive ports, derive state directories. Here is the skeleton, condensed from OxO2’s oxo2-env.sh:

#!/bin/bash
PROJECT_ROOT=/home/you/projects/myproject
MAIN_CHECKOUT=$PROJECT_ROOT/myproject
# --- 1. Identify the checkout this shell is in ---------------------------
# Only a checkout of *this* repo counts: its git-common-dir resolves to the
# main checkout's .git. Sourced anywhere else, fall back to main defaults.
repo_root=$(git rev-parse --show-toplevel 2>/dev/null)
common_dir=$(git rev-parse --path-format=absolute --git-common-dir 2>/dev/null)
if [ "$common_dir" != "$MAIN_CHECKOUT/.git" ]; then
repo_root=$MAIN_CHECKOUT
fi
# --- 2. Claim a worktree index -------------------------------------------
# Main checkout = 0. A new worktree scans its siblings' claimed indices
# (via git worktree list) and persists the lowest free one in a
# gitignored .worktree-index file.
if [ "$repo_root" = "$MAIN_CHECKOUT" ]; then
WORKTREE_INDEX=0
else
if [ ! -f "$repo_root/.worktree-index" ]; then
taken=$(git -C "$repo_root" worktree list --porcelain \
| sed -n 's/^worktree //p' \
| while IFS= read -r wt; do
[ -f "$wt/.worktree-index" ] && cat "$wt/.worktree-index"
done)
candidate=1
while printf '%s\n' "$taken" | grep -qx "$candidate"; do
candidate=$((candidate + 1))
done
echo "$candidate" > "$repo_root/.worktree-index"
fi
WORKTREE_INDEX=$(cat "$repo_root/.worktree-index")
fi
export WORKTREE_INDEX
# --- 3. Derive the port block --------------------------------------------
if [ "$WORKTREE_INDEX" -eq 0 ]; then
FRONTEND_PORT=8080; BACKEND_PORT=8081; SOLR_PORT=8983; SOLR_PORT_TEST=8984
else
base=$((20000 + WORKTREE_INDEX * 100))
FRONTEND_PORT=$base
BACKEND_PORT=$((base + 10))
SOLR_PORT=$((base + 83))
SOLR_PORT_TEST=$((base + 84))
fi
export FRONTEND_PORT BACKEND_PORT SOLR_PORT SOLR_PORT_TEST
# --- 4. Derive state directories and config ------------------------------
if [ "$WORKTREE_INDEX" -eq 0 ]; then
export APP_DATA=$PROJECT_ROOT/data
export SOLR_HOME=$PROJECT_ROOT/solr-data
export APP_CONFIG=$MAIN_CHECKOUT/config.json # full corpus
else
state=$PROJECT_ROOT/worktree-state/$(basename "$repo_root")
export APP_DATA=$state/data
export SOLR_HOME=$state/solr-data
export SOLR_LOGS_DIR=$state/solr-logs
mkdir -p "$APP_DATA" "$SOLR_HOME" "$SOLR_LOGS_DIR"
export APP_CONFIG=$repo_root/config-test.json # test-scale
fi
# --- 5. Service URLs derive from the ports -------------------------------
export SOLR_URL=http://localhost:$SOLR_PORT/solr
export BACKEND_URL=http://localhost:$BACKEND_PORT

Two design decisions in the port scheme are load-bearing, and both come from traps I described in Step 1 plus one more:

  • Base 20000, spacing ×100. The spacing keeps each instance’s whole trio of ports (listen, stop at −1000, JMX at +10000) clear of every other instance’s trio. The base keeps everything the scheme can produce (roughly 19xxx–30xxx) above the privileged range (0–1023) and below 32768 — because Linux hands out ports 32768–60999 to outgoing connections (the ephemeral range), and a fixed dev port in that range works for weeks and then fails with “address already in use” when some outgoing connection happened to borrow it first. Advice you will find online to base your scheme at 40000 is subtly wrong for exactly this reason.
  • Index 0 keeps the legacy ports. My main OxO2 behaves exactly as it did before any of this existed, so nothing about my primary development flow changes.

The index-claiming logic deserves a note too: because the first source from a new worktree claims the lowest free index automatically and persists it in a gitignored .worktree-index file, there is no manual bookkeeping — and no way for a freshly copied environment file to silently reuse the donor worktree’s ports, which is the classic failure of copy-the-.env-around schemes.

Step 5 — Load it automatically with direnv

Each checkout gets a one-line, gitignored .envrc:

source /home/you/projects/myproject/myproject-env.sh

With direnv hooked into your shell, that file loads when you cd into the worktree and unloads when you leave. Two properties make direnv exactly right for this job:

  • Every session agrees. Any terminal — and any AI agent session started in that worktree — gets the same ports and paths without remembering to source anything.
  • The direnv allow gate. direnv refuses to run a new or changed .envrc until you approve it. A fresh worktree’s environment therefore cannot load “accidentally”; approving it is the natural moment at which the worktree claims its index.

Add .envrc and .worktree-index to .gitignore. If you use Claude Code’s claude --worktree, you can list .envrc in .worktreeinclude so new worktrees arrive with the file already in place — since ours is one identical line everywhere, it can be copied verbatim safely.

Step 6 — Give worktrees a test-scale dataset that travels

An isolated stack is useless if loading the data takes hours and hundreds of gigabytes. The full OxO2 corpus — the whole of OLS plus every Mapping Commons registry — loads once, into the main checkout’s shared directories. Worktrees default to a different, committed config that loads a deliberately small dataset.

Getting that config to travel took one real change. Our first test config referenced fixture files by absolute path (file:///home/<user>/…) — machine-specific paths outside the repo, so the config worked in exactly one place. The fix was to commit trimmed fixture slices into the repo and get the data-load’s downloader to resolve a repo-relative path in the config against the config file’s own directory. Now every worktree — and CI — resolves the same fixtures.

The trimming is worth copying: each fixture keeps semantically important data. A small script regenerates the data from a full export whenever they need refreshing.

The principle I wanted to apply here is: a worktree dataload should be small enough to run quickly and real enough to catch real bugs, and its fixtures must live in the repo so every checkout resolves them identically.

Step 7 — Keep the test stack separate from the dev stack

There is a second collision axis I have encountered: within OxO2 we have integration tests that runs against Solr. These tests are somewhere between unit tests and end-to-end tests. Unlike end-to-end tests they test specific aspects of the dataload. OxO2’s integration tests therefore run against their own environment contract — OXO2_DATA_TEST, SOLR_HOME_TEST, OXO2_SOLR_HOST_TEST — never against the Solr used for end-to-end testing. The harness:

  • fails fast if any *_TEST variable is missing or blank, rather than falling back to the production values (a fallback here would be a data-destroying bug);
  • parses the test Solr port out of OXO2_SOLR_HOST_TEST and starts/stops an isolated Solr on it,
    once per suite;
  • injects the *_TEST values into the pipeline subprocess as the plain variables the pipeline
    expects, so the production pipeline code runs unmodified against the test workspace.

Notice how the two schemes compose: the env script derives both SOLR_PORT and SOLR_PORT_TEST from the worktree index. Worktree 1 end-to-end tests run on 20183 while its integration tests runs on 20184; worktree 2 uses 20283 and 20284. Every combination of (worktree, dev-vs-test) has its own port and its own state directory, so n agents can each run the full end-to-end suite simultaneously, while their dev stacks stay up.

Step 8 — The daily workflow

With all the pieces in place, here is the entire ritual for a new parallel workstream:

cd ~/projects/myproject/myproject # main checkout
git worktree add ../worktrees/fix-ranking -b fix-ranking
cd ../worktrees/fix-ranking
echo 'source /home/you/projects/myproject/myproject-env.sh' > .envrc
direnv allow # claims the next free index
# build, then run the integration tests — fully isolated
mvn clean install -DskipTests
mvn -pl myproject-integration-tests -am verify
# or bring up the dev stack for interactive work
./loadData.nextflow && ./startBackend.sh &
cd frontend && npm install && npm run dev

Then hand the worktree to an agent — every command it runs inherits the right ports and paths from direnv. Teardown is simply:

$SOLR_SCRIPT/solr stop # solr determines $STOP_PORT from $SOLR_PORT-1000
# From oxo2 main
git worktree remove ../worktrees/fix-ranking
rm -rf ../worktree-state/fix-ranking # state removal is deliberate
git worktree prune # for cases when stale worktrees are lingering

When something collides …

Diagnostic help for the three failures you will still occasionally hit:

  • “Address already in use”ss -ltnp lists listening ports with owning processes;
    lsof -i :20183 names the culprit for one port. Also check the whole block (20100–20199) with ss -ltnp '( sport >= :20100 and sport <= :20199 )'.
  • $SOLR_SCRIPT/solr status shows the wrong instancestatus and stop find instances via PID files in SOLR_PID_DIR, which defaults to the install’s bin/ directory, shared by every instance started from that install. Per-worktree PID (and logs) directories fix this.
  • Stale worktree metadata — after deleting a worktree directory manually, git worktree list still shows it; git worktree prune cleans up.

What this bought me

The setup of this was reasonably simple as I already had the shell script I could direct Claude to reuse and set this up. The changes to the data loader and fixture took a bit of time to finetune and test. Disk cost per worktree is trivial because the full dataset exists once and worktrees load small test datasets.

The value I get from this is that I can run multiple AI agents in parallel, each on its own worktree with its own end-to-end test environment. As the complete environment is setup automatically for each worktree, I do not have to remember to assign ports and directories etc. Whenever I forget which ports are applicable to my current worktree direnv show_dump "$DIRENV_DIFF" gives a quick view on the relevant environment variables.

I hope this was useful. Or, if you already have a system in place for end-to-end testing across worktrees, let me know your approach in the comments.

uml2semantic v0.0.3: XMI Support for UML-to-OWL Conversion

uml2semantics converts UML class diagrams into OWL 2 ontologies, enabling you to reason over your conceptual models and discover inconsistencies or unintended consequences. The UML-to-OWL translation is based on UML to OWL, which provides the related Manchester syntax and SROIQ semantics.

uml2semantics v0.0.3 now supports reading of XMI files. Previously, users had to manually create TSV files to describe their classes and attributes. With XMI support, you can now export your UML class diagram directly from a modelling tool like Enterprise Architect and feed it straight into uml2semantics.

Why does XMI matter? Many organisations already maintain UML class diagrams in modelling tools, such as Sparx Enterprise Architect, representing the core entities of their enterprise data. To make their data Findable, Accessible, Interoperable and Reusable (FAIR), and AI-Ready, being able to describe their data by ontologies, is an essential step.

Getting Started

  1. Download uml2semantics.jar from the latest release
  2. Requires Java 11+
  3. Run with your XMI file:
java -jar uml2semantics.jar \
-m "your-model.xml" \
-o "output.rdf" \
-p "prefix:http://your-ontology-iri#" \
-i "http://your-ontology-iri/v1"

See the README for the full CLI parameter reference and additional examples.

Example: Generating OWL from an XMI File

Consider the following UML class diagram, which includes a generalization set with Complete and Overlapping constraints:

In this diagram, Person is the superclass of Employee and Employer, with a generalization set marked as {complete, overlapping}. This means every Person is at least either an Employee or an Employer (complete), but it is also possible that Person are both (overlapping).

To convert this XMI file to an OWL ontology, run:

java -jar uml2semantics.jar \
-m "./examples/xmi/sparx/Employer-WithGeneralizationSet-CompleteOverlapping.xml" \
-o "./uml2semantics/examples/xmi/sparx/Employer-WithGeneralizationSet-CompleteOverlapping.rdf" \
-p "emp:http://uml2semantics.org/examples/employer#" \
-i "http://uml2semantics.org/examples/employer/v.0.1"

This produces an OWL ontology at the specified output path. Because the generalization set is Complete and Overlapping, uml2semantics generates an owl:equivalentClass axiom stating that Person is equivalent to the union of Employee and Employer.

Example: Combining TSV and XMI with TSV Override

A feature of uml2semantics is the ability to combine XMI and TSV inputs using the --overrides option. This is particularly useful when you want to integrate your UML model with existing linked data vocabularies, such as Schema.org.

For instance, suppose you want the Person class in your ontology to use the Schema.org IRI http://schema.org/Person, instead of the auto-generated http://uml2semantics.org/examples/employer#Person. You can achieve this with a TSV override file for classes:

CurieNameDefinitionParentNames
schema:PersonPerson

Similarly, you can map attributes to Schema.org properties. The following TSV override maps the name attribute to schema:givenName and surname to schema:familyName:

ClassCurieNameClassEnumOrPrivitiveTypeMinMultiplicityMaxMultiplicityDefinition
Personschema:givenNamenamexsd:string
Personschema:familyNamesurnamexsd:string

Now run uml2semantics with both the XMI file and the TSV overrides:

java -jar uml2semantics.jar \
-m "./examples/xmi/sparx/Employer-WithGeneralizationSet-CompleteOverlapping.xml" \
-c "./examples/xmi/sparx/Employer - Classes.tsv" \
-a "./examples/xmi/sparx/Employer - Attributes.tsv" \
--overrides TSV \
-o "./uml2semantics/examples/xmi/sparx/Employer-WithGeneralizationSet-CompleteOverlapping-TSVOverride.rdf" \
-p "emp:http://uml2semantics.org/examples/employer#" \
-i "http://uml2semantics.org/examples/employer/v.0.1"

The result: the Person class now has the IRI http://schema.org/Person, and its name and surname attributes use schema:givenName and schema:familyName respectively. The rest of the model — the generalization set, associations, and other classes — comes from the XMI file as before.

This approach is valuable when integrating existing UML class diagrams with linked data. Overrides are not limited to CURIEs — you can add entirely new classes and attributes via TSV that don’t exist in the XMI.

What XMI Features Are Supported

  • Classes with attributes — including name, type, and multiplicity
  • Generalizations (inheritance) — subclass/superclass relationships
  • Generalization sets with all four constraint combinations:
    • Complete + Disjoint — translated to owl:DisjointUnion
    • Complete + Overlapping — translated to owl:equivalentClass with owl:unionOf
    • Incomplete + Disjoint — translated to owl:AllDisjointClasses
    • Incomplete + Overlapping — translated to subclass relationships only
  • Associations between classes — translated to OWL object properties

Note: enumerations are not yet supported.

Conclusion

If uml2semantics is of interest to you, please let me know:

  1. What features will you like to see in this tool?
  2. If you are using a different modelling tool, it will be very helpful if you can provide an example XMI export and image of your UML class diagram. XMI is supposed to be standard, but as we all know, standards are made to be broken :-).

GenAI: Where is the money?

I came across this video that gives a no nonsense review of where GenAI profitability can be found. I highly recommend watching it, but for those in hurry, here is the TLDR:

  1. ROI is not productivity/time saved/lines of code written/number of emails written etc.
  2. Only 11% of 2400 companies surveyed in 2025 were able to increase ROI using GenAI.
  3. Companies that saw increased ROI are those that redesigned their business processes to integrate seamlessly with GenAI. However, this associated change management for many also obliterated any gains.
  4. GenAI Wrappers addressing domain specific needs seem more likely to increase ROI with this market estimated to grow to $38 billion by end 2025.