← Back to Posts

AI coding agents outgrew Git's working tree

10/25/2026

AI coding agents outgrew Git's working tree

A couple of months ago, while building Lunaris, I wrote about how the extension mostly writes itself now. Instead of working on one feature at a time, I usually have three or four agents (Lunaris, Claude Code, or Antigravity) running at the same time on the same codebase.

At first, this felt like a superpower. Then I started losing code.

Here is what kept happening inside ~/code/lunaris/extension. Almost every feature in the VS Code extension has to register a message handler in src/webview/ipc/index.ts and wire up state in frontend/src/views/ChatView.vue:

// src/webview/ipc/index.ts
import { launchBrowserHandler } from './launchBrowser'
import { toggleVoiceHandler } from './toggleVoice.js'
import { mergeVoiceHandler } from './mergeVoice.js'
import { switchBrowserTabHandler } from './switchBrowserTab.js'

export const IpcHandlerMap: Record<string, IpcHandler> = {
  launchBrowser: launchBrowserHandler,
  switchBrowserTab: switchBrowserTabHandler,
  toggleVoice: toggleVoiceHandler,
  mergeVoice: mergeVoiceHandler
  // ...
}

Suppose I have one agent working on Whisper voice transcription (toggleVoiceHandler) and a second agent working on browser tab switching (switchBrowserTabHandler). Both agents are running in ~/code/lunaris/extension at the same time.

Agent 1 finishes first and writes its handler to src/webview/ipc/index.ts. Ten seconds later, Agent 2 finishes its turn. One of two things happens:

  1. Agent 2 had read src/webview/ipc/index.ts a minute ago, so when it writes its changes, it overwrites what Agent 1 just saved.
  2. Or worse, Agent 2 re-reads src/webview/ipc/index.ts before committing, sees toggleVoiceHandler sitting there uncommitted, assumes it's leftover junk that doesn't belong to its task, and deletes it.

And if one of those agents decides to run git checkout -b feat/voice-input so it can work on a branch? It moves .git/HEAD for the entire folder. My editor, my watcher running npm run compile, and the other three agents all get yanked onto feat/voice-input mid-run.

The problem isn't Git's object store

Git's object model (blobs, trees, and commits inside .git/objects) is great. There is nothing wrong with how Git stores history.

The problem is that Git gives an entire repository folder a single .git/HEAD, a single .git/index (with a single .git/index.lock), and a single working tree on disk. That made complete sense in 2005 when one human was typing into one editor at a time. It falls apart when four autonomous processes are editing files, switching branches, and running builds in the same directory.

The usual answer is git worktree.

I tried using worktrees for a while and hated the workflow. Every worktree is a separate folder on disk. None of your untracked .env files or local SQLite databases are there. Your node_modules and Rust target/ directories aren't there either, so every new worktree means waiting on a cold install and a cold compile, or maintaining hacky symlink scripts. Plus, if you want to actually look at the code in your editor, you end up with four separate IDE windows open.

I didn't want four folders. I wanted one folder (~/code/lunaris), and I wanted multiple agents to work in it at the same time without stepping on each other.

So I built Tessera (tes).

Parallel Agents in ~/code/lunaris

How Tessera works

Tessera is a CLI (tes) written in Rust that stores session and branch state inside .git/tessera. It doesn't replace Git or need a background server. Every tes commit and tes merge writes standard Git blobs, trees, and commits straight into .git/objects/ and refs/heads/*, so git push and GitHub PRs work the same way they always have.

What changes is how your working tree, branches, builds, and merges behave locally.

1. Separating Editor, Shell, and Agent Branches

In raw Git, your editor, your terminal shells, and your agents all share .git/HEAD.

Tessera tracks them as separate sessions inside .git/tessera/:

Editor, Terminal, and Agent Branches in ~/code/lunaris

When you run tes init and tes env --install in a repo, each shell and each AI agent gets its own session ID.

My editor can stay parked on main while I switch two terminal windows in the same ~/code/lunaris directory onto different branches:

# Terminal 1
$ tes switch feat/voice-input
(tes:feat/voice-input) ~/code/lunaris $ npm run compile

# Terminal 2
$ tes switch feat/browser-tool
(tes:feat/browser-tool) ~/code/lunaris $ npx vitest run

Both shells are still in ~/code/lunaris, and .git/HEAD hasn't moved.

When you prompt an AI agent, the rules installed by tes init tell the agent to coordinate through tes automatically:

# 1. Claim the branch and files before editing
$ tes claim --branch feat/voice-input --intent "Add Whisper voice IPC handler" src/webview/ipc/index.ts src/webview/ipc/toggleVoice.ts

# 2. Reconcile edits right after writing to disk
$ tes hook post-tool --path src/webview/ipc/index.ts src/webview/ipc/toggleVoice.ts

# 3. Run the build or tests inside the branch sandbox
$ tes exec -- npm run compile

# 4. Commit to the branch
$ tes commit -m "feat(voice): add toggleVoice IPC handler"

When Agent 1 is on feat/voice-input editing src/webview/ipc/index.ts and Agent 2 is on feat/browser-tool editing the same src/webview/ipc/index.ts, tes hook post-tool snapshots each agent's edit into its own branch tree inside .git/tessera/. Neither agent sees the other branch's uncommitted lines, so neither agent can overwrite or revert the other's work.

If I want my editor to switch over to feat/voice-input so I can inspect the files in the file tree, I pass --editor (or click the branch in the status bar):

$ tes switch feat/voice-input --editor

2. Copy-on-Write Sandboxes

Isolating Git refs doesn't help much if two agents run npm run compile or cargo test at the same time against the same files and build locks on disk.

When an agent runs tes exec -- npm run compile (or when you run a command inside a tes switch shell), Tessera materializes that branch's files inside a Copy-on-Write sandbox under .git/tessera/sandboxes/.

Instead of running npm install or recompiling Rust crates from scratch, Tessera uses OS-level Copy-on-Write syscalls (clonefile(2) on macOS APFS, ioctl(FICLONE) reflinks on Linux Btrfs/XFS, and hardlink/junction fallbacks elsewhere) to clone existing dependency and build caches into the sandbox in a few milliseconds:

  • JS / TS: node_modules, .pnpm-store, .next, .nuxt, .vite, .turbo
  • Rust: target
  • Python: .venv, venv, .pytest_cache, .mypy_cache, .ruff_cache
  • PHP / Go / Ruby: vendor, .bundle

Note, Tessera also copies your untracked .env* files and .gitignored local files (like local SQLite databases) into the sandbox, and rewrites hardcoded absolute paths inside cloned build caches (node_modules/.bin shims, .venv/bin/activate, CMake caches) so binaries don't point back at the root checkout.

Each session also gets a deterministic slot (1, 2, 3, ...) with its own port and database environment variables:

Slot 1 (feat/voice-input)  -> TES_PORT=3100, DATABASE_URL=sqlite:///.../tessera_slot_1.db
Slot 2 (feat/browser-tool) -> TES_PORT=3200, DATABASE_URL=sqlite:///.../tessera_slot_2.db

If you turn on tes config set port_isolation true, Tessera also sets PORT and injects port flags for Vite, Next.js, Nuxt, Astro, Cargo, Rails, Django, and Go so two dev servers can run side-by-side on :3100 and :3200 without port collisions.

3. 3-Way AST Merging with Tree-sitter

What if two agents aren't even on separate branches? Half the time, I don't bother naming a branch for small tasks. I just open two agents on main and ask them to change things.

Take src/core/InferenceGateway.ts from the Lunaris engine:

export class InferenceGateway {
  public async getModels(): Promise<CloudModel[]> {
    const now = Date.now()
    if (this._cachedModels && now - this._cacheTimestamp < MODEL_CACHE_TTL_MS) {
      return this._cachedModels
    }

    return this._fetchAndCache()
  }
}

Suppose both agents are on main:

  • Agent 1 is asked to add a forceRefresh: boolean = false parameter to getModels() to bypass the TTL cache.
  • Agent 2 is asked to add a localOnly: boolean = false parameter to getModels() so offline mode can filter out cloud models.

With line-based diffing, both agents modified the public async getModels() signature line. Whoever saves second wins, or a 3-way text merge spits out conflict markers on the signature:

<<<<<<< ours
  public async getModels(forceRefresh: boolean = false): Promise<CloudModel[]> {
=======
  public async getModels(localOnly: boolean = false): Promise<CloudModel[]> {
>>>>>>> theirs

With Tessera, both agents run tes claim on src/core/InferenceGateway.ts before editing, which records the base blob SHA from main. When each agent saves and runs tes hook post-tool --path src/core/InferenceGateway.ts, Tessera parses the base, ours, and theirs files into syntax trees with Tree-sitter.

Same-Branch 3-Way AST Merging on main

Because Tessera diffs the syntax tree instead of raw lines, it sees that Agent 1 added a ParamNode (forceRefresh = false), Agent 2 added a ParamNode (localOnly = false), and their if blocks in the method body don't clobber the same statement. It merges both edits directly onto disk:

export class InferenceGateway {
  public async getModels(
    forceRefresh: boolean = false,
    localOnly: boolean = false
  ): Promise<CloudModel[]> {
    const now = Date.now()
    if (
      !forceRefresh &&
      this._cachedModels &&
      now - this._cacheTimestamp < MODEL_CACHE_TTL_MS
    ) {
      return localOnly
        ? this._cachedModels.filter((m) => !this.cloudModelIds.has(m.id))
        : this._cachedModels
    }

    return this._fetchAndCache(localOnly)
  }
}

And if you run tes status, Tessera keeps track of which session owns which changes on main:

$ tes status
Branch: main
Session: claude-c3d4

Staged / Session Changes:
  M src/core/InferenceGateway.ts

Peer Session Changes on main:
  M src/core/InferenceGateway.ts (session: antigravity-a1b2, intent: "Add forceRefresh param to getModels")

Merging branches, lockfiles, and migrations

The same merge engine runs when you merge feature branches back into main:

$ tes merge feat/voice-input feat/browser-tool --into main -m "feat: add voice input and browser tool"

Remember src/webview/ipc/index.ts from the start of this post, where both branches added imports at the bottom of the import block and entries at the bottom of IpcHandlerMap? Because tes merge parses the TypeScript AST and object literal properties, both handlers merge into IpcHandlerMap without a conflict.

Besides Tree-sitter AST merging for source files (and <script setup> / <template> blocks in .vue and .svelte files), tes merge also handles a few things that constantly break in multi-agent repos:

  • Manifests and lockfiles: Dedicated merge drivers for package.json, Cargo.toml, Cargo.lock, pnpm-lock.yaml, yarn.lock, and poetry.lock merge dependency blocks by key and package stanza instead of line position.
  • Sequential SQL migrations: If two branches both create a migration prefixed 0015_*.sql, Tessera detects the number collision during tes merge and resequences the second branch's migration to 0016_*.sql.
  • Cross-directory renames: If one branch moves a file to a new directory while another branch edits a function inside it, Tessera matches the deleted and added blobs by content similarity and AST-merges the function changes into the new path.
  • O(1) unchanged subtree skipping: While walking the base, ours, and theirs Git trees, any directory whose tree OID didn't change on one side is resolved immediately without reading its blobs.

Editor Extension & 2D Branch Graph

While everything works from the CLI, I also built an extension for VS Code and VS Code-based editors (Antigravity, Cursor, Windsurf, Kiro, and VSCodium) so I can see what all running agents are doing at a glance.

Here is a screenshot from Antigravity while using Tessera to build Tessera itself (lunr):

Tessera Branch Graph and Sidebar in Antigravity

In the screenshot above, my editor is on main (ee883d9), while two feature branches are branched off HEAD in the same checkout:

  • feat/universal-block-merge has a committed change on src/crates/merge/src/text_merge.rs.
  • feat/universal-cow-ignore has an active agent session (agy-22e103) modifying cow.rs, lib.rs, and snapshot.rs in src/crates/workspace/, with its dev server assigned to localhost:3100.

In the Tessera sidebar on the right, you can see each branch, its dev server port, and every active agent session. Clicking Jump to Terminal (iTerm.app) under a session focuses the exact terminal window, tmux pane, or editor chat where that agent is running. From the Branch Graph header, you can switch your editor into any branch or click Merge into main to merge finished branches.

If two agents edit the same file on your active branch, the extension highlights each agent's lines in the editor with a colored gutter border and an inline label showing which agent owns those lines.

And when two agents do change the exact same expression to two different values, Tessera flags a real conflict, writes standard <<<<<<< conflict markers into the file, and lets you resolve it either by editing and saving the file or choosing AST Union, Keep Ours, or Keep Theirs in the Conflict Resolver. Every claim, file reconciliation, and merge is also logged in .git/tessera (tes oplog), so tes undo rolls back any operation cleanly.

Getting Started

If you want to try Tessera on your own repos, you can install the tes CLI with:

curl -fsSL https://tessera.getlunaris.dev/install.sh | sh

Or on Windows (PowerShell):

irm https://tessera.getlunaris.dev/install.ps1 | iex

Then initialize it inside any Git repository:

$ tes init
$ tes env --install

You can check out the docs at tessera.getlunaris.dev. If you run into any bugs or have questions, feel free to reach out at donaldpakkies@gmail.com.