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
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,12 @@ message agents, or inspect projects, providers, and environments.
## Environment Setup Script

- To make a repo work with bb worktrees, run `bb guide environments`. It
documents the repo-level `.bb-env-setup.sh` setup hook.
documents the repo-level `.bb-env-setup.sh` setup hook and the
`.worktreeinclude` file.
- A new worktree checks out tracked files only. Commit a `.worktreeinclude`
file at the repo root to list untracked files, such as `.env`, that bb must
copy from the source checkout. It uses gitignore pattern syntax. bb copies
the matches before it runs `.bb-env-setup.sh`.

## Remote Client

Expand Down
2 changes: 2 additions & 0 deletions docs/platform-support.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,6 +128,8 @@ rebuild the native dependency, for example `npm rebuild better-sqlite3`.
- The supported setup hook is POSIX `.bb-env-setup.sh`.
- The same shell-based hook contract is used across macOS, Linux, and WSL2.
- No parallel `.bb-env-setup.ts` product-path mechanism is supported.
- The `.worktreeinclude` copy step runs no shell. It works on every platform,
including native Windows.

## Line Ending Policy

Expand Down
46 changes: 40 additions & 6 deletions docs/worktrees.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ with its own branch. Worktrees let bb work on multiple things in parallel
without touching your main checkout, and they make it easy to throw away
whatever the agent does without affecting the rest of your work.

You can pair a worktree with a **setup script** that bb runs the first time
the worktree is created — useful for installing dependencies, copying a
`.env`, generating secrets, or anything else you need before the agent
starts.
You can pair a worktree with a **`.worktreeinclude` file** that lists the local
files each new worktree needs, and with a **setup script** that bb runs the
first time the worktree is created — useful for installing dependencies,
generating secrets, or anything else you need before the agent starts.

## What is a managed worktree?

Expand Down Expand Up @@ -42,21 +42,55 @@ When you omit `--base-branch`, bb chooses the project's default worktree base,
preferring the origin default branch when safe. Pass `--base-branch <name>`
only when you need a specific base.

## Copy local files with `.worktreeinclude`

A new worktree checks out tracked files only. Your `.env`, your local
certificates, and anything else git ignores stay behind in your main checkout.

Commit a `.worktreeinclude` file at the root of your repo to list what a
worktree needs. It uses gitignore syntax — one pattern per line, `#` for
comments, `!` to negate an earlier pattern:

```gitignore
# Local credentials the agent needs
.env
.env.*
!.env.example
certs/
```

bb copies every untracked file in the source checkout that matches a pattern,
after it creates the worktree and before it runs `.bb-env-setup.sh`. Your
setup script can therefore read the copied files.

Contract:

- bb copies files. It does not create symlinks, and each worktree gets its own
copy — an edit inside the worktree does not change your main checkout.
- bb never replaces anything the worktree already has. If the branch tracks a
file at that path, the tracked file wins and bb reports the skip.
- bb skips symlinks in the source checkout rather than copying their targets,
and it never writes through a symlink in the worktree.
- A pattern that matches nothing, an unreadable file, or a failed copy is
reported in the provisioning transcript. Provisioning continues.
- Large directories such as `node_modules` are copied file by file, which is
slow. Install dependencies in `.bb-env-setup.sh` instead.

## Run setup with `.bb-env-setup.sh`

Drop a file named `.bb-env-setup.sh` at the root of your project. If bb finds
one when it creates a worktree, it runs the script inside the new worktree
before handing the thread to the agent.

Use it for anything the agent will need in a fresh checkout — install
dependencies, copy a `.env`, sync local state, generate tokens, etc.
dependencies, sync local state, generate tokens, etc. To bring local files in
from your main checkout, prefer `.worktreeinclude` above.

```bash
#!/usr/bin/env bash
set -euo pipefail

pnpm install
cp ~/.config/myapp/.env .
```

Contract:
Expand Down
6 changes: 6 additions & 0 deletions packages/domain/src/setup-script.ts
Original file line number Diff line number Diff line change
@@ -1 +1,7 @@
export const DEFAULT_ENV_SETUP_SCRIPT_NAME = ".bb-env-setup.sh";

/**
* Gitignore-style pattern file. It names untracked files that a new worktree
* must receive from the source checkout, such as `.env`.
*/
export const WORKTREE_INCLUDE_FILE_NAME = ".worktreeinclude";
2 changes: 1 addition & 1 deletion packages/host-daemon-contract/src/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ import {
providerCliStatusResponseSchema,
} from "./local.js";

export const HOST_DAEMON_PROTOCOL_VERSION = 71 as const;
export const HOST_DAEMON_PROTOCOL_VERSION = 72 as const;

export {
BRANCH_LIST_LIMIT_MAX,
Expand Down
11 changes: 6 additions & 5 deletions packages/host-daemon-contract/test/contract.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1023,11 +1023,12 @@ describe("host-daemon local schemas", () => {
});

describe("host-daemon command schemas", () => {
// Provider usage gained Claude model-scoped windows and duration-aware Codex
// labels in version 71. Older daemons omit or mislabel them, so the bump
// forces an update before the server requests provider usage.
it("uses protocol version 71 for provider usage normalization", () => {
expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(71);
// Worktree provisioning gained the .worktreeinclude copy step in version 72.
// An older daemon connects, skips the copy, and emits no such transcript
// entry, so a repo that lists its .env silently gets a worktree without it.
// The bump forces an update before the server provisions a worktree.
it("uses protocol version 72 for .worktreeinclude worktree provisioning", () => {
expect(HOST_DAEMON_PROTOCOL_VERSION).toBe(72);
});

it("binds Plan cancellation to a required turn id and typed result", () => {
Expand Down
108 changes: 105 additions & 3 deletions packages/host-workspace/src/provisioning.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import fs from "node:fs/promises";
import path from "node:path";
import {
DEFAULT_ENV_SETUP_SCRIPT_NAME,
WORKTREE_INCLUDE_FILE_NAME,
createTerminalOutputLineReader,
readTerminalOutputLines,
type ProvisioningTranscriptEntry,
Expand All @@ -24,6 +25,10 @@ import {
runGitWithWorktreeMetadataLock,
withWorktreeMetadataLock,
} from "./worktree-metadata-lock.js";
import {
copyWorktreeIncludeFiles,
type CopyWorktreeIncludeFilesResult,
} from "./worktree-include.js";

type ProgressCallback = (entry: ProvisioningTranscriptEntry) => void;
type EmitStepArgs = {
Expand Down Expand Up @@ -236,6 +241,12 @@ function throwIfProvisionAborted(signal: AbortSignal | undefined): void {
}
}

function isProvisionAbortError(error: unknown): boolean {
return (
error instanceof WorkspaceError && error.code === "provision_cancelled"
);
}

async function resolveRemoteBaseBranch(
sourcePath: string,
baseBranch: string,
Expand All @@ -245,9 +256,7 @@ async function resolveRemoteBaseBranch(
return null;
}

const remotes = (
await runGit(["remote"], { cwd: sourcePath, signal })
).stdout
const remotes = (await runGit(["remote"], { cwd: sourcePath, signal })).stdout
.split("\n")
.map((remote) => remote.trim())
.filter(Boolean);
Expand Down Expand Up @@ -389,6 +398,12 @@ export async function createWorktree(
keySuffix: "target",
cwd: args.targetPath,
});
await copyIncludedFiles({

@SawyerHood SawyerHood Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚨 slopcop/review — P1: Increase the host daemon protocol version

This behavior runs in the host daemon. An old enrolled daemon stays connected at protocol version 71 and silently omits this copy step.

The new transcript entries also cross the daemon connection. Increase HOST_DAEMON_PROTOCOL_VERSION and update its contract test.

sourcePath: args.sourcePath,
targetPath: args.targetPath,
onProgress: args.onProgress,
signal: args.signal,
});
await runSetupScript({
workspacePath: args.targetPath,
timeoutMs: args.timeoutMs,
Expand Down Expand Up @@ -417,6 +432,93 @@ export async function createWorktree(
}
}

/**
* Cap on paths named in one transcript entry. A broad pattern can match
* thousands of files, and the daemon keeps and forwards the whole transcript.
*/
const WORKTREE_INCLUDE_TRANSCRIPT_PATH_LIMIT = 20;

function summarizePaths(paths: readonly string[]): string {
const shown = paths.slice(0, WORKTREE_INCLUDE_TRANSCRIPT_PATH_LIMIT);
const hiddenCount = paths.length - shown.length;
const suffix = hiddenCount > 0 ? `, and ${hiddenCount} more` : "";
return `${shown.join(", ")}${suffix}`;
}

/**
* Copy the untracked files listed in `.worktreeinclude` into the new worktree
* and report the result in the provisioning transcript. This runs before the
* setup script so the script can read a copied `.env`.
*
* A failure here never fails provisioning: the transcript reports what bb
* skipped and the thread still starts. Only cancellation propagates.
*/
async function copyIncludedFiles(args: {
sourcePath: string;
targetPath: string;
onProgress: ProgressCallback | undefined;
signal: AbortSignal | undefined;
}): Promise<void> {
throwIfProvisionAborted(args.signal);
const startedAt = Date.now();
let result: CopyWorktreeIncludeFilesResult;
try {
result = await copyWorktreeIncludeFiles({
sourcePath: args.sourcePath,
targetPath: args.targetPath,
signal: args.signal,
});
} catch (error) {
if (isProvisionAbortError(error)) {
throw error;
}
emitOutput(
args.onProgress,
"worktree-include",
`Skipped ${WORKTREE_INCLUDE_FILE_NAME}: ${
error instanceof Error ? error.message : String(error)
}`,
);
return;
}
if (!result.ran) {
return;
}

for (const skipped of result.skipped.slice(
0,
WORKTREE_INCLUDE_TRANSCRIPT_PATH_LIMIT,
)) {
emitOutput(args.onProgress, "worktree-include", `Skipped ${skipped}`);
}
const hiddenSkipCount =
result.skipped.length - WORKTREE_INCLUDE_TRANSCRIPT_PATH_LIMIT;
if (hiddenSkipCount > 0) {
emitOutput(
args.onProgress,
"worktree-include",
`Skipped ${hiddenSkipCount} more file(s)`,
);
}
if (result.copied.length > 0) {
emitOutput(
args.onProgress,
"worktree-include",
`Copied ${result.copied.length} file(s): ${summarizePaths(
result.copied,
)}`,
);
}
emitStep({
onProgress: args.onProgress,
key: "worktree-include-completed",
text: `Copied ${result.copied.length} file(s) from ${WORKTREE_INCLUDE_FILE_NAME}`,
status: "completed",
startedAt,
metadata: { durationMs: Date.now() - startedAt },
});
}

export async function runSetupScript(
args: RunSetupScriptArgs,
): Promise<{ ran: boolean; exitCode?: number; output?: string }> {
Expand Down
Loading
Loading