---
name: marcus/worktree-switching
source: https://app.decimal.ai/s/marcus-worktree-switching@1/SKILL.md
source_sha256: a2d69d86aa6d
---

# Worktree Switching

Sidecar supports seamless switching between git worktrees. When switching:
1. All plugins are stopped, reinitialized with the new WorkDir, and restarted
2. Per-worktree state (active plugin, sidebar selections) is saved/restored
3. Project-specific themes are applied
4. If a worktree is deleted externally, sidecar gracefully falls back to main

## Core Mechanism

### Project Switching

Worktree switching uses `Model.switchProject()` in `internal/app/model.go`:

```go
m.switchProject(worktreePath)
```

This triggers in order:
1. Save active plugin for old WorkDir
2. Update `m.ui.WorkDir` to new path
3. Apply resolved theme for new path
4. Call `registry.Reinit(newWorkDir)` -- stops all plugins, updates context, reinits all
5. Send `WindowSizeMsg` to all plugins for layout recalculation
6. Restore saved active plugin for new WorkDir
7. Show toast notification

### Registry Reinitialization

`Registry.Reinit()` in `internal/plugin/registry.go`:

```go
func (r *Registry) Reinit(newWorkDir string) []tea.Cmd {
    // Stop all plugins (reverse order)
    for i := len(r.plugins) - 1; i >= 0; i-- {
        r.safeStop(r.plugins[i])
    }
    // Update context
    r.ctx.WorkDir = newWorkDir
    // Reinit all plugins
    for _, p := range r.plugins {
        r.safeInit(p)
    }
    // Collect and return start commands
    return startCmds
}
```

## Plugin Responsibilities on Worktree Switch

### Handle Reinitialization Cleanly

Your plugin will be stopped and reinitialized on worktree switch. Ensure:

1. **`Stop()`** releases all resources (watchers, goroutines, channels)
2. **`Init(ctx)`** resets state and reads from new `ctx.WorkDir`
3. **`Start()`** kicks off fresh async work for the new context

```go
func (p *Plugin) Stop() {
    p.stopOnce.Do(func() {
        if p.watcher != nil {
            p.watcher.Close()
        }
        close(p.done)
    })
}

func (p *Plugin) Init(ctx *plugin.Context) error {
    p.ctx = ctx
    p.items = nil            // Reset state
    p.stopOnce = sync.Once{} // Reset stop guard
    p.done = make(chan struct{})
    return nil
}
```

### Handle WindowSizeMsg After Switch

After reinitialization, the app sends `tea.WindowSizeMsg`. Handle it in `Update`:

```go
case tea.WindowSizeMsg:
    p.width = msg.Width
    p.height = msg.Height
    return p, nil
```

### Persist Per-Worktree State

Use `internal/state` to save/restore preferences keyed by WorkDir:

```go
// Restore state in Init or Start
saved := state.GetMyPluginState(p.ctx.WorkDir)
if saved.Selection != "" {
    p.selection = saved.Selection
}

// Save state on user action
state.SetMyPluginState(p.ctx.WorkDir, MyPluginState{
    Selection: p.selection,
})
```

Add state struct and accessors following `internal/state/state.go`:

```go
type MyPluginState struct {
    Selection string `json:"selection,omitempty"`
}

func GetMyPluginState(workdir string) MyPluginState {
    mu.RLock()
    defer mu.RUnlock()
    if current == nil || current.MyPlugin == nil {
        return MyPluginState{}
    }
    return current.MyPlugin[workdir]
}
```

State is saved to `~/.config/sidecar/state.json` keyed by absolute WorkDir path. State is automatically per-worktree when you pass `p.ctx.WorkDir`.

## Deleted Worktree Detection

When a worktree is deleted externally, plugins should detect this and request fallback to main.

### App-Level Commands

Defined in `internal/app/commands.go`:

- `SwitchWorktreeMsg{WorktreePath}` -- requests switching to a specific worktree
- `SwitchWorktree(path) tea.Cmd` -- helper to create the above
- `SwitchToMainWorktreeMsg{MainWorktreePath}` -- requests fallback to main worktree
- `SwitchToMainWorktree(mainPath) tea.Cmd` -- helper to create the above

### Detection Pattern (from workspace plugin)

**1. Define plugin-local message** (`internal/plugins/workspace/worktree.go`):
```go
type WorkDirDeletedMsg struct {
    MainWorktreePath string
}
```

**2. Detect deletion in refresh command:**
```go
func (p *Plugin) refreshWorktrees() tea.Cmd {
    workDir := p.ctx.WorkDir
    return func() tea.Msg {
        if _, err := os.Stat(workDir); os.IsNotExist(err) {
            mainPath := findMainWorktreeFromDeleted(workDir)
            if mainPath != "" {
                return WorkDirDeletedMsg{MainWorktreePath: mainPath}
            }
        }
        return RefreshDoneMsg{Worktrees: worktrees, Err: err}
    }
}
```

**3. Handle message, return app command:**
```go
case WorkDirDeletedMsg:
    p.refreshing = false
    if msg.MainWorktreePath != "" {
        return p, app.SwitchToMainWorktree(msg.MainWorktreePath)
    }
    return p, nil
```

## Git Helpers

`internal/app/git.go` provides:

| Function | Purpose |
|----------|---------|
| `GetWorktrees(workDir)` | List all worktrees for the repo |
| `GetMainWorktreePath(workDir)` | Get path to main worktree |
| `WorktreeNameForPath(workDir, path)` | Derive display name for a worktree |
| `GetAllRelatedPaths(workDir)` | Get all paths sharing the same repo |

## Per-WorkDir State Keys

| Key | Purpose |
|-----|---------|
| `ActivePlugin` | Which plugin tab was focused |
| `FileBrowser` | File browser selections and view state |
| `Workspace` | Workspace/shell selections |

## Best Practices

1. **Reset all state in `Init()`** -- do not carry over stale data from previous worktree
2. **Use `sync.Once` for `Stop()`** -- prevents double-close panics during rapid switching
3. **Validate WorkDir exists** before expensive operations
4. **Store WorkDir at command creation time** -- closures may execute after switch
5. **Keep `Start()` non-blocking** -- return commands that do async work

## Testing Worktree Switching

1. Create a worktree: `git worktree add ../my-feature feature-branch`
2. Switch to it via project switcher or workspace plugin
3. Verify your plugin reinitializes with correct data
4. Delete the worktree externally and trigger a refresh
5. Verify graceful fallback to main repo