--- name: worktree-switching description: > Git worktree support in sidecar: worktree detection, switching between worktrees, worktree state management, and plugin reinitialization. Covers the full lifecycle of worktree context switching including registry reinit, per-worktree state persistence, deleted worktree detection and fallback. Use when working on git worktree features or worktree-related functionality. --- # 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