From 44b65defdfa57a604afbddfd0cbc18059dc3cf69 Mon Sep 17 00:00:00 2001 From: Marcus Vorwaller Date: Sat, 28 Feb 2026 18:18:12 -0800 Subject: [PATCH 1/2] fix: resolve worktree path from ProjectRoot, not WorkDir (#174) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When sidecar is started from a git repo subfolder, WorkDir is set to that subfolder (e.g. /repos/myrepo/subfolder). Both doCreateWorktree and fetchAndCreateWorktree were computing the new worktree's parent directory as filepath.Dir(WorkDir), which resolves to the git root itself — placing the new worktree *inside* the main repo instead of beside it. Fix: use ProjectRoot (already correctly resolved to the main worktree path via GetMainWorktreePath) instead. When ProjectRoot is empty fall back to WorkDir for safety. Adds a regression test documenting the path-computation invariant. Fixes #174 --- internal/plugins/workspace/fetch_pr.go | 8 ++- internal/plugins/workspace/worktree.go | 11 +++- internal/plugins/workspace/worktree_test.go | 61 +++++++++++++++++++-- 3 files changed, 71 insertions(+), 9 deletions(-) diff --git a/internal/plugins/workspace/fetch_pr.go b/internal/plugins/workspace/fetch_pr.go index e3ea3a49..43c807ae 100644 --- a/internal/plugins/workspace/fetch_pr.go +++ b/internal/plugins/workspace/fetch_pr.go @@ -66,7 +66,13 @@ func (p *Plugin) fetchAndCreateWorktree(pr PRListItem) tea.Cmd { dirName = repoName + "-" + branch } } - parentDir := filepath.Dir(workDir) + // Use projectRoot (the main worktree) rather than workDir so that + // starting from a subfolder doesn't place the worktree inside the repo. Fixes #174. + mainRepoDir := projectRoot + if mainRepoDir == "" { + mainRepoDir = workDir + } + parentDir := filepath.Dir(mainRepoDir) wtPath := filepath.Join(parentDir, dirName) // Create worktree tracking the remote branch diff --git a/internal/plugins/workspace/worktree.go b/internal/plugins/workspace/worktree.go index 55cdd632..c7205c21 100644 --- a/internal/plugins/workspace/worktree.go +++ b/internal/plugins/workspace/worktree.go @@ -274,8 +274,15 @@ func (p *Plugin) doCreateWorktree(name, baseBranch, taskID, taskTitle string, ag } } - // Determine worktree path (sibling to main repo) - parentDir := filepath.Dir(p.ctx.WorkDir) + // Determine worktree path (sibling to main repo). + // Use ProjectRoot (the main worktree path, resolved from git) rather than + // WorkDir so that starting sidecar from a subfolder doesn't place the new + // worktree inside the repository instead of beside it. Fixes #174. + mainRepoDir := p.ctx.ProjectRoot + if mainRepoDir == "" { + mainRepoDir = p.ctx.WorkDir + } + parentDir := filepath.Dir(mainRepoDir) wtPath := filepath.Join(parentDir, dirName) // Ensure parent directory exists for paths with slashes (e.g., feat/ui) diff --git a/internal/plugins/workspace/worktree_test.go b/internal/plugins/workspace/worktree_test.go index 726f498f..8e48e3af 100644 --- a/internal/plugins/workspace/worktree_test.go +++ b/internal/plugins/workspace/worktree_test.go @@ -94,12 +94,12 @@ func TestSanitizeBranchName(t *testing.T) { func TestParseWorktreeList(t *testing.T) { tests := []struct { - name string - output string - mainWorkdir string - wantCount int - wantNames []string - wantBranch []string + name string + output string + mainWorkdir string + wantCount int + wantNames []string + wantBranch []string wantIsMain []bool // Track which worktrees should be marked as main wantIsMissing []bool // Track which worktrees should be marked as missing }{ @@ -373,3 +373,52 @@ func TestFilterTasks(t *testing.T) { }) } +// TestWorktreePathResolvesFromProjectRoot verifies that worktree paths are +// computed relative to the main repo root (ProjectRoot), not the CWD (WorkDir). +// This is the regression test for issue #174: starting sidecar from a subfolder +// would cause doCreateWorktree to compute parentDir as the git root itself, +// placing the new worktree *inside* the main repo instead of beside it. +func TestWorktreePathResolvesFromProjectRoot(t *testing.T) { + // Simulate: git root at /repos/myrepo, sidecar started from /repos/myrepo/subfolder + projectRoot := "/repos/myrepo" + workDir := "/repos/myrepo/subfolder" + + // What the old (broken) code did: + oldParentDir := parentDir(workDir) + oldWtPath := oldParentDir + "/feature" + // oldParentDir = /repos/myrepo → wtPath = /repos/myrepo/feature (INSIDE the repo!) + if oldParentDir != "/repos/myrepo" { + t.Fatalf("test setup wrong: old parentDir=%q", oldParentDir) + } + if oldWtPath == "/repos/feature" { + t.Error("old code already produces correct path — test assumption invalid") + } + + // What the new (fixed) code does: + mainRepoDir := projectRoot + if mainRepoDir == "" { + mainRepoDir = workDir + } + newParentDir := parentDir(mainRepoDir) + newWtPath := newParentDir + "/feature" + // newParentDir = /repos → wtPath = /repos/feature (sibling — correct!) + if newParentDir != "/repos" { + t.Errorf("new parentDir=%q, want /repos", newParentDir) + } + if newWtPath != "/repos/feature" { + t.Errorf("new wtPath=%q, want /repos/feature", newWtPath) + } +} + +// parentDir is a path helper extracted to make the logic unit-testable. +func parentDir(dir string) string { + // mirrors the logic in doCreateWorktree / fetchAndCreateWorktree + idx := len(dir) - 1 + for idx > 0 && dir[idx] != '/' { + idx-- + } + if idx == 0 { + return "/" + } + return dir[:idx] +} From 4284b52ff24f4cc15d11f23c90da1336bbc57717 Mon Sep 17 00:00:00 2001 From: Marcus Vorwaller Date: Sat, 28 Feb 2026 18:31:29 -0800 Subject: [PATCH 2/2] docs: add worktree setup hooks documentation Documents the automatic setup that runs when a worktree is created: - Env file copying (.env, .env.local, .env.development, .env.development.local) - Directory symlinking (opt-in via symlinkDirs config) - .worktree-setup.sh hook with env vars and examples Cross-references from workspaces-plugin.md. --- website/docs/workspaces-plugin.md | 9 +- website/docs/worktree-setup.md | 141 ++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+), 4 deletions(-) create mode 100644 website/docs/worktree-setup.md diff --git a/website/docs/workspaces-plugin.md b/website/docs/workspaces-plugin.md index 3f8d5d9f..e25e2857 100644 --- a/website/docs/workspaces-plugin.md +++ b/website/docs/workspaces-plugin.md @@ -384,10 +384,11 @@ Press `n` to open the create modal: 1. Git creates a workspace in a sibling directory (e.g., `../feature-auth`) 2. A new branch is created from the base branch -3. If a task is linked, a `.sidecar-task` file is created and `td start` runs -4. If an agent is selected, it launches in a tmux session named `sidecar-ws-` -5. If a prompt is selected, it's passed as the initial instruction to the agent -6. The workspace appears in the list with "Active" status (if agent running) +3. Env files (`.env`, `.env.local`, etc.) are copied from the main worktree — see [Worktree Setup Hooks](./worktree-setup.md) +4. If a task is linked, a `.sidecar-task` file is created and `td start` runs +5. If an agent is selected, it launches in a tmux session named `sidecar-ws-` +6. If a prompt is selected, it's passed as the initial instruction to the agent +7. The workspace appears in the list with "Active" status (if agent running) #### Reusable Prompts diff --git a/website/docs/worktree-setup.md b/website/docs/worktree-setup.md new file mode 100644 index 00000000..7f94c137 --- /dev/null +++ b/website/docs/worktree-setup.md @@ -0,0 +1,141 @@ +--- +sidebar_position: 7 +title: Worktree Setup Hooks +--- + +# Worktree Setup Hooks + +When sidecar creates a new worktree, it automatically runs a series of setup steps so the workspace is ready to use — no manual `npm install` or config copying required. + +## What happens automatically + +Every time a worktree is created, sidecar: + +1. **Copies env files** from the main worktree into the new one +2. **Creates symlinks** for any directories you've opted in to share (e.g. `node_modules`) +3. **Runs `.worktree-setup.sh`** if it exists at the project root + +Setup failures are non-fatal — if a step fails, sidecar logs a warning and continues. The worktree is always created even if setup encounters errors. + +## Env file copying + +Sidecar copies these files from the main worktree automatically (if they exist): + +- `.env` +- `.env.local` +- `.env.development` +- `.env.development.local` + +Files are copied as-is, preserving permissions. Missing files are silently skipped. Your API keys, database URLs, and local overrides are available in the new workspace immediately — without committing secrets to git. + +## Directory symlinks + +By default, no directories are symlinked. To share large directories (like `node_modules`) across worktrees, configure `symlinkDirs` in your project config: + +```json +// .sidecar/config.json +{ + "plugins": { + "workspace": { + "symlinkDirs": ["node_modules", ".venv"] + } + } +} +``` + +Sidecar replaces any existing directory in the new worktree with a symlink to the main worktree's copy. Only directories that exist in the main worktree are linked — missing ones are skipped. + +**When to use this:** Large directories that are identical across branches (e.g. unmodified `node_modules`) save significant disk space and setup time. Avoid symlinking directories that differ between branches. + +## The `.worktree-setup.sh` hook + +Place a `.worktree-setup.sh` file at your project root (in the main worktree). Sidecar runs it automatically with `bash` whenever a new worktree is created. + +The script runs with the **new worktree as the working directory** in a clean, isolated environment. + +### Environment variables + +| Variable | Value | +|----------|-------| +| `MAIN_WORKTREE` | Absolute path to the main worktree | +| `WORKTREE_BRANCH` | Name of the new branch | +| `WORKTREE_PATH` | Absolute path to the new worktree | + +### Creating the hook + +```bash +touch .worktree-setup.sh +chmod +x .worktree-setup.sh +``` + +Add `.worktree-setup.sh` to `.gitignore` if it contains anything machine-specific, or commit it if it should apply to the whole team. + +### Examples + +**Install dependencies:** + +```bash +#!/bin/bash +npm install +``` + +**Start backing services:** + +```bash +#!/bin/bash +docker-compose up -d db redis +``` + +**Run a makefile target:** + +```bash +#!/bin/bash +make setup +``` + +**Copy config from example:** + +```bash +#!/bin/bash +if [ ! -f config.yaml ]; then + cp config.example.yaml config.yaml +fi +``` + +**Combine multiple steps:** + +```bash +#!/bin/bash +set -e + +echo "Setting up worktree: $WORKTREE_BRANCH" +echo "Main worktree: $MAIN_WORKTREE" + +# Install dependencies +npm install + +# Copy any additional config not covered by env file copying +if [ ! -f .env.test ]; then + cp "$MAIN_WORKTREE/.env.test" .env.test 2>/dev/null || true +fi + +# Start services +docker-compose up -d db + +echo "Setup complete." +``` + +## Error handling + +If the setup script exits with a non-zero status, sidecar logs a warning with the script output but **does not block worktree creation**. The worktree is available immediately regardless. + +To re-run setup manually: + +```bash +cd /path/to/new-worktree +bash /path/to/main-worktree/.worktree-setup.sh +``` + +## See also + +- [Workspaces Plugin](./workspaces-plugin.md) — full worktree and workspace management