| 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.WorkDir to new path
- Apply resolved theme for new path
- Call
registry.Reinit(newWorkDir) -- stops all plugins, updates context, reinits all
- Send
WindowSizeMsg to 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 {
for i := len(r.plugins) - 1; i >= 0; i-- {
r.safeStop(r.plugins[i])
}
r.ctx.WorkDir = newWorkDir
for _, p := range r.plugins {
r.safeInit(p)
}
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 new ctx.WorkDir
Start() 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
p.stopOnce = sync.Once{}
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:
saved := state.GetMyPluginState(p.ctx.WorkDir)
if saved.Selection != "" {
p.selection = saved.Selection
}
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 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):
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.Once for Stop() -- 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