# Isolate work with repository clones

> Create VMs with independent Git clones using devvm --clone, switch existing VMs between mounted folders and clones, and fetch VM branches to the host.

- Repository: devvmhq/devvm
- GitHub: https://github.com/devvmhq/devvm
- Human docs: https://grok-wiki.com/public/docs/devvmhq-devvm-4242986d2853
- Complete Markdown: https://grok-wiki.com/public/docs/devvmhq-devvm-4242986d2853/llms-full.txt

## Source Files

- `internal/cli/clone.go`
- `internal/vm/clone.go`
- `internal/hostgit/hostgit.go`
- `internal/cli/rm.go`
- `internal/vm/up.go`

---

---
title: "Isolate work with repository clones"
description: "Create VMs with independent Git clones using devvm --clone, switch existing VMs between mounted folders and clones, and fetch VM branches to the host."
---

`devvm --clone` provisions a project VM with an independent Git repository clone on the guest disk instead of mounting the host project folder read-write. The VM mounts only the read-only shared tools directory (`/mnt/devvm-tools`) and configured agent settings directories from the host store, preventing processes inside the guest from modifying the host working tree or `.git` directory. Existing VMs can inspect or toggle clone mode using `devvm clone [on|off]`, which remakes the VM from its image while preserving its home folder and VM settings.

## Clone lifecycle and host-guest sync

When a VM runs in clone mode (`clone.on = true` in `vm.json` and `git.mode = "clone"`), `devvm` seeds the guest repository over SSH on launch, configures a host Git remote pointing to the VM, and verifies unfetched commits and uncommitted worktree changes before deleting or remaking the VM.

```mermaid
sequenceDiagram
    autonumber
    participant CLI as devvm CLI (internal/vm)
    participant HostGit as Host Git (internal/hostgit)
    participant SSH as Pinned VM SSH (internal/vmssh)
    participant Guest as Guest VM Clone

    CLI->>HostGit: Validate repo root, HEAD commit, symbolic ref, OwnRepo
    CLI->>Guest: Create VM with Project="", CloneAt=<dir> (no folder mount)
    CLI->>Guest: sudo mkdir -p <dir> && chown && git init -q && receive.denyCurrentBranch=updateInstead
    CLI->>HostGit: git push --no-verify --no-recurse-submodules --no-signed (<all> or worktree ref)
    HostGit->>SSH: GIT_SSH_COMMAND (pinned HostKeyAlias, BatchMode=yes, ForwardAgent=no)
    SSH->>Guest: git-receive-pack '<dir>'
    CLI->>HostGit: git remote add <remote> <vm-name>:<dir>
    Note over HostGit,Guest: User or coding agent commits on branches inside Guest
    CLI->>Guest: Pre-delete / clone off check (for-each-ref, worktree list, status --porcelain)
    CLI->>HostGit: Offer fetch into refs/heads/saved/<remote>/<branch> (--refmap="")
    CLI->>HostGit: Save unique remote-tracking commits & git remote remove <remote>
```

## Create a VM with `--clone`

<Steps>
  <Step title="Verify repository prerequisites">
    Run `devvm --clone` from the top-level directory of a Git repository that has at least one commit and an attached branch (`HEAD` must be a symbolic reference, not detached).
  </Step>
  <Step title="Launch the clone VM">
    Create the VM and seed its guest clone:

    ```bash
    devvm --clone
    ```

    During creation, `devvm` sets `spec.Project = ""` and `spec.CloneAt = f.Dir`, requiring `backend.FeatureSSH` on the selected backend. Because the host folder is not mounted, host `.git` immutable flag locking (`chflags uchg`) and set-id file scans are skipped.
  </Step>
  <Step title="Fetch guest commits to the host">
    Once the VM is running and `~/.ssh/config` includes the `devvm` SSH configuration, fetch branches from the guest repository into the host repository using the added remote:

    ```bash
    git fetch devvm
    git log -p main..devvm/main
    ```
  </Step>
</Steps>

<Note>
Once a VM is created with `--clone`, subsequent `devvm` launches in that folder automatically open the clone VM without needing `--clone` again. Passing `--clone` to an existing VM that mounts the host folder fails with `clone_needs_new_vm`; use `devvm clone on` to switch an existing VM.
</Note>

### Repository validation rules

Before building images or creating a VM (`checkCloneRepo` and `gitprotect.OwnRepo`), and again immediately before seeding (`seedClone`), `devvm` validates the host directory so that pushing into the VM cannot leak objects or branches from an unrelated repository on the host:

| Check | Implementation rule | Failure message ID |
| --- | --- | --- |
| Host `git` binary | `exec.LookPath("git")` must succeed on the host | `clone_needs_git` |
| Repository top-level | `git rev-parse --show-toplevel` must equal the physical project directory | `clone_not_top` |
| Initial commit exists | `git rev-parse -q --verify HEAD` must succeed | `clone_no_commits` |
| Attached branch | `git symbolic-ref -q HEAD` must succeed (detached `HEAD` is refused) | `clone_detached` |
| Self-contained `.git` or valid linked worktree | `.git` must be a directory with no `commondir` pointing elsewhere, or a regular `.git` file for a linked worktree with a bidirectional link (`<repo>/.git/worktrees/<name>/gitdir` resolves back to `<dir>/.git` and `commondir` resolves to `<repo>/.git`), and `git rev-parse --path-format=absolute --absolute-git-dir --git-common-dir` must match | `clone_git_elsewhere` |
| No alternate object stores | Neither the git dir nor common dir may contain `objects/info/alternates` | `clone_alternates` |
| No symlinked refs or objects | Neither `objects`, `packed-refs`, `reftable`, `refs`, nor any entry inside `objects/` or `refs/` may be a symbolic link | `clone_git_elsewhere` |

<Tip>
If a repository uses `objects/info/alternates`, run `git repack -a -d` inside the repository and remove `.git/objects/info/alternates` before running `devvm --clone`.
</Tip>

### Seeding and branch copying

When `setUpVM` runs on a `--clone` VM where `clone.made.seeded` is `false`, `seedClone` initializes the guest repository at the exact host folder path (`f.Dir`) so shells and editors open at identical paths:

```sh
sudo mkdir -p -- "$1" && sudo chown -- "$(id -u):$(id -g)" "$1" && cd -- "$1" &&
git init -q && git config receive.denyCurrentBranch updateInstead && git symbolic-ref HEAD "refs/heads/$2"
```

After initializing the guest repository and pointing its `HEAD` to the host's current branch, `devvm` pushes refs from the host into the guest:

- **Standard repository:** Pushes `--all` (every local branch under `refs/heads/`).
- **Linked worktree (`gitDir != commonDir`):** Pushes only `refs/heads/<branch>:refs/heads/<branch>` and, if configured and valid as a single non-wildcard ref (`git check-ref-format`), its upstream ref `<upstream>:<upstream>`. Other branches in the common repository remain on the host.
- **Excluded items:** Tags, uncommitted staged or unstaged changes, untracked files, and ignored files (such as `.env` or `node_modules`) are not copied. If `git status --porcelain --untracked-files=no` is non-empty on the host, `devvm` emits the `uncommitted_not_copied` warning.
- **Retry on failure:** If seeding fails (for example, if SSH cannot reach the guest), `clone.made.seeded` remains `false` and the next `devvm` launch retries `seedClone`.

### Host remote and SSH config

During `seedClone`, `devvm` selects a remote name (`freeRemote`) and records it in `vm.json` under `clone.made.remote` before adding the remote to the host repository:

1. `devvm` (if unused or already pointing to `<vm-name>:<dir>`)
2. `devvm-<folder-slug>`
3. `<vm-name>` (full VM name, such as `devvm-web-71a99b1076d92c`)

If all three remote names are already taken by other URLs, `seedClone` fails with `remotes_taken`.

The remote URL is set to `<vm-name>:<dir>`. For standard `git fetch` or `git push` commands on the host to resolve `<vm-name>`:

- The VM must be running (`devvm stop` removes the VM from `~/.local/share/devvm/ssh_config`).
- `~/.ssh/config` must contain the `Include ~/.local/share/devvm/ssh_config` directive. If `devvm` cannot add the `Include` line (for example, when run without an interactive terminal), it warns with `fetch_until` and you can fetch explicitly with:

```bash
GIT_SSH_COMMAND='ssh -F ~/.local/share/devvm/ssh_config' git fetch devvm
```

## Hardened host Git and SSH transport

Because a mounted VM or a nested repository could write malicious configuration into `.git/config`, every Git subprocess that `devvm` executes on the host goes through `internal/hostgit.Command`, and every clone push or fetch uses a locked-down `GIT_SSH_COMMAND` in `internal/vm/clone.go`.

### Host Git environment and config overrides

`hostgit.Command` strips all `GIT_*` variables from the inherited environment, removes relative entries from `PATH`, and injects `GIT_CONFIG_COUNT` key-value pairs that take precedence over repository, worktree, and global config files:

| Setting / Variable | Value | Purpose |
| --- | --- | --- |
| `GIT_TERMINAL_PROMPT` | `0` | Never prompt interactively on the terminal |
| `GIT_NO_LAZY_FETCH` | `1` | Prevent partial-clone lazy object fetching from arbitrary remotes |
| `GIT_ALLOW_PROTOCOL` | `ssh` | Restrict transport protocol strictly to `ssh`, overriding any `protocol.<name>.allow` in `.git/config` |
| `GIT_SSH_COMMAND` | `false` (or pinned SSH function) | Neutralize `core.sshCommand` unless explicitly overridden by `clones.gitSSH` |
| `GIT_SSH_VARIANT` | `ssh` | Ignore `ssh.variant` overrides in repository config |
| `core.fsmonitor` | `false` | Disable `fsmonitor` background daemons or commands |
| `core.hooksPath` | `/dev/null` (`os.DevNull`) | Disable directory-based Git hooks |
| `core.alternateRefsCommand` | `true` | Prevent executing arbitrary commands to list alternate refs |
| `protocol.allow` / `protocol.ssh.allow` | `never` / `always` | Allow only SSH transport |
| `maintenance.auto` / `gc.auto` | `false` / `0` | Prevent background `git maintenance` or `git gc` |
| `hook.<event>.enabled` | `false` | Disable all 28 `githooks(5)` events and any `hook.<name>.enabled` defined in the repo config |
| `filter.<name>.clean` / `smudge` / `process` / `required` | `""` / `""` / `""` / `false` | Disable all clean, smudge, and process filter drivers named in the repo config |

When checking dirty status on the host (`hostgit.Status`), `devvm` runs `git -C <dir> --no-optional-locks status --ignore-submodules=all --porcelain --untracked-files=no` so Git neither writes index locks nor enters submodules.

### Pinned SSH wrapper for push and fetch

When `seedClone` pushes branches (`git push -q --no-verify --no-recurse-submodules --no-signed`) or `fetchUnfetched` fetches branches (`git fetch --no-tags --no-recurse-submodules --refmap=`), `gitSSHCommand` constructs a POSIX shell function for `GIT_SSH_COMMAND`:

```go
// internal/vm/clone.go
func gitSSHCommand(s vmssh.SSH, dir string) string {
	run := func(verb string) string {
		return "exec " + shell.QuoteJoin(append([]string{"ssh"}, s.CommandArgs(verb+" "+shell.Quote(dir))...))
	}
	return `f() { for c; do :; done; case $c in "git-receive-pack "*) ` + run("git-receive-pack") +
		` ;; "git-upload-pack "*) ` + run("git-upload-pack") + ` ;; esac; exit 1; }; f`
}
```

This wrapper ignores the destination host, port, and flags that `git` passes to `ssh` (defeating `url.<base>.insteadOf`, `url.<base>.pushInsteadOf`, or modified remote URLs in `.git/config`) and connects directly to `127.0.0.1:<vm-ssh-port>` with:
- `-F none` (ignores `~/.ssh/config`)
- `-o BatchMode=yes`
- `-o UserKnownHostsFile=~/.local/share/devvm/vms/<hash>/run/known_hosts` and `-o GlobalKnownHostsFile=/dev/null`
- `-o HostKeyAlias=<vm-name>` and `-o StrictHostKeyChecking=yes`
- `-o ForwardAgent=no` and `-o ClearAllForwardings=yes`
- Command restricted strictly to `git-receive-pack '<dir>'` or `git-upload-pack '<dir>'`

## Subfolders and multiple agents in one clone VM

### Subfolder resolution

- Running `devvm`, `devvm host`, or `devvm code` inside a subdirectory of a `--clone` VM's project folder uses the parent folder's `--clone` VM (unless a nested `.git` repository sits in between). `target.shellDir` tests whether the subdirectory exists inside the guest clone (`test -d <sub-path>`): if present, the shell or editor opens in that subdirectory; if absent (for example, an untracked host directory), it falls back to the clone's top-level directory (`t.f.Dir`).
- Running `devvm --clone` inside a subdirectory always targets the subdirectory's own VM (`need{own: o.Clone}`), identical to `--here`, and requires the subdirectory itself to be the root of a valid Git repository.

### Running multiple coding agents with Git worktrees

Multiple coding agents can work concurrently inside a single `--clone` VM by creating linked Git worktrees inside the clone directory (the parent directory above the clone is owned by `root`, so worktrees must be placed inside the clone):

```bash
echo .worktrees/ >> .git/info/exclude
git worktree add -b agent/api .worktrees/api
cd .worktrees/api
```

All worktrees inside the clone share the guest repository object store, so a single `git fetch devvm` on the host retrieves every worktree branch (such as `devvm/agent/api`). When the VM is deleted or switched off clone mode, `cloneCheck` inspects up to 100 linked worktrees inside the clone directory for uncommitted changes.

## Switch clone mode on an existing VM (`devvm clone`)

`devvm clone` inspects or switches whether the current folder's VM uses an isolated clone (`on`) or mounts the host folder (`off`).

<CodeGroup>
```bash title="Show current mode"
devvm clone
# Output: on (or off)

devvm clone --json
```

```bash title="Switch mounted VM to clone mode"
devvm clone on
# Or non-interactively in scripts:
devvm clone on --force
```

```bash title="Switch clone VM to mounted folder"
devvm clone off
# Skip clone unfetched/dirty checks (loses unfetched guest work):
devvm clone off --force --no-clone-check
```
</CodeGroup>

### Command syntax and flags

```text
devvm clone [on|off] [flags]
```

<ParamField body="on | off" type="string">
  Target clone mode. Omit the positional argument to print the current mode (`on` or `off`). Passing `on` or `off` remakes the VM from its current image in the requested mode and opens a shell in it.
</ParamField>

<ParamField body="--force, -f" type="boolean" default="false">
  Skip the remake confirmation prompt (`remake_confirm`), which is required when running without an interactive terminal or with `--no-input`. Does **not** bypass the interactive prompts for unfetched commits or uncommitted changes during `devvm clone off`.
</ParamField>

<ParamField body="--no-clone-check" type="boolean" default="false">
  Skip checking a `--clone` VM's clone for unfetched commits and uncommitted changes before `devvm clone off` replaces the VM disk. Unfetched commits and uncommitted changes in the guest are lost.
</ParamField>

<ParamField body="--no-export" type="boolean" default="false">
  Skip exporting coding agent session history before remaking the VM.
</ParamField>

<ParamField body="--clean-home" type="boolean" default="false">
  Initialize the remade VM with the image's clean home folder instead of saving and restoring the existing VM's home folder (`home.tar.gz`).
</ParamField>

<ParamField body="--json" type="boolean" default="false">
  Print the current clone mode as a version 1 JSON object (`{"version": 1, "clone": "on"}`). Cannot be combined with `on`, `off`, `--force`, `--no-export`, `--clean-home`, or `--no-clone-check` (`json_shows_only`).
</ParamField>

<ParamField body="--here" type="boolean" default="false">
  Use this folder's own VM, never a parent folder's VM. Mutually exclusive with `--parent`.
</ParamField>

<ParamField body="--parent" type="boolean" default="false">
  Use the nearest parent folder's VM, even across a nested Git repository. Mutually exclusive with `--here`.
</ParamField>

### How `devvm clone on` and `devvm clone off` remake the VM

Switching clone mode uses the transactional remake pipeline (`Lifecycle.SetClone` -> `remaking.remake`):

| Transition | Pre-remake checks | State & host mutations during remake | Rollback (`putBack`) if interrupted before home restore |
| --- | --- | --- | --- |
| `devvm clone on` | Runs `checkCloneRepo` on the host folder and `remaking.check` (backend `FeatureSSH`, image layers, disk space). Prompts `remake_confirm` unless `--force`. | Exports agent history (unless `--no-export`), saves `/home/<user>` to `home.tar.gz` (unless `--clean-home`), renames old VM to `devvm-<hash>-old`, creates new VM with `CloneAt = dir`, sets `clone.on = true`, starts VM, restores home, deletes `devvm-<hash>-old`, runs `gitprotect.Check` + unlocks host `.git` (`nouchg`), sets `git.mode = "clone"`, and runs `seedClone`. | Deletes new VM, renames `devvm-<hash>-old` back, resets `clone` section to `cloneInVM{}`, preserves host `.git` locks. |
| `devvm clone off` | Runs `remaking.check`, prompts `remake_confirm` (unless `--force`), then runs `cloneCheck` (unless `--no-clone-check`) to offer fetching unfetched branches and warn on dirty worktrees. | Exports agent history, saves home, renames old VM to `devvm-<hash>-old`, creates new VM mounting `Project = dir`, sets `clone.on = false` and `git.mode` (`on` on macOS, `off` on Linux), saves unique remote-tracking branches under `saved/<remote>/*`, removes host Git remote, locks host `.git` (`uchg` on macOS), starts VM, restores home, and deletes `devvm-<hash>-old`. | Deletes new VM, renames `devvm-<hash>-old` back, unlocks host `.git`, re-adds host Git remote `<remote>`, sets `git.mode = "clone"` and `clone.on = true`. |

<Warning>
Because a `--clone` VM stores its repository clone on the guest disk, `devvm layer add`, `devvm layer rm`, `devvm update`, and `devvm adopt <VM>` refuse to run on a `--clone` VM (`remake_clone` / `adopt_clone`). To change layers or update the base image of a `--clone` VM, run `devvm clone off`, run the layer or update command, and then run `devvm clone on`.
</Warning>

## Pre-delete checks and `saved/` branches (`devvm rm`)

When `devvm rm` (or `devvm clone off`) runs on a seeded `--clone` VM (`clone.made.seeded == true`), `cloneCheck.run` starts the VM if stopped (enforcing its network confinement self-check) and inspects the guest clone before any disk state is destroyed.

### 1. Unfetched commits check

`devvm` runs `git -C <dir> for-each-ref --format="%(objectname) %(refname)" refs/heads` inside the VM (capped at `cloneOutMax = 1 MiB`). For each branch SHA matching `^[0-9a-f]{40,64}$`, `devvm` checks on the host whether the commit is already reachable from any ref in the host repository:

```bash
git -C <dir> rev-list -n1 <sha> --not --all
```

If any guest branches contain commits missing from the host repository:
- Branch names rejected by `git check-ref-format refs/heads/<branch>` are warned (`branch_names_invalid`) and skipped.
- `devvm` prompts `fetch_before_delete` (`[Y/n]`) offering to fetch the branches into `saved/<remote>/<branch>`.
- Without a terminal or with `--no-input`, `devvm` refuses with `no_terminal_fetch` (even if `--force` was passed) and keeps the VM.

### 2. Uncommitted worktree changes check

Next, `cloneCheck.changes` inspects the main clone directory and linked worktrees inside it:
- Runs `git -C <dir> worktree list --porcelain -z` in the guest.
- Filters linked worktrees (`cloneWorktrees`) to clean absolute paths strictly below `<dir>`, skipping `bare` and `prunable` entries, up to `cloneWorktreesMax = 100` worktrees.
- Runs `git status --porcelain` in `<dir>` and in each existing linked worktree directory, printing up to `cloneShown = 20` status lines total across all worktrees.
- If any worktree has uncommitted changes—or if the clone has more than 100 linked worktrees (`clone_worktrees_unchecked`)—`devvm` prompts `delete_anyway` (for `devvm rm`) or `remake_anyway` (for `devvm clone off`).
- Without a terminal or with `--no-input`, `devvm` refuses with `no_terminal_delete` (even if `--force` was passed) and keeps the VM.

### 3. Remote removal and `saved/<remote>/<branch>` preservation

When the VM is deleted (or switched to `clone off`) and the host remote's URL still matches `<vm-name>:<dir>`, `removeRemote` removes the remote with `git remote remove <remote>`. Because removing a remote deletes its remote-tracking refs (`refs/remotes/<remote>/*`), `saveFetched` first checks whether any remote-tracking branch contains commits not reachable from any other ref in the host repo:

```bash
git -C <dir> rev-list -n1 <sha> --not --exclude=refs/remotes/<remote>/* --all
```

Any remote-tracking branch with unique commits is atomically created as a local branch via `git update-ref --stdin` under `saved/<remote>/<branch>` before `git remote remove <remote>` runs.

- **Collision-free prefix selection (`savedPrefix`):** `devvm` checks `refs/heads/saved/<remote>`. If a local branch already exists at, inside, or above that prefix, it tries `saved/<remote>-2`, `saved/<remote>-3`, up to `saved/<remote>-99`. If all 99 prefixes are taken, `fetchUnfetched` fails with `saved_names_taken` and `removeRemote` warns `remote_kept` and leaves the remote intact.
- **Deleting a clone VM whose host folder is gone (`devvm rm <VM>`):** If the host project folder was deleted, there is no host repository to receive unfetched commits, so `devvm rm <VM>` refuses a `--clone` VM unless `--no-clone-check` is passed (`rm_gone_clone`).

## State schema in `vm.json`

A `--clone` VM records its clone state in the `clone` section and sets the `git` section's `mode` to `"clone"` in `~/.local/share/devvm/vms/<hash>/vm.json`:

```json
{
  "clone": {
    "on": true,
    "made": {
      "remote": "devvm",
      "seeded": true
    }
  },
  "git": {
    "mode": "clone"
  }
}
```

<ResponseField name="clone.on" type="boolean">
  `true` when the VM uses an isolated guest clone instead of mounting the host project folder.
</ResponseField>

<ResponseField name="clone.made.remote" type="string">
  Name of the Git remote added to the host repository (`devvm`, `devvm-<slug>`, or `<vm-name>`). Recorded before `git remote add` runs so interrupted creations or `devvm rm` clean it up.
</ResponseField>

<ResponseField name="clone.made.seeded" type="boolean">
  `true` once `seedClone` has initialized the guest repository, pushed the host branches, and added the host remote.
</ResponseField>

<ResponseField name="git.mode" type="string">
  Set to `"clone"` while `clone.on` is `true`, indicating that host `.git` protection locks do not apply.
</ResponseField>

## Related pages

<CardGroup cols={2}>
  <Card title="Host and Git isolation boundaries" href="/host-and-git-isolation">
    Read-only .git mount protection, symlink and hardlink containment, set-id file audits, and secret placeholder injection.
  </Card>
  <Card title="VM lifecycle and state" href="/vm-lifecycle-and-state">
    How project paths map to VM names and hashes, state persistence in vm.json, and in-place vs. remake transitions.
  </Card>
  <Card title="Connect SSH, VS Code, and VNC desktop" href="/ssh-editors-and-desktop">
    Configure ~/.ssh/config Include entries, per-VM SSH agents, and VS Code Remote-SSH connections.
  </Card>
  <Card title="CLI reference: network, security, and agent commands" href="/cli-security-network-and-agent-commands">
    Complete command syntax and flags for devvm clone, git-protect, network, secret, and agent.
  </Card>
</CardGroup>
