Max Nardit
← Artikel
10 mindev

Claude Code Worktrees: was claude --worktree isoliert und welche Ports, Datenbanken und Installationen nicht

Getestet mit Claude Code 2.1.316: Zwei Worktrees kollidierten auf demselben Port, Docker Compose plante für jeden ein eigenes Postgres-Volume, Skript-Läufe hinterließen gesperrte Checkouts und übrig gebliebene Branches, und die Standardbasis ließ den nicht gepushten Commit weg.

Inhalt

Die Worktrees-Seite von Claude Code widmet dem, was nach dem Anlegen des Checkouts passiert, genau einen Satz: „ask Claude to install dependencies, or run your project’s setup yourself in the worktree directory“. In diesem Satz steckt der Großteil der Probleme mit parallelen Sessions. claude --worktree trennt Dateien. Der Port, an den sich dein Dev-Server bindet, die Datenbank, mit der er spricht, und die Installation, die er braucht, bleiben geteilt, fehlen oder sind doppelt, und das Flag kümmert sich um nichts davon.

Ich habe das Flag in einem Wegwerf-Repository mit Claude Code 2.1.316 und git 2.43.0 unter Linux ausprobiert, gegen ein lokales Bare-Remote. Alle Läufe waren nicht interaktiv, claude -p mit Haiku. Was ich ausgeführt habe, steht mit seiner Ausgabe da. Was aus der Worktrees-Dokumentation stammt, ist so gekennzeichnet, ebenso meine eigenen Überlegungen dazu, was eine gemeinsame Maschine teilt. Pfade sind auf ~/lab/shop gekürzt.

Was legt Claude Code mit —worktree an?

Einen verknüpften Git-Worktree im Repository, auf einem neuen Branch, und die Session startet darin. claude --worktree alpha (oder -w alpha) hat .claude/worktrees/alpha/ im Wurzelverzeichnis des Repositorys angelegt, auf einem Branch namens worktree-alpha. Ein Worktree ist ein zweiter Checkout desselben Repositorys mit eigenen Dateien und eigenem Index; der Beitrag zu Codex-Worktrees geht genauer darauf ein. Zwei Sessions in zwei Worktrees bearbeiten zwei getrennte Dateisätze, und das ist das ganze Versprechen.

Laut Doku und --help: Ohne Namen denkt sich Claude Code einen aus, etwa bright-running-fox. Interaktive Läufe brauchen Workspace-Trust, also startest du in einem neuen Verzeichnis einmal claude und bestätigst den Dialog, sonst bricht --worktree ab und sagt dir genau das; claude -p --worktree überspringt diese Prüfung, das deckt sich mit meinen Läufen. --tmux öffnet die Session in einer eigenen tmux-Session und setzt --worktree voraus. In einer laufenden Session kannst du Claude auch bitten, „in a worktree“ zu arbeiten, dann legt es einen mit dem Tool EnterWorktree an.

Trag .claude/worktrees/ vor dem ersten Lauf in .gitignore ein. Sonst taucht jeder Worktree in deinem Haupt-Checkout als untracked Dateien auf.

Von welchem Commit startet ein Claude Code Worktree?

Vom Default-Branch des Remotes, nicht von dem Commit, auf dem du gerade bist. Die Einstellung heißt worktree.baseRef, ihr Standard ist "fresh": Branch vom Default-Branch auf origin. Der andere Wert, "head", startet von deinem aktuellen lokalen HEAD. Dauerhaft setzt du das in der .claude/settings.json des Projekts.

Mein Checkout stand auf feature/discount, einen Commit vor origin/main, mit einer nicht committeten Änderung an web/index.html. Der Prompt kam über stdin, deshalb steht bei --allowedTools=Bash ein Gleichheitszeichen (den Grund liefert der Abschnitt zum Aufräumen):

$ echo "Run exactly this one bash command and print its raw output only, nothing else: pwd; git status --short --branch; git log --oneline -3; ls -A" \
    | claude -p --worktree beta --model haiku --allowedTools=Bash
~/lab/shop/.claude/worktrees/beta
## worktree-beta
40e5569 initial
.git
.gitignore
api
web

40e5569 ist origin/main. Der nicht gepushte Commit fehlt, und vom Feature-Branch ist im Worktree nichts da. Dann derselbe Prompt mit der anderen Basis, übergeben per --settings:

$ echo "…" | claude -p --worktree gamma --model haiku --allowedTools=Bash --settings '{"worktree":{"baseRef":"head"}}'
## worktree-gamma
ef9e2fd add discount (local, not pushed)
40e5569 initial
BasisWorktree startet beiNicht gepushter CommitNicht committete Änderung an einer getrackten Datei
"fresh" (Standard)origin/mainneinnein
"head"deinem aktuellen Commitjanein

Die letzte Spalte übersieht man leicht. Keine der beiden Einstellungen nimmt das Arbeitsverzeichnis mit: Der "head"-Worktree hatte web/index.html ohne die Änderung. Ein Worktree startet von einem Commit, also bleibt eine getrackte Änderung, die du nicht committet hast, zurück.

Laut Doku holt "fresh" den Default-Branch per Fetch, wenn das Repository in den letzten 24 Stunden nicht gefetcht wurde, gibt nach fünf Sekunden auf und nimmt dann die zwischengespeicherte Ref. Ohne Remote, oder wenn origin/HEAD weder gecacht noch abrufbar ist, fällt es auf deinen lokalen HEAD zurück. baseRef kann keinen Branch benennen. Willst du von einem bestimmten Branch starten, leg den Worktree selbst mit git worktree add an und starte claude darin. claude --worktree "#1234" startet stattdessen von einem Pull Request, gefetcht von origin.

Welche Basis passt, hängt von der Aufgabe ab. "fresh" passt zu „fix diesen Bug auf main“, während du auf deinem Branch weiterarbeitest. "head" passt zu einer zweiten Session, die auf dem aufbauen soll, was du gerade gemacht hast. Dann committest du vorher, denn nur committete Arbeit kommt mit.

Wie kommen .env und node_modules in einen Claude Code Worktree?

Über eine Datei .worktreeinclude im Wurzelverzeichnis des Repositorys, in .gitignore-Syntax. Claude Code kopiert Dateien, die darauf passen, und zwar nur, wenn sie gitignored sind. Mein Quell-Checkout hatte eine .env und ein Verzeichnis node_modules/ mit einem Platzhalterpaket, left-pad. Der erste Worktree bekam keins von beiden. Dann:

$ printf '.env\nnode_modules/\n' > .worktreeinclude
$ echo "…ls -A; ls node_modules; cat .env" | claude -p --worktree gamma …
.env
.git
.gitignore
api
node_modules
web
left-pad
SECRET=abc

Die Datei .worktreeinclude selbst war untracked, nie committet, und hat trotzdem funktioniert. Und node_modules/ kam als eigene Dateien herüber, nicht als Links: Die Inode-Nummern unterschieden sich von den Originalen. Die Platte ist ext4, das kennt keine Reflinks, also belegt jede Kopie echten Platz. In einem JavaScript-Projekt mit großem node_modules zahlt jeder Worktree dafür, und eine kopierte Installation kann für den Worktree falsch sein, wenn sein Lockfile von der des Quell-Checkouts abweicht. Für Abhängigkeiten ist die Installation im Worktree der sicherere Standard. .worktreeinclude passt zu kleinen Dateien wie .env, und jeder Worktree hält dann seine eigene Kopie der Secrets, solange er existiert.

Was blockiert die Worktree-Isolation von Claude Code tatsächlich?

Bearbeitungen und git-Befehle, die auf den Haupt-Checkout zielen, beurteilt nach Pfad und Befehlstext. Laut dem Isolationsabschnitt der Doku verweigert Claude Code, solange eine Session in einem Worktree isoliert ist, ein Edit, Write oder NotebookEdit mit Ziel im Haupt-Checkout und einen Befehl, dessen Arbeitsverzeichnis der Haupt-Checkout ist. Für Bash und Monitor verweigert es außerdem git, das per git -C, --git-dir, GIT_DIR, GIT_WORK_TREE oder cd dorthin umgelenkt wird, und jeden Befehl, dessen git-Nutzung es aus dem Text nicht prüfen kann. PowerShell bekommt nur die Prüfung des Arbeitsverzeichnisses.

Die Doku benennt die Lücke ausdrücklich: Keine dieser Prüfungen verfolgt, welche Dateien ein Shell-Befehl schreibt, also wird ein cp oder eine Shell-Umleitung in den Haupt-Checkout von ihnen nicht verweigert. Das bleibt deinen Berechtigungsregeln überlassen und der Sandbox, falls du eine nutzt.

Einiges wird absichtlich geteilt, ebenfalls laut Doku. git-Schreibvorgänge aus einem Worktree landen im .git des Haupt-Repositorys, Commits funktionieren also von innen. „Yes, and don’t ask again“ für einen Bash-Befehl in einer Worktree-Session speichert die Regel in .claude/settings.local.json des Haupt-Checkouts, wo sie für jeden Worktree gilt und das Löschen desjenigen überlebt, in dem du sie bestätigt hast. In Hooks bleibt ${CLAUDE_PROJECT_DIR} beim Projektwurzelverzeichnis, in dem die Session gestartet ist, und der Worktree-Pfad kommt im Feld cwd der Hook-Eingabe an, das auch späteren cds folgt.

Die Isolation von Claude Code arbeitet also ebenfalls auf Dateiebene. Sie hält Bearbeitungen und git der Session aus deinem Checkout heraus. Um alles, was die Session startet, kümmert sie sich nicht.

Welche Ressourcen teilen sich parallele Claude Code Worktrees trotzdem?

Alles außerhalb der Dateien des Repositorys: Ports, Datenbanken, Container, Caches und API-Limits. Das geht zuerst kaputt, wenn zwei Sessions beide die App starten.

Der Port ist der einfachste Fall. Ich habe einen Server für einen Worktree gestartet und denselben Befehl für den anderen, auf einem freien Port, 3107:

$ python3 -m http.server -d .claude/worktrees/beta 3107 &
$ python3 -m http.server -d .claude/worktrees/gamma 3107
OSError: [Errno 98] Address already in use

Ein Dev-Server mit festem Port scheitert genauso. next dev verhält sich anders, wenn du weder --port noch PORT angibst: Laut start-server.ts im Next.js-Repository probiert es den nächsten Port, bis zu zehnmal, und meldet, dass der ursprüngliche Port belegt ist und stattdessen ein anderer verfügbarer Port genutzt wird. Die zweite Session hat dann einen funktionierenden Server auf einem Port, den sie nicht angefragt hat. Ob der Agent diese Warnung liest oder weiter gegen localhost:3000 testet, das der ersten Session gehört, ist die offene Frage.

Docker Compose ist weniger offensichtlich, weil es einen Teil des Stacks isoliert, ob du willst oder nicht. Standardmäßig benennt Compose ein Projekt nach seinem Verzeichnis, sofern nicht -p, COMPOSE_PROJECT_NAME oder ein name: auf oberster Ebene etwas anderes sagt, und das Verzeichnis eines Worktrees ist sein Name. Ich habe dieselbe compose.yaml, einen Postgres-Dienst, der 5432:5432 veröffentlicht, mit einem benannten Volume, in den Haupt-Checkout und zwei Worktrees gelegt und Compose gefragt, was es bauen würde (Ausgabe auf die relevanten Felder gekürzt):

shop:  name: shop   published: "5432"  volume: shop_pgdata
beta:  name: beta   published: "5432"  volume: beta_pgdata
gamma: name: gamma  published: "5432"  volume: gamma_pgdata

Ich habe bei docker compose config aufgehört und die Container nicht gestartet. Der Plan zeigt schon beide Hälften. Jeder Worktree bekommt einen eigenen Volume-Namen, also bekommt eine Session, die in einem neuen Worktree die Datenbank hochfährt, ein neues Volume: leer, sofern es kein Init-Skript oder dein Setup befüllt. Und jedes Projekt veröffentlicht denselben Host-Port, also kann nur eine dieser Datenbanken gleichzeitig laufen. Compose isoliert die Daten, die du wahrscheinlich teilen wolltest, und teilt den Port, den du trennen musst.

Der Rest ist Überlegung, hier nicht getestet. Ein Postgres direkt auf dem Host, außerhalb von Docker, wird von jedem Worktree geteilt, der dieselben Verbindungseinstellungen behält, also schreiben zwei Sessions, die Migrationen von zwei Branches ausführen, in dieselbe Datenbank. Paket-Caches wie ~/.npm werden ebenfalls geteilt, das ist harmlos. Rate-Limits einer externen API werden geteilt und gern vergessen: Zwei Sessions, die dieselbe Testsuite gegen ein Sandbox-Konto laufen lassen, ziehen aus demselben Kontingent.

Nichts davon ist ein Fehler von --worktree. Ein Worktree ist ein Werkzeug auf Dateiebene, und die Koordination drumherum muss außerhalb der Agents liegen. In der Praxis braucht jeder Worktree eigene Werte für alles, was maschinenweit gilt, und die kopierte .env ist der falsche Ort dafür, weil sie Port und Datenbanknamen des Haupt-Checkouts in jeden Worktree trägt. In meiner Testumgebung hat das funktioniert: den Port in compose.yaml aus einer Variablen lesen und die Werte pro Worktree in eine zweite Datei schreiben, die die kopierte überschreibt.

yaml
services:
  db:
    image: postgres:16
    ports:
      - "${PGPORT:-5432}:5432"
    environment:
      POSTGRES_DB: ${PGDATABASE:-shop}
$ printf 'PGPORT=5433\nPGDATABASE=shop_alpha\n' > .env.worktree
$ docker compose --env-file .env --env-file .env.worktree config
        published: "5433"
      POSTGRES_DB: shop_alpha

Das spätere --env-file gewinnt. Deine App muss dieselben zwei Dateien für ihren eigenen Port und Connection-String lesen. Wenn dir eine gemeinsame Datenbank lieber ist als eine leere pro Worktree, nimm eine einzige Datenbank auf dem Host oder ein externes Volume, das alle Worktrees teilen, und akzeptiere, dass sich die Branches dann die Daten teilen.

Bekommen Hintergrund-Sessions und Subagents von Claude Code automatisch Worktrees?

Hintergrund-Sessions meistens schon, vor ihrer ersten Bearbeitung; Subagents nur, wenn du darum bittest. Laut Agent-View-Doku beginnt eine mit claude --bg gestartete Session in deinem Arbeitsverzeichnis und wechselt in einen Worktree unter .claude/worktrees/, bevor sie Dateien bearbeitet. Den Wechsel lässt sie aus, wenn du eine schon offene Session in den Hintergrund geschickt hast, wenn die Session oder die bearbeitete Datei schon in einem verknüpften Worktree liegt, wenn der Schreibvorgang außerhalb des Arbeitsverzeichnisses liegt oder wenn die Session außerhalb eines git-Repositorys läuft und kein WorktreeCreate-Hook konfiguriert ist. worktree.bgIsolation: "none" in den Projekteinstellungen schaltet das ab.

Subagents teilen das Verzeichnis des Elternprozesses, es sei denn, einer hat isolation: worktree im Frontmatter oder du sagst Claude, „use worktrees for your agents“. Der Haken liegt in der Basis. Laut Doku nutzen Subagent-Worktrees dasselbe worktree.baseRef wie --worktree, also startet ein Subagent, der das Feature refactoren soll, an dem du gerade baust, mit dem Standard "fresh" von origin/main, ohne deine nicht gepushten Commits. Die Doku empfiehlt für genau diesen Fall "head".

Ebenfalls laut Doku: Ein Subagent in seinem eigenen Worktree liest CLAUDE.md und .claude/rules/ aus dem Checkout der Hauptunterhaltung, nicht aus dem Worktree, selbst wenn der Branch des Worktrees andere Versionen davon enthält. Ein Subagent-Worktree ohne Änderungen wird entfernt, wenn der Subagent fertig ist. Die übrigen, zusammen mit den Worktrees von Hintergrund-Sessions, werden nach cleanupPeriodDays weggeräumt, aber erst, wenn sie keine geänderten Dateien und keine nicht gepushten Commits mehr enthalten.

Wie räumt man Claude Code Worktrees auf, und was bleibt liegen?

Interaktiv erledigt Claude Code das meiste beim Beenden. Laut Doku entfernt eine unbenannte Session in einem sauberen Worktree den Worktree und seinen Branch automatisch, eine benannte Session fragt vorher, und ein Worktree mit Änderungen oder neuen Commits fragt immer. claude -p hat keine Abfrage beim Beenden, also bleiben seine Worktrees, und zwar gesperrt:

$ git worktree remove .claude/worktrees/beta
fatal: cannot remove a locked working tree, lock reason: claude session beta (pid 1153756 start 52741970)
use 'remove -f -f' to override or unlock first

Bevor du etwas entfernst, hol die Arbeit heraus. worktree-beta ist ein gewöhnlicher Branch, also merge oder cherry-picke ihn aus dem Haupt-Checkout wie jeden anderen. Dann, sobald die Session, der die Sperre gehört, beendet ist:

$ git worktree unlock .claude/worktrees/beta
$ git worktree remove .claude/worktrees/beta
$ git branch -d worktree-beta
Deleted branch worktree-beta (was 40e5569).

Die dritte Zeile steht da, weil git worktree remove das Verzeichnis löscht und den Branch worktree-beta behält. Nachdem ich zwei Worktrees von Hand entfernt hatte, blieben zwei Branches übrig. Nimm -d, das einen Branch mit nicht gemergten Commits verweigert, und heb -D für einen Branch auf, den du wirklich wegwerfen willst.

Mein Lauf hat zwei weniger offensichtliche Reste zutage gefördert. Ein claude -p-Aufruf, der an seinen eigenen Argumenten scheiterte, hat seinen Worktree trotzdem angelegt und gesperrt. Mein erster Versuch setzte --allowedTools "Bash" vor den Prompt, das variadische Flag nahm den Prompt als zweiten Tool-Namen, Claude Code beendete sich mit „Input must be provided either through stdin or as a prompt argument“, und git worktree list zeigte alpha auf 40e5569, gesperrt. Ein Skript, das claude -p --worktree in einer Schleife aufruft, braucht einen Aufräumschritt, der auch läuft, wenn der Aufruf scheitert. Und das Entfernen des Worktrees gamma mit der kopierten .env und node_modules darin lief ganz ohne Warnung. git verweigert das Entfernen eines Worktrees mit geänderten oder untracked Dateien, aber ignorierte Dateien werden mit dem Verzeichnis gelöscht, einschließlich allem, was ein Agent in einen ignorierten Pfad wie einen Build- oder Ausgabeordner geschrieben hat.

Laut Doku gibt die Bereinigung veralteter Sperren in einer späteren Session eine Sperre frei, deren Prozess beendet ist. Um die Sperren selbst zu sehen, gibt git worktree list --verbose jeden Sperrgrund mit seiner PID aus:

$ git worktree list --verbose
~/lab/shop                          ef9e2fd [feature/discount]
~/lab/shop/.claude/worktrees/alpha  40e5569 [worktree-alpha]
	locked: claude session alpha (pid 1153520 start 52741354)

Was du einrichten solltest, bevor eine zweite Claude Code Session auf demselben Repository läuft

Fünf Dinge, und die letzten drei betreffen das, was der Worktree nicht anfasst. Trag .claude/worktrees/ in .gitignore ein. Entscheide die Basis: "fresh" für Arbeit, die von main startet, und "head", wenn die zweite Session oder ein Subagent auf deinem Branch aufbauen muss, nachdem du committet hast, was sie braucht. Leg .env und andere kleine ignorierte Dateien in .worktreeinclude und führ die Installation der Abhängigkeiten im Worktree aus, statt sie zu kopieren. Gib jedem Worktree einen eigenen Host-Port und Datenbanknamen, in einer Datei, die die kopierte .env überschreibt, bevor der zweite Dev-Server oder die zweite Datenbank startet. Und nach jedem claude -p-Lauf: merge, was du behalten willst, dann unlock, remove und git branch -d, sonst stapeln sich gesperrte Checkouts und worktree-*-Branches.

Wenn der Branch vorher feststeht, ist der Weg von Hand immer noch kürzer: git worktree add -b fix/login ../shop-login origin/main benennt Branch und Startpunkt in einem Befehl, und ein darin gestartetes claude arbeitet dort. Zu diesem Schluss kam auch der Beitrag zu den verwalteten Worktrees von Codex CLI. Das verwaltete Flag lohnt sich, wenn der Checkout wegwerfbar ist und du das Aufräumen abgenommen haben willst. Laut Doku passiert das beim interaktiven Beenden; bei Skript-Läufen passiert es, wie meine Testumgebung zeigt, überhaupt nicht.

01 / Diskussion

Hier gibt es keine Kommentarspalte. Diskussionen laufen auf X.

Max Nardit@mnardit

02 / Weitere Artikel