# Migration guide

> Breaking changes between rr versions and how to upgrade your config.

This document covers breaking changes and upgrade instructions between versions.

## Contents

- [Version compatibility](#version-compatibility)
- [Upgrading from v0.28.0](#upgrading-from-v0280-local-host-routing-task-pins-config-search-lock-interrupts)
- [Upgrading to v0.27.0](#upgrading-to-v0270-doctor-exit-codes-error-codes-case-sensitive-names)
- [Upgrading to v0.26.0](#upgrading-to-v0260-worktree-pruning)
- [Upgrading to v0.24.0](#upgrading-to-v0240-commands-run-in-your-current-subdirectory)
- [Upgrading to v0.23.0](#upgrading-to-v0230-task-args-worktrees-excludes-fallback)
- [Upgrading to v0.22.0](#upgrading-to-v0220-parallel-tasks-reject-extra-args)
- [Upgrading to v0.21.0](#upgrading-to-v0210-structured-output-by-default)
- [Upgrading to v0.10.0](#upgrading-to-v0100-defaultshost-removed)
- [v0.5.x to v0.6.0](#v05x-to-v060-global-config-separation)
- [Troubleshooting upgrades](#troubleshooting-upgrades)

## Version compatibility

rr uses semantic versioning. The config file includes a `version` field to help with migrations:

```yaml
version: 1  # Current schema version
```

When the schema changes in incompatible ways, the version number bumps. rr refuses to load a config whose `version` is newer than it supports and tells you to upgrade rr. The schema is still at `version: 1`: every change below happened without a bump, so check the sections for the releases you're skipping.

rr is pre-1.0, so breaking changes ship in minor releases. [CHANGELOG.md](https://github.com/rileyhilliard/rr/blob/main/CHANGELOG.md) has the full detail for each version.

## Upgrading from v0.28.0 (local host routing, task pins, config search, lock interrupts)

**Runs on this machine report the local host's name.** If your global config has a host with `local: true` (say `dev`), `--local`, local mode and `local_fallback` runs now go through it. The connect events, the fallback `warn` event and the result say `host: "dev"` instead of `host: "local"`, a `lock` phase runs on `dev`, and sync is skipped with `reason: in_place` instead of `local`. To tell why a run was local, read `details.reason` on the connect event, or `details.local_reason` and `details.fallback` on the result; those are unchanged. Don't match `host == "local"`.

These runs also get what a run on `--host dev` gets: the host's `setup_commands`, `shell` and `require` checks (a missing tool now fails with `DEPENDENCY_MISSING`), the project's `defaults.setup`, the host's `env` for tasks, its lock, no terminal, and tasks at the project root. A parallel task with `--local` runs all its subtasks on `dev`, including ones pinned to other hosts with `hosts:`: `--local` overrides task pins. Without a local host, nothing changes.

**A task's `hosts:` decides where it runs, and `--local` overrides it.** rr now picks only among a pinned task's hosts instead of checking the pin after picking (and locking) a host, so a pinned task is never refused for landing on the wrong host. `--host` naming a host outside the list is refused before any lock wait. `rr <task> --local` now runs a pinned task on this machine instead of refusing it with `can't run on host 'local'`. If you relied on a pin to stop `--local` for a task that can't run on a laptop, guard it in the command itself (check `hostname` or the OS and exit non-zero).

**A host can't be named `local`.** Rename it in `~/.rr/config.yaml` (for example to `dev`) and update the `hosts:` lists that use it.

**Nested worktrees need their own `.rr.yaml`.** The search for `.rr.yaml` stops at the git top level of the checkout you're in. A worktree created inside the main checkout used to load the main checkout's `.rr.yaml` and make the main checkout the project root, so rr ran or synced the main checkout's code. It now fails with `CONFIG_NOT_FOUND` and names the file it skipped. Commit `.rr.yaml` so the worktree's branch has it, or copy it in. Only a `.rr.yaml` inside an enclosing git checkout is refused this way; one in a plain directory above your repos (`~/code/.rr.yaml` over `~/code/foo`) is ignored, and the repo uses your global hosts, with no error. That's a change if you ran rr from a repo's top-level directory, where it used to load that file (from subdirectories it was already ignored): copy or commit `.rr.yaml` into each repo that should use it. `rr monitor` keeps working in a nested worktree without `.rr.yaml`, with your global hosts.

**Stopping a lock wait exits 130.** Ctrl+C or SIGTERM while rr waits for a lock now stops the wait, and rr exits 130 with error code `INTERRUPTED` instead of waiting on. Treat it as the user's decision, not as `LOCK_HELD`. The lock timeout message also changed, from "someone else is using this remote" to "another run is using <host>"; branch on `LOCK_HELD`, not the text.

## Upgrading to v0.27.0 (doctor exit codes, error codes, case-sensitive names)

Most of these changes affect scripts and agents that read rr's exit codes and structured output.

**`rr doctor` exits 1 when a check fails.** It used to exit 0 whatever it found. Now it exits 1 on any failed check and 0 when there are only warnings. The JSON envelope still says `success: true`, since doctor itself ran, and `data.summary.all_clear` still carries the verdict. A script that runs `rr doctor` under `set -e` now stops on a real failure:

```bash
rr doctor || echo "doctor found a blocker"
```

Checks were regraded at the same time, so a missing SSH agent, a missing default key file, no `.rr.yaml`, or an offline host that the project doesn't use are warnings, not failures. An unreachable host fails only when no host in scope is reachable and `local_fallback` wouldn't take over.

**Error codes changed for three kinds of failure.** Codes used to be guessed from the error message, and some were wrong. If you branch on `error.code`, update these:

| Failure | Old code | New code |
| --- | --- | --- |
| No `.rr.yaml` found, or `--config` points at a missing file | `CONFIG_INVALID` | `CONFIG_NOT_FOUND` |
| Host name not in the global config (`--host`, `hosts:`, `rr unlock`, `rr provision`, `rr host remove`, `rr monitor`) | `CONFIG_INVALID` or `CONFIG_NOT_FOUND` | `HOST_NOT_FOUND` |
| rsync missing locally or on the host | `RSYNC_FAILED` | `DEPENDENCY_MISSING` |
| `ssh-copy-id` missing (`rr setup`) | `SSH_CONNECTION_FAILED` | `DEPENDENCY_MISSING` |
| Tools from `require:` missing on the host | `COMMAND_FAILED` | `DEPENDENCY_MISSING` |

**`-v` is no longer an rr flag.** The global `-v`/`--verbose` flag had no effect, and `-v` swallowed task arguments: `rr test -v` ran the task without `-v`. Put task flags after `--`:

```bash
rr test -- -v      # passes -v to the task
rr test -v         # CONFIG_INVALID, with a hint to use --
```

`--verbose` still parses so existing scripts keep working, but it's hidden and emits a `config` warn event. Use `RR_DEBUG=1` for debug logs.

**Remove the `output:` section from `.rr.yaml`.** It was validated but never read. rr now warns about it (a `config` warn event, or a styled warning with `--pretty`) and ignores it. Unknown keys and the old `defaults.host` get the same kind of warning, so a typo no longer silently does nothing. Configs that loaded before still load.

**Host, task, and env names are case-sensitive.** rr used to lowercase every key in its config files, so host `MyBox` was stored as `mybox`, task `Build` ran as `rr build`, and `env: {FOO: bar}` exported `foo=bar`. Keys are now kept exactly as written. If you worked around the old behavior, match the case used in the config: `hosts: [MyBox]` and `--host MyBox`, `rr Build`, and `$FOO` in commands that read the variable. Task and host names containing a dot now load as written instead of being split apart. Env var names must be valid shell names (letters, digits, underscores, not starting with a digit); anything else fails validation with an error naming the key.

**`pull`, `logs`, and `provision` are reserved task names.** Tasks with those names already collided with the built-in commands. Rename them.

**Local runs report a different connect event.** `--local` and local mode (project `local_fallback` with no `hosts:` listed) emit a connect `complete` event with `host: "local"` and `details.reason` of `local_flag` or `local_mode`, and the result carries `details.local_reason` with the same value. They used to emit a connect `warn` event with `reason: hosts_unreachable`. Consumers that matched `hosts_unreachable` to detect local runs should match the new reasons. Real fallbacks still warn with `hosts_unreachable` or `all_hosts_locked`. `--local` also works with no hosts configured now.

**`rr tasks` fails on an invalid config.** It exits non-zero with an error envelope on stderr, the same error `rr <task>` gives. It used to write error envelopes to stdout. A project with no hosts configured still lists its tasks.

**Parallel subtasks get the full env and setup.** Subtasks now merge host `env`, then `defaults.env`, then the task's `env`, and run host `setup_commands` plus `defaults.setup` after the `cd` into the project directory, the same as single tasks. If a subtask depended on not seeing `defaults.env`, or on setup running before the `cd`, adjust it. Env values in parallel subtasks, local and remote, used to arrive literally; they're now expanded by the shell like single-task values, so write `\$` for a literal dollar sign (a password like `pa$$word` becomes `pa\$\$word`). Local parallel subtasks also used to set env on the process, so setup commands could read it; env is now exported after setup everywhere, so a setup command that reads an env value (say `source $VENV/bin/activate`) should set that variable itself.

**Parallel subtask `pull:` now runs.** Each subtask's files land in `<dest>/<name>_<index>/`, named like its log file: the subtask name with `/ \ : * ? " < > |` replaced by `-`, then its position in the flattened `parallel:` list (`test:unit` first in the list lands in `test-unit_0/`). Subtasks that run on the same host share one remote directory, so give each one its own output path (a different junit file per subtask, say) or they overwrite each other on the remote. `pull:` set on the parallel task itself does nothing and warns; move it to the subtasks.

## Upgrading to v0.26.0 (worktree pruning)

- **Syncs delete stale worktree directories.** `sync.prune_worktrees` defaults to `true`: after each sync, rr removes remote `<repo>@<worktree>` directories whose git worktree no longer exists locally. If you keep anything in those remote directories that you need after deleting the local worktree, set `sync.prune_worktrees: false` and run `rr prune` yourself.
- **`prune` is a reserved task name.** A task called `prune` in `.rr.yaml` now fails validation. Rename it.
- **Go 1.26.8 is required to build from source** (`go install`, `make build`).

## Upgrading to v0.24.0 (commands run in your current subdirectory)

`rr run` and `rr exec` now `cd` into the subdirectory you invoked them from before running. Before, every ad-hoc command ran at the project root.

```bash
cd backend
rr run "make"               # now uses backend/Makefile, not the root one
rr run "cat ../README.md"   # root-relative paths need ../
rr run --cwd . "make"       # run at the project root, the old behavior
```

Named tasks are unaffected: `rr test` runs the same way from any directory. The directory a command ran in is reported as `details.remote_cwd`.

## Upgrading to v0.23.0 (task args, worktrees, excludes, fallback)

**Task args are shell-quoted.** Extra args appended to a single-command task arrive as quoted arguments, so `rr test "-k foo bar"` is one argument and globs or `$VARS` in appended args are no longer expanded remotely. Put globs in the task's `run` string instead.

**Compound tasks need an `{args}` placeholder.** A task whose `run` contains pipes, `&&`, redirections, `$()`, or backticks errors when given extra args, since before they silently landed on the last command in the pipeline. Mark where args go:

```yaml
tasks:
  test:
    run: pytest {args:-.} -n 4 | tail -20
```

**Worktrees get their own remote directory.** In a linked git worktree, `${PROJECT}` expands to `repo@worktree-name`. The first sync from an existing worktree is a cold sync into the new directory, and the old shared directory is left in place for you to delete. To keep the old layout, set `sync.worktree_isolation: false`.

**Default excludes use bare patterns.** `.git/`, `.venv/`, and `node_modules/` became `.git`, `.venv`, and `node_modules`. A custom `sync.exclude` list replaces the defaults, so update yours the same way if it has the trailing-slash forms and you sync from worktrees (where `.git` is a file).

**`local_fallback` takes a mode.** Valid values are `never`, `on-unreachable`, and `always`. Booleans still work (`true` = `always`, `false` = `never`). With `always`, when every host is locked by a live process, rr now waits up to `lock.wait_timeout` (default `1m`) before running locally, where before it fell back immediately.

**Local paths in commands are rewritten.** `rr run`/`rr exec` rewrite absolute paths under the project to the remote project directory, and task args become project-relative. Set `rewrite_paths: false` (in the project config or global `defaults`) to pass paths through untouched.

## Upgrading to v0.22.0 (parallel tasks reject extra args)

Passing args to a parallel task used to drop them silently. It's now an error. To forward args to each subtask, set `forward_args: true` on the parallel task (with `{args}` placeholders in the subtasks where needed), or run the command ad hoc with `rr run "<command> <args>"`.

## Upgrading to v0.21.0 (structured output by default)

**Output is structured JSON unless you ask for `--pretty`.** All commands emit JSON phase events (connect, sync, lock, exec) on stderr and pass command stdout/stderr through undecorated. Spinners, colors, and formatted summaries need `--pretty` / `-p`:

```bash
# Before
rr run "make test"             # human-readable
rr run --machine "make test"   # JSON

# After
rr run --pretty "make test"    # human-readable
rr run "make test"             # JSON
```

`--machine` / `-m` still parses but does nothing. Drop it from scripts at your convenience. `--no-phases` (v0.22.0) suppresses the intermediate phase events and keeps the final result event. `rr monitor` is still an interactive TUI.

**Locks go stale much sooner, down from 10 minutes.** Active locks are refreshed by a 30-second heartbeat, and a lock without a refresh for `lock.stale` is reclaimed. The current default is `90s` in a project with `.rr.yaml`. `lock.timeout` no longer has to be larger than `lock.stale`.

**Sync respects `.gitignore`.** `sync.respect_gitignore` defaults to `true`, so gitignored files stop reaching the remote. If a remote command needs a gitignored file (a generated config, a local `.env`), set `sync.respect_gitignore: false`. Negation patterns (`!path`) were mishandled before v0.22.3, so use v0.22.3 or later if you rely on them.

**AI agent directories are excluded.** `.claude/`, `.cursor/`, `.aider/`, and `.copilot/` are no longer synced by default.

## Upgrading to v0.10.0 (`defaults.host` removed)

`defaults.host` in `~/.rr/config.yaml` no longer does anything. Host priority comes from the order of the `hosts` list in `.rr.yaml`, first entry first:

```yaml
# .rr.yaml
hosts:
  - gpu-box   # tried first
  - mini
```

Move your preferred host to the top of that list and delete `defaults.host`. A leftover `defaults.host` doesn't error; it has no effect, and current rr versions print a config warning about it.

## v0.5.x to v0.6.0 (global config separation)

Host definitions have moved from `.rr.yaml` to `~/.rr/config.yaml`. This allows you to define hosts once and share project configs with your team.

### What changed

- **Hosts are now global**: Host definitions moved from project `.rr.yaml` to `~/.rr/config.yaml`
- **Projects reference hosts by name**: Instead of defining hosts, projects now just reference them with `host: <name>`
- **New global defaults**: Settings like `default`, `local_fallback`, and `probe_timeout` are now in the global config

Later releases changed some of this: v0.10.0 removed `defaults.host` (see [above](#upgrading-to-v0100-defaultshost-removed)), and `.rr.yaml` accepts `local_fallback` again as a per-project override.

### Migration steps

**1. Create your global config**

Move your host definitions to `~/.rr/config.yaml`:

```yaml
# ~/.rr/config.yaml
version: 1

hosts:
  gpu-box:
    ssh:
      - gpu-local
      - gpu-vpn
    dir: ~/projects/${PROJECT}

  mini:
    ssh:
      - mini-local
    dir: ~/dev/${PROJECT}

defaults:
  host: gpu-box
  local_fallback: false
  probe_timeout: 2s
```

Or use the CLI:
```bash
rr host add  # Interactive host setup
```

**2. Update your project configs**

Replace host definitions with a reference:

```yaml
# Before (.rr.yaml)
version: 1
hosts:
  gpu-box:
    ssh: [gpu-local, gpu-vpn]
    dir: ~/projects/${PROJECT}
default: gpu-box
local_fallback: false
probe_timeout: 2s
sync:
  exclude:
    - .git/
    - node_modules/

# After (.rr.yaml)
version: 1
host: gpu-box  # Just reference the global host
sync:
  exclude:
    - .git/
    - node_modules/
```

**3. Remove old host fields**

Delete these from your `.rr.yaml` (they now live in global config):
- `hosts:` section
- `default:`
- `local_fallback:`
- `probe_timeout:`

### Quick migration

If you want to keep your current setup working:

```bash
# Copy hosts from project to global config
mkdir -p ~/.rr
cat > ~/.rr/config.yaml << 'EOF'
version: 1
hosts:
  # Paste your hosts here from .rr.yaml
EOF

# Update .rr.yaml to reference hosts by name
# Add a hosts section with your preferred order (first = highest priority)
```

### Benefits

- **One place for hosts**: Define each machine once, use it in any project
- **Shareable project configs**: Team members can share `.rr.yaml` without overwriting each other's SSH settings
- **Personal machine names**: Use your own names for hosts without affecting others

## Troubleshooting upgrades

### A setting stopped having an effect

rr doesn't reject config keys it doesn't recognize. Current versions print a config warning for each one (a `config` warn event, or a styled warning with `--pretty`) that names the key and the file; older versions skipped them silently. If a setting seems to do nothing after an upgrade, look for that warning, then check the sections above and [configuration.md](/docs/configuration/) for the key's current name and location.

### Config validation failures

Run `rr doctor` to diagnose config issues:

```bash
rr doctor
```

This checks your config syntax and reports any problems with actionable suggestions.

### Checking your version

```bash
rr version
```

Compare with the latest release at https://github.com/rileyhilliard/rr/releases.

### Clean install

If you're having persistent issues, try a clean install:

```bash
# Homebrew
brew uninstall rr
brew install rileyhilliard/tap/rr

# Go
go clean -cache
go install github.com/rileyhilliard/rr/cmd/rr@latest
```

### Getting help

If you're stuck:

1. Check [Troubleshooting](/docs/troubleshooting/)
2. Run `rr doctor` for diagnostics
3. Open an issue at https://github.com/rileyhilliard/rr/issues
