Running Multiple Claude Agents on One Repo: git worktree for Isolation, rebase for Convergence
Mac CLI plus a handful of Telegram chats, all pointing Claude at the same repo, none aware of each other — sharing one clone is guaranteed to blow up. git worktree pins each chat to its own branch and directory while keeping .git object storage shared, so disk cost is near-zero; pair that with rebase-onto-main at session start and PR merges at the end, and concurrent Claude agents go from "impossible" to "unremarkable".
The previous post covered how the Mac CLI and the Telegram bot split Claude's workload — that's "which one for which situation." This post solves a different problem: at any moment there are 3–5 Claude processes on the same Oracle instance editing the same deeppin repo — one via ssh from the Mac, one per active Telegram chat — **how do they not step on each other?**
`git worktree list` right now shows the deeppin repo with four working trees attached: the main clone on main, and three others — one per Telegram chat — each pinned to its own branch. This structure wasn't bolted on later. The Telegram bot has done it this way since v1, because without it, concurrent Claude agents basically don't work.
§ 011. What breaks without isolation
The intuitive approach is "all Claude sessions share one clone." It breaks within seconds. Every Claude turn does some combination of:
- Reading and writing files
- Running shell commands (pytest, git status, ls)
- git add / git commit / sometimes git push
Two concurrent sessions produce a swarm of races:
- A is mid-edit of file.py; B reads file.py and sees a half-written intermediate state
- A commits; B has staged unrelated files — A's commit accidentally includes B's changes
- A checks out branch x; B tries to check out branch y — HEAD is a single slot, whoever lands second clobbers the other's working context
Claude doesn't queue well. If you force every shell call to acquire a lock and every git commit to wait for its peer to release, the whole workflow becomes painful, and a single stuck session stalls everyone. Locks aren't the answer.
§ 022. One clone per chat? Disk runs out
Second idea: full git clone per chat. Physical isolation, clean. Cost:
# Per-clone cost estimate deeppin source ~ 80 MB node_modules (frontend) ~ 600 MB .git full object store ~ 50 MB ────────────────────────────── Per clone ~ 730 MB # 3–5 concurrent Claude sessions → 2–4 GB extra disk # Each independently fetches from origin → bandwidth multiplies
Oracle Free Tier's 200GB sounds like a lot, but the same machine also runs prod + staging + prometheus + grafana + searxng + the HuggingFace cache. A few chat clones chew through that quickly. Bigger problem: separate clones don't share objects. A commit in chat A isn't visible in chat B until A pushes to GitHub and B fetches — that's not concurrent editing, it's distributed multi-region replication.
§ 033. git worktree: one object store, many working trees
git worktree is a native primitive since git 2.5. One repo can have multiple working trees:
- Each worktree is a separate directory with its own HEAD and index
- All worktrees share the same .git object store (branches, commits, blobs)
- A branch can be checked out in only one worktree at a time — built-in mutual exclusion
Disk math works out: the primary clone has 80MB of source + 50MB of .git; each additional worktree costs just ~80MB of source. The .git dir in a linked worktree is a file, not a directory — it just points back to the main repo's object store. One copy of history forever.
$ git worktree list /home/ubuntu/deeppin 341a05c [main] /home/ubuntu/claude-telegram/.../chat--5105727107 341a05c [chat--5105727107] /home/ubuntu/claude-telegram/.../chat--5183801015 4c145db [chat--5183801015] /home/ubuntu/claude-telegram/.../chat-7133002692 7963c9a [chat-7133002692]
Four directories, four branches, one .git. A commit in any worktree is immediately visible in git log from the others (after checking out the branch) — not via push/pull, but because they physically share storage.
§ 044. Worktree lifecycle in the Telegram bot
claude-telegram/bot/worktree.py exposes three core functions. The structure is small; the essence is just this:
async def ensure_worktree(source: Path, chat_id: int, root: Path) -> Path:
"""Ensure a worktree exists for chat_id; return its absolute path."""
if not await _is_git_repo(source):
raise WorktreeError(f"{source} is not a git repository")
target = root / f"chat-{chat_id}"
branch = f"chat-{chat_id}"
if target.exists():
# Already there: validate it's a worktree of `source`, then reuse
return target.resolve()
# Branch doesn't exist → create from HEAD
if not branch_exists(source, branch):
run_git(source, "branch", branch)
# worktree add: check out branch into target
run_git(source, "worktree", "add", str(target), branch)
return target.resolve()On the first message from a Telegram chat, the bot calls ensure_worktree. The branch name is simply chat-<id> — Telegram chat_id is already unique and stable. On subsequent messages, target exists and we reuse it.
When the bot spawns the Claude process, it passes cwd = the worktree's absolute path. Claude's entire filesystem view starts there; it has no knowledge of /home/ubuntu/deeppin or the other chat-*/ directories.
§ 055. Sync at session start: rebase_onto_main
Worktree isolation stops two sessions from colliding on one working tree, but it introduces a new problem: chat-<id> drifts away from main over time. If I shipped a PR from chat A, then open chat B a few days later, chat B's base is still days-old main — it doesn't know what A merged.
Sync at session start:
async def rebase_onto_main(worktree: Path, main_branch="main"):
# Fetch latest main from origin
run_git(worktree, "fetch", "origin", main_branch)
# Rebase current branch onto origin/main
code, _, err = run_git(worktree, "rebase", f"origin/{main_branch}")
if code != 0:
# Conflict: abort and let the human handle it
run_git(worktree, "rebase", "--abort")
return False, f"rebase failed (aborted): {err}"
return True, "rebased onto origin/main"On conflict, don't try to auto-resolve — `rebase --abort` rolls back and the function returns an error for the human to handle. Claude can edit code fine, but conflict resolution is the kind of judgment call where silent mistakes mean lost work.
Net effect: every Claude session starts with base = latest main plus whatever this branch had already accumulated. No more writing changes on top of stale assumptions.
§ 066. Convergence: the PR model
When a chat-<id> branch is done with a piece of work, ship it via a normal GitHub PR into main. Same as any feature branch — the only wrinkle is that the branch name is auto-managed by the bot.
After merge, the branch still exists but is now stale. Two choices:
- Keep it: next time this chat continues, ensure_worktree reuses the same worktree + branch, and rebase_onto_main brings it back to latest main — clean continuity
- Clean up: /wt-reset removes the worktree but preserves the branch (so there's still a record of "what was this chat working on?" via git checkout)
I lean toward keeping. Continuations are usually related topics, and picking up the same branch is cleaner than opening a new one — rebase_onto_main refreshes the base anyway.
§ 077. What it means for Claude Code itself
Claude has no awareness of this concurrency setup. It boots up with cwd pointing at a directory and works within it:
- All Read / Edit / Write / Bash path resolution is rooted at cwd — other worktrees are invisible
- git status / git log / git diff calls see only the current worktree's perspective
- git commit targets the branch bound to this worktree (chat-<id>) — main is untouchable by accident
- pytest / build runs are fully independent — node_modules / __pycache__ in different worktrees don't cross-pollute
No cross-session locks, queues, or semaphores. Isolation lives in two hard fences — filesystem and git branch — and by construction sessions can't step on each other.
§ 088. Coordination with the Mac workflow
On the Mac I use worktrees on demand. For single-session interactive work — one Claude in one iTerm tab — the main clone is plenty; `git checkout feat/xxx` and go. But the moment I want a second Claude running alongside (say, a small fix in another iTerm tab, or keeping the dev server pinned to one branch while editing another) the same races that bite on Oracle bite on Mac. Time to spin up a worktree.
# Temporary worktree on Mac git worktree add ../deeppin-hotfix fix/xxx cd ../deeppin-hotfix claude # fully isolated from the main-dir Claude session # After the PR merges git worktree remove ../deeppin-hotfix # branch is preserved, just the directory goes
All paths converge at GitHub PR:
main (GitHub)
▲
│ PR / merge
│
┌────────────┼────────────┐
│ │ │
feat/xxx (Mac main) fix/xxx (Mac temp wt) chat-<id> (Oracle wt)
│ │ │
│ local commit + push │ bot commits + pushes
│ │ │
MacBook OracleThe merge point is the PR. If a Mac feat/ branch and some chat-<id> touched the same files, GitHub flags the conflict during PR review — surfaced centrally, resolved by a human. Better there than after merging into main.
Implicit contract: every Claude session's output goes through a PR, never directly to main. Earlier posts (CI/CD, staging-asymmetry's workflow table) already codify this; the worktree model strengthens it — branches are structurally divorced from main, and pushing to main takes explicit extra steps.
§ 099. A few non-obvious details
- Branch name = chat-<telegram_id> directly — no naming needed from users, globally unique, stable across sessions for the same chat
- ff_pull vs. rebase_onto_main: the main clone stays on main and uses fast-forward pull to keep history linear; worktree branches may have diverged by several commits, so rebase is the right shape
- The primary clone isn't a chat workspace: CI/CD and docker compose point at it, and Claude never writes to it — reduces accidental interference
- Worktree directory layout is workspace-scoped: workspaces/<workspace>/chat-<id>/, so one bot can manage multiple repos (one workspace root per project)
§ 1010. Why it's worth building
The tech isn't hard — git worktree has been around for a decade, and bot/worktree.py is under 100 lines. The hard part is recognizing "concurrent Claude needs filesystem-level isolation" as the problem to solve.
Once you see it, the rest is assembly. What it unlocks is a working mode that doesn't exist elsewhere: one person with N Claude agents, each on independent tracks, merging independently, no coordination overhead. Big refactor on the Mac while small fixes run in Telegram, plus a new feature dropped in from the subway. Fully concurrent, zero coordination.
For a solo developer, this is what makes "one person running multi-threaded" more than a slogan. Without worktree, all of the above has to queue.