Skip to content

turbo merge

Combine the changes several sessions made since they were forked from a common point back into one session. Use it when you branched a session with turbo fork and want the work from each branch in one place.

Usage

text
turbo merge [options] <target> <source> [<source>...]
turbo merge --status <target>
turbo merge --continue <target>
turbo merge --abort <target>

<target> must be stopped. Sources may be running; each is paused only for the moment it takes to record its state and is resumed afterwards.

Description

  • Merging is three-way. The merge base is the most recent snapshot every session in the merge has in common, found by walking each session's fork history. Sessions that share no fork point are refused; pass --base to name one yourself.
  • A path only one session changed is applied as it is. A path several sessions changed to the same result is applied once. A path several sessions changed differently is a conflict and goes to the merge driver for that kind of content.
  • Nothing in the target changes until the final swap. A preview, an unresolved conflict, a rejected resolution and a failing --verify all leave the target exactly as it was.
  • The swap names the overlay it expects to replace: the target's overlay as the plan was computed against it. If the target was written to in the meantime, the swap is refused and nothing changes, because the result was computed without those writes. Run the merge again to plan against what the target now holds.
  • A snapshot is named by what it holds, so its id is snp- followed by the whole content address, sixty-four hexadecimal digits. Printed output abbreviates it to the first twelve; --format=json carries all of it.
  • A part of a state unit whose selectors matched nothing in the unit is listed in the brief. That is a note rather than an error: a descriptor cannot know an application's tables in advance, and those entries, if they appear later, merge as the rest of their unit.
  • An unresolved conflict leaves a merge workspace under the target's session directory: merge-state.json, a readable brief.md, and one directory per conflict holding base, ours, one theirs.<session> per source, and an empty resolved slot to write into.
  • The merged result is built in a staging session named <target>.merging before it lands. Every file written there is read back and checked against what the plan said it would be.
  • The gate is on by default whenever a resolution was authored or a driver wrote conflict markers, so resolved work never lands untested. Start the staging session, build and test, then turbo merge --continue. Use --no-gate to opt out and --gate to force it for a clean merge.
  • Editing anything in the staging session between the merge and --continue is recorded as a post-merge edit, whether or not the path was in conflict.
  • Registry values and files are merged by default, and lock files, crash dumps and the per-machine local layer are left out entirely.
  • Application state is merged at a second level. A turbo state descriptor claims a region of the session's overlay as a state unit with a type, a class and a structural signature. A claimed region leaves path-by-path classification and is merged once, at its format's own grain, under the policy its class sets. What counts as application state comes from those descriptors and from nothing else, so the merge learns every application the state subsystem learns.
  • A unit whose signature differs between two sessions is at a different state schema version on each side. Their values are not comparable, so the merge reads no content and reports a version conflict.
  • Sessions forked onto a different image set with fork --images are refused, because their overlays cannot be compared path for path.
  • Hardened sessions created with --sandbox are refused: their state lives in service-owned storage the client cannot read.

Merge Drivers

The driver for a path is chosen by its name and content type.

DriverApplies toBehaviorConflict markers
textSource, configuration and other text filesLine-based three-way merge; hunks that do not overlap combineGit-style <<<<<<< blocks written into the file
keyed.json, .ini and .plist files and registry values no descriptor claimsThe same key-level merge a claimed unit gets, without a class or a signature above it; arrays are compared wholeWritten beside the file, so the document stays parseable
binaryAnything elseIdentical content is kept; anything else needs a side chosenWritten beside the file
unitEvery path a turbo state descriptor claimsThree-way at the unit's own grain, listed under Unit Types below. The unit's class sets the policy below thatWritten beside the file
git-repoA repository's .git directoryThe working tree merges normally; the repository is kept from the target, so it sees the merged tree as uncommitted changesNever conflicts
ignoreLock files, crash dumps, the local layerLeft out of the mergeNot applicable

Unit Types

The unit driver merges each unit type at the grain that type's own editor addresses. The last column says whether a merged file comes back with the comments and the ordering the user wrote.

TypeWhat one entry isComments and order preserved
jsonOne JSON Pointer to a leaf. An array is one entry, because an element's position is part of its meaningKey order preserved; the format has no comments
jsoncSame as jsonYes, both
iniA section and a key together, so the same key under two headings is two entriesYes, both
rdpOne connection setting. The setting's type is part of its value, so a setting that changed type is a changed settingYes, both
registryOne value, compared as <kind>:<canonical bytes> so two values differing only in type are different valuesNot applicable
dirOne file of the tree, by content hashNot applicable
kvOne entry of the store, by content hashNot applicable
sqliteOne row, addressed as described belowThe result is the target's own database with rows applied, so everything else is byte-identical
plistOne key path to a leaf. An array is one entry. Binary property lists come back binaryKey order preserved; the editor drops XML comments
xmlOne element's text, or one attribute. An element's position among its same-named siblings is part of its addressOrder preserved; the editor drops comments
yamlOne mapping path to a leaf. A sequence is one entryOrder preserved; the editor drops comments
propertiesOne property keyYes, both
mozprefsOne preference. Its type is part of its value, so a boolean true is not the string "true"Yes, both

A sqlite unit is addressed row by row. A row's identity is the table's primary key where one is declared, and otherwise a single column carrying a unique index, which is the shape a key-value table such as VS Code's ItemTable takes. A row's value is the remaining columns, each in a canonical form that keeps its storage class, so a text 1 and an integer 1 are different values. Where a sub-unit selects rows by a key pattern, the merger sees only the rows that pattern matches; a table with neither a primary key nor a unique column is irreducible, naming the table. The merged result is produced by applying inserts, updates and deletes to a copy of the target's database, never by rewriting the file, so indexes, user_version and every page the merge did not touch survive unchanged.

State Unit Classes

The class a descriptor gives a unit is the merge policy for that unit.

ClassMeaningMerge policy
criticalMust survive; often licensing, identity or secretsMerged at the format's grain. Every collision is a conflict for whoever is resolving; --prefer never settles one
importantThe user's own configuration and dataMerged at the format's grain; disjoint keys, entries and values combine without a decision
derivedReconstructible from other state, such as recent-file lists and window layoutThe target's copy is kept, with one line saying so
cacheDisposableLeft out of the merge entirely

A unit its descriptor marks as secret-bearing, such as a browser cookie store or a credential cache, is never read. Its conflict directory in the workspace holds the signatures alone, choosing a side is the only resolution it accepts, and --units=none does not reopen it.

A unit whose parts are classified differently, such as a key-value store holding both identity and recent-file lists, splits into sub-units along the rules its descriptor gives. A part the split cannot place is treated as critical.

A unit type the merge does not yet understand is still classified and still follows its class policy. A collision inside one is irreducible, with the reason.

Conflict Classes

ClassCauseHow to resolve
valueThe same path changed differently on two or more sidesThe driver merges it where it can; otherwise --accept, --resolve or --prefer
versionA state unit is signed differently on two sides, so they are at different state schema versionsMigrate the older side with turbo state plan and turbo state run, then merge again, or choose a side with --accept. Never merged automatically; --prefer does not apply and authored content is refused
structuralDeleted on one side and modified on another, or a file on one side and a directory on another--accept or --resolve only; --prefer does not apply
dependentA rule binds paths that must move together, such as a registry key with its subkeys or a .git directoryResolved as one group, by the group's id
irreducibleContent no driver understands changed on more than one sideA side must be chosen
policyPolicy forbids writing the path into the targetDrop the path or escalate

Options

Planning

  • --preview — Compute the plan, print it, and write nothing.
  • --base=VALUE — Use this snapshot as the merge base instead of the computed one. Needed for sessions forked before fork points were recorded. A snapshot id is snp- and sixty-four hexadecimal digits, so any leading part of one is accepted as long as it is at least eight digits and names exactly one stored snapshot; a prefix that names several is refused and lists them.
  • --path=VALUE — Restrict the merge to a subtree. Repeatable.
  • --registry-path=VALUE — Restrict the merge to a registry key. Repeatable.
  • --subsystems=VALUE — Which subsystems to merge. Supported values: files, registry. Default: both. registry is refused on macOS, which has no registry.
  • --units=VALUE — Which state units to merge semantically. Supported values: all (default), none, or a comma-separated list of unit-id globs such as vscode.*. none merges every claimed path as a raw file instead, which is the escape hatch for a descriptor that is wrong about a path; it does not open a secret-bearing unit.

Resolution

  • --prefer=VALUE — Default side for value conflicts. Supported values: ours, theirs, or a session name. theirs is only valid with one source. Does not apply to structural, version or policy conflicts, nor to any conflict inside a critical state unit.
  • --accept=VALUE — Resolve one conflict by taking a side, as <id>=<session> or <id>=ours. The id is a conflict id or, for a single-path conflict, the path itself. Repeatable.
  • --resolve=VALUE — Resolve one conflict with content you wrote, as <id>=<file> or <id>=delete. <id> alone takes whatever is in the workspace's resolved slot. Repeatable.
  • --resolutions=VALUE — Apply a JSON array of resolutions from a file. This is the form an agent uses.
  • --reason=VALUE — Rationale recorded with this invocation's resolutions, in the session record and the audit stream.
  • --markers — Put both sides into the result and carry on instead of stopping. Text files get conflict markers; other formats get each side written beside the file.

Landing

  • --new=VALUE — Write the result to a new stopped session with this name and leave every input untouched.
  • --gate — Stop after building the result so it can be started, tested and fixed before it lands.
  • --no-gate — Swap without stopping, even when resolutions were authored.
  • --verify=VALUE — Run this command in the staging session; swap only when it exits zero.

Resuming

  • --status — Print the pending merge for the target: its conflicts, what is resolved and by whom, and where it stopped. Add --format=brief to print it as a prompt for whoever has to finish it.
  • --continue — Finish a pending merge. Refuses while any conflict marker remains.
  • --abort — Discard a pending merge and leave the target as it was. After a completed merge it restores the overlay the merge replaced, which is the snapshot recorded as mergeUndoSnapshot.

Output

  • --format=VALUE — Use the specified format for output. Supported values: json, brief.

Examples

Branch a prepared session twice and see what the branches did:

bash
turbo fork dev-vscode agent-a
turbo fork dev-vscode agent-b
turbo merge --preview dev-vscode agent-a agent-b
text
Merge base snp-4WCZvMDCY7JJSFEvh
  union       142 paths
  identical     3 paths
  kept target  61 paths (derived units)
  excluded     88 paths (cache units)
  units      vscode.settings (jsonc, 1 path), vscode.extensionsDir (dir, 2 paths), vscode.globalStorage.state (sqlite, 1 path), vscode.globalStorage.state#mru (sqlite, derived, the target's copy kept (1 path)), vscode.globalStorage.state#secrets (sqlite, credentials, the target's copy kept unread), vscode.workspaceStorage (dir, derived, the target's copy kept (61 paths))
  conflicts     3
    cfl-4MJyMitrdrh8nThz8  value       src/server/routes.ts     agent-a overlaps at base line 31-44
    cfl-7Qk2mVxN1sRtYbZwP  structural  docs/old-readme.md       deleted by agent-a, modified by agent-b
    cfl-9Fs5pLwR3mQtXvNdC  value       state.vscdb              vscode.globalStorage.state (sqlite, critical): entries ItemTable/workbench.panel.state

The editor's key-value database is one unit, and its parts are not merged alike. The recent-workspace rows are derived, so the target's copies are kept and nobody is asked about them. The credential rows are secret-bearing, so no side's content is read at all. The rows that are neither combine row by row, and the one row both sides wrote differently is the conflict above, named by its table and key.

Resolve from outside, submitting decisions with reasons and proving the result before it lands:

bash
turbo merge dev-vscode agent-a agent-b --resolutions=decisions.json --verify="npm test"

decisions.json holds one object per conflict:

json
[
  {
    "id": "cfl-7Qk2mVxN1sRtYbZwP",
    "action": "accept",
    "session": "agent-b",
    "reason": "agent-a's delete predates agent-b's doc fix"
  },
  {
    "id": "cfl-4MJyMitrdrh8nThz8",
    "action": "resolve",
    "content": "answers/routes.ts",
    "reason": "kept both route registrations, ordered by path"
  }
]

Resolve from inside instead, using the same idiom a coding agent already uses for a git conflict:

bash
turbo merge dev-vscode agent-a agent-b --accept=cfl-7Qk2mVxN1sRtYbZwP=agent-b --markers --gate
turbo merge --status dev-vscode --format=brief > brief.md
turbo start dev-vscode.merging
# resolve the markers and run the tests in that session, then:
turbo merge --continue dev-vscode

Undo a merge that has already landed:

bash
turbo merge --abort dev-vscode

A merge whose sources still write to host paths has nothing to combine. Under the default write-copy isolation a write to a host path goes to the host, so two branches editing a host-mounted workspace overwrite each other. Run the session with merge or full isolation for the workspace before forking it.

Output

With --format=json the command emits a merge object holding the plan, the conflicts and the outcome:

json
{
  "merge": {
    "target": "dev-vscode",
    "sources": ["agent-a", "agent-b"],
    "base": "snp-4WCZvMDCY7JJSFEvh",
    "union": 142,
    "identical": 3,
    "keptTarget": 61,
    "units": [
      {
        "id": "vscode.settings",
        "type": "jsonc",
        "class": "critical",
        "outcome": "merged",
        "paths": 1,
        "detail": "1 path"
      },
      {
        "id": "vscode.globalStorage.state",
        "type": "sqlite",
        "class": "critical",
        "outcome": "merged",
        "paths": 1,
        "detail": "1 path"
      },
      {
        "id": "vscode.globalStorage.state",
        "subUnit": "mru",
        "type": "sqlite",
        "class": "derived",
        "outcome": "kept-target",
        "paths": 1,
        "detail": "derived, the target's copy kept (1 path)"
      },
      {
        "id": "vscode.globalStorage.state",
        "subUnit": "secrets",
        "type": "sqlite",
        "class": "critical",
        "outcome": "kept-target",
        "paths": 1,
        "detail": "credentials, the target's copy kept unread",
        "secretBearing": true
      }
    ],
    "conflicts": [
      {
        "id": "cfl-4MJyMitrdrh8nThz8",
        "class": "value",
        "subsystem": "files",
        "paths": ["src/server/routes.ts"],
        "driver": "text",
        "detail": "agent-a overlaps at base line 31-44",
        "resolved": false,
        "sides": [
          { "session": "dev-vscode", "change": "modified", "entry": "file", "hash": "…", "size": 4096 },
          { "session": "agent-a", "change": "modified", "entry": "file", "hash": "…", "size": 4210 }
        ]
      },
      {
        "id": "cfl-2NpQrStUvWxYzAbCd",
        "class": "version",
        "subsystem": "files",
        "paths": ["@PROFILE@\\AppData\\Roaming\\Code\\User\\settings.json"],
        "driver": "unit",
        "unitId": "vscode.settings",
        "unitType": "jsonc",
        "unitClass": "critical",
        "detail": "vscode.settings is at different state schema versions: agent-a jsonc:5f3a…, dev-vscode jsonc:91cd…",
        "resolved": false,
        "signatures": {
          "dev-vscode": "jsonc:91cd…",
          "agent-a": "jsonc:5f3a…"
        },
        "sides": [
          { "session": "dev-vscode", "change": "modified", "entry": "file", "hash": "…", "size": 812 },
          { "session": "agent-a", "change": "modified", "entry": "file", "hash": "…", "size": 907 }
        ]
      }
    ],
    "outcome": "conflicts",
    "markersWritten": false
  }
}

outcome is one of preview, conflicts, gate, merged or aborted. Each entry in units reports one state unit, with outcome one of merged, kept-target, excluded, conflict or unchanged. A unit whose descriptor classifies parts of it differently reports one entry for the whole unit and one more per part, each with its own subUnit and class. A conflict inside a unit carries unitId, unitType and unitClass, plus subUnitId when the conflict is confined to one part, and a version conflict also carries each side's signatures.

Registry Value Encoding

A registry value is compared, reported and answered in as <kind>:<canonical bytes>. The kind leads, so two values that differ only in type are different values.

KindCanonical bytes
StringThe characters, as written
ExpandStringThe characters, unexpanded
MultiStringThe strings joined by a newline, in order, with no trailing separator
DWordThe decimal integer, with a leading minus for negatives
QWordThe decimal integer, with a leading minus for negatives
BinaryUppercase hex, one byte per pair, separated by -

On macOS

The verb, its options, its output and its exit codes are the same on macOS. What differs is who owns the storage underneath it.

  • Every session in the merge must be stopped, sources as well as the target. The Turbo sandbox service owns container storage on macOS and cannot pause a running session while its overlay is read, so a running source is refused by name before anything is frozen, and nothing is left behind. On Windows a running source is suspended for the moment it takes to freeze it and resumed afterwards.
  • The staging session the result is built in is an ordinary container the sandbox service creates, named <target>.merging like everywhere else. It appears in turbo sessions while the merge waits at the gate, and --verify runs its command there through the ordinary turbo run path.
  • Freezing a session, branching one, building the merged result and putting it in place are all operations the sandbox service performs. A service that does not provide them refuses the merge and names what is missing, like this:
text
This Turbo sandbox service cannot branch sessions: it does not provide snapshotContainer.
Forking and merging a session need the container snapshot operations snapshotContainer,
forkContainer, materializeMerge and replaceContainerSandbox, which arrived in Turbo sandbox
service 26.9.11.1. Update the Turbo sandbox service to 26.9.11.1 or later, then run the
command again. Nothing has been changed.
  • There is no registry on macOS. --subsystems=registry is refused by name rather than accepted and matching nothing, and --registry-path matches nothing.
  • The sandbox service must be version 26.9.11.1 or later, which is where the four container snapshot operations arrived. The refusal above names that version.
  • An editor's profile is not in the container root on macOS. It lives in an encrypted image the sandbox service attaches when the session starts, and the service freezes its contents into the snapshot under a profile/ prefix, so what a state unit calls Code/User is profile/User there. The merge is unaffected; it reads the unit, not the place.

A snapshot has the same identity on both platforms. A session's overlay is named by what it holds, so the same overlay frozen on Windows and on macOS is the same snapshot with the same snp- id.

Exit Codes

CodeMeaning
0Merged, or a preview that found no conflicts
20Conflicts are unresolved and the merge is pending. Nothing in the target changed
21The result was built and is waiting at the gate, or a --verify command failed
-1The merge could not be attempted: no common base, a running target, or a session it cannot read

Codes 20 and 21 are outcomes rather than errors, so a script or an agent can tell a decision it has to make from something that went wrong.

  • turbo fork — branch a session and record the fork point that merge works over.
  • turbo diff — see what one session changed against its base images.
  • turbo commit — turn a session's changes into an image.