Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion internal/plugins/workspace/fetch_pr.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions internal/plugins/workspace/worktree.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
61 changes: 55 additions & 6 deletions internal/plugins/workspace/worktree_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}{
Expand Down Expand Up @@ -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]
}
9 changes: 5 additions & 4 deletions website/docs/workspaces-plugin.md
Original file line number Diff line number Diff line change
Expand Up @@ -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-<name>`
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-<name>`
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

Expand Down
141 changes: 141 additions & 0 deletions website/docs/worktree-setup.md
Original file line number Diff line number Diff line change
@@ -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