From 9c76e1887b2bf730a47c9ee8351620aa164a4e93 Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Wed, 5 Aug 2026 19:57:53 +0000 Subject: [PATCH 1/2] Support .worktreeinclude files for new worktrees A new worktree checks out tracked files only, so a local .env or a credentials directory stays behind in the source checkout. Add a repo-root .worktreeinclude file that lists what a worktree needs. It uses gitignore pattern syntax. bb copies every matching untracked file from the source checkout after `git worktree add` and before .bb-env-setup.sh, so the setup script can read the copied files. Matching runs through `git ls-files --others --ignored --exclude-from`, which makes git's own matcher decide every pattern. Directory patterns, `**`, and `!` negation behave exactly as they do in .gitignore, and the feature needs no new dependency. bb copies files only. It skips symlinks rather than copying their targets, and it refuses any destination that leaves the worktree through a committed symlink. No failure here fails provisioning: each copy and each skip goes to the thread's provisioning transcript. Co-Authored-By: Claude Opus 5 (1M context) --- .../skills/builtin-skills/bb-cli/SKILL.md | 7 +- docs/platform-support.md | 2 + docs/worktrees.md | 44 ++++- packages/domain/src/setup-script.ts | 6 + packages/host-workspace/src/provisioning.ts | 81 +++++++- .../host-workspace/src/worktree-include.ts | 148 +++++++++++++++ .../test/worktree-include.test.ts | 177 ++++++++++++++++++ .../src/generated/templates.generated.ts | 2 +- .../src/templates/bb-guide-environments.md | 30 +-- 9 files changed, 475 insertions(+), 22 deletions(-) create mode 100644 packages/host-workspace/src/worktree-include.ts create mode 100644 packages/host-workspace/test/worktree-include.test.ts diff --git a/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md b/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md index 5f186bd24b..3a77e0faab 100644 --- a/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md +++ b/apps/server/src/services/skills/builtin-skills/bb-cli/SKILL.md @@ -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 diff --git a/docs/platform-support.md b/docs/platform-support.md index 50e9a0c999..3a61f5a853 100644 --- a/docs/platform-support.md +++ b/docs/platform-support.md @@ -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 diff --git a/docs/worktrees.md b/docs/worktrees.md index 38f1df5b1a..dbe814ac2e 100644 --- a/docs/worktrees.md +++ b/docs/worktrees.md @@ -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? @@ -42,6 +42,38 @@ When you omit `--base-branch`, bb chooses the project's default worktree base, preferring the origin default branch when safe. Pass `--base-branch ` 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 a tracked file. Only untracked files are candidates. +- bb skips symlinks in the source checkout rather than copying their targets. +- 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 @@ -49,14 +81,14 @@ 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: diff --git a/packages/domain/src/setup-script.ts b/packages/domain/src/setup-script.ts index e34769df3a..8b01ef2888 100644 --- a/packages/domain/src/setup-script.ts +++ b/packages/domain/src/setup-script.ts @@ -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"; diff --git a/packages/host-workspace/src/provisioning.ts b/packages/host-workspace/src/provisioning.ts index 1b66fe7969..3fe3338ada 100644 --- a/packages/host-workspace/src/provisioning.ts +++ b/packages/host-workspace/src/provisioning.ts @@ -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, @@ -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 = { @@ -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, @@ -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); @@ -389,6 +398,12 @@ export async function createWorktree( keySuffix: "target", cwd: args.targetPath, }); + await copyIncludedFiles({ + sourcePath: args.sourcePath, + targetPath: args.targetPath, + onProgress: args.onProgress, + signal: args.signal, + }); await runSetupScript({ workspacePath: args.targetPath, timeoutMs: args.timeoutMs, @@ -417,6 +432,66 @@ export async function createWorktree( } } +/** + * 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 names every skipped + * entry and the thread still starts. + */ +async function copyIncludedFiles(args: { + sourcePath: string; + targetPath: string; + onProgress: ProgressCallback | undefined; + signal: AbortSignal | undefined; +}): Promise { + 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) { + emitOutput(args.onProgress, "worktree-include", `Skipped ${skipped}`); + } + if (result.copied.length > 0) { + emitOutput( + args.onProgress, + "worktree-include", + `Copied ${result.copied.length} file(s): ${result.copied.join(", ")}`, + ); + } + 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 }> { diff --git a/packages/host-workspace/src/worktree-include.ts b/packages/host-workspace/src/worktree-include.ts new file mode 100644 index 0000000000..e7542c4eba --- /dev/null +++ b/packages/host-workspace/src/worktree-include.ts @@ -0,0 +1,148 @@ +import fs from "node:fs/promises"; +import path from "node:path"; +import { WORKTREE_INCLUDE_FILE_NAME } from "@bb/domain"; +import { runGit } from "./git.js"; + +export interface CopyWorktreeIncludeFilesArgs { + /** Existing checkout that owns the `.worktreeinclude` file. */ + sourcePath: string; + /** Freshly created worktree that receives the copies. */ + targetPath: string; + signal?: AbortSignal; +} + +export interface CopyWorktreeIncludeFilesResult { + /** False when the source checkout has no usable `.worktreeinclude`. */ + ran: boolean; + /** Repo-relative paths copied into the worktree. */ + copied: string[]; + /** Human-readable reasons for entries that were not copied. */ + skipped: string[]; +} + +const EMPTY_RESULT: CopyWorktreeIncludeFilesResult = { + ran: false, + copied: [], + skipped: [], +}; + +/** + * True when `.worktreeinclude` holds at least one pattern. Git rejects + * `ls-files --ignored` when every exclude source is empty, so a file of only + * comments must short-circuit before we shell out. + */ +function hasPattern(contents: string): boolean { + return contents + .split(/\r?\n/u) + .map((line) => line.trim()) + .some((line) => line.length > 0 && !line.startsWith("#")); +} + +async function readIncludeFile(sourcePath: string): Promise { + try { + return await fs.readFile( + path.join(sourcePath, WORKTREE_INCLUDE_FILE_NAME), + "utf8", + ); + } catch { + return null; + } +} + +/** + * List untracked files in `sourcePath` that match the `.worktreeinclude` + * patterns. `--others` limits the walk to untracked paths, and `--ignored` + * with `--exclude-from` (and no `--exclude-standard`) makes the include file + * the only exclude source, so git's own gitignore matcher decides every + * pattern — including directory patterns, `**`, and negation. + */ +async function listMatchingFiles( + sourcePath: string, + signal: AbortSignal | undefined, +): Promise { + const result = await runGit( + [ + "ls-files", + "--others", + "--ignored", + `--exclude-from=${WORKTREE_INCLUDE_FILE_NAME}`, + "-z", + ], + { cwd: sourcePath, allowFailure: true, signal }, + ); + if (result.exitCode !== 0) { + return []; + } + return result.stdout.split("\0").filter(Boolean); +} + +function isInside(parentRealPath: string, childRealPath: string): boolean { + const relative = path.relative(parentRealPath, childRealPath); + return ( + relative === "" || + (!relative.startsWith("..") && !path.isAbsolute(relative)) + ); +} + +/** + * Copy the untracked files a repo lists in `.worktreeinclude` from the source + * checkout into a new worktree. A fresh worktree contains tracked files only, + * so local `.env` files and credentials never arrive on their own. + * + * Nothing here is fatal: a missing file, an unreadable entry, or a failed copy + * is reported and provisioning continues. + */ +export async function copyWorktreeIncludeFiles( + args: CopyWorktreeIncludeFilesArgs, +): Promise { + const contents = await readIncludeFile(args.sourcePath); + if (contents === null || !hasPattern(contents)) { + return EMPTY_RESULT; + } + + const relativePaths = await listMatchingFiles(args.sourcePath, args.signal); + if (relativePaths.length === 0) { + return { ran: true, copied: [], skipped: [] }; + } + + let targetRealPath: string; + try { + targetRealPath = await fs.realpath(args.targetPath); + } catch (error) { + return { + ran: true, + copied: [], + skipped: [`${args.targetPath}: ${describeError(error)}`], + }; + } + + const copied: string[] = []; + const skipped: string[] = []; + for (const relativePath of relativePaths) { + const sourceFile = path.join(args.sourcePath, relativePath); + const targetFile = path.join(targetRealPath, relativePath); + try { + const stats = await fs.lstat(sourceFile); + if (stats.isSymbolicLink()) { + skipped.push(`${relativePath}: symlink`); + continue; + } + await fs.mkdir(path.dirname(targetFile), { recursive: true }); + const parentRealPath = await fs.realpath(path.dirname(targetFile)); + if (!isInside(targetRealPath, parentRealPath)) { + skipped.push(`${relativePath}: destination escapes the worktree`); + continue; + } + await fs.copyFile(sourceFile, targetFile); + copied.push(relativePath); + } catch (error) { + skipped.push(`${relativePath}: ${describeError(error)}`); + } + } + + return { ran: true, copied, skipped }; +} + +function describeError(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/host-workspace/test/worktree-include.test.ts b/packages/host-workspace/test/worktree-include.test.ts new file mode 100644 index 0000000000..2430dfd87c --- /dev/null +++ b/packages/host-workspace/test/worktree-include.test.ts @@ -0,0 +1,177 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { afterEach, describe, expect, it } from "vitest"; +import { WORKTREE_INCLUDE_FILE_NAME } from "@bb/domain"; +import { createWorktree } from "../src/provisioning.js"; +import { runGit } from "../src/git.js"; +import { copyWorktreeIncludeFiles } from "../src/worktree-include.js"; + +const tempDirs: string[] = []; + +async function makeTempDir(prefix: string): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + tempDirs.push(dir); + return dir; +} + +async function writeFile(filePath: string, contents: string): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile(filePath, contents, "utf8"); +} + +/** Repo with a committed `.gitignore` plus the given tracked extra files. */ +async function initRepo(gitignore: string): Promise { + const repoPath = await makeTempDir("bb-worktree-include-repo-"); + await runGit(["init", "-b", "main"], { cwd: repoPath }); + await runGit(["config", "user.name", "BB Tests"], { cwd: repoPath }); + await runGit(["config", "user.email", "bb@example.com"], { cwd: repoPath }); + await writeFile(path.join(repoPath, "README.md"), "hello\n"); + await writeFile(path.join(repoPath, ".gitignore"), gitignore); + await runGit(["add", "."], { cwd: repoPath }); + await runGit(["commit", "-m", "Initial commit"], { cwd: repoPath }); + return repoPath; +} + +afterEach(async () => { + await Promise.all( + tempDirs + .splice(0) + .map((dir) => fs.rm(dir, { recursive: true, force: true })), + ); +}); + +describe("copyWorktreeIncludeFiles", () => { + it("copies gitignored matches and leaves unmatched files behind", async () => { + const sourcePath = await initRepo(".env*\nsecrets/\nbuild/\n"); + await writeFile(path.join(sourcePath, ".env"), "TOKEN=1\n"); + await writeFile(path.join(sourcePath, ".env.local"), "TOKEN=2\n"); + await writeFile(path.join(sourcePath, "secrets/key.pem"), "pem\n"); + await writeFile(path.join(sourcePath, "build/output.js"), "built\n"); + await writeFile( + path.join(sourcePath, WORKTREE_INCLUDE_FILE_NAME), + "# local credentials\n\n.env*\nsecrets/\n", + ); + const targetPath = await makeTempDir("bb-worktree-include-target-"); + + const result = await copyWorktreeIncludeFiles({ sourcePath, targetPath }); + + expect(result.ran).toBe(true); + expect([...result.copied].sort()).toEqual([ + ".env", + ".env.local", + "secrets/key.pem", + ]); + await expect( + fs.readFile(path.join(targetPath, "secrets/key.pem"), "utf8"), + ).resolves.toBe("pem\n"); + await expect( + fs.stat(path.join(targetPath, "build/output.js")), + ).rejects.toThrow(); + }); + + it("honours negation patterns the way gitignore does", async () => { + const sourcePath = await initRepo("secrets/\n"); + await writeFile(path.join(sourcePath, "secrets/keep.pem"), "keep\n"); + await writeFile(path.join(sourcePath, "secrets/skip.pem"), "skip\n"); + await writeFile( + path.join(sourcePath, WORKTREE_INCLUDE_FILE_NAME), + "secrets/*.pem\n!secrets/skip.pem\n", + ); + const targetPath = await makeTempDir("bb-worktree-include-target-"); + + const result = await copyWorktreeIncludeFiles({ sourcePath, targetPath }); + + expect(result.copied).toEqual(["secrets/keep.pem"]); + }); + + it("skips symlinks instead of copying what they point at", async () => { + const sourcePath = await initRepo(".env\n"); + const outsideDir = await makeTempDir("bb-worktree-include-outside-"); + await writeFile(path.join(outsideDir, "real.env"), "OUTSIDE=1\n"); + await fs.symlink( + path.join(outsideDir, "real.env"), + path.join(sourcePath, ".env"), + ); + await writeFile( + path.join(sourcePath, WORKTREE_INCLUDE_FILE_NAME), + ".env\n", + ); + const targetPath = await makeTempDir("bb-worktree-include-target-"); + + const result = await copyWorktreeIncludeFiles({ sourcePath, targetPath }); + + expect(result.copied).toEqual([]); + expect(result.skipped).toEqual([".env: symlink"]); + await expect(fs.stat(path.join(targetPath, ".env"))).rejects.toThrow(); + }); + + it("does nothing when the file holds no patterns", async () => { + const sourcePath = await initRepo(".env\n"); + await writeFile(path.join(sourcePath, ".env"), "TOKEN=1\n"); + await writeFile( + path.join(sourcePath, WORKTREE_INCLUDE_FILE_NAME), + "# nothing here\n\n", + ); + const targetPath = await makeTempDir("bb-worktree-include-target-"); + + const result = await copyWorktreeIncludeFiles({ sourcePath, targetPath }); + + expect(result).toEqual({ ran: false, copied: [], skipped: [] }); + await expect(fs.stat(path.join(targetPath, ".env"))).rejects.toThrow(); + }); +}); + +describe("createWorktree with .worktreeinclude", () => { + it("copies the files before the setup script reads them", async () => { + const sourcePath = await initRepo(".env\n"); + await writeFile( + path.join(sourcePath, ".bb-env-setup.sh"), + "set -eu\ncp .env copied-by-setup\n", + ); + await writeFile( + path.join(sourcePath, WORKTREE_INCLUDE_FILE_NAME), + ".env\n", + ); + await runGit(["add", "."], { cwd: sourcePath }); + await runGit(["commit", "-m", "Add setup script"], { cwd: sourcePath }); + await writeFile(path.join(sourcePath, ".env"), "TOKEN=1\n"); + const parentDir = await makeTempDir("bb-worktree-include-parent-"); + const targetPath = path.join(parentDir, "feature"); + + await createWorktree({ + sourcePath, + targetPath, + branchName: "feature", + baseBranch: "main", + timeoutMs: 900000, + }); + + await expect( + fs.readFile(path.join(targetPath, "copied-by-setup"), "utf8"), + ).resolves.toBe("TOKEN=1\n"); + }); + + it("still provisions when a listed file is missing", async () => { + const sourcePath = await initRepo(".env\n"); + await writeFile( + path.join(sourcePath, WORKTREE_INCLUDE_FILE_NAME), + ".env\n", + ); + const parentDir = await makeTempDir("bb-worktree-include-parent-"); + const targetPath = path.join(parentDir, "feature"); + + await createWorktree({ + sourcePath, + targetPath, + branchName: "feature", + baseBranch: "main", + timeoutMs: 900000, + }); + + await expect( + fs.stat(path.join(targetPath, "README.md")), + ).resolves.toBeDefined(); + await expect(fs.stat(path.join(targetPath, ".env"))).rejects.toThrow(); + }); +}); diff --git a/packages/templates/src/generated/templates.generated.ts b/packages/templates/src/generated/templates.generated.ts index 2590c6e4e5..7d6f40027e 100644 --- a/packages/templates/src/generated/templates.generated.ts +++ b/packages/templates/src/generated/templates.generated.ts @@ -50,7 +50,7 @@ export const templateDefinitions = [ }, { "id": "bbGuideEnvironments", - "body": "Environment commands\n\nEnvironments determine where threads run. Multiple threads can share an environment\n(e.g., a coding thread and a review thread in the same worktree).\n\nMaking your repo work with bb:\n\n Commit a .bb-env-setup.sh script at the repo root when new bb worktrees need\n repo-specific setup. After bb creates a new managed worktree environment, it\n looks for .bb-env-setup.sh inside that new workspace. If the file is absent,\n provisioning continues with no error.\n\n The script must be tracked by git. A fresh worktree only checks out tracked\n files, so an untracked .bb-env-setup.sh in your source checkout will not be\n present and will not run.\n\n BB runs the hook as `env bash .bb-env-setup.sh` with cwd set to the new\n workspace. POSIX shell setup scripts are not supported on Windows. The hook\n inherits the host daemon's sanitized environment: NODE_ENV and every BB_*\n variable are removed, and bb does not inject BB_PROJECT_ID, BB_ENVIRONMENT_ID,\n or BB_SOURCE_PATH.\n\n The hook runs only for newly-created managed worktree environments. It does\n not run for direct/project-checkout environments, personal scratch workspaces,\n or reconnecting an existing managed worktree.\n\n A non-zero exit, timeout, signal, or cancellation fails provisioning and bb\n removes the new worktree. Keep optional setup steps non-fatal inside the\n script if the environment should still open. Provisioning progress reports\n \"Running .bb-env-setup.sh\" and then \".bb-env-setup.sh finished\",\n \".bb-env-setup.sh failed\", or \".bb-env-setup.sh cancelled\".\n\n New worktrees do not contain gitignored files such as .env.local. To copy\n them from the original checkout, locate the source root through git's common\n directory:\n\n common_dir=$(git rev-parse --path-format=absolute --git-common-dir)\n source_root=$(dirname \"$common_dir\")\n workspace_root=$(pwd -P)\n\n A real setup script should then copy a fixed list of needed env files if they\n exist in source_root and are missing in workspace_root, warn and continue on\n optional copy failures, then run dependency setup such as pnpm install.\n\n For files that customize agent instructions and skills (AGENTS.md,\n .bb/AGENTS.md, .bb/skills/), run `bb guide agent-configuration`.\n\n bb environment show Show environment details (path, branch, status)\n\n bb environment status Show workspace status\n --merge-base-branch Include merge-base status\n\n bb environment branches List local and remote branches\n --query Filter branch names\n --limit Limit local and remote results\n\n bb environment paths Search workspace paths\n --query Fuzzy path query\n --limit Maximum results\n --files Include only files unless combined with --directories\n --directories Include only directories unless combined with --files\n\n bb environment diff Show file summary and full git diff\n bb environment diff-files List changed-file metadata\n --target uncommitted, branch_committed, all, or commit (required)\n --merge-base-branch Required for branch_committed and all\n --sha Required for commit\n\n bb environment diff-file Read one side of a changed file\n --target Diff target (required)\n --path Repository-relative path (required)\n --side File side (required)\n --merge-base-ref Required for branch_committed and all\n --sha Required for commit\n\n bb environment diff-patch Fetch selected file patches\n --target Diff target (required)\n --path Changed path; repeat for multiple files (required)\n --merge-base-branch Required for branch_committed and all\n --sha Required for commit\n\n bb environment update Update environment metadata\n --merge-base-branch Set merge-base branch override\n --clear-merge-base-branch Clear merge-base override\n --name Set display name\n --clear-name Clear display name\n\n bb environment commit Create a commit in the environment\n\n bb environment squash-merge Squash-merge into a target branch\n --merge-base-branch Target branch (required)\n\n bb environment archive-threads Archive all threads in an environment\n\n bb environment pull-request show Inspect a pull request\n bb environment pull-request ready Mark a pull request ready\n bb environment pull-request draft Convert a pull request to draft\n bb environment pull-request merge Merge a pull request\n --method merge, squash, or rebase\n\nEvery inspection command accepts an arbitrary environment ID and supports\n`--json`. Non-git status/diff responses are reported explicitly. `diff-file`\nprints UTF-8 content directly and labels base64 binary content; diff and patch\ntruncation markers are preserved.\n\nRemote access (bb connect):\n\n Expose this bb server at .getbb.app so you can reach it from any\n browser. Claim a handle at https://getbb.app, copy the connect command it\n generates, then run it here to\n pair:\n\n bb connect --code --server https://.getbb.app\n --code One-time pairing code from the dashboard\n --server https://.getbb.app (from the dashboard)\n\n Pairing returns immediately: the bb SERVER redeems the code, stores the\n credential, and holds the tunnel itself — so it stays up as long as bb is\n running and reconnects on restart (no foreground process).\n Without an installed bb, pair via npm:\n `npx -p bb-app@latest bb connect --code --server `.\n\n bb connect status Show the server's connect status\n bb connect off Disconnect and forget the pairing\n bb connect expose [--host ] Share a host's HTTP port\n bb connect unexpose [--host ] Stop sharing on that host\n bb connect shares [--host ] List that host's shares\n bb connect servers List every bb on this account (handle, url, live)\n\n Port sharing works from threads on any enrolled host. In a thread,\n `bb connect expose ` resolves the thread environment's host; outside a\n thread it defaults to the server host. `--host ` overrides that\n choice for expose, unexpose, and shares. Server-host URLs use\n `https://--.getbb.app`; machine-host URLs use\n `https://--.getbb.app` and proxy directly through that\n machine's daemon. Access is owner-session-gated — only viewers signed into\n the owner's getbb.app account can open the URL; it is not a public internet\n link. Agents should run expose from the thread that started the server, share\n the returned URL, and unexpose from the same thread when it stops.\n `bb connect status` shows all shares with host + URL. `shares --json` returns\n the resolved `host` and rows with `hostId`, `hostName`, `port`, and `url`.\n\n Remote access is owned by the builtin \"connect\" plugin (Plugins → connect\n shows the URL, QR code, and shared ports). Disabling the plugin\n (`bb plugin disable connect`) cuts off all remote access; re-enable with\n `bb plugin enable connect`.", + "body": "Environment commands\n\nEnvironments determine where threads run. Multiple threads can share an environment\n(e.g., a coding thread and a review thread in the same worktree).\n\nMaking your repo work with bb:\n\n Commit a .bb-env-setup.sh script at the repo root when new bb worktrees need\n repo-specific setup. After bb creates a new managed worktree environment, it\n looks for .bb-env-setup.sh inside that new workspace. If the file is absent,\n provisioning continues with no error.\n\n The script must be tracked by git. A fresh worktree only checks out tracked\n files, so an untracked .bb-env-setup.sh in your source checkout will not be\n present and will not run.\n\n BB runs the hook as `env bash .bb-env-setup.sh` with cwd set to the new\n workspace. POSIX shell setup scripts are not supported on Windows. The hook\n inherits the host daemon's sanitized environment: NODE_ENV and every BB_*\n variable are removed, and bb does not inject BB_PROJECT_ID, BB_ENVIRONMENT_ID,\n or BB_SOURCE_PATH.\n\n The hook runs only for newly-created managed worktree environments. It does\n not run for direct/project-checkout environments, personal scratch workspaces,\n or reconnecting an existing managed worktree.\n\n A non-zero exit, timeout, signal, or cancellation fails provisioning and bb\n removes the new worktree. Keep optional setup steps non-fatal inside the\n script if the environment should still open. Provisioning progress reports\n \"Running .bb-env-setup.sh\" and then \".bb-env-setup.sh finished\",\n \".bb-env-setup.sh failed\", or \".bb-env-setup.sh cancelled\".\n\n New worktrees do not contain untracked files such as .env.local. To copy\n them from the source checkout, commit a .worktreeinclude file at the repo\n root. It uses gitignore syntax: one pattern per line, # for comments, ! to\n negate an earlier pattern. bb copies each untracked file in the source\n checkout that matches a pattern:\n\n .env\n .env.*\n !.env.example\n certs/\n\n bb copies files only. It follows no symlinks and it replaces no tracked\n file. The copy runs after `git worktree add` and before .bb-env-setup.sh, so\n the setup script can read the copied files. A pattern that matches nothing,\n or a file bb cannot read, is reported in the provisioning transcript and\n does not fail provisioning.\n\n Large directories such as node_modules are copied file by file. Install\n dependencies in .bb-env-setup.sh instead of listing them here.\n\n For files that customize agent instructions and skills (AGENTS.md,\n .bb/AGENTS.md, .bb/skills/), run `bb guide agent-configuration`.\n\n bb environment show Show environment details (path, branch, status)\n\n bb environment status Show workspace status\n --merge-base-branch Include merge-base status\n\n bb environment branches List local and remote branches\n --query Filter branch names\n --limit Limit local and remote results\n\n bb environment paths Search workspace paths\n --query Fuzzy path query\n --limit Maximum results\n --files Include only files unless combined with --directories\n --directories Include only directories unless combined with --files\n\n bb environment diff Show file summary and full git diff\n bb environment diff-files List changed-file metadata\n --target uncommitted, branch_committed, all, or commit (required)\n --merge-base-branch Required for branch_committed and all\n --sha Required for commit\n\n bb environment diff-file Read one side of a changed file\n --target Diff target (required)\n --path Repository-relative path (required)\n --side File side (required)\n --merge-base-ref Required for branch_committed and all\n --sha Required for commit\n\n bb environment diff-patch Fetch selected file patches\n --target Diff target (required)\n --path Changed path; repeat for multiple files (required)\n --merge-base-branch Required for branch_committed and all\n --sha Required for commit\n\n bb environment update Update environment metadata\n --merge-base-branch Set merge-base branch override\n --clear-merge-base-branch Clear merge-base override\n --name Set display name\n --clear-name Clear display name\n\n bb environment commit Create a commit in the environment\n\n bb environment squash-merge Squash-merge into a target branch\n --merge-base-branch Target branch (required)\n\n bb environment archive-threads Archive all threads in an environment\n\n bb environment pull-request show Inspect a pull request\n bb environment pull-request ready Mark a pull request ready\n bb environment pull-request draft Convert a pull request to draft\n bb environment pull-request merge Merge a pull request\n --method merge, squash, or rebase\n\nEvery inspection command accepts an arbitrary environment ID and supports\n`--json`. Non-git status/diff responses are reported explicitly. `diff-file`\nprints UTF-8 content directly and labels base64 binary content; diff and patch\ntruncation markers are preserved.\n\nRemote access (bb connect):\n\n Expose this bb server at .getbb.app so you can reach it from any\n browser. Claim a handle at https://getbb.app, copy the connect command it\n generates, then run it here to\n pair:\n\n bb connect --code --server https://.getbb.app\n --code One-time pairing code from the dashboard\n --server https://.getbb.app (from the dashboard)\n\n Pairing returns immediately: the bb SERVER redeems the code, stores the\n credential, and holds the tunnel itself — so it stays up as long as bb is\n running and reconnects on restart (no foreground process).\n Without an installed bb, pair via npm:\n `npx -p bb-app@latest bb connect --code --server `.\n\n bb connect status Show the server's connect status\n bb connect off Disconnect and forget the pairing\n bb connect expose [--host ] Share a host's HTTP port\n bb connect unexpose [--host ] Stop sharing on that host\n bb connect shares [--host ] List that host's shares\n bb connect servers List every bb on this account (handle, url, live)\n\n Port sharing works from threads on any enrolled host. In a thread,\n `bb connect expose ` resolves the thread environment's host; outside a\n thread it defaults to the server host. `--host ` overrides that\n choice for expose, unexpose, and shares. Server-host URLs use\n `https://--.getbb.app`; machine-host URLs use\n `https://--.getbb.app` and proxy directly through that\n machine's daemon. Access is owner-session-gated — only viewers signed into\n the owner's getbb.app account can open the URL; it is not a public internet\n link. Agents should run expose from the thread that started the server, share\n the returned URL, and unexpose from the same thread when it stops.\n `bb connect status` shows all shares with host + URL. `shares --json` returns\n the resolved `host` and rows with `hostId`, `hostName`, `port`, and `url`.\n\n Remote access is owned by the builtin \"connect\" plugin (Plugins → connect\n shows the URL, QR code, and shared ports). Disabling the plugin\n (`bb plugin disable connect`) cuts off all remote access; re-enable with\n `bb plugin enable connect`.", "fileName": "bb-guide-environments.md", "kind": "instruction", "title": "bb Guide — Environments", diff --git a/packages/templates/src/templates/bb-guide-environments.md b/packages/templates/src/templates/bb-guide-environments.md index 447a3d8b34..2e2cfbacfb 100644 --- a/packages/templates/src/templates/bb-guide-environments.md +++ b/packages/templates/src/templates/bb-guide-environments.md @@ -37,17 +37,25 @@ Making your repo work with bb: "Running .bb-env-setup.sh" and then ".bb-env-setup.sh finished", ".bb-env-setup.sh failed", or ".bb-env-setup.sh cancelled". - New worktrees do not contain gitignored files such as .env.local. To copy - them from the original checkout, locate the source root through git's common - directory: - - common_dir=$(git rev-parse --path-format=absolute --git-common-dir) - source_root=$(dirname "$common_dir") - workspace_root=$(pwd -P) - - A real setup script should then copy a fixed list of needed env files if they - exist in source_root and are missing in workspace_root, warn and continue on - optional copy failures, then run dependency setup such as pnpm install. + New worktrees do not contain untracked files such as .env.local. To copy + them from the source checkout, commit a .worktreeinclude file at the repo + root. It uses gitignore syntax: one pattern per line, # for comments, ! to + negate an earlier pattern. bb copies each untracked file in the source + checkout that matches a pattern: + + .env + .env.* + !.env.example + certs/ + + bb copies files only. It follows no symlinks and it replaces no tracked + file. The copy runs after `git worktree add` and before .bb-env-setup.sh, so + the setup script can read the copied files. A pattern that matches nothing, + or a file bb cannot read, is reported in the provisioning transcript and + does not fail provisioning. + + Large directories such as node_modules are copied file by file. Install + dependencies in .bb-env-setup.sh instead of listing them here. For files that customize agent instructions and skills (AGENTS.md, .bb/AGENTS.md, .bb/skills/), run `bb guide agent-configuration`. From 5ccf70351cfcaaf7382a0ddaf1ff45827a887e2d Mon Sep 17 00:00:00 2001 From: Sawyer Hood Date: Wed, 5 Aug 2026 20:16:10 +0000 Subject: [PATCH 2/2] Address SlopCop review findings Two defects and three contract gaps from the review of #1029: - Never write through a destination that already exists. fs.copyFile followed a symlink tracked by the base branch and wrote the copied secret outside the worktree; it also replaced an existing tracked file, which the docs promised bb would not do. bb now skips any present destination, using lstat plus COPYFILE_EXCL to close the race. - Bump HOST_DAEMON_PROTOCOL_VERSION to 72. The copy step runs in the host daemon. An old daemon stays connected, skips the copy, and emits no such transcript entry, so a repo that lists its .env silently gets a worktree without it. - Stop the copy loop on cancellation instead of copying every remaining file and then deleting all of it during cleanup. - Report git ls-files failures and non-ENOENT include-file read errors rather than converting them into a false zero-match success. - Cap the paths named in one transcript entry. A broad pattern could put thousands of paths into the transcript the daemon keeps and forwards. Co-Authored-By: Claude Opus 5 (1M context) --- docs/worktrees.md | 6 +- packages/host-daemon-contract/src/commands.ts | 2 +- .../test/contract.test.ts | 11 +-- packages/host-workspace/src/provisioning.ts | 35 +++++++- .../host-workspace/src/worktree-include.ts | 70 +++++++++++++--- .../test/worktree-include.test.ts | 79 +++++++++++++++++++ .../src/generated/templates.generated.ts | 2 +- .../src/templates/bb-guide-environments.md | 10 +-- 8 files changed, 187 insertions(+), 28 deletions(-) diff --git a/docs/worktrees.md b/docs/worktrees.md index dbe814ac2e..841347e79f 100644 --- a/docs/worktrees.md +++ b/docs/worktrees.md @@ -67,8 +67,10 @@ 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 a tracked file. Only untracked files are candidates. -- bb skips symlinks in the source checkout rather than copying their targets. +- 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 diff --git a/packages/host-daemon-contract/src/commands.ts b/packages/host-daemon-contract/src/commands.ts index 5f5554bbf4..afdad80363 100644 --- a/packages/host-daemon-contract/src/commands.ts +++ b/packages/host-daemon-contract/src/commands.ts @@ -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, diff --git a/packages/host-daemon-contract/test/contract.test.ts b/packages/host-daemon-contract/test/contract.test.ts index af9660c78c..5ab68d5c04 100644 --- a/packages/host-daemon-contract/test/contract.test.ts +++ b/packages/host-daemon-contract/test/contract.test.ts @@ -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", () => { diff --git a/packages/host-workspace/src/provisioning.ts b/packages/host-workspace/src/provisioning.ts index 3fe3338ada..06d3b61b28 100644 --- a/packages/host-workspace/src/provisioning.ts +++ b/packages/host-workspace/src/provisioning.ts @@ -432,13 +432,26 @@ 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 names every skipped - * entry and the thread still starts. + * 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; @@ -472,14 +485,28 @@ async function copyIncludedFiles(args: { return; } - for (const skipped of result.skipped) { + 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): ${result.copied.join(", ")}`, + `Copied ${result.copied.length} file(s): ${summarizePaths( + result.copied, + )}`, ); } emitStep({ diff --git a/packages/host-workspace/src/worktree-include.ts b/packages/host-workspace/src/worktree-include.ts index e7542c4eba..743c1b95c5 100644 --- a/packages/host-workspace/src/worktree-include.ts +++ b/packages/host-workspace/src/worktree-include.ts @@ -1,7 +1,8 @@ +import { constants as fsConstants } from "node:fs"; import fs from "node:fs/promises"; import path from "node:path"; import { WORKTREE_INCLUDE_FILE_NAME } from "@bb/domain"; -import { runGit } from "./git.js"; +import { runGit, WorkspaceError } from "./git.js"; export interface CopyWorktreeIncludeFilesArgs { /** Existing checkout that owns the `.worktreeinclude` file. */ @@ -38,14 +39,30 @@ function hasPattern(contents: string): boolean { .some((line) => line.length > 0 && !line.startsWith("#")); } +function isMissingFileError(error: unknown): boolean { + return ( + error instanceof Error && + "code" in error && + (error as NodeJS.ErrnoException).code === "ENOENT" + ); +} + +/** + * Read `.worktreeinclude`, or return null when the repo has no such file. Any + * other read failure — a permission error, a directory in its place — is a + * real problem the caller must report. + */ async function readIncludeFile(sourcePath: string): Promise { try { return await fs.readFile( path.join(sourcePath, WORKTREE_INCLUDE_FILE_NAME), "utf8", ); - } catch { - return null; + } catch (error) { + if (isMissingFileError(error)) { + return null; + } + throw error; } } @@ -68,14 +85,38 @@ async function listMatchingFiles( `--exclude-from=${WORKTREE_INCLUDE_FILE_NAME}`, "-z", ], - { cwd: sourcePath, allowFailure: true, signal }, + { cwd: sourcePath, signal }, ); - if (result.exitCode !== 0) { - return []; - } return result.stdout.split("\0").filter(Boolean); } +/** + * True when anything already occupies `targetPath`, including a broken + * symlink. `lstat` never follows the last component, so a symlink planted by + * the base branch reports itself rather than what it points at. + */ +async function pathPresent(targetPath: string): Promise { + try { + await fs.lstat(targetPath); + return true; + } catch (error) { + if (isMissingFileError(error)) { + return false; + } + throw error; + } +} + +function throwIfAborted(signal: AbortSignal | undefined): void { + if (signal?.aborted) { + throw new WorkspaceError( + "provision_cancelled", + "Workspace provisioning was cancelled", + { cause: signal.reason }, + ); + } +} + function isInside(parentRealPath: string, childRealPath: string): boolean { const relative = path.relative(parentRealPath, childRealPath); return ( @@ -89,8 +130,10 @@ function isInside(parentRealPath: string, childRealPath: string): boolean { * checkout into a new worktree. A fresh worktree contains tracked files only, * so local `.env` files and credentials never arrive on their own. * - * Nothing here is fatal: a missing file, an unreadable entry, or a failed copy - * is reported and provisioning continues. + * A per-file failure is collected in `skipped` and the remaining files still + * copy. An unreadable include file or a failed `git ls-files` throws, because + * silently reporting zero matches would hide the real cause. Cancellation + * throws `provision_cancelled` between files. */ export async function copyWorktreeIncludeFiles( args: CopyWorktreeIncludeFilesArgs, @@ -119,6 +162,7 @@ export async function copyWorktreeIncludeFiles( const copied: string[] = []; const skipped: string[] = []; for (const relativePath of relativePaths) { + throwIfAborted(args.signal); const sourceFile = path.join(args.sourcePath, relativePath); const targetFile = path.join(targetRealPath, relativePath); try { @@ -127,13 +171,19 @@ export async function copyWorktreeIncludeFiles( skipped.push(`${relativePath}: symlink`); continue; } + if (await pathPresent(targetFile)) { + skipped.push(`${relativePath}: already exists in the worktree`); + continue; + } await fs.mkdir(path.dirname(targetFile), { recursive: true }); const parentRealPath = await fs.realpath(path.dirname(targetFile)); if (!isInside(targetRealPath, parentRealPath)) { skipped.push(`${relativePath}: destination escapes the worktree`); continue; } - await fs.copyFile(sourceFile, targetFile); + // COPYFILE_EXCL fails rather than following a symlink or replacing a + // file that appeared between the check above and this write. + await fs.copyFile(sourceFile, targetFile, fsConstants.COPYFILE_EXCL); copied.push(relativePath); } catch (error) { skipped.push(`${relativePath}: ${describeError(error)}`); diff --git a/packages/host-workspace/test/worktree-include.test.ts b/packages/host-workspace/test/worktree-include.test.ts index 2430dfd87c..c5dd1671e4 100644 --- a/packages/host-workspace/test/worktree-include.test.ts +++ b/packages/host-workspace/test/worktree-include.test.ts @@ -106,6 +106,85 @@ describe("copyWorktreeIncludeFiles", () => { await expect(fs.stat(path.join(targetPath, ".env"))).rejects.toThrow(); }); + it("does not write through a symlink already in the worktree", async () => { + const sourcePath = await initRepo(".env\n"); + await writeFile(path.join(sourcePath, ".env"), "SECRET=1\n"); + await writeFile( + path.join(sourcePath, WORKTREE_INCLUDE_FILE_NAME), + ".env\n", + ); + const outsideDir = await makeTempDir("bb-worktree-include-outside-"); + const hostFile = path.join(outsideDir, "host-file"); + await writeFile(hostFile, "untouched\n"); + const targetPath = await makeTempDir("bb-worktree-include-target-"); + // The base branch can track a symlink where the source has a real file. + await fs.symlink(hostFile, path.join(targetPath, ".env")); + + const result = await copyWorktreeIncludeFiles({ sourcePath, targetPath }); + + expect(result.copied).toEqual([]); + expect(result.skipped).toEqual([".env: already exists in the worktree"]); + await expect(fs.readFile(hostFile, "utf8")).resolves.toBe("untouched\n"); + }); + + it("does not replace a file the worktree already has", async () => { + const sourcePath = await initRepo("config.json\n"); + await writeFile( + path.join(sourcePath, "config.json"), + '{"from":"source"}\n', + ); + await writeFile( + path.join(sourcePath, WORKTREE_INCLUDE_FILE_NAME), + "config.json\n", + ); + const targetPath = await makeTempDir("bb-worktree-include-target-"); + await writeFile( + path.join(targetPath, "config.json"), + '{"from":"branch"}\n', + ); + + const result = await copyWorktreeIncludeFiles({ sourcePath, targetPath }); + + expect(result.copied).toEqual([]); + await expect( + fs.readFile(path.join(targetPath, "config.json"), "utf8"), + ).resolves.toBe('{"from":"branch"}\n'); + }); + + it("reports a git listing failure instead of claiming zero matches", async () => { + const sourcePath = await makeTempDir("bb-worktree-include-nonrepo-"); + await writeFile( + path.join(sourcePath, WORKTREE_INCLUDE_FILE_NAME), + ".env\n", + ); + const targetPath = await makeTempDir("bb-worktree-include-target-"); + + await expect( + copyWorktreeIncludeFiles({ sourcePath, targetPath }), + ).rejects.toThrow(/git ls-files/u); + }); + + it("stops copying once provisioning is cancelled", async () => { + const sourcePath = await initRepo("secrets/\n"); + await writeFile(path.join(sourcePath, "secrets/a.pem"), "a\n"); + await writeFile(path.join(sourcePath, "secrets/b.pem"), "b\n"); + await writeFile( + path.join(sourcePath, WORKTREE_INCLUDE_FILE_NAME), + "secrets/\n", + ); + const targetPath = await makeTempDir("bb-worktree-include-target-"); + const controller = new AbortController(); + // Abort after git has listed the matches, before the copy loop runs. + const listed = copyWorktreeIncludeFiles({ + sourcePath, + targetPath, + signal: controller.signal, + }); + controller.abort(); + + await expect(listed).rejects.toThrow(/cancelled/u); + }); + it("does nothing when the file holds no patterns", async () => { const sourcePath = await initRepo(".env\n"); await writeFile(path.join(sourcePath, ".env"), "TOKEN=1\n"); diff --git a/packages/templates/src/generated/templates.generated.ts b/packages/templates/src/generated/templates.generated.ts index 7d6f40027e..1474b6f534 100644 --- a/packages/templates/src/generated/templates.generated.ts +++ b/packages/templates/src/generated/templates.generated.ts @@ -50,7 +50,7 @@ export const templateDefinitions = [ }, { "id": "bbGuideEnvironments", - "body": "Environment commands\n\nEnvironments determine where threads run. Multiple threads can share an environment\n(e.g., a coding thread and a review thread in the same worktree).\n\nMaking your repo work with bb:\n\n Commit a .bb-env-setup.sh script at the repo root when new bb worktrees need\n repo-specific setup. After bb creates a new managed worktree environment, it\n looks for .bb-env-setup.sh inside that new workspace. If the file is absent,\n provisioning continues with no error.\n\n The script must be tracked by git. A fresh worktree only checks out tracked\n files, so an untracked .bb-env-setup.sh in your source checkout will not be\n present and will not run.\n\n BB runs the hook as `env bash .bb-env-setup.sh` with cwd set to the new\n workspace. POSIX shell setup scripts are not supported on Windows. The hook\n inherits the host daemon's sanitized environment: NODE_ENV and every BB_*\n variable are removed, and bb does not inject BB_PROJECT_ID, BB_ENVIRONMENT_ID,\n or BB_SOURCE_PATH.\n\n The hook runs only for newly-created managed worktree environments. It does\n not run for direct/project-checkout environments, personal scratch workspaces,\n or reconnecting an existing managed worktree.\n\n A non-zero exit, timeout, signal, or cancellation fails provisioning and bb\n removes the new worktree. Keep optional setup steps non-fatal inside the\n script if the environment should still open. Provisioning progress reports\n \"Running .bb-env-setup.sh\" and then \".bb-env-setup.sh finished\",\n \".bb-env-setup.sh failed\", or \".bb-env-setup.sh cancelled\".\n\n New worktrees do not contain untracked files such as .env.local. To copy\n them from the source checkout, commit a .worktreeinclude file at the repo\n root. It uses gitignore syntax: one pattern per line, # for comments, ! to\n negate an earlier pattern. bb copies each untracked file in the source\n checkout that matches a pattern:\n\n .env\n .env.*\n !.env.example\n certs/\n\n bb copies files only. It follows no symlinks and it replaces no tracked\n file. The copy runs after `git worktree add` and before .bb-env-setup.sh, so\n the setup script can read the copied files. A pattern that matches nothing,\n or a file bb cannot read, is reported in the provisioning transcript and\n does not fail provisioning.\n\n Large directories such as node_modules are copied file by file. Install\n dependencies in .bb-env-setup.sh instead of listing them here.\n\n For files that customize agent instructions and skills (AGENTS.md,\n .bb/AGENTS.md, .bb/skills/), run `bb guide agent-configuration`.\n\n bb environment show Show environment details (path, branch, status)\n\n bb environment status Show workspace status\n --merge-base-branch Include merge-base status\n\n bb environment branches List local and remote branches\n --query Filter branch names\n --limit Limit local and remote results\n\n bb environment paths Search workspace paths\n --query Fuzzy path query\n --limit Maximum results\n --files Include only files unless combined with --directories\n --directories Include only directories unless combined with --files\n\n bb environment diff Show file summary and full git diff\n bb environment diff-files List changed-file metadata\n --target uncommitted, branch_committed, all, or commit (required)\n --merge-base-branch Required for branch_committed and all\n --sha Required for commit\n\n bb environment diff-file Read one side of a changed file\n --target Diff target (required)\n --path Repository-relative path (required)\n --side File side (required)\n --merge-base-ref Required for branch_committed and all\n --sha Required for commit\n\n bb environment diff-patch Fetch selected file patches\n --target Diff target (required)\n --path Changed path; repeat for multiple files (required)\n --merge-base-branch Required for branch_committed and all\n --sha Required for commit\n\n bb environment update Update environment metadata\n --merge-base-branch Set merge-base branch override\n --clear-merge-base-branch Clear merge-base override\n --name Set display name\n --clear-name Clear display name\n\n bb environment commit Create a commit in the environment\n\n bb environment squash-merge Squash-merge into a target branch\n --merge-base-branch Target branch (required)\n\n bb environment archive-threads Archive all threads in an environment\n\n bb environment pull-request show Inspect a pull request\n bb environment pull-request ready Mark a pull request ready\n bb environment pull-request draft Convert a pull request to draft\n bb environment pull-request merge Merge a pull request\n --method merge, squash, or rebase\n\nEvery inspection command accepts an arbitrary environment ID and supports\n`--json`. Non-git status/diff responses are reported explicitly. `diff-file`\nprints UTF-8 content directly and labels base64 binary content; diff and patch\ntruncation markers are preserved.\n\nRemote access (bb connect):\n\n Expose this bb server at .getbb.app so you can reach it from any\n browser. Claim a handle at https://getbb.app, copy the connect command it\n generates, then run it here to\n pair:\n\n bb connect --code --server https://.getbb.app\n --code One-time pairing code from the dashboard\n --server https://.getbb.app (from the dashboard)\n\n Pairing returns immediately: the bb SERVER redeems the code, stores the\n credential, and holds the tunnel itself — so it stays up as long as bb is\n running and reconnects on restart (no foreground process).\n Without an installed bb, pair via npm:\n `npx -p bb-app@latest bb connect --code --server `.\n\n bb connect status Show the server's connect status\n bb connect off Disconnect and forget the pairing\n bb connect expose [--host ] Share a host's HTTP port\n bb connect unexpose [--host ] Stop sharing on that host\n bb connect shares [--host ] List that host's shares\n bb connect servers List every bb on this account (handle, url, live)\n\n Port sharing works from threads on any enrolled host. In a thread,\n `bb connect expose ` resolves the thread environment's host; outside a\n thread it defaults to the server host. `--host ` overrides that\n choice for expose, unexpose, and shares. Server-host URLs use\n `https://--.getbb.app`; machine-host URLs use\n `https://--.getbb.app` and proxy directly through that\n machine's daemon. Access is owner-session-gated — only viewers signed into\n the owner's getbb.app account can open the URL; it is not a public internet\n link. Agents should run expose from the thread that started the server, share\n the returned URL, and unexpose from the same thread when it stops.\n `bb connect status` shows all shares with host + URL. `shares --json` returns\n the resolved `host` and rows with `hostId`, `hostName`, `port`, and `url`.\n\n Remote access is owned by the builtin \"connect\" plugin (Plugins → connect\n shows the URL, QR code, and shared ports). Disabling the plugin\n (`bb plugin disable connect`) cuts off all remote access; re-enable with\n `bb plugin enable connect`.", + "body": "Environment commands\n\nEnvironments determine where threads run. Multiple threads can share an environment\n(e.g., a coding thread and a review thread in the same worktree).\n\nMaking your repo work with bb:\n\n Commit a .bb-env-setup.sh script at the repo root when new bb worktrees need\n repo-specific setup. After bb creates a new managed worktree environment, it\n looks for .bb-env-setup.sh inside that new workspace. If the file is absent,\n provisioning continues with no error.\n\n The script must be tracked by git. A fresh worktree only checks out tracked\n files, so an untracked .bb-env-setup.sh in your source checkout will not be\n present and will not run.\n\n BB runs the hook as `env bash .bb-env-setup.sh` with cwd set to the new\n workspace. POSIX shell setup scripts are not supported on Windows. The hook\n inherits the host daemon's sanitized environment: NODE_ENV and every BB_*\n variable are removed, and bb does not inject BB_PROJECT_ID, BB_ENVIRONMENT_ID,\n or BB_SOURCE_PATH.\n\n The hook runs only for newly-created managed worktree environments. It does\n not run for direct/project-checkout environments, personal scratch workspaces,\n or reconnecting an existing managed worktree.\n\n A non-zero exit, timeout, signal, or cancellation fails provisioning and bb\n removes the new worktree. Keep optional setup steps non-fatal inside the\n script if the environment should still open. Provisioning progress reports\n \"Running .bb-env-setup.sh\" and then \".bb-env-setup.sh finished\",\n \".bb-env-setup.sh failed\", or \".bb-env-setup.sh cancelled\".\n\n New worktrees do not contain untracked files such as .env.local. To copy\n them from the source checkout, commit a .worktreeinclude file at the repo\n root. It uses gitignore syntax: one pattern per line, # for comments, ! to\n negate an earlier pattern. bb copies each untracked file in the source\n checkout that matches a pattern:\n\n .env\n .env.*\n !.env.example\n certs/\n\n bb copies files only. It follows no symlinks, and it replaces nothing that\n the worktree already has. The copy runs after `git worktree add` and before\n .bb-env-setup.sh, so the setup script can read the copied files. A pattern\n that matches nothing, or a file bb cannot read, is reported in the\n provisioning transcript and does not fail provisioning.\n\n Large directories such as node_modules are copied file by file. Install\n dependencies in .bb-env-setup.sh instead of listing them here.\n\n For files that customize agent instructions and skills (AGENTS.md,\n .bb/AGENTS.md, .bb/skills/), run `bb guide agent-configuration`.\n\n bb environment show Show environment details (path, branch, status)\n\n bb environment status Show workspace status\n --merge-base-branch Include merge-base status\n\n bb environment branches List local and remote branches\n --query Filter branch names\n --limit Limit local and remote results\n\n bb environment paths Search workspace paths\n --query Fuzzy path query\n --limit Maximum results\n --files Include only files unless combined with --directories\n --directories Include only directories unless combined with --files\n\n bb environment diff Show file summary and full git diff\n bb environment diff-files List changed-file metadata\n --target uncommitted, branch_committed, all, or commit (required)\n --merge-base-branch Required for branch_committed and all\n --sha Required for commit\n\n bb environment diff-file Read one side of a changed file\n --target Diff target (required)\n --path Repository-relative path (required)\n --side File side (required)\n --merge-base-ref Required for branch_committed and all\n --sha Required for commit\n\n bb environment diff-patch Fetch selected file patches\n --target Diff target (required)\n --path Changed path; repeat for multiple files (required)\n --merge-base-branch Required for branch_committed and all\n --sha Required for commit\n\n bb environment update Update environment metadata\n --merge-base-branch Set merge-base branch override\n --clear-merge-base-branch Clear merge-base override\n --name Set display name\n --clear-name Clear display name\n\n bb environment commit Create a commit in the environment\n\n bb environment squash-merge Squash-merge into a target branch\n --merge-base-branch Target branch (required)\n\n bb environment archive-threads Archive all threads in an environment\n\n bb environment pull-request show Inspect a pull request\n bb environment pull-request ready Mark a pull request ready\n bb environment pull-request draft Convert a pull request to draft\n bb environment pull-request merge Merge a pull request\n --method merge, squash, or rebase\n\nEvery inspection command accepts an arbitrary environment ID and supports\n`--json`. Non-git status/diff responses are reported explicitly. `diff-file`\nprints UTF-8 content directly and labels base64 binary content; diff and patch\ntruncation markers are preserved.\n\nRemote access (bb connect):\n\n Expose this bb server at .getbb.app so you can reach it from any\n browser. Claim a handle at https://getbb.app, copy the connect command it\n generates, then run it here to\n pair:\n\n bb connect --code --server https://.getbb.app\n --code One-time pairing code from the dashboard\n --server https://.getbb.app (from the dashboard)\n\n Pairing returns immediately: the bb SERVER redeems the code, stores the\n credential, and holds the tunnel itself — so it stays up as long as bb is\n running and reconnects on restart (no foreground process).\n Without an installed bb, pair via npm:\n `npx -p bb-app@latest bb connect --code --server `.\n\n bb connect status Show the server's connect status\n bb connect off Disconnect and forget the pairing\n bb connect expose [--host ] Share a host's HTTP port\n bb connect unexpose [--host ] Stop sharing on that host\n bb connect shares [--host ] List that host's shares\n bb connect servers List every bb on this account (handle, url, live)\n\n Port sharing works from threads on any enrolled host. In a thread,\n `bb connect expose ` resolves the thread environment's host; outside a\n thread it defaults to the server host. `--host ` overrides that\n choice for expose, unexpose, and shares. Server-host URLs use\n `https://--.getbb.app`; machine-host URLs use\n `https://--.getbb.app` and proxy directly through that\n machine's daemon. Access is owner-session-gated — only viewers signed into\n the owner's getbb.app account can open the URL; it is not a public internet\n link. Agents should run expose from the thread that started the server, share\n the returned URL, and unexpose from the same thread when it stops.\n `bb connect status` shows all shares with host + URL. `shares --json` returns\n the resolved `host` and rows with `hostId`, `hostName`, `port`, and `url`.\n\n Remote access is owned by the builtin \"connect\" plugin (Plugins → connect\n shows the URL, QR code, and shared ports). Disabling the plugin\n (`bb plugin disable connect`) cuts off all remote access; re-enable with\n `bb plugin enable connect`.", "fileName": "bb-guide-environments.md", "kind": "instruction", "title": "bb Guide — Environments", diff --git a/packages/templates/src/templates/bb-guide-environments.md b/packages/templates/src/templates/bb-guide-environments.md index 2e2cfbacfb..d205fa2375 100644 --- a/packages/templates/src/templates/bb-guide-environments.md +++ b/packages/templates/src/templates/bb-guide-environments.md @@ -48,11 +48,11 @@ Making your repo work with bb: !.env.example certs/ - bb copies files only. It follows no symlinks and it replaces no tracked - file. The copy runs after `git worktree add` and before .bb-env-setup.sh, so - the setup script can read the copied files. A pattern that matches nothing, - or a file bb cannot read, is reported in the provisioning transcript and - does not fail provisioning. + bb copies files only. It follows no symlinks, and it replaces nothing that + the worktree already has. The copy runs after `git worktree add` and before + .bb-env-setup.sh, so the setup script can read the copied files. A pattern + that matches nothing, or a file bb cannot read, is reported in the + provisioning transcript and does not fail provisioning. Large directories such as node_modules are copied file by file. Install dependencies in .bb-env-setup.sh instead of listing them here.