# Your agent is probably using git worktrees

> Coding agents reach for git worktrees as the unit of parallel work. The mental model and four commands to stay in control.

April 20, 2026 · 12 min read · https://yasint.dev/agents-and-git-worktrees/
Tags: git, engineering, workflow

---

I've used git worktrees for years. Mostly to review a PR without blowing up my current checkout, or to keep a long-running build isolated while I hacked on something else. Useful. Not life-changing.

Then I started running coding agents on multiple tasks at once, and the work suddenly had to go _somewhere_. The obvious somewheres don't really work.

A second branch? I already have one checked out. A fresh clone of the repo for every task? Slow, wasteful, everything drifts apart the moment I fetch. `git stash`? Fine for one task. Not four.

That's when worktrees stopped being a nice little trick and started feeling like the thing they were always built for.

My agents lean on them constantly now. Most of the time I don't even see it happen.

---

## What a worktree actually is

OK so here's the thing. A git repo has one `.git/` directory. That's where every commit, every tree, every blob you've ever made lives. It's the database.

A _worktree_ is a separate working directory that shares that database, but has its own HEAD, its own index, its own files on disk. That's it. That's the whole concept.

In plain terms: multiple branches checked out at the same time, in different folders. No stashing. No "you have uncommitted changes, cannot switch branch." Each worktree minds its own business, and the shared `.git/` is the only thing tying them together.

![Shared .git with three working dirs on different branches](./worktrees.png "One database, three worktrees. Each one gets its own branch, its own index, its own files.")

Before you ask: yes, you could just clone the repo three times. You'd also duplicate the object database three times, wait three times as long to set them up, and watch them drift independently every time you fetch.

You could also branch-switch in your existing checkout. Fine, until you have uncommitted changes, a running dev server, or a build watcher that doesn't know the files changed out from under it.

Worktrees sit in the middle. One database underneath, several working dirs alongside it. Making or deleting one stops feeling like a decision you have to think about.

```text
my-repo/              ← main worktree (contains .git/)
  .git/               ← the shared object database
  src/
../feature-x/         ← extra worktree on branch feature-x
  src/                ← fully independent working files
../bugfix-y/          ← extra worktree on branch bugfix-y
  src/
```

---

## Why agents reach for them

Because isolation is the natural unit of parallel work.

Two processes can't safely share a working directory. One agent runs `git checkout` mid-edit and the other's uncommitted changes vanish. A build writes to the same folder a watcher is staring at, and that watcher has a bad time. Give each task its own folder and most of this quietly stops happening.

You could clone the repo for each task to get that isolation. Worktrees get you there without duplicating the object store or waiting out a fresh setup every time.

Creating a worktree is nearly free. You get a fresh folder with its own HEAD and index, and the object database already on disk serves it. Nothing gets fetched or copied, which also means nothing to drift.

So the shape of a reasonable agent is short: spin up a worktree on a new branch, do the work, merge or throw it away. That's the whole loop.

If your agent runs multiple tasks in parallel without stomping on your working copy, this is almost certainly how. Mine does, constantly.

---

## What you give up by treating them as invisible plumbing

Here's the part that snuck up on me. When _I_ create a worktree, I know it exists. I picked the path. I'll remember to clean it up. When an _agent_ creates one, none of that is true. The tool is fine. I've been using it for years. What's different now is that when the agent uses it, it all happens without me looking.

A few ways this bites:

**Orphan worktrees pile up on disk.** Agent crashed, got killed, forgot to clean up. Doesn't really matter why. The folder sticks around. `du -sh` quietly grows. `git status` in my main checkout doesn't know a thing about it.

**Orphan branches multiply too.** I `rm -rf` the folder because it's the obvious move. The branch stays. The admin entry inside `.git/` stays. `git branch` gets longer and longer, and I start wondering where all those names came from.

**`pwd` stops being reliable.** I open a terminal inside what I _think_ is my main checkout, run `git status`, and see unfamiliar changes. I'm in a worktree. Commands I run do things I didn't expect.

**Commits get forgotten.** I made one in a worktree. Never merged it. The agent moved on. That commit is now reachable only from a branch I never look at, in a folder I've stopped opening.

None of these are the agent's fault. The agent's doing what I asked. The gap is that I wasn't watching.

---

## The four commands you actually need

Four. That's it.

(Well, four plus one bonus at the end, but really, four.)

```sh
git worktree add -b feature-x ../feature-x
```

Makes a new branch `feature-x` from HEAD, and sets up a new worktree at `../feature-x` checked out on it. Drop the `-b` if the branch already exists.

Use this when you want to spin up an isolated checkout yourself. Your agent does it for you, yes. You can too.

```sh
git worktree list
```

Every worktree. Its path, its HEAD, its branch. If something feels off, run this first. The annotations matter. `(bare)`, `(detached HEAD)`, `prunable` all tell you something.

```sh
git worktree remove <path>
```

The _right_ way to delete a worktree. Removes the folder and the admin entry git keeps inside `.git/`.

`rm -rf` is what leaves the admin entry behind. Don't do that.

```sh
git worktree prune
```

For when you already did the thing I just told you not to do.

`prune` reaps admin entries whose folders have vanished. Safe to run whenever. Good thing to run after you've been sloppy.

Oh, and: `git worktree add --detach <path>` makes a worktree in detached-HEAD state, no new branch. Useful for a one-off diagnostic checkout. That's the bonus.

---

## Go break it

Here's the whole thing in one sandbox. Add a few worktrees. Try `rm -rf` on one. The folder vanishes but the admin entry inside `.git/worktrees/` is still there, flagged red. Run `list` and spot the `prunable` annotation. Then `prune` to reap it. That's the contrast worth learning.

<script is:inline>{`
window.__wt = {
  c: {
    bg:'#0d0c0b', surface:'#1a1816', border:'#2a2622',
    accent:'#d4976a', accentHover:'#e8b08a', text:'#b8b0a3',
    heading:'#e8e2d9', muted:'#5c564e', warm300:'#9c9488',
    warm400:'#7a7268', warm700:'#2a2622',
    term:'#0a0908', hlBg:'#1f1b17', danger:'#c98a7d'
  },
  mono: "'Space Mono', monospace",
  reducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
  el: function(tag, styles, attrs) {
    var e = document.createElement(tag);
    if (styles) Object.assign(e.style, styles);
    if (attrs) Object.keys(attrs).forEach(function(k) { e.setAttribute(k, attrs[k]); });
    return e;
  },
  btn: function(label, onClick, small, ariaLabel) {
    var b = document.createElement('button');
    b.textContent = label;
    if (ariaLabel) b.setAttribute('aria-label', ariaLabel);
    Object.assign(b.style, {
      fontFamily: "'Space Mono', monospace", fontSize: small ? '13px' : '14px',
      padding: small ? '6px 12px' : '8px 18px',
      background: 'transparent', color: '#d4976a', border: '1px solid #2a2622',
      borderRadius: '6px', cursor: 'pointer', transition: 'all 0.15s'
    });
    b.addEventListener('mouseenter', function() { b.style.borderColor = '#d4976a'; b.style.background = '#1a1816'; });
    b.addEventListener('mouseleave', function() { b.style.borderColor = '#2a2622'; b.style.background = 'transparent'; });
    b.addEventListener('click', onClick);
    return b;
  },
  text: function(str, color) {
    var s = document.createElement('span');
    s.textContent = str;
    if (color) s.style.color = color;
    return s;
  },
  clearChildren: function(el) {
    while (el.firstChild) el.removeChild(el.firstChild);
  }
};
`}</script>

<Figure n="01" label="Worktree sandbox">
  <div id="w-wt-sandbox" class="not-prose" role="region" aria-label="git worktree sandbox">
    <noscript><p style="color:#9c9488;font-family:'Space Mono',monospace;font-size:13px;">This interactive widget requires JavaScript.</p></noscript>
  </div>
</Figure>

<script is:inline>{`
(function() {
  var root = document.getElementById('w-wt-sandbox');
  if (!root) return;
  var W = window.__wt, c = W.c, el = W.el;

  var HEAD_SHA = 'abc1234';
  var NAMES = ['feature-x', 'bugfix-y', 'experiment-1', 'refactor-2', 'wip-3', 'try-4'];
  function fresh() {
    return {
      main: { path: 'my-repo', branch: 'main', head: HEAD_SHA },
      extras: [],
      nextIdx: 0
    };
  }
  var state = fresh();

  var wrap = el('div', { background: c.surface, border: '1px solid ' + c.border, borderRadius: '8px', padding: '24px', fontFamily: W.mono, fontSize: '14px' });

  // Filesystem panel
  var fsLabel = el('div', { color: c.warm300, fontSize: '11px', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: '8px' });
  fsLabel.textContent = 'worktrees (on disk)';
  var fsPanel = el('div', { display: 'flex', flexDirection: 'column', gap: '4px', marginBottom: '18px' });

  // Admin entries panel
  var adminLabel = el('div', { color: c.warm300, fontSize: '11px', textTransform: 'uppercase', letterSpacing: '0.08em', marginBottom: '8px' });
  adminLabel.textContent = '.git/worktrees/ (admin entries)';
  var adminPanel = el('div', { display: 'flex', flexDirection: 'column', gap: '4px', marginBottom: '18px', minHeight: '36px' });

  // Terminal (output)
  var termWrap = el('div', { background: c.term, border: '1px solid ' + c.border, borderRadius: '6px', padding: '12px 14px', marginBottom: '18px', minHeight: '56px' });
  var termOutput = el('pre', {
    margin: 0, fontFamily: W.mono, fontSize: '13px',
    color: c.warm300, whiteSpace: 'pre-wrap', wordBreak: 'break-word'
  });
  termOutput.textContent = '# hit + add worktree, then rm -rf, then list. see the prunable flag? that\\'s what prune cleans up.';
  termWrap.appendChild(termOutput);

  // Global actions
  var actions = el('div', { display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap' });
  var addBtn = W.btn('+ add worktree', doAdd, true);
  var listBtn = W.btn('list', doList, true);
  var pruneBtn = W.btn('prune', doPrune, true);
  var resetBtn = W.btn('Reset', function() { state = fresh(); render(); termOutput.textContent = '# reset.'; termOutput.style.color = c.warm300; }, true);
  actions.appendChild(addBtn);
  actions.appendChild(listBtn);
  actions.appendChild(pruneBtn);
  actions.appendChild(resetBtn);

  wrap.appendChild(fsLabel);
  wrap.appendChild(fsPanel);
  wrap.appendChild(adminLabel);
  wrap.appendChild(adminPanel);
  wrap.appendChild(termWrap);
  wrap.appendChild(actions);
  root.appendChild(wrap);

  function printTerm(text, warn) {
    termOutput.textContent = text;
    termOutput.style.color = warn ? c.danger : c.warm300;
  }

  function doAdd() {
    if (state.nextIdx >= NAMES.length) {
      printTerm('# out of sample branch names. Reset to start over.', true);
      return;
    }
    var name = NAMES[state.nextIdx++];
    var path = '../' + name;
    state.extras.push({ path: path, branch: name, head: HEAD_SHA, dir: true, admin: true });
    printTerm("$ git worktree add -b " + name + " " + path + "\\nPreparing worktree (new branch '" + name + "')\\nHEAD is now at " + HEAD_SHA + " Initial commit");
    render();
  }

  function doList() {
    var lines = ['$ git worktree list'];
    lines.push(formatList(state.main.path, state.main.head, state.main.branch, false));
    state.extras.forEach(function(wt) {
      if (wt.dir) {
        lines.push(formatList(wt.path, wt.head, wt.branch, false));
      } else if (wt.admin) {
        lines.push(formatList(wt.path, wt.head, wt.branch, true));
      }
    });
    printTerm(lines.join('\\n'));
  }

  function formatList(path, head, branch, prunable) {
    var line = pad(path, 22) + ' ' + head + ' [' + branch + ']';
    if (prunable) line += '  prunable';
    return line;
  }

  function pad(s, n) {
    if (s.length >= n) return s;
    return s + new Array(n - s.length + 1).join(' ');
  }

  function doPrune() {
    var pruned = [];
    state.extras = state.extras.filter(function(wt) {
      if (wt.admin && !wt.dir) { pruned.push(wt.branch); return false; }
      return true;
    });
    if (pruned.length === 0) {
      printTerm('$ git worktree prune\\n(nothing to prune)');
    } else {
      printTerm('$ git worktree prune\\nRemoving worktrees/' + pruned.join('\\nRemoving worktrees/'));
    }
    render();
  }

  function doRemove(idx) {
    var wt = state.extras[idx];
    if (!wt) return;
    if (!wt.dir) {
      printTerm("$ git worktree remove " + wt.path + "\\nfatal: '" + wt.path + "' is not a working tree\\n# (directory is gone. run prune to reap the admin entry.)", true);
      return;
    }
    state.extras.splice(idx, 1);
    printTerm("$ git worktree remove " + wt.path + "\\n# removed directory and admin entry cleanly.");
    render();
  }

  function doRmrf(idx) {
    var wt = state.extras[idx];
    if (!wt || !wt.dir) return;
    wt.dir = false;
    printTerm("$ rm -rf " + wt.path + "\\n# directory gone. admin entry still lives inside .git/worktrees/. orphan.", true);
    render();
  }

  function renderFs() {
    W.clearChildren(fsPanel);
    // Main worktree first
    fsPanel.appendChild(makeFsRow(state.main.path, state.main.branch, state.main.head, true, true, -1));
    // Extras
    state.extras.forEach(function(wt, i) {
      // Only show in filesystem if dir is present OR it was once created (we show it as missing/struck-through if orphan)
      fsPanel.appendChild(makeFsRow(wt.path, wt.branch, wt.head, wt.dir, false, i));
    });
    if (state.extras.length === 0) {
      var hint = el('div', { color: c.muted, fontSize: '12px', padding: '4px 4px 0 4px', fontStyle: 'italic' });
      hint.textContent = '(no extra worktrees yet)';
      fsPanel.appendChild(hint);
    }
  }

  function makeFsRow(path, branch, head, present, isMain, idx) {
    var row = el('div', {
      display: 'flex', alignItems: 'center', gap: '10px',
      padding: '8px 10px', borderRadius: '4px',
      border: '1px solid ' + c.border, flexWrap: 'wrap'
    });
    if (!present && !isMain) {
      row.style.borderLeftColor = c.danger;
      row.style.borderLeftWidth = '3px';
      row.style.background = 'rgba(201,138,125,0.05)';
    }
    var pathSpan = el('span', {
      color: present ? c.heading : c.warm400,
      fontSize: '14px', flex: '2', minWidth: '140px',
      wordBreak: 'break-all',
      textDecoration: present ? 'none' : 'line-through'
    });
    pathSpan.textContent = path + '/';
    var branchSpan = el('span', { color: c.accent, fontSize: '13px', minWidth: '100px' });
    branchSpan.textContent = '[' + branch + ']';
    var headSpan = el('span', { color: c.warm400, fontSize: '12px' });
    headSpan.textContent = head;

    row.appendChild(pathSpan);
    row.appendChild(branchSpan);
    row.appendChild(headSpan);

    if (isMain) {
      var tag = el('span', { color: c.muted, fontSize: '11px', marginLeft: 'auto' });
      tag.textContent = 'main worktree';
      row.appendChild(tag);
    } else {
      var btns = el('div', { display: 'flex', gap: '6px', marginLeft: 'auto' });
      var rmBtn = W.btn('remove', function() { doRemove(idx); }, true);
      rmBtn.style.fontSize = '11px'; rmBtn.style.padding = '4px 10px';
      btns.appendChild(rmBtn);
      if (present) {
        var rmrfBtn = W.btn('rm -rf', function() { doRmrf(idx); }, true);
        rmrfBtn.style.fontSize = '11px'; rmrfBtn.style.padding = '4px 10px';
        rmrfBtn.style.color = c.danger; rmrfBtn.style.borderColor = c.warm400;
        btns.appendChild(rmrfBtn);
      }
      row.appendChild(btns);
    }
    return row;
  }

  function renderAdmin() {
    W.clearChildren(adminPanel);
    var shown = 0;
    state.extras.forEach(function(wt) {
      if (!wt.admin) return;
      shown++;
      var orphan = !wt.dir;
      var row = el('div', {
        display: 'flex', alignItems: 'center', gap: '10px',
        padding: '6px 10px', borderRadius: '4px',
        border: '1px solid ' + (orphan ? c.danger : c.border),
        background: orphan ? 'rgba(201,138,125,0.06)' : 'transparent'
      });
      var name = el('span', { color: c.heading, fontSize: '14px', flex: '1' });
      name.textContent = '.git/worktrees/' + wt.branch + '/';
      var status = el('span', { color: orphan ? c.danger : c.warm400, fontSize: '12px' });
      status.textContent = orphan ? 'orphan (dir gone)' : 'healthy';
      row.appendChild(name);
      row.appendChild(status);
      adminPanel.appendChild(row);
    });
    if (shown === 0) {
      var empty = el('div', { color: c.muted, fontSize: '12px', padding: '4px 4px 0 4px', fontStyle: 'italic' });
      empty.textContent = '(empty)';
      adminPanel.appendChild(empty);
    }
  }

  function render() { renderFs(); renderAdmin(); }
  render();
})();
`}</script>

---

Agents handle worktrees fine. They really do.

Still, if you can't name what they're doing, you can't audit the work, recover when something fails, or clean up after. So look.

Four commands.
