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:
- All plugins are stopped, reinitialized with the new WorkDir, and restarted
- Per-worktree state (active plugin, sidebar selections) is saved/restored
- Project-specific themes are applied
- 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:
m.switchProject(worktreePath)
This triggers in order:
- Save active plugin for old WorkDir
- Update
m.ui.WorkDirto new path - Apply resolved theme for new path
- Call
registry.Reinit(newWorkDir)-- stops all plugins, updates context, reinits all - Send
WindowSizeMsgto all plugins for layout recalculation - Restore saved active plugin for new WorkDir
- Show toast notification
Registry Reinitialization
Registry.Reinit() in internal/plugin/registry.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:
Stop()releases all resources (watchers, goroutines, channels)Init(ctx)resets state and reads from newctx.WorkDirStart()kicks off fresh async work for the new context
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:
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:
// 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:
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 worktreeSwitchWorktree(path) tea.Cmd-- helper to create the aboveSwitchToMainWorktreeMsg{MainWorktreePath}-- requests fallback to main worktreeSwitchToMainWorktree(mainPath) tea.Cmd-- helper to create the above
Detection Pattern (from workspace plugin)
1. Define plugin-local message (internal/plugins/workspace/worktree.go):
type WorkDirDeletedMsg struct {
MainWorktreePath string
}
2. Detect deletion in refresh command:
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:
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
- Reset all state in
Init()-- do not carry over stale data from previous worktree - Use
sync.OnceforStop()-- prevents double-close panics during rapid switching - Validate WorkDir exists before expensive operations
- Store WorkDir at command creation time -- closures may execute after switch
- Keep
Start()non-blocking -- return commands that do async work
Testing Worktree Switching
- Create a worktree:
git worktree add ../my-feature feature-branch - Switch to it via project switcher or workspace plugin
- Verify your plugin reinitializes with correct data
- Delete the worktree externally and trigger a refresh
- Verify graceful fallback to main repo