Skip to content

Umbraco Features with Claude Worktrees

Calculating...

One database per git worktree: building Umbraco features in parallel with Claude Code

TL;DR. I run several Claude Code sessions against the same Umbraco 17 site at once, one feature per session. Claude Code gives each session its own git worktree, so the code is isolated. The database isn't, and uSync happily imports whichever branch started last. So I wrote a PowerShell hook that copies the dev database for every new worktree, points the worktree at that copy, keeps media working, picks ports that don't clash, and drops the lot when the worktree goes. It's about twenty seconds of SQL Server work on session start and then you never think about it again. Install steps are at the bottom.

Contents

  1. The problem
  2. Why not just spin up a fresh database?
  3. Hooking into Claude Code
  4. What happens on SessionStart
  5. What happens on WorktreeRemove
  6. Things that bit me
  7. What it doesn't do
  8. Would it work on your project?
  9. How to install it

The problem

I've got into the habit of running a few Claude Code sessions at the same time, each one working on a different feature of the same Umbraco site. It's brilliant. One session is building a new block, another is fixing a bug in a controller, and I'm sat in the middle reviewing diffs with a coffee.

Claude Code makes this possible with git worktrees. A worktree is a second checkout of the same repo in a different folder, on its own branch. Two sessions can edit, build and commit without ever touching each other's files. Perfect.

Except an Umbraco site isn't just files.

Our appsettings.Local.json points at one SQL Server database, and the project has uSync set up like this:

"uSync": {
  "Settings": {
    "ImportAtStartup": "All"
  }
}

That means every time you start the site from a worktree, uSync imports that branch's document types, data types and compositions into the shared database. Start the site from worktree A, then from worktree B, and B's schema stomps all over A's. Start A again and it stomps right back. The backoffice flips between two versions of the content model and neither session can trust what it's looking at.

Ports are a second, smaller headache. launchSettings.json pins the site to https://localhost:44373, so two worktrees can't even run at the same time. Not ideal when the whole point is running things in parallel.

Why not just spin up a fresh database?

My first thought was an empty database per worktree and let Umbraco's unattended install do the rest. That falls over almost immediately on this site. uSync here only exports schema (content types, data types, languages, media types), not content, and wwwroot/media is gitignored. So a fresh install gives you a beautifully empty site with nothing to click on. Not much use for testing a feature.

What you actually want is a copy of the main dev database with the branch's schema applied on top. uSync does the second half for free at startup. The first half is a BACKUP ... WITH COPY_ONLY followed by a RESTORE ... WITH MOVE, which on a dev sized database takes about twenty seconds. I can live with twenty seconds.

Hooking into Claude Code

Claude Code has lifecycle hooks, configured in .claude/settings.json. That file is committed, so once it's in the repo the whole team gets the hooks for free. Two events do everything I need:

  • SessionStart fires when a session starts, resumes or compacts. Its cwd is the worktree root. This is where the database gets provisioned.
  • WorktreeRemove fires when Claude removes a worktree. This is where the database gets dropped.

There's no hook for "pull request created", and honestly I didn't want one. After the PR goes up you still need the site running to deal with review comments. The lifetime of the worktree is the right lifetime for the database.

Both events run the same PowerShell script. It reads the event JSON from stdin and branches on hook_event_name:

{
  "hooks": {
    "SessionStart": [
      {
        "hooks": [
          {
            "type": "command",
            "shell": "powershell",
            "command": "powershell -NoProfile -ExecutionPolicy Bypass -File \"$env:CLAUDE_PROJECT_DIR\\.claude\\hooks\\worktree-db.ps1\"",
            "timeout": 300
          }
        ]
      }
    ],
    "WorktreeRemove": [
      {
        "hooks": [
          {
            "type": "command",
            "shell": "powershell",
            "command": "powershell -NoProfile -ExecutionPolicy Bypass -File \"$env:CLAUDE_PROJECT_DIR\\.claude\\hooks\\worktree-db.ps1\"",
            "timeout": 120
          }
        ]
      }
    ]
  }
}

What happens on SessionStart

1. Work out if this is a worktree, and where the main checkout lives. Git will tell you both. In a worktree, --git-dir is <main>/.git/worktrees/<name> while --git-common-dir is <main>/.git. If they're the same, we're in the main checkout and there's nothing to do.

$gitDir = git -C $Path rev-parse --path-format=absolute --git-dir
$common = git -C $Path rev-parse --path-format=absolute --git-common-dir
$isWorktree = ($gitDir -ne $common)
$mainRoot = Split-Path -Parent $common

2. Read the connection string from the main checkout's appsettings.Local.json. That file is gitignored and specific to each machine, which is exactly why the script reads it rather than hardcoding a server name. Server, database and credentials all come from there, so nothing in the committed script is tied to my laptop.

3. Sweep. Before provisioning anything, list every database named <db>_wt_* and compare it against git worktree list. Anything without a matching worktree gets dropped. This runs on every session, including ones in the main checkout, so worktrees that were removed by hand or by the desktop app still get tidied up eventually.

4. Copy the database. The target name comes from the worktree folder, so a worktree at .claude/worktrees/multi-feature-workflow-c09980 gets a database called tgp_wt_multi_feature_workflow_c09980. The copy uses SQL Server's own default paths, which the server reports through SERVERPROPERTY, so it works against a remote instance just as well as a local one:

BACKUP DATABASE [tgp] TO DISK = N'<InstanceDefaultBackupPath>\tgp_worktree_seed.bak'
  WITH COPY_ONLY, INIT, FORMAT;

RESTORE FILELISTONLY FROM DISK = N'...';  -- to get the logical file names

RESTORE DATABASE [tgp_wt_...] FROM DISK = N'...'
  WITH MOVE N'tgp' TO N'<InstanceDefaultDataPath>\tgp_wt_....mdf',
       MOVE N'tgp_log' TO N'<InstanceDefaultLogPath>\tgp_wt_..._log.ldf',
       REPLACE, RECOVERY;

COPY_ONLY matters here. It doesn't disturb the backup chain if your dev database happens to be in full recovery mode.

5. Write the worktree's own appsettings.Local.json. It's a copy of the main one with the database name swapped out, plus one extra Umbraco setting:

"Umbraco": {
  "CMS": {
    "Global": {
      "UmbracoMediaPhysicalRootPath": "C:\\...\\TGP\\TGP\\wwwroot\\media"
    }
  }
}

Media files aren't in git, so without this the worktree would show broken images everywhere. UmbracoMediaPhysicalRootPath points Umbraco at the main checkout's media folder instead. I'd originally planned a directory junction for this and bottled it. git worktree remove --force deletes the worktree directory, and I really didn't fancy finding out whether it follows junctions into the real media folder. A config setting can't delete anything. Much safer.

6. Pick some ports and tell Claude. A hash of the worktree name gives a stable pair of ports in the 45000 range. The script checks they're free and saves them into the generated config, so later sessions print the same pair even while the site is running. Then it writes one line to stdout:

Worktree database 'tgp_wt_multi_feature_workflow_c09980' on INI-WIN-066 is ready
(copy of 'tgp'; media served from the main checkout). Run the site from TGP with:
dotnet run --urls "https://localhost:45980;http://localhost:45981"

Whatever a SessionStart hook prints to stdout gets added to Claude's context. So every session starts already knowing which database it owns and how to run the site. Nobody has to type anything.

What happens on WorktreeRemove

Drop the database, then remove the worktree:

ALTER DATABASE [tgp_wt_...] SET SINGLE_USER WITH ROLLBACK IMMEDIATE;
DROP DATABASE [tgp_wt_...];
git -C $mainRoot worktree remove --force $worktreePath

That second step caught me out. Registering a WorktreeRemove hook replaces Claude Code's default removal. So if your script only drops the database, the worktree folder just sits there. The hook has to do the git worktree remove itself, and exit with an error code if the folder is still around afterwards.

Things that bit me

I'd love to say this all worked first time. It did not.

  • $result[0] on a single row from sqlcmd. PowerShell unrolls an array with one element into a plain string, and [0] on a string gives you its first character. My backup path became C, which SQL Server helpfully resolved relative to its default backup folder as ...\Backup\C\tgp_worktree_seed.bak. Wrap it in @(...)[0] and move on with your life.
  • $ErrorActionPreference = 'Stop' and native commands. In Windows PowerShell 5.1, a native exe writing anything to stderr under Stop becomes a terminating error. The script runs with Continue and checks $LASTEXITCODE explicitly instead.
  • $env:CLAUDE_PROJECT_DIR is the main checkout, not the worktree. Use it to find the script. Use the event's cwd for the worktree.
  • The port probe sees your own site. On a resumed session the hook ran while the worktree's site was already up, saw its own ports as busy, and cheerfully printed a different pair. Hence persisting the ports in the generated config.
  • Builds touch tracked files. Umbraco regenerates umbraco-package-schema.json on every build. Not a hook problem, but it shows up in git status after you've tested, so keep an eye out.

What it doesn't do

  • It's Windows PowerShell plus sqlcmd. No bash version yet. Sorry Mac folks.
  • It needs a SQL Server you can BACKUP and RESTORE against: a local instance, Express, LocalDB or a dev server. Azure SQL has no BACKUP DATABASE, so that's out.
  • It assumes one worktree runs one site. Two sites in the same worktree would still collide.
  • The desktop app's own worktree cleanup may not fire WorktreeRemove. The sweep on the next session start covers it, so the worst case is a stale database hanging around for a few hours.

Would it work on your project?

Yes, if you're on Windows with a SQL Server you can back up. All the bits that are specific to my project are parameters with sensible defaults. Another Umbraco site needs at most the web project folder name. A .NET site that isn't Umbraco adds the connection string name and switches the media setting off:

"command": "powershell -NoProfile -ExecutionPolicy Bypass -File \"$env:CLAUDE_PROJECT_DIR\\.claude\\hooks\\worktree-db.ps1\" -WebProject MySite -ConnectionStringName DefaultConnection -NoMediaSetting"

Everything else (server, credentials, source database) is read from your own local config at runtime. You can also run the script by hand with -Cwd <worktree> or -Remove <worktree> to try it outside Claude Code.

It took about an hour to build, and the combination is what makes it worth it. A copy of the real database. The branch's schema applied by uSync on first run. Media still visible. Ports that don't clash. The whole thing torn down when the worktree goes. Parallel sessions stop being something you manage and become something that just happens.

How to install it

Here's the step by step. This assumes Windows, Windows PowerShell 5.1, and Claude Code with worktrees turned on.

1. Check the prerequisites.

  • A SQL Server you can BACKUP and RESTORE against (local instance, Express, LocalDB or a dev server).
  • sqlcmd on your PATH. Open a terminal and run sqlcmd -? to make sure it's there.
  • An appsettings.Local.json in your main checkout with a working connection string. The script reads this, so if the site already runs from your main checkout you're good.
  • If it's an Umbraco site, uSync with ImportAtStartup turned on, otherwise the branch's schema won't land in the copied database.

2. Grab the script.

TODO: link to the script (gist or repo).

Drop it into your repo at .claude/hooks/worktree-db.ps1.

3. Register the hooks.

Open .claude/settings.json (create it if it doesn't exist) and add the SessionStart and WorktreeRemove entries from the hooks section above. Both point at the same script.

4. Adjust the parameters if you need to.

The defaults assume an Umbraco site with the standard Umbraco connection string name. If your web project folder has a different name, add -WebProject YourFolder to the command. If it's not an Umbraco site, add -ConnectionStringName YourConnectionString -NoMediaSetting as well.

5. Test it by hand first.

Create a worktree and run the script against it directly, so you're not debugging through Claude:

git worktree add .claude/worktrees/test-db -b test-db
powershell -NoProfile -ExecutionPolicy Bypass -File .claude\hooks\worktree-db.ps1 -Cwd .claude\worktrees\test-db

You should see the "Worktree database ... is ready" line and a new <db>_wt_test_db database on your server. Then tear it down:

powershell -NoProfile -ExecutionPolicy Bypass -File .claude\hooks\worktree-db.ps1 -Remove .claude\worktrees\test-db

The database and the worktree folder should both be gone.

6. Start a Claude Code session in a worktree.

Open the project in Claude Code with worktrees enabled and start a new session. The first thing in Claude's context should be the "Worktree database ... is ready" line with your dotnet run command. Run the site on the ports it gives you and check the backoffice loads with your content and media intact.

7. Commit it.

Commit .claude/settings.json and .claude/hooks/worktree-db.ps1 so the rest of the team gets the same setup. Leave appsettings.Local.json gitignored as it always was.

That's it. From now on every worktree gets its own database on the way in and loses it on the way out, and you can get back to running three features at once with a coffee in hand.