diff --git a/plugins/multi-repo/DESIGN.md b/plugins/multi-repo/DESIGN.md index f0552030..625169f5 100644 --- a/plugins/multi-repo/DESIGN.md +++ b/plugins/multi-repo/DESIGN.md @@ -20,7 +20,9 @@ Core references below were verified against bb at commit `611e34892` (2026-09-24 ## 1. Concepts -A **project's source is a `.bb` git repo** — the workspace definition, not the code. It contains: +A **project's source is the primary repo** — one real repo whose default +branch carries `.bb/`, a directory holding the workspace definition, not the +code. `.bb/` contains: ``` repos.json the repo set @@ -68,14 +70,39 @@ Keep it strict JSON. Documentation lives in the seeded `AGENTS.md` and the plugi ### Where `.bb` lives -`project_sources.path` is the canonical `.bb` checkout, per `(projectId, hostId)` — one per machine (`packages/db/src/schema.ts:496`). - -Two ways in, same code path afterwards: - -- **Shared** — the `.bb` repo has a remote. Clone it, create the project pointing at it. A teammate gets the whole workspace definition from one URL. -- **Local, from scratch** — create the project pointing at a fresh directory. The plugin initializes `.bb` on first use. No remote, no setup. - -The local variant upgrades to shared with `git remote add` and a push. Nothing else in the design changes and there is no migration. +`project_sources.path` is the canonical project-source checkout, per +`(projectId, hostId)` — one per machine (`packages/db/src/schema.ts:496`). +There are two layouts, detected from the default branch's committed tree: + +- **primary** — the checkout is a clone of the primary repo and the definition + sits at `P/.bb/`. `repos.json` lists only the other repos; P is derived from + the checkout's `origin` and materialised into the workspace in addition to + them. `.bb/` is copied from the canonical checkout to `/.bb`, + because core reads `/.bb/AGENTS.md` and `.bb/skills` from the + workspace root only. +- **standalone** — the checkout is a `.bb` git repo with the definition at its + root. This is the original layout, still read and written as before so a + machine that has not adopted keeps working. + +Reads (`readProjectFiles`, `repoSet`) resolve the default branch's committed +tip — `origin/`, or the local default branch when the plugin's own +commit is ahead of it — never the working tree or a feature branch. + +Ways in: + +- **Primary, from an existing repo** — point the project at a clone of P. If it + does not carry `.bb/`, the plugin seeds it on the default branch and publishes + a `guidance-` branch with a pull request. +- **Shared** — the standalone `.bb` repo has a remote. Clone it, create the + project pointing at it. +- **Local, from scratch** — create the project pointing at a fresh directory; + the plugin initializes a standalone `.bb` there. + +`bb repos adopt ` (and the panel) copies a standalone tree into +`.bb/` on a branch of the primary and opens a pull request; once it merges, a +later run re-points `project_sources.path` in place. That is BB-106 Case A: the +SDK's `bb.sdk.projects.sources.update` re-points the source row for the same +project id, so no store re-key and no steward respawn is needed. ## 3. Why a plain-directory workspace root diff --git a/plugins/multi-repo/README.md b/plugins/multi-repo/README.md index f36ad51c..b1fc0454 100644 --- a/plugins/multi-repo/README.md +++ b/plugins/multi-repo/README.md @@ -10,9 +10,15 @@ containing a checkout of each**. .bb/ the project definition: repos.json, AGENTS.md, skills/ ``` -A project's source stops being *code* and becomes the **workspace definition**: -a small `.bb` git repo holding the repo set, cross-repo guidance, and project -skills. Every repo is equal — there is no privileged primary repo. +A project's source is the **primary repo** — one real repo whose default +branch carries `.bb/`, a directory holding the repo set, cross-repo guidance +and project skills. `.bb/repos.json` lists only the *other* repos; the primary +is derived from the project source's own `origin` and is not repeated. The +standalone `.bb`-repo layout is still supported, so a machine that has not +adopted keeps working; `bb repos adopt` moves a standalone definition into the +primary, per machine. + +Every repo is equal in the workspace; the primary is one of them. Standalone: no bb fork, no bb-internal packages, no dependency on any other plugin. @@ -94,7 +100,7 @@ parse fails the launch with a message naming the offending entry. | **Repos** nav panel | The repo set as a list — add by URL with completions from local checkouts, change a base branch inline, remove — each change one commit in `.bb`. A **New project** form that creates a multi-repo project in one step. `repos.json` as text behind a disclosure, which opens itself when the file does not parse. | | **Changes** thread panel | Per-repo diff and pull request. Opened from the thread panel's Actions list. | | Composer banner | One row above the composer naming the repos that changed, their file counts and the `+`/`−` totals. Click it to open the Changes panel. Hidden when nothing has changed. | -| `bb repos` | `list`, `add`, `remove` (each takes `--project`), and `status` for the current thread. | +| `bb repos` | `list`, `add`, `remove` (each takes `--project`), `adopt `, and `status` for the current thread. | | Agent tools | `workspace_list_repos`, `workspace_add_repo`, `workspace_remove_repo`, `workspace_publish_guidance`. | | Agent instructions | A generated table of `repo → branch → path`, plus a `multi-repo` skill. | @@ -126,6 +132,26 @@ A second benefit: two threads cannot both check out `main` as worktrees of one repo, but independent clones can — and `.bb` is on its default branch in every workspace. +### Where `.bb` lives + +`.bb/` is a directory on the primary repo's default branch, next to the code, +reviewed like code. The project source is a clone of that primary repo, and +core reads `/.bb/AGENTS.md` and `/.bb/skills` from the +workspace root — so each thread materialises `.bb/` there from the primary's +`.bb/`. Reads (`readProjectFiles`, `repoSet`) come from the default branch's +committed tip, never the working tree or a feature branch. + +The **standalone** layout — a small `.bb` git repo that is the project source — +is still read and written exactly as before. `bb repos adopt ` copies +a standalone tree into `.bb/` on a branch of the primary and opens a pull +request; once it merges, running `bb repos adopt` again re-points +`project_sources.path` in place. The project id, every stewards binding and +every thread stay as they are (BB-106, Case A). + +A workspace built before an adopt keeps publishing guidance to the old +standalone checkout; `bb repos status` and the Changes panel say so, and a +branch it publishes has to be re-applied to the primary by hand. + ### The object cache One bare repo per remote, per machine, at `/repos/.git`. @@ -174,17 +200,24 @@ the generated block cannot be edited. ### Writing back to `.bb` The canonical `.bb` checkout is outside the agent's sandbox, so the write is -inverted: the canonical repo *fetches* from the workspace clone, initiated by -the plugin's host entry. +inverted: the plugin's host entry does it. + +On the **standalone** layout, the canonical repo *fetches* from the workspace +clone: ``` git -C fetch /.bb HEAD:refs/heads/guidance- ``` -That avoids the sandbox and `receive.denyCurrentBranch` at once, and leaves a -named branch to review and merge. `workspace_publish_guidance` is the agent's -entry point; `repos.json` edits from the tools, the CLI and the panel take the -same host-side route, which is why they work under `accept-edits`. +On the **primary** layout, `.bb/` is a directory in the primary repo and the +default branch is reviewed like code, so `workspace_publish_guidance` builds a +`guidance-` branch with the workspace's `.bb/` content and stops +there — it never moves the default branch. `mergeGuidance` reports that a +lesson needs a pull request (BB-108 adds that flow). That avoids the sandbox and +`receive.denyCurrentBranch` at once, and leaves a named branch to review and +merge. `workspace_publish_guidance` is the agent's entry point; `repos.json` +edits from the tools, the CLI and the panel take the same host-side route, +which is why they work under `accept-edits`. ### Reading `.bb` from another plugin diff --git a/plugins/multi-repo/changes-panel.tsx b/plugins/multi-repo/changes-panel.tsx index a10be166..8e5ed703 100644 --- a/plugins/multi-repo/changes-panel.tsx +++ b/plugins/multi-repo/changes-panel.tsx @@ -625,6 +625,18 @@ export function ChangesPanel({ threadId }: { threadId: string }) { )} + {workspace.predatesAdopt && ( +
+
This workspace predates the adopt
+

+ It was built from the standalone .bb at{" "} + {workspace.projectSourcePath}, and the project source is now a + primary repo. Guidance published from this thread goes to the old checkout; re-apply such a branch to + the primary by hand. +

+
+ )} + {workspace.sharedWith.length > 0 && (
diff --git a/plugins/multi-repo/contract.ts b/plugins/multi-repo/contract.ts index cf5e02d2..f22f9f3a 100644 --- a/plugins/multi-repo/contract.ts +++ b/plugins/multi-repo/contract.ts @@ -15,6 +15,10 @@ import { PROJECT_FILE_PATH_MAX, projectFilePathProblem } from "./names.js"; const absolutePath = z.string().min(1).max(4096); +/** Which of the two project-source shapes a checkout has. */ +export const projectLayoutSchema = z.enum(["standalone", "primary"]); +export type ProjectLayoutValue = z.infer; + /** * The most text one file in `readProjectFiles` may carry. * @@ -238,6 +242,8 @@ export const hostContract = defineRpcContract({ reposJson: z.string().max(200_000).nullable(), remote: z.string().max(2000).nullable(), defaultBranch: z.string().max(300).nullable(), + /** `standalone` (definition at the root) or `primary` (`.bb/`). */ + layout: projectLayoutSchema, warnings: z.array(z.string().max(600)).max(20), }) .strict(), @@ -276,6 +282,8 @@ export const hostContract = defineRpcContract({ path: absolutePath, reposJson: z.string().max(200_000), message: z.string().min(1).max(300), + /** Which layout's definition path to write; defaults to `standalone`. */ + layout: projectLayoutSchema.optional(), }) .strict(), output: z @@ -332,6 +340,10 @@ export const hostContract = defineRpcContract({ operationId: z.string().min(1).max(200), pathKey: z.string().min(1).max(200), projectSourcePath: absolutePath, + /** Which shape the project source has; decides how `.bb/` is materialized. */ + layout: projectLayoutSchema.optional(), + /** The primary work repo's directory; `.bb/` is copied out of it. */ + primaryDir: z.string().min(1).max(100).nullable().optional(), repos: z.array(repoRequestSchema).max(64), branchName: z.string().min(1).max(300), fetchTtlMs: z.number().int().nonnegative(), @@ -343,6 +355,8 @@ export const hostContract = defineRpcContract({ .object({ root: absolutePath, repos: z.array(workspaceRepoSchema).max(64), + /** The default-branch commit `.bb/` was materialized from, or null. */ + definitionBase: z.string().max(64).nullable(), }) .strict(), }, @@ -479,6 +493,8 @@ export const hostContract = defineRpcContract({ projectSourcePath: absolutePath, workspaceBbPath: absolutePath, threadId: z.string().min(1).max(100), + /** The default-branch commit the workspace `.bb/` was materialized from. */ + definitionBase: z.string().max(64).nullable().optional(), }) .strict(), output: z @@ -513,6 +529,56 @@ export const hostContract = defineRpcContract({ }) .strict(), }, + + /** + * Copy a standalone `.bb` tree into a primary repo as `.bb/` on a branch, or + * report that it already carries one. Idempotent; never touches the default + * branch. + */ + adoptProjectSource: { + input: z + .object({ + standalonePath: absolutePath, + primaryUrl: z.string().min(1).max(2000), + primaryPath: absolutePath, + branch: z.string().min(1).max(300), + openPullRequest: z.boolean(), + }) + .strict(), + output: z + .object({ + status: z.enum(["already", "pull-request", "failed"]), + message: z.string().max(2000), + prUrl: z.string().max(600).nullable(), + warnings: z.array(z.string().max(600)).max(20), + primaryPath: absolutePath, + defaultBranch: z.string().max(300).nullable(), + }) + .strict(), + }, + + /** Clone/ensure a primary repo and seed `.bb/` on it when it does not carry one. */ + preparePrimaryProjectSource: { + input: z + .object({ + url: z.string().min(1).max(2000), + path: absolutePath, + seed: z.string().max(200_000), + branch: z.string().min(1).max(300), + openPullRequest: z.boolean(), + }) + .strict(), + output: z + .object({ + ok: z.boolean(), + layout: projectLayoutSchema, + seeded: z.boolean(), + prUrl: z.string().max(600).nullable(), + warnings: z.array(z.string().max(600)).max(20), + message: z.string().max(2000), + }) + .strict(), + }, }); export type HostContract = typeof hostContract; @@ -545,6 +611,10 @@ export const repoSetViewSchema = z /** Null when this project has no `.bb` checkout on a reachable machine. */ projectSourcePath: absolutePath.nullable(), hostId: z.string().max(100).nullable(), + /** `standalone` (definition at the root) or `primary` (`.bb/`). */ + layout: projectLayoutSchema, + /** The primary repo derived from the source's `origin`, or null. */ + primary: z.object({ dir: z.string().max(100), url: z.string().max(2000) }).strict().nullable(), /** The file's raw text, for the editor. Null when it does not exist yet. */ reposJson: z.string().max(200_000).nullable(), /** Parsed entries, empty when `error` is set. */ @@ -572,6 +642,13 @@ export const workspaceViewSchema = z branchName: z.string().max(300), hostId: z.string().max(100), projectSourcePath: absolutePath, + /** + * True when this workspace was provisioned from a standalone `.bb` and the + * project source has since been adopted into a primary repo. Guidance + * published from it still goes to the old canonical checkout, so it has to + * be re-applied to the primary by hand. + */ + predatesAdopt: z.boolean(), repos: z.array(workspaceRepoSchema).max(64), /** * The other threads attached to this same checkout — forks, which keep @@ -765,6 +842,16 @@ export const rpcContract = defineRpcContract({ source: z.discriminatedUnion("kind", [ z.object({ kind: z.literal("fresh"), repos: z.array(repoSeedSchema).max(64) }).strict(), z.object({ kind: z.literal("clone"), url: z.string().min(1).max(2000) }).strict(), + z + .object({ + kind: z.literal("primary"), + url: z.string().min(1).max(2000), + /** The other repos to seed `.bb/repos.json` with. */ + repos: z.array(repoSeedSchema).max(64), + /** Publish a seed branch and open a pull request. */ + openPullRequest: z.boolean().optional(), + }) + .strict(), ]), }) .strict(), @@ -774,6 +861,44 @@ export const rpcContract = defineRpcContract({ projectId: z.string().max(100).nullable(), error: z.string().max(2000).nullable(), warnings: z.array(z.string().max(600)).max(20), + /** The layout the source ended up with, when the project was created. */ + layout: projectLayoutSchema.nullable(), + /** The seed/adopt pull request, when one was opened. */ + prUrl: z.string().max(600).nullable(), + }) + .strict(), + }, + + /** + * Adopt a standalone `.bb` into a primary repo, per machine. + * + * The first run copies the tree to `.bb/` on a branch and opens a pull + * request; a later run, once `.bb/` is on the default branch, re-points the + * project source at the primary checkout. Case A of BB-106: the project id, + * every binding and every thread stay as they are. + */ + adoptProjectSource: { + input: z + .object({ + projectId: z.string().min(1).max(100), + /** URL or local path of the primary repo. */ + primary: z.string().min(1).max(2000), + /** Where the primary clone lives or should be cloned to. */ + path: absolutePath, + /** Publish a branch and open a pull request. */ + openPullRequest: z.boolean().optional(), + }) + .strict(), + output: z + .object({ + status: z.enum(["already", "pull-request", "failed"]), + message: z.string().max(2000), + prUrl: z.string().max(600).nullable(), + warnings: z.array(z.string().max(600)).max(20), + primaryPath: absolutePath, + defaultBranch: z.string().max(300).nullable(), + /** True when the project source now points at the primary checkout. */ + repointed: z.boolean(), }) .strict(), }, diff --git a/plugins/multi-repo/fork.test.ts b/plugins/multi-repo/fork.test.ts index 0e183295..78a84da2 100644 --- a/plugins/multi-repo/fork.test.ts +++ b/plugins/multi-repo/fork.test.ts @@ -78,12 +78,13 @@ function hostResponder(call: HostRpcCall): unknown { }), remote: null, defaultBranch: "main", + layout: "standalone", warnings: [], }; case "discoverCheckouts": return { checkouts: [] }; case "provisionWorkspace": - return { root: ROOT, repos: WORKSPACE_REPOS }; + return { root: ROOT, repos: WORKSPACE_REPOS, definitionBase: null }; case "workspaceStatus": return { repos: WORKSPACE_REPOS.map((repo) => ({ diff --git a/plugins/multi-repo/host.ts b/plugins/multi-repo/host.ts index a1643edf..e7fd2065 100644 --- a/plugins/multi-repo/host.ts +++ b/plugins/multi-repo/host.ts @@ -21,10 +21,13 @@ import { readPullRequests, runPrAction } from "./gh.js"; import { EMPTY_REPOS, resolveRepoEntry, type ReposFile } from "./repos.js"; import type { RepoSeed } from "./contract.js"; import { + adoptProjectSource, bootstrapProjectSource, cloneProjectSource, defaultProjectSourcePath, + detectProjectLayout, mergeGuidance, + preparePrimaryProjectSource, projectSourceBranch, projectSourceRemote, pushGuidance, @@ -95,11 +98,13 @@ export function createMultiRepoHostEntry() { if (!bootstrap.bootstrapped) { warnings.push(...(await refreshProjectSource(input.path, input.fetchTtlMs, context.signal))); } + const layout = await detectProjectLayout(input.path, context.signal); return { bootstrapped: bootstrap.bootstrapped, - reposJson: await readReposJson(input.path), + reposJson: await readReposJson(input.path, layout, context.signal), remote: await projectSourceRemote(input.path, context.signal), defaultBranch: await projectSourceBranch(input.path, context.signal), + layout, warnings: warnings.slice(0, 20), }; }, @@ -107,7 +112,10 @@ export function createMultiRepoHostEntry() { async readProjectSourceFiles(input, context) { // Never bootstraps: a read must not `git init` anyone's directory. const warnings = await refreshProjectSource(input.path, input.fetchTtlMs, context.signal); - const read = await readCommittedFiles(input.path, input.files, PROJECT_FILE_MAX_CHARS, context.signal); + const layout = await detectProjectLayout(input.path, context.signal); + const read = await readCommittedFiles(input.path, input.files, PROJECT_FILE_MAX_CHARS, context.signal, { + prefix: layout === "primary" ? ".bb/" : "", + }); return { ...read, warnings: warnings.slice(0, 20) }; }, @@ -120,7 +128,13 @@ export function createMultiRepoHostEntry() { }, async writeProjectSourceRepos(input, context) { - const result = await writeReposJson(input.path, input.reposJson, input.message, context.signal); + const result = await writeReposJson( + input.path, + input.reposJson, + input.message, + context.signal, + input.layout ?? "standalone", + ); return { committed: result.committed, head: result.head, message: result.message }; }, @@ -139,6 +153,8 @@ export function createMultiRepoHostEntry() { dataDir: context.experimental_paths.dataDir, pathKey: input.pathKey, projectSourcePath: input.projectSourcePath, + layout: input.layout ?? "standalone", + primaryDir: input.primaryDir ?? null, repos: input.repos, branchName: input.branchName, fetchTtlMs: input.fetchTtlMs, @@ -146,7 +162,7 @@ export function createMultiRepoHostEntry() { report, signal: context.signal, }); - return { root: result.root, repos: result.repos }; + return { root: result.root, repos: result.repos, definitionBase: result.definitionBase }; }, async addWorkspaceRepo(input, context) { @@ -244,6 +260,7 @@ export function createMultiRepoHostEntry() { input.workspaceBbPath, input.threadId, context.signal, + input.definitionBase ?? null, ); }, @@ -256,6 +273,29 @@ export function createMultiRepoHostEntry() { context.signal, ); }, + + async adoptProjectSource(input, context) { + return adoptProjectSource({ + standalonePath: input.standalonePath, + primaryUrl: input.primaryUrl, + primaryPath: input.primaryPath, + branch: input.branch, + openPullRequest: input.openPullRequest, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + }, + + async preparePrimaryProjectSource(input, context) { + const parsed = JSON.parse(input.seed) as ReposFile; + return preparePrimaryProjectSource({ + url: input.url, + path: input.path, + seed: parsed, + branch: input.branch, + openPullRequest: input.openPullRequest, + ...(context.signal === undefined ? {} : { signal: context.signal }), + }); + }, }, }); } diff --git a/plugins/multi-repo/layout.test.ts b/plugins/multi-repo/layout.test.ts index f791fe96..ad71d432 100644 --- a/plugins/multi-repo/layout.test.ts +++ b/plugins/multi-repo/layout.test.ts @@ -17,7 +17,9 @@ function manifest(overrides: Partial = {}): WorkspaceManifest threadId: "thr_1", projectId: "proj_1", branchName: "dylan/fix-the-thing", + layout: "standalone", projectSourcePath: "/home/me/projects/acme", + definitionBase: null, createdAt: 0, repos: [ { diff --git a/plugins/multi-repo/layout.ts b/plugins/multi-repo/layout.ts index 7171b660..13874869 100644 --- a/plugins/multi-repo/layout.ts +++ b/plugins/multi-repo/layout.ts @@ -19,6 +19,8 @@ * ceiling is testable without a server. */ +import type { ProjectLayout } from "./paths.js"; + /** Core truncates a contribution at 4096 characters. Stay under it deliberately. */ export const INSTRUCTIONS_MAX = 4096; @@ -53,8 +55,12 @@ export interface WorkspaceManifest { projectId: string; /** The shared thread branch, from `context.suggestedBranchName`. */ branchName: string; + /** Which layout the project source had when this workspace was provisioned. */ + layout: ProjectLayout; /** The canonical `.bb` checkout on this machine, outside the sandbox. */ projectSourcePath: string; + /** The default-branch commit `.bb/` was materialized from, for a primary project. */ + definitionBase: string | null; repos: WorkspaceRepo[]; createdAt: number; } @@ -129,7 +135,9 @@ export function formatInstructions(manifest: WorkspaceManifest): string { lines.push(""); lines.push( - "`.bb/` is the project's own repo: the repo set (`repos.json`), cross-repo guidance (`AGENTS.md`), and project skills. It is shared by every thread in this project and versioned, so it is the right place to record durable cross-repo knowledge — and the wrong place for anything specific to this thread.", + manifest.layout === "primary" + ? "`.bb/` is the project definition: the repo set (`repos.json`), cross-repo guidance (`AGENTS.md`), and project skills. It is a directory on the primary repo's default branch, shared by every thread in this project and versioned, so it is the right place to record durable cross-repo knowledge — and the wrong place for anything specific to this thread. Changes to it are reviewed like code: edit them and publish the branch with `workspace_publish_guidance` (it commits for you), then land them with a pull request." + : "`.bb/` is the project's own repo: the repo set (`repos.json`), cross-repo guidance (`AGENTS.md`), and project skills. It is shared by every thread in this project and versioned, so it is the right place to record durable cross-repo knowledge — and the wrong place for anything specific to this thread.", ); lines.push(""); lines.push( diff --git a/plugins/multi-repo/new-project-form.tsx b/plugins/multi-repo/new-project-form.tsx index da18a550..cba4778f 100644 --- a/plugins/multi-repo/new-project-form.tsx +++ b/plugins/multi-repo/new-project-form.tsx @@ -31,7 +31,7 @@ interface NewProjectFormProps { onCreated: (projectId: string, warnings: readonly string[]) => void; } -type Mode = "fresh" | "clone"; +type Mode = "fresh" | "clone" | "primary"; export function NewProjectForm({ onCancel, onCreated }: NewProjectFormProps) { const rpc = useRpc(); @@ -43,6 +43,7 @@ export function NewProjectForm({ onCancel, onCreated }: NewProjectFormProps) { const [pathExists, setPathExists] = useState(false); const [mode, setMode] = useState("fresh"); const [cloneUrl, setCloneUrl] = useState(""); + const [primaryUrl, setPrimaryUrl] = useState(""); const [repos, setRepos] = useState([]); const [suggestions, setSuggestions] = useState([]); const [submitting, setSubmitting] = useState(false); @@ -97,7 +98,7 @@ export function NewProjectForm({ onCancel, onCreated }: NewProjectFormProps) { // Local checkouts on the chosen machine, for the add row's completions. // Keyed on the path so the sibling scan looks beside where `.bb` will go. useEffect(() => { - if (hostId === null || path.length === 0 || mode !== "fresh") return; + if (hostId === null || path.length === 0 || mode === "clone") return; let cancelled = false; void rpc .call("suggestRepos", { hostId, path }) @@ -127,23 +128,32 @@ export function NewProjectForm({ onCancel, onCreated }: NewProjectFormProps) { hostId !== null && name.trim().length > 0 && path.trim().length > 0 && - (mode === "fresh" || cloneUrl.trim().length > 0); + (mode === "fresh" || (mode === "clone" && cloneUrl.trim().length > 0) || (mode === "primary" && primaryUrl.trim().length > 0)); const submit = (event: FormEvent) => { event.preventDefault(); if (!ready || hostId === null) return; setSubmitting(true); setError(null); + const source = + mode === "fresh" + ? { kind: "fresh" as const, repos } + : mode === "clone" + ? { kind: "clone" as const, url: cloneUrl.trim() } + : { kind: "primary" as const, url: primaryUrl.trim(), repos, openPullRequest: true }; void rpc .call("createProject", { name: name.trim(), hostId, path: path.trim(), - source: mode === "fresh" ? { kind: "fresh", repos } : { kind: "clone", url: cloneUrl.trim() }, + source, }) .then((result) => { if (result.ok && result.projectId !== null) { - onCreated(result.projectId, result.warnings); + onCreated(result.projectId, [ + ...result.warnings, + ...(result.prUrl === null ? [] : [`Seed pull request: ${result.prUrl}`]), + ]); } else { setError(result.error ?? "Could not create the project."); } @@ -232,6 +242,11 @@ export function NewProjectForm({ onCancel, onCreated }: NewProjectFormProps) { {pathExists && mode === "clone" && ( Something is already there, and a clone needs an empty directory. )} + {pathExists && mode === "primary" && ( + + An existing checkout of the primary repo is fetched and used as it is. + + )}
@@ -245,10 +260,14 @@ export function NewProjectForm({ onCancel, onCreated }: NewProjectFormProps) { setMode("clone")} /> An existing .bb repo +
- {mode === "clone" ? ( + {mode === "clone" && ( - ) : ( + )} + + {mode === "primary" && ( + + )} + + {mode !== "clone" && (

Repos

{repos.length === 0 ? ( diff --git a/plugins/multi-repo/paths.ts b/plugins/multi-repo/paths.ts index 552379b0..f60cb5e9 100644 --- a/plugins/multi-repo/paths.ts +++ b/plugins/multi-repo/paths.ts @@ -36,6 +36,29 @@ export function assertSafeSegment(value: string, what: string): string { */ export const PROJECT_SOURCE_DIR = ".bb"; +/** + * Which of the two shapes a project source has. + * + * - `standalone` — the project source is a `.bb` git repo of its own; the + * definition (`repos.json`, `AGENTS.md`, `skills/`) sits at its root. This is + * the original layout, still supported so a machine that has not adopted yet + * keeps working. + * - `primary` — the project source is a clone of the primary repo P and the + * definition sits at `P/.bb/`. `repos.json` lists only the *other* repos; + * P is derived from the checkout's `origin`. + */ +export type ProjectLayout = "standalone" | "primary"; + +/** + * The path prefix under the project source that holds the definition. + * + * Empty for `standalone`, `.bb/` for `primary` — always a POSIX-style prefix + * because it is used both as a filesystem path segment and as a git pathspec. + */ +export function projectDefinitionPrefix(layout: ProjectLayout): string { + return layout === "primary" ? `${PROJECT_SOURCE_DIR}/` : ""; +} + /** * A stable cache directory name for a repo URL. * diff --git a/plugins/multi-repo/primary.test.ts b/plugins/multi-repo/primary.test.ts new file mode 100644 index 00000000..4eb6437d --- /dev/null +++ b/plugins/multi-repo/primary.test.ts @@ -0,0 +1,431 @@ +/** + * The primary-repo layout (BB-106): `.bb/` is a directory on the primary + * repo's default branch, the project source is a clone of that repo, and + * `repos.json` lists only the other repos. + * + * Built against real git repositories because the properties that matter — + * reads from the default branch rather than the working tree, `.bb/` landing + * at the workspace root, guidance publishing to a branch and not the default — + * are git facts, not string shapes. + */ +import { access, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { git, gitIn, gitLine } from "./git.js"; +import { EMPTY_REPOS, dirFromUrl } from "./repos.js"; +import { repoSetViewSchema } from "./contract.js"; +import { + adoptProjectSource, + bootstrapProjectSource, + detectProjectLayout, + mergeGuidance, + preparePrimaryProjectSource, + pushGuidance, + readCommittedFiles, + readReposJson, + resolveDefaultBranchRef, +} from "./source.js"; +import { provisionWorkspace } from "./workspace.js"; + +let scratch: string; +let dataDir: string; + +async function exists(target: string): Promise { + try { + await access(target); + return true; + } catch { + return false; + } +} + +async function makeRepo(name: string, branch = "main"): Promise { + const dir = path.join(scratch, "remotes", name); + await mkdir(dir, { recursive: true }); + await git(["init", "--quiet", `--initial-branch=${branch}`, dir]); + await gitIn(dir, ["config", "user.email", "test@example.invalid"]); + await gitIn(dir, ["config", "user.name", "Test"]); + await writeFile(path.join(dir, "README.md"), `# ${name}\n`, "utf8"); + await gitIn(dir, ["add", "-A"]); + await gitIn(dir, ["commit", "--quiet", "-m", "initial"]); + return dir; +} + +/** Add `.bb/` to a repo on its current branch and commit it. */ +async function commitDefinition(dir: string, agents = "primary guidance\n"): Promise { + await mkdir(path.join(dir, ".bb", "skills"), { recursive: true }); + await writeFile(path.join(dir, ".bb", "repos.json"), '{"version":1,"repos":[]}\n', "utf8"); + await writeFile(path.join(dir, ".bb", "AGENTS.md"), agents, "utf8"); + await writeFile(path.join(dir, ".bb", "skills", ".gitkeep"), "", "utf8"); + await gitIn(dir, ["add", "-A"]); + await gitIn(dir, ["commit", "--quiet", "-m", "add .bb"]); +} + +beforeEach(async () => { + scratch = await mkdtemp(path.join(tmpdir(), "multi-repo-primary-")); + dataDir = path.join(scratch, "host-data"); + await mkdir(dataDir, { recursive: true }); +}); + +afterEach(async () => { + await rm(scratch, { recursive: true, force: true }); +}); + +describe("the panel's repo-set view", () => { + it("carries the layout and the primary without a branch field", () => { + const parsed = repoSetViewSchema.safeParse({ + projectSourcePath: "/home/me/primary", + hostId: "host-a", + layout: "primary", + primary: { dir: "primary", url: "git@github.com:you/primary.git" }, + reposJson: null, + repos: [], + error: null, + cache: [], + }); + expect(parsed.success).toBe(true); + }); +}); + +describe("layout detection", () => { + it("calls a root-level definition standalone and a .bb/ one primary", async () => { + const standalone = path.join(scratch, "standalone"); + await mkdir(standalone, { recursive: true }); + await bootstrapProjectSource(standalone, EMPTY_REPOS); + expect(await detectProjectLayout(standalone)).toBe("standalone"); + + const primary = await makeRepo("primary"); + await commitDefinition(primary); + expect(await detectProjectLayout(primary)).toBe("primary"); + }); + + it("does not flip layout on an uncommitted .bb/ in the working tree", async () => { + const standalone = path.join(scratch, "standalone"); + await mkdir(standalone, { recursive: true }); + await bootstrapProjectSource(standalone, EMPTY_REPOS); + // A person halfway through creating `.bb/`: not committed, so not the layout. + await mkdir(path.join(standalone, ".bb"), { recursive: true }); + await writeFile(path.join(standalone, ".bb", "repos.json"), "{}\n", "utf8"); + expect(await detectProjectLayout(standalone)).toBe("standalone"); + }); +}); + +describe("default-branch reads", () => { + it("reads repos.json from the default branch, not the checked-out feature branch", async () => { + const remote = await makeRepo("primary"); + await commitDefinition(remote); + const clone = path.join(scratch, "primary-clone"); + await git(["clone", "--quiet", remote, clone]); + await gitIn(clone, ["config", "user.email", "test@example.invalid"]); + await gitIn(clone, ["config", "user.name", "Test"]); + + await gitIn(clone, ["checkout", "--quiet", "-b", "feature"]); + await writeFile(path.join(clone, ".bb", "repos.json"), '{"version":1,"repos":[{"dir":"x","url":"u"}]}\n', "utf8"); + await gitIn(clone, ["add", "-A"]); + await gitIn(clone, ["commit", "--quiet", "-m", "feature edit"]); + + expect(await resolveDefaultBranchRef(clone)).toBe("refs/heads/main"); + // The feature branch's edit is not the project's committed default truth. + expect(await readReposJson(clone, "primary")).toBe('{"version":1,"repos":[]}\n'); + }); + + it("reads project files from .bb/ at the default branch for another plugin", async () => { + const primary = await makeRepo("primary"); + await mkdir(path.join(primary, ".bb", "stewards"), { recursive: true }); + await writeFile(path.join(primary, ".bb", "stewards", "project.json"), '{"version":1}\n', "utf8"); + await gitIn(primary, ["add", "-A"]); + await gitIn(primary, ["commit", "--quiet", "-m", "add stewards config"]); + + const read = await readCommittedFiles(primary, ["stewards/project.json"], 200_000, undefined, { prefix: ".bb/" }); + expect(read.error).toBeNull(); + expect(read.files[0]).toEqual({ path: "stewards/project.json", content: '{"version":1}\n' }); + }); + + it("prefers the local default branch when the plugin's own commit is ahead of origin", async () => { + const remote = await makeRepo("primary"); + await commitDefinition(remote); + const clone = path.join(scratch, "primary-clone"); + await git(["clone", "--quiet", remote, clone]); + await gitIn(clone, ["config", "user.email", "test@example.invalid"]); + await gitIn(clone, ["config", "user.name", "Test"]); + // The plugin's own `.bb` edit, committed locally and not pushed. + await writeFile(path.join(clone, ".bb", "repos.json"), '{"version":1,"repos":[{"dir":"mine","url":"u"}]}\n', "utf8"); + await gitIn(clone, ["add", "-A"]); + await gitIn(clone, ["commit", "--quiet", "-m", "local edit"]); + + expect(await resolveDefaultBranchRef(clone)).toBe("refs/heads/main"); + expect(await readReposJson(clone, "primary")).toContain('"mine"'); + }); +}); + +describe("provisioning from a primary repo", () => { + it("clones the primary, copies .bb/ to the workspace root, and leaves the root non-git", async () => { + const primary = await makeRepo("primary"); + await commitDefinition(primary, "cross-repo guidance\n"); + const canonical = path.join(scratch, "canonical"); + await git(["clone", "--quiet", primary, canonical]); + + const result = await provisionWorkspace({ + dataDir, + pathKey: "thread1", + projectSourcePath: canonical, + layout: "primary", + primaryDir: "primary", + repos: [{ dir: "primary", url: primary, branch: null }], + branchName: "dylan/work", + fetchTtlMs: 0, + mirrors: [], + }); + + expect(await exists(path.join(result.root, ".git"))).toBe(false); + expect(await exists(path.join(result.root, "primary", ".git"))).toBe(true); + // Core reads `.bb/AGENTS.md` and `.bb/skills` from the workspace root, so + // the definition is materialized there, not at `/.bb`. + expect(await readFile(path.join(result.root, ".bb", "AGENTS.md"), "utf8")).toBe("cross-repo guidance\n"); + expect(await exists(path.join(result.root, ".bb", "repos.json"))).toBe(true); + expect(await exists(path.join(result.root, ".bb", ".git"))).toBe(false); + const source = result.repos.find((repo) => repo.dir === ".bb"); + expect(source?.status).toBe("ready"); + }); + + it("fails the .bb row by name when the primary does not carry .bb/ on the branch", async () => { + const primary = await makeRepo("primary"); + const canonical = path.join(scratch, "canonical"); + await git(["clone", "--quiet", primary, canonical]); + const result = await provisionWorkspace({ + dataDir, + pathKey: "thread1", + projectSourcePath: canonical, + layout: "primary", + primaryDir: "primary", + repos: [{ dir: "primary", url: primary, branch: null }], + branchName: "dylan/work", + fetchTtlMs: 0, + mirrors: [], + }); + const source = result.repos.find((repo) => repo.dir === ".bb"); + expect(source?.status).toBe("failed"); + expect(source?.message).toContain(".bb/"); + }); +}); + +describe("guidance on a primary repo", () => { + async function pair(): Promise<{ canonical: string; workspaceBb: string }> { + const primary = await makeRepo("primary"); + await commitDefinition(primary, "before\n"); + const canonical = path.join(scratch, "canonical"); + await git(["clone", "--quiet", primary, canonical]); + // The workspace's copy of `.bb`, edited by the agent. + const workspaceBb = path.join(scratch, "workspace-bb"); + await mkdir(workspaceBb, { recursive: true }); + await writeFile(path.join(workspaceBb, "AGENTS.md"), "learned something\n", "utf8"); + await writeFile(path.join(workspaceBb, "repos.json"), '{"version":1,"repos":[]}\n', "utf8"); + return { canonical, workspaceBb }; + } + + it("publishes guidance- without moving the default branch", async () => { + const { canonical, workspaceBb } = await pair(); + const before = await gitLine(canonical, ["rev-parse", "HEAD"]); + const result = await pushGuidance(canonical, workspaceBb, "thr_abc123"); + expect(result.ok).toBe(true); + expect(result.ref).toBe("refs/heads/guidance-thr_abc123"); + expect(await gitLine(canonical, ["show", "guidance-thr_abc123:.bb/AGENTS.md"])).toBe("learned something"); + // The default branch is reviewed like code: the branch is where it stops. + expect(await gitLine(canonical, ["rev-parse", "HEAD"])).toBe(before); + expect(await gitLine(canonical, ["show", "HEAD:.bb/AGENTS.md"])).toBe("before"); + }); + + it("reports that a lesson needs a pull request instead of fast-forwarding", async () => { + const { canonical } = await pair(); + const result = await mergeGuidance(canonical, "thr_lesson", ".bb/AGENTS.md", "a lesson"); + expect(result.ok).toBe(false); + expect(result.message).toMatch(/pull request/u); + }); + + it("commits the workspace's uncommitted .bb edits before publishing (standalone)", async () => { + const canonical = path.join(scratch, "standalone-canonical"); + await mkdir(canonical, { recursive: true }); + await bootstrapProjectSource(canonical, EMPTY_REPOS); + const workspaceBb = path.join(scratch, "standalone-workspace-bb"); + await git(["clone", "--quiet", canonical, workspaceBb]); + await writeFile(path.join(workspaceBb, "AGENTS.md"), "edited but not committed\n", "utf8"); + + const result = await pushGuidance(canonical, workspaceBb, "thr_uncommitted"); + expect(result.ok).toBe(true); + expect(await gitLine(canonical, ["show", "guidance-thr_uncommitted:AGENTS.md"])).toBe("edited but not committed"); + }); +}); + +describe("adopting a standalone .bb into a primary repo", () => { + async function standalone(): Promise { + const dir = path.join(scratch, "standalone"); + await mkdir(dir, { recursive: true }); + await bootstrapProjectSource(dir, EMPTY_REPOS); + await gitIn(dir, ["config", "user.email", "test@example.invalid"]); + await gitIn(dir, ["config", "user.name", "Test"]); + await writeFile(path.join(dir, "AGENTS.md"), "project conventions\n", "utf8"); + await gitIn(dir, ["add", "-A"]); + await gitIn(dir, ["commit", "--quiet", "-m", "conventions"]); + return dir; + } + + it("copies the tree to .bb/ on a branch, then reports already-adopted after the merge", async () => { + const source = await standalone(); + const primary = await makeRepo("primary"); + const primaryPath = path.join(scratch, "primary-clone"); + + const first = await adoptProjectSource({ + standalonePath: source, + primaryUrl: primary, + primaryPath, + branch: "bb/adopt-bb-test", + openPullRequest: false, + }); + expect(first.status).toBe("pull-request"); + // The tree is on the branch, and the default branch is untouched. + expect(await gitLine(primaryPath, ["show", "bb/adopt-bb-test:.bb/AGENTS.md"])).toBe("project conventions"); + expect(await gitLine(primaryPath, ["show", "bb/adopt-bb-test:.bb/repos.json"])).toContain('"repos"'); + expect(await gitLine(primaryPath, ["show", "main:.bb/AGENTS.md"])).toBeNull(); + + // Simulate the pull request merging. + await gitIn(primaryPath, ["checkout", "--quiet", "main"]); + await gitIn(primaryPath, ["merge", "--ff-only", "--quiet", "bb/adopt-bb-test"]); + + const second = await adoptProjectSource({ + standalonePath: source, + primaryUrl: primary, + primaryPath, + branch: "bb/adopt-bb-again", + openPullRequest: false, + }); + expect(second.status).toBe("already"); + expect(second.primaryPath).toBe(primaryPath); + }); + + it("is idempotent: a second run finds .bb/ and copies nothing", async () => { + const source = await standalone(); + const primary = await makeRepo("primary"); + await commitDefinition(primary, "already here\n"); + const primaryPath = path.join(scratch, "primary-clone"); + + const result = await adoptProjectSource({ + standalonePath: source, + primaryUrl: primary, + primaryPath, + branch: "bb/adopt-bb-test", + openPullRequest: false, + }); + expect(result.status).toBe("already"); + expect(await gitLine(primaryPath, ["show", "main:.bb/AGENTS.md"])).toBe("already here"); + }); +}); + +describe("seeding a new primary project", () => { + it("seeds .bb/ on the default branch and publishes a seed branch", async () => { + const primary = await makeRepo("primary"); + const path_ = path.join(scratch, "primary-clone"); + const result = await preparePrimaryProjectSource({ + url: primary, + path: path_, + seed: { version: 1, repos: [{ dir: "other", url: "https://example.com/other", inferredDir: true }] }, + branch: "bb/seed-bb-test", + openPullRequest: false, + }); + expect(result.ok).toBe(true); + expect(result.layout).toBe("primary"); + expect(result.seeded).toBe(true); + // On the default branch so the first thread can provision... + expect(await gitLine(path_, ["show", "main:.bb/repos.json"])).toContain("other"); + // ...and published for review on a branch. + expect(await gitLine(path_, ["show", "bb/seed-bb-test:.bb/AGENTS.md"])).not.toBeNull(); + }); + + it("uses a repo that already carries .bb/ as it is", async () => { + const primary = await makeRepo("primary"); + await commitDefinition(primary, "existing\n"); + const path_ = path.join(scratch, "primary-clone"); + const result = await preparePrimaryProjectSource({ + url: primary, + path: path_, + seed: EMPTY_REPOS, + branch: "bb/seed-bb-test", + openPullRequest: false, + }); + expect(result.layout).toBe("primary"); + expect(result.seeded).toBe(false); + }); + + it("provisions before the seed pull request has merged, from the canonical checkout", async () => { + const primary = await makeRepo("primary"); + const canonical = path.join(scratch, "primary-clone"); + const dir = dirFromUrl(primary) ?? "primary"; + const seeded = await preparePrimaryProjectSource({ + url: primary, + path: canonical, + seed: EMPTY_REPOS, + branch: "bb/seed-bb-test", + openPullRequest: false, + }); + expect(seeded.seeded).toBe(true); + + const result = await provisionWorkspace({ + dataDir, + pathKey: "thread1", + projectSourcePath: canonical, + layout: "primary", + primaryDir: dir, + repos: [{ dir, url: primary, branch: null }], + branchName: "dylan/work", + fetchTtlMs: 0, + mirrors: [], + }); + const source = result.repos.find((repo) => repo.dir === ".bb"); + expect(source?.status).toBe("ready"); + expect(await exists(path.join(result.root, ".bb", "AGENTS.md"))).toBe(true); + }); + + it("seeds a checkout sitting on a feature branch without touching it or pushing its work", async () => { + const primary = await makeRepo("primary"); + const checkout = path.join(scratch, "primary-clone"); + await git(["clone", "--quiet", primary, checkout]); + await gitIn(checkout, ["config", "user.email", "test@example.invalid"]); + await gitIn(checkout, ["config", "user.name", "Test"]); + await gitIn(checkout, ["checkout", "--quiet", "-b", "feature"]); + await writeFile(path.join(checkout, "wip.txt"), "unpushed work\n", "utf8"); + await gitIn(checkout, ["add", "-A"]); + await gitIn(checkout, ["commit", "--quiet", "-m", "wip"]); + + const result = await preparePrimaryProjectSource({ + url: primary, + path: checkout, + seed: EMPTY_REPOS, + branch: "bb/seed-bb-test", + openPullRequest: false, + }); + expect(result.ok).toBe(true); + // The seed is on its branch and on the local default branch... + expect(await gitLine(checkout, ["show", "bb/seed-bb-test:.bb/repos.json"])).toContain('"repos"'); + expect(await gitLine(checkout, ["show", "main:.bb/repos.json"])).toContain('"repos"'); + // ...not on the feature branch, and the feature branch's WIP is not on the seed. + expect(await gitLine(checkout, ["show", "feature:.bb/repos.json"])).toBeNull(); + expect(await gitLine(checkout, ["show", "bb/seed-bb-test:wip.txt"])).toBeNull(); + expect(await gitLine(checkout, ["show", "feature:wip.txt"])).toBe("unpushed work"); + expect(await gitLine(checkout, ["rev-parse", "--abbrev-ref", "HEAD"])).toBe("feature"); + }); +}); + +describe("the default-branch ref", () => { + it("uses origin/main when origin/HEAD is absent rather than the checked-out branch", async () => { + const remote = await makeRepo("primary"); + await commitDefinition(remote); + const checkout = path.join(scratch, "primary-clone"); + await git(["clone", "--quiet", remote, checkout]); + await gitIn(checkout, ["config", "user.email", "test@example.invalid"]); + await gitIn(checkout, ["config", "user.name", "Test"]); + await gitIn(checkout, ["remote", "set-head", "origin", "-d"]); + await gitIn(checkout, ["checkout", "--quiet", "-b", "feature"]); + + expect(await resolveDefaultBranchRef(checkout)).toBe("refs/heads/main"); + }); +}); diff --git a/plugins/multi-repo/repos-panel.tsx b/plugins/multi-repo/repos-panel.tsx index a63835e2..806692a1 100644 --- a/plugins/multi-repo/repos-panel.tsx +++ b/plugins/multi-repo/repos-panel.tsx @@ -272,6 +272,11 @@ export function ReposPanel() { const [jsonOpen, setJsonOpen] = useState(false); const [saving, setSaving] = useState(false); const [saveError, setSaveError] = useState(null); + const [adopting, setAdopting] = useState(false); + const [adoptPrimary, setAdoptPrimary] = useState(""); + const [adoptPath, setAdoptPath] = useState(""); + const [adoptBusy, setAdoptBusy] = useState(false); + const [adoptResult, setAdoptResult] = useState(null); // Only the latest request may land. Loads overlap whenever the project // changes under a realtime refresh — creating a project publishes the change @@ -371,6 +376,29 @@ export function ReposPanel() { ); const editing = draft !== null; + + const runAdopt = useCallback(() => { + if (projectId === null || adoptPrimary.trim().length === 0 || adoptPath.trim().length === 0) return; + setAdoptBusy(true); + setAdoptResult(null); + void rpc + .call("adoptProjectSource", { + projectId, + primary: adoptPrimary.trim(), + path: adoptPath.trim(), + openPullRequest: true, + }) + .then((result) => { + setAdoptResult([result.message, ...result.warnings]); + if (result.status === "already") { + setAdopting(false); + reload(); + load(); + } + }) + .catch((error: unknown) => setAdoptResult([error instanceof Error ? error.message : "Could not adopt."])) + .finally(() => setAdoptBusy(false)); + }, [rpc, projectId, adoptPrimary, adoptPath, reload, load]); const save = useCallback(() => { if (projectId === null || draft === null) return; setSaving(true); @@ -539,8 +567,98 @@ export function ReposPanel() { Each change is committed to the project's .bb repo. Existing workspaces keep the repos they were created with; changes apply to new threads.

+

+ Layout: {view.layout} + {view.primary === null ? null : ( + <> + {" · primary "} + {view.primary.dir} (from the source's origin, not repeated in + repos.json) + + )} +

+ {view.layout === "standalone" && ( +
+
+

Adopt into a primary repo

+ {!adopting && ( + + )} +
+ {adopting && ( +
+

+ Copies this standalone .bb tree to .bb/ on + a branch of the primary repo and opens a pull request. Once it merges, run Adopt again to + re-point the project source. The project id, bindings and threads are unchanged. +

+ + +
+ + +
+ {adoptResult !== null && ( +
    + {adoptResult.map((line) => ( +
  • {line}
  • + ))} +
+ )} +
+ )} +
+ )} +
{ `CREATE INDEX IF NOT EXISTS workspace_threads_workspace ON workspace_threads (host_id, path_key)`, `INSERT OR IGNORE INTO workspace_threads (thread_id, host_id, path_key, bound_at) SELECT thread_id, host_id, path_key, created_at FROM workspaces`, + // The project-source layout this workspace was provisioned from. Existing + // rows default to `standalone`, which is what they are; a workspace whose + // layout no longer matches the project's is one that predates an adopt. + `ALTER TABLE workspaces ADD COLUMN layout TEXT NOT NULL DEFAULT 'standalone'`, + // The default-branch commit `.bb/` was materialized from, so guidance + // publish can warn when `.bb/` moved on the default branch since. + `ALTER TABLE workspaces ADD COLUMN definition_base TEXT`, ]); const host = bb.hosts.experimental_client({ @@ -172,14 +179,16 @@ export default async function multiRepoPlugin(bb: BbPluginApi): Promise { function saveManifest(manifest: WorkspaceManifest): void { db.prepare( `INSERT INTO workspaces - (host_id, path_key, thread_id, project_id, root, branch_name, project_source_path, repos, instructions, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + (host_id, path_key, thread_id, project_id, root, branch_name, project_source_path, layout, definition_base, repos, instructions, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (host_id, path_key) DO UPDATE SET thread_id = excluded.thread_id, project_id = excluded.project_id, root = excluded.root, branch_name = excluded.branch_name, project_source_path = excluded.project_source_path, + layout = excluded.layout, + definition_base = excluded.definition_base, repos = excluded.repos, instructions = excluded.instructions, created_at = excluded.created_at`, @@ -191,6 +200,8 @@ export default async function multiRepoPlugin(bb: BbPluginApi): Promise { manifest.root, manifest.branchName, manifest.projectSourcePath, + manifest.layout, + manifest.definitionBase, JSON.stringify(manifest.repos), formatInstructions(manifest), manifest.createdAt, @@ -206,7 +217,9 @@ export default async function multiRepoPlugin(bb: BbPluginApi): Promise { projectId: String(row.project_id), root: String(row.root), branchName: String(row.branch_name), + layout: row.layout === "primary" ? "primary" : "standalone", projectSourcePath: String(row.project_source_path), + definitionBase: typeof row.definition_base === "string" ? row.definition_base : null, repos: JSON.parse(String(row.repos)) as WorkspaceRepo[], createdAt: Number(row.created_at), }; @@ -442,6 +455,116 @@ export default async function multiRepoPlugin(bb: BbPluginApi): Promise { return { hostId: preferred.hostId, path: preferred.path }; } + /** + * True when a workspace was built from a standalone `.bb` and the project's + * source is now a primary checkout — i.e. a machine has run adopt since. + * + * Such a workspace keeps publishing guidance to the old canonical checkout + * until it ends; the branch it publishes has to be re-applied to the primary + * by hand. Every surface that describes the workspace says so. + */ + async function predatesAdopt(manifest: WorkspaceManifest): Promise { + if (manifest.layout !== "standalone") return false; + try { + const current = await projectSource(manifest.projectId); + return current !== null && current.path !== manifest.projectSourcePath; + } catch { + return false; + } + } + + /** + * The adopt flow behind both the RPC and `bb repos adopt`. + * + * Runs host-side on the project's machine, then, when the primary already + * carries `.bb/`, re-points `project_sources.path` in place. That is the + * whole of Case A: no new project, no store re-key, no respawn. + */ + async function performAdopt( + projectId: string, + primary: string, + path: string, + openPullRequest: boolean, + ): Promise<{ + status: "already" | "pull-request" | "failed"; + message: string; + prUrl: string | null; + warnings: string[]; + primaryPath: string; + defaultBranch: string | null; + repointed: boolean; + }> { + const location = await projectSource(projectId); + if (location === null) { + return { + status: "failed", + message: "This project has no checkout on any machine.", + prUrl: null, + warnings: [], + primaryPath: path, + defaultBranch: null, + repointed: false, + }; + } + const branch = `bb/adopt-bb-${Date.now().toString(36)}`; + let result: { + status: "already" | "pull-request" | "failed"; + message: string; + prUrl: string | null; + warnings: string[]; + primaryPath: string; + defaultBranch: string | null; + }; + try { + result = await host.call( + "adoptProjectSource", + { + standalonePath: location.path, + primaryUrl: primary, + primaryPath: path, + branch, + openPullRequest, + }, + { hostId: location.hostId, timeoutMs: CREATE_TIMEOUT_MS }, + ); + } catch (error) { + return { + status: "failed", + message: errorMessage(error), + prUrl: null, + warnings: [], + primaryPath: path, + defaultBranch: null, + repointed: false, + }; + } + + let repointed = false; + if (result.status === "already") { + try { + const project = await bb.sdk.projects.get({ projectId }); + const source = project.sources.find( + (entry) => entry.type === "local_path" && entry.hostId === location.hostId && entry.path === location.path, + ); + if (source !== undefined) { + await bb.sdk.projects.sources.update({ + projectId, + sourceId: source.id, + type: "local_path", + path: result.primaryPath, + }); + repointed = true; + changed(); + } else { + result.warnings.push("The project source row could not be found; re-point it by hand."); + } + } catch (error) { + result.warnings.push(`Could not re-point the project source: ${errorMessage(error)}`); + } + } + return { ...result, repointed }; + } + /** * Read the repo set. * @@ -454,17 +577,19 @@ export default async function multiRepoPlugin(bb: BbPluginApi): Promise { location: SourceLocation, options: { bootstrap: boolean }, signal?: AbortSignal, - ): Promise<{ text: string | null; file: ReposFile | null; error: string | null }> { + ): Promise<{ text: string | null; file: ReposFile | null; error: string | null; layout: ProjectLayout; remote: string | null }> { const prepared = await host.call( "prepareProjectSource", { path: location.path, fetchTtlMs: CACHE_FRESH_MS, bootstrap: options.bootstrap }, { hostId: location.hostId, timeoutMs: READ_TIMEOUT_MS, ...(signal === undefined ? {} : { signal }) }, ); - if (prepared.reposJson === null) return { text: null, file: EMPTY_REPOS, error: null }; + if (prepared.reposJson === null) { + return { text: null, file: EMPTY_REPOS, error: null, layout: prepared.layout ?? "standalone", remote: prepared.remote }; + } const parsed = parseReposFile(prepared.reposJson); return parsed.ok - ? { text: prepared.reposJson, file: parsed.value, error: null } - : { text: prepared.reposJson, file: null, error: parsed.error }; + ? { text: prepared.reposJson, file: parsed.value, error: null, layout: prepared.layout ?? "standalone", remote: prepared.remote } + : { text: prepared.reposJson, file: null, error: parsed.error, layout: prepared.layout ?? "standalone", remote: prepared.remote }; } async function writeRepoSet( @@ -472,10 +597,11 @@ export default async function multiRepoPlugin(bb: BbPluginApi): Promise { file: ReposFile, message: string, signal?: AbortSignal, + layout: ProjectLayout = "standalone", ): Promise { const result = await host.call( "writeProjectSourceRepos", - { path: location.path, reposJson: serializeReposFile(file), message }, + { path: location.path, reposJson: serializeReposFile(file), message, layout }, { hostId: location.hostId, timeoutMs: READ_TIMEOUT_MS, ...(signal === undefined ? {} : { signal }) }, ); // The file is written either way, but an uncommitted change is not shared @@ -518,7 +644,7 @@ export default async function multiRepoPlugin(bb: BbPluginApi): Promise { } const edited = addRepo(current.file, entry); if (!edited.ok) return { ok: false, code: "invalid_repo_set", error: edited.error }; - await writeRepoSet(location, edited.value, `Add ${entry.dir} to the repo set`, signal); + await writeRepoSet(location, edited.value, `Add ${entry.dir} to the repo set`, signal, current.layout); return { ok: true, dir: entry.dir }; } @@ -533,7 +659,7 @@ export default async function multiRepoPlugin(bb: BbPluginApi): Promise { } const edited = removeRepo(current.file, dir); if (!edited.ok) return { ok: false, code: "unknown_repo", error: edited.error }; - await writeRepoSet(location, edited.value, `Remove ${dir} from the repo set`, signal); + await writeRepoSet(location, edited.value, `Remove ${dir} from the repo set`, signal, current.layout); return { ok: true, dir }; } @@ -551,7 +677,7 @@ export default async function multiRepoPlugin(bb: BbPluginApi): Promise { if (!edited.ok) return { ok: false, code: "unknown_repo", error: edited.error }; const next = edited.value.repos.find((repo) => repo.dir === dir)?.branch; const message = next === undefined ? `Use the default branch for ${dir}` : `Base ${dir} on ${next}`; - await writeRepoSet(location, edited.value, message, signal); + await writeRepoSet(location, edited.value, message, signal, current.layout); return { ok: true, dir }; } @@ -667,21 +793,70 @@ export default async function multiRepoPlugin(bb: BbPluginApi): Promise { context.report.step("Looking for repos already on this machine"); const mirrors = await localCheckouts(hostId, location.path, context.signal); + // A primary project's repo set is `repos.json`'s entries *plus* the + // primary, which is not repeated in the file. It is identified by the + // project source's own `origin`, so a source without one cannot say + // which clone is the primary. + const layout: ProjectLayout = prepared.layout ?? "standalone"; + + if (layout === "primary" && prepared.remote === null) { + return { + status: "failed", + message: `The project source is a primary repo with no origin. Add one so the primary can be identified, or make it a standalone .bb repo.`, + }; + } + const primaryRequest = + layout === "primary" && prepared.remote !== null + ? primaryRepoRequest(prepared.remote) + : null; + if (layout === "primary" && primaryRequest === null) { + return { + status: "failed", + message: `Could not derive a directory name for the primary repo from ${prepared.remote}.`, + }; + } + if (repoSet.repos.length === 0) { const seeded = proposeRepoSet(mirrors); - if (seeded.repos.length === 0) { + if (seeded.repos.length === 0 && layout !== "primary") { return { status: "failed", message: `No repos are configured. Add some to ${REPOS_FILE} in ${location.path}, or run \`bb repos add \`.`, }; } - context.report.log( - `Seeding an empty repo set with ${seeded.repos.length} repo(s) found on this machine: ${seeded.repos - .map((repo) => repo.dir) - .join(", ")}`, + if (seeded.repos.length > 0) { + context.report.log( + `Seeding an empty repo set with ${seeded.repos.length} repo(s) found on this machine: ${seeded.repos + .map((repo) => repo.dir) + .join(", ")}`, + ); + await writeRepoSet(location, seeded, "Seed the repo set from checkouts on this machine", context.signal, layout); + repoSet = seeded; + } + } + + const repoRequests = repoSet.repos.map(toRepoRequest); + if ( + primaryRequest !== null && + !repoRequests.some((repo) => normalizeRemoteUrl(repo.url) === normalizeRemoteUrl(primaryRequest.url)) + ) { + repoRequests.push(primaryRequest); + } + // A repos.json entry with the same directory name as the primary but a + // different remote (a fork) would clone two repos into one directory + // and fail the provision; name it instead. + if (primaryRequest !== null) { + const clash = repoRequests.find( + (repo) => + repo.dir.toLowerCase() === primaryRequest.dir.toLowerCase() && + normalizeRemoteUrl(repo.url) !== normalizeRemoteUrl(primaryRequest.url), ); - await writeRepoSet(location, seeded, "Seed the repo set from checkouts on this machine", context.signal); - repoSet = seeded; + if (clash !== undefined) { + return { + status: "failed", + message: `repos.json has "${clash.dir}", which collides with the primary repo's directory "${primaryRequest.dir}". Rename the entry or set the primary's directory.`, + }; + } } // Claim before writing. Two environments must never share a root, and @@ -691,14 +866,16 @@ export default async function multiRepoPlugin(bb: BbPluginApi): Promise { return { status: "failed", message: `${root} is already in use by another environment.` }; } - context.report.step(`Preparing ${repoSet.repos.length} repo(s)`); + context.report.step(`Preparing ${repoRequests.length} repo(s)`); const provisioned = await host.call( "provisionWorkspace", { operationId, pathKey: context.pathKey, projectSourcePath: location.path, - repos: repoSet.repos.map(toRepoRequest), + layout, + primaryDir: primaryRequest?.dir ?? null, + repos: repoRequests, branchName: context.suggestedBranchName, fetchTtlMs: CACHE_FRESH_MS, mirrors, @@ -713,7 +890,9 @@ export default async function multiRepoPlugin(bb: BbPluginApi): Promise { threadId: context.thread.id, projectId: context.project.id, branchName: context.suggestedBranchName, + layout, projectSourcePath: location.path, + definitionBase: provisioned.definitionBase, repos: provisioned.repos, createdAt: Date.now(), }; @@ -802,6 +981,24 @@ export default async function multiRepoPlugin(bb: BbPluginApi): Promise { return { dir: repo.dir, url: repo.url, branch: repo.branch ?? null }; } + /** + * The primary repo as a workspace repo request, from the project source's + * `origin`. It is not repeated in `repos.json`, so every primary project's + * workspace gets one clone of it in addition to the entries in the file. + */ + function primaryRepoRequest(remote: string): RepoRequest | null { + const dir = dirFromUrl(remote); + if (dir === null || dir === PROJECT_SOURCE_DIR) return null; + return { dir, url: remote, branch: null }; + } + + /** The primary repo as the panel's read-only view (no `branch`). */ + function primaryView(remote: string | null): { dir: string; url: string } | null { + if (remote === null) return null; + const request = primaryRepoRequest(remote); + return request === null ? null : { dir: request.dir, url: request.url }; + } + /** * Turn discovered checkouts into a starting repo set. * @@ -991,9 +1188,9 @@ export default async function multiRepoPlugin(bb: BbPluginApi): Promise { bb.agents.registerTool({ name: "workspace_publish_guidance", description: - "Publish commits made in this workspace's .bb directory (AGENTS.md, skills, repos.json) back to the project's canonical checkout, as a branch to review and merge.", + "Publish changes under this workspace's .bb directory (AGENTS.md, skills, repos.json) as a guidance- branch to review and merge.", instructions: - "Commit inside .bb first, then call workspace_publish_guidance. The canonical checkout is outside your sandbox, so a plain git push will not reach it.", + "Edit the files under .bb/ first, then call workspace_publish_guidance. It commits them host-side and publishes a guidance- branch — there is no need (and, where .bb/ is a directory rather than a repo, no way) to commit inside .bb yourself.", presentation: { label: { pending: "Publishing workspace guidance", completed: "Published workspace guidance" } }, parameters: z.object({}), async execute(_input, { threadId, signal }) { @@ -1007,6 +1204,7 @@ export default async function multiRepoPlugin(bb: BbPluginApi): Promise { { projectSourcePath: manifest.projectSourcePath, workspaceBbPath: source.path, + definitionBase: manifest.definitionBase, // The calling thread, not the workspace's creator: two threads // sharing one checkout must not push onto one `guidance-` // ref and silently overwrite each other's proposal. @@ -1113,10 +1311,21 @@ export default async function multiRepoPlugin(bb: BbPluginApi): Promise { if (input.options.json) { return { exitCode: 0, - stdout: JSON.stringify({ source: location, repos: set.file.repos, cache: cache.entries }, null, 2), + stdout: JSON.stringify( + { + source: location, + layout: set.layout, + primary: primaryView(set.remote), + repos: set.file.repos, + cache: cache.entries, + }, + null, + 2, + ), }; } - if (set.file.repos.length === 0) { + const primaryDir = set.layout === "primary" && set.remote !== null ? dirFromUrl(set.remote) : null; + if (set.file.repos.length === 0 && primaryDir === null) { return { exitCode: 0, stdout: `No repos configured in ${location.path}/${REPOS_FILE}.` }; } const byUrl = new Map(cache.entries.map((entry) => [entry.url, entry])); @@ -1125,7 +1334,19 @@ export default async function multiRepoPlugin(bb: BbPluginApi): Promise { const state = entry === undefined || !entry.present ? "not cached" : describeCacheEntry(entry); return `${repo.dir}\t${repo.branch ?? "(default)"}\t${repo.url}\t${state}`; }); - return { exitCode: 0, stdout: ["dir\tbranch\turl\tcache", ...lines].join("\n") }; + const primaryLine = + primaryDir === null || set.remote === null + ? [] + : [`${primaryDir}\t(default)\t${set.remote}\t(primary)`]; + return { + exitCode: 0, + stdout: [ + `layout\t${set.layout}`, + "dir\tbranch\turl\tcache", + ...primaryLine, + ...lines, + ].join("\n"), + }; }, }), @@ -1196,10 +1417,18 @@ export default async function multiRepoPlugin(bb: BbPluginApi): Promise { `${repo.dir}\t${repo.branch ?? "-"}\t+${repo.ahead}/-${repo.behind}\t${repo.dirty} changed, ${repo.untracked} untracked${repo.error === null ? "" : `\t${repo.error}`}`, ); const shared = sharingThreads(manifest, ctx.threadId); + const stale = await predatesAdopt(manifest); return { exitCode: 0, stdout: [ `${manifest.root} (branch ${manifest.branchName})`, + `layout\t${manifest.layout}`, + ...(stale + ? [ + `This workspace predates the adopt: it was built from the standalone .bb at ${manifest.projectSourcePath}.`, + "Guidance published from it goes to that checkout, not the primary repo; re-apply such a branch to the primary by hand.", + ] + : []), ...(shared.length === 0 ? [] : [ @@ -1212,6 +1441,35 @@ export default async function multiRepoPlugin(bb: BbPluginApi): Promise { }; }, }), + + adopt: cliCommand({ + summary: "Copy a standalone .bb into a primary repo and re-point the project source", + positionals: [ + { name: "primary", description: "URL or local path of the primary repo", required: true }, + ], + options: { + project: PROJECT_OPTION, + path: { type: "string", description: "Where the primary clone lives (default: beside the .bb checkout)" }, + "no-pr": { type: "boolean", description: "Do not open a pull request for the seed branch" }, + }, + async run(input, ctx) { + const projectId = await resolveProjectId(ctx, input.options.project); + const location = await requireSource(projectId); + const primaryPath = + input.options.path ?? `${parentOf(location.path)}/${dirFromUrl(input.positionals.primary) ?? "primary"}`; + const result = await performAdopt(projectId, input.positionals.primary, primaryPath, !input.options["no-pr"]); + const lines = [ + result.message, + ...(result.repointed + ? [`Re-pointed the project source to ${result.primaryPath}.`] + : result.status === "pull-request" + ? ["Merge the pull request, then run `bb repos adopt` again to re-point the project source."] + : []), + ...result.warnings, + ]; + return { exitCode: result.status === "failed" ? 1 : 0, stdout: lines.join("\n") }; + }, + }), }, }), ); @@ -1252,7 +1510,16 @@ export default async function multiRepoPlugin(bb: BbPluginApi): Promise { async repoSet({ projectId }) { const location = await projectSource(projectId); if (location === null) { - return { projectSourcePath: null, hostId: null, reposJson: null, repos: [], error: null, cache: [] }; + return { + projectSourcePath: null, + hostId: null, + layout: "standalone" as const, + primary: null, + reposJson: null, + repos: [], + error: null, + cache: [], + }; } try { const set = await readRepoSet(location, { bootstrap: false }); @@ -1273,6 +1540,8 @@ export default async function multiRepoPlugin(bb: BbPluginApi): Promise { return { projectSourcePath: location.path, hostId: location.hostId, + layout: set.layout, + primary: primaryView(set.remote), reposJson: set.text, repos, error: set.error, @@ -1282,6 +1551,8 @@ export default async function multiRepoPlugin(bb: BbPluginApi): Promise { return { projectSourcePath: location.path, hostId: location.hostId, + layout: "standalone" as const, + primary: null, reposJson: null, repos: [], error: errorMessage(error), @@ -1360,12 +1631,12 @@ export default async function multiRepoPlugin(bb: BbPluginApi): Promise { // directory, which made this panel unable to be the thing that sets a // project up. Committing a repo set is a deliberate enough act to // stand up the repo that holds it. - await host.call( + const prepared = await host.call( "prepareProjectSource", { path: location.path, fetchTtlMs: CACHE_FRESH_MS, bootstrap: true }, { hostId: location.hostId, timeoutMs: READ_TIMEOUT_MS }, ); - await writeRepoSet(location, parsed.value, `Update the repo set`); + await writeRepoSet(location, parsed.value, `Update the repo set`, undefined, prepared.layout ?? "standalone"); return { ok: true, error: null }; } catch (error) { return { ok: false, error: errorMessage(error) }; @@ -1429,7 +1700,9 @@ export default async function multiRepoPlugin(bb: BbPluginApi): Promise { async createProject({ name, hostId, path, source }) { const warnings: string[] = []; - const fail = (error: string) => ({ ok: false as const, projectId: null, error, warnings }); + let layout: ProjectLayout | null = null; + let prUrl: string | null = null; + const fail = (error: string) => ({ ok: false as const, projectId: null, error, warnings, layout, prUrl }); // Refuse to stack a second project on a directory one already owns. Core // may or may not reject it, and either way the panel would then show two @@ -1451,6 +1724,7 @@ export default async function multiRepoPlugin(bb: BbPluginApi): Promise { { hostId, timeoutMs: READ_TIMEOUT_MS }, ); warnings.push(...prepared.warnings); + layout = prepared.layout ?? "standalone"; if (!prepared.bootstrapped && source.repos.length > 0) { // Adopting a directory that is already a `.bb` repo: the seed was // not written, so merge the entries in the ordinary way. @@ -1460,13 +1734,34 @@ export default async function multiRepoPlugin(bb: BbPluginApi): Promise { if (!outcome.ok) warnings.push(outcome.error); } } - } else { + } else if (source.kind === "clone") { const cloned = await host.call( "cloneProjectSource", { url: source.url, path }, { hostId, timeoutMs: CREATE_TIMEOUT_MS }, ); if (!cloned.ok) return fail(cloned.message ?? "Could not clone the .bb repo."); + layout = "standalone"; + } else { + // A primary repo: clone it if it is not here, seed `.bb/` when it + // does not carry one, and publish the seed for review. + const resolved = resolveSeed(source.repos); + if (!resolved.ok) return fail(resolved.error); + const prepared = await host.call( + "preparePrimaryProjectSource", + { + url: source.url, + path, + seed: serializeReposFile({ version: 1, repos: resolved.repos }), + branch: `bb/seed-bb-${Date.now().toString(36)}`, + openPullRequest: source.openPullRequest ?? true, + }, + { hostId, timeoutMs: CREATE_TIMEOUT_MS }, + ); + warnings.push(...prepared.warnings); + if (!prepared.ok) return fail(prepared.message); + layout = prepared.layout ?? "standalone"; + prUrl = prepared.prUrl; } } catch (error) { return fail(errorMessage(error)); @@ -1478,7 +1773,7 @@ export default async function multiRepoPlugin(bb: BbPluginApi): Promise { source: { type: "local_path", hostId, path }, }); changed(); - return { ok: true, projectId: project.id, error: null, warnings: warnings.slice(0, 20) }; + return { ok: true, projectId: project.id, error: null, warnings: warnings.slice(0, 20), layout, prUrl }; } catch (error) { return fail( `${path} is ready but the project could not be created: ${errorMessage(error)}. Try again, or create it with \`bb project create --root ${path}\`.`, @@ -1486,6 +1781,19 @@ export default async function multiRepoPlugin(bb: BbPluginApi): Promise { } }, + /** + * Adopt a standalone `.bb` into a primary repo, per machine (BB-106). + * + * The first run copies the tree to `.bb/` on a branch and opens a pull + * request; a later run, once `.bb/` is on the default branch, re-points the + * project source at the primary checkout. Case A: `projects.sources.update` + * re-points the row in place, so the project id, every stewards binding and + * every thread stay as they are. + */ + async adoptProjectSource({ projectId, primary, path, openPullRequest }) { + return performAdopt(projectId, primary, path, openPullRequest ?? true); + }, + async workspace({ threadId }) { const manifest = await resolveManifest(threadId); if (manifest === null) return { workspace: null }; @@ -1495,6 +1803,7 @@ export default async function multiRepoPlugin(bb: BbPluginApi): Promise { branchName: manifest.branchName, hostId: manifest.hostId, projectSourcePath: manifest.projectSourcePath, + predatesAdopt: await predatesAdopt(manifest), repos: manifest.repos, sharedWith: await describeSharing(manifest, threadId), }, diff --git a/plugins/multi-repo/skills/multi-repo/SKILL.md b/plugins/multi-repo/skills/multi-repo/SKILL.md index 79240a57..9a9df77d 100644 --- a/plugins/multi-repo/skills/multi-repo/SKILL.md +++ b/plugins/multi-repo/skills/multi-repo/SKILL.md @@ -65,11 +65,13 @@ It is the **wrong** place for anything specific to one thread. The canonical `.bb` checkout lives outside your sandbox, so `git push` from `.bb` will not reach it. Instead: -1. Commit inside `/.bb` as normal. +1. Edit the files under `/.bb`. 2. Call `workspace_publish_guidance`. -That lands your commits on a `guidance-` branch in the canonical -checkout, for a person to review and merge. +It commits them host-side and lands them on a `guidance-` branch in +the canonical checkout, for a person to review and merge. There is no need to +commit inside `.bb` yourself — and in the primary layout `.bb` is a plain +directory with no git repo, so you cannot. ## A shared checkout diff --git a/plugins/multi-repo/source.ts b/plugins/multi-repo/source.ts index 454918e9..24ae25da 100644 --- a/plugins/multi-repo/source.ts +++ b/plugins/multi-repo/source.ts @@ -15,17 +15,108 @@ * Everything in this module runs host-side, outside the agent's sandbox. That * is what makes the write-back in {@link pushGuidance} possible at all. */ -import { access, mkdir, readdir, readFile, writeFile } from "node:fs/promises"; -import { homedir } from "node:os"; +import { access, cp, mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises"; +import { homedir, tmpdir } from "node:os"; import path from "node:path"; import { projectFilePathProblem, slugify } from "./names.js"; -import { git, gitIn, gitInOrThrow, gitLine, firstProblemLine, splitNul } from "./git.js"; +import { git, gitIn, gitInOrThrow, gitLine, firstProblemLine, run, splitNul } from "./git.js"; import { REPOS_FILE, serializeReposFile, type ReposFile } from "./repos.js"; +import { + PROJECT_SOURCE_DIR, + projectDefinitionPrefix, + type ProjectLayout, +} from "./paths.js"; const FETCH_MARKER = ".git/bb-multi-repo-fetched-at"; const GIT_TIMEOUT_MS = 30_000; const FETCH_TIMEOUT_MS = 120_000; +/** True when the ref resolves to a commit. */ +async function refExists(dir: string, ref: string, signal?: AbortSignal): Promise { + const result = await gitIn(dir, ["rev-parse", "--verify", "--quiet", `${ref}^{commit}`], { + timeoutMs: GIT_TIMEOUT_MS, + ...(signal === undefined ? {} : { signal }), + }); + return result.code === 0 && result.stdout.trim().length > 0; +} + +/** + * The default branch's committed tip, as a ref usable with `git ls-tree`. + * + * `origin/` is the project's shared truth and is preferred. The one + * case where the local default branch is used instead is when it is strictly + * ahead of the remote-tracking ref — the plugin's own committed `.bb` edits + * (a seed, a `repos.json` change) live there, and a read that ignored them + * would make `bb repos add` appear to have done nothing. A local branch that + * has diverged from the remote is never used: that is a person's own history, + * not the project's. Never the working tree, and never whatever feature branch + * the checkout happens to be on. + */ +export async function resolveDefaultBranchRef(dir: string, signal?: AbortSignal): Promise { + const options = { timeoutMs: GIT_TIMEOUT_MS, ...(signal === undefined ? {} : { signal }) }; + const originHead = await gitLine(dir, ["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"], options); + let branch = + originHead !== null && originHead.startsWith("origin/") ? originHead.slice("origin/".length) : null; + // A hand-made checkout may have no `origin/HEAD`. The conventional default + // names are tried before the checked-out branch, so a feature branch is + // never mistaken for the default; the refresh also runs + // `git remote set-head origin --auto` to set it properly. + if (branch === null || branch.length === 0) { + for (const candidate of ["main", "master"]) { + if (await refExists(dir, `refs/remotes/origin/${candidate}`, signal)) { + branch = candidate; + break; + } + } + } + if (branch === null || branch.length === 0) { + const current = await gitLine(dir, ["rev-parse", "--abbrev-ref", "HEAD"], options); + if (current !== null && current !== "HEAD") branch = current; + } + const originRef = branch === null ? null : `refs/remotes/origin/${branch}`; + const localRef = branch === null ? null : `refs/heads/${branch}`; + const hasOrigin = originRef !== null && (await refExists(dir, originRef, signal)); + const hasLocal = localRef !== null && (await refExists(dir, localRef, signal)); + if (hasLocal && hasOrigin) { + const localSha = await gitLine(dir, ["rev-parse", localRef as string], options); + const originSha = await gitLine(dir, ["rev-parse", originRef as string], options); + if (localSha !== null && localSha === originSha) return localRef; + const ahead = await gitIn(dir, ["merge-base", "--is-ancestor", originRef as string, localRef as string], options); + return ahead.code === 0 ? localRef : originRef; + } + if (hasLocal) return localRef; + if (hasOrigin) return originRef; + return (await refExists(dir, "HEAD", signal)) ? "HEAD" : null; +} + +/** + * Which layout a project source checkout has. + * + * Decided from the default branch's committed tree, never the working tree, so + * an uncommitted `.bb/` someone is halfway through creating does not flip the + * layout under them. A directory that is not a repo, or has no commit yet, is + * `standalone` — that is the layout `bootstrapProjectSource` initializes, and + * the fallback only reaches a directory whose committed tree has neither + * definition. + */ +export async function detectProjectLayout(dir: string, signal?: AbortSignal): Promise { + const ref = await resolveDefaultBranchRef(dir, signal); + if (ref !== null) { + const primary = await gitIn(dir, ["cat-file", "-e", `${ref}:${PROJECT_SOURCE_DIR}/${REPOS_FILE}`], { + timeoutMs: GIT_TIMEOUT_MS, + ...(signal === undefined ? {} : { signal }), + }); + if (primary.code === 0) return "primary"; + const standalone = await gitIn(dir, ["cat-file", "-e", `${ref}:${REPOS_FILE}`], { + timeoutMs: GIT_TIMEOUT_MS, + ...(signal === undefined ? {} : { signal }), + }); + if (standalone.code === 0) return "standalone"; + } + if (await exists(path.join(dir, PROJECT_SOURCE_DIR, REPOS_FILE))) return "primary"; + return "standalone"; +} + /** * The seeded `AGENTS.md`. * @@ -41,10 +132,11 @@ const FETCH_TIMEOUT_MS = 120_000; */ export const SEEDED_AGENTS_MD = `# Cross-repo guidance -This file lives in \`.bb/\`, the project's own git repo. It is **shared by every -thread in this project and versioned**, so it is the right place to record -durable cross-repo knowledge — and the wrong place for anything specific to one -thread. +This file lives in \`.bb/\`, a directory on the primary repo's default branch. +It is **shared by every thread in this project and versioned**, so it is the +right place to record durable cross-repo knowledge — and the wrong place for +anything specific to one thread. Changes to it are reviewed like code: commit +them, publish the branch, and land them with a pull request. \`.bb/\` holds three things: @@ -130,6 +222,43 @@ export async function bootstrapProjectSource( return { bootstrapped: true, warnings }; } +/** + * Seed the `.bb/` definition inside a primary repo that does not carry one yet. + * + * Unlike {@link bootstrapProjectSource} this never runs `git init`: the primary + * is a real repo that already exists, and `.bb/` is a directory added to it. + * Nothing here overwrites a file that already exists, so a repo that carries a + * partial `.bb/` is completed rather than replaced. + * + * The commit lands on the checkout's current branch. The caller (the + * new-project flow) publishes a branch and opens the pull request; this + * function is only the tree and the commit. + */ +export async function seedPrimaryDefinition( + dir: string, + seed: ReposFile, + signal?: AbortSignal, +): Promise { + const warnings: string[] = []; + if (!(await isGitRepo(dir))) { + return { bootstrapped: false, warnings: [`${dir} is not a git repo, so it cannot carry .bb/.`] }; + } + const options = { timeoutMs: GIT_TIMEOUT_MS, ...(signal === undefined ? {} : { signal }) }; + const definitionDir = path.join(dir, PROJECT_SOURCE_DIR); + await mkdir(path.join(definitionDir, "skills"), { recursive: true }); + const reposPath = path.join(definitionDir, REPOS_FILE); + if (!(await exists(reposPath))) await writeFile(reposPath, serializeReposFile(seed), "utf8"); + const agentsPath = path.join(definitionDir, "AGENTS.md"); + if (!(await exists(agentsPath))) await writeFile(agentsPath, SEEDED_AGENTS_MD, "utf8"); + const keepPath = path.join(definitionDir, "skills", ".gitkeep"); + if (!(await exists(keepPath))) await writeFile(keepPath, "", "utf8"); + + await gitIn(dir, ["add", "--", PROJECT_SOURCE_DIR], options); + const commit = await commitAll(dir, "Initialize .bb in the primary repo", signal); + if (!commit.committed && commit.message !== null) warnings.push(commit.message); + return { bootstrapped: true, warnings }; +} + async function exists(target: string): Promise { try { await access(target); @@ -257,6 +386,10 @@ export async function refreshProjectSource( return warnings; } await writeFile(markerPath, String(Date.now()), "utf8").catch(() => undefined); + // Keep `origin/HEAD` pointing at the remote's default branch, so a read + // never has to guess it from the checked-out branch. Best-effort: a remote + // that cannot answer leaves the ref as it was. + await gitIn(dir, ["remote", "set-head", "origin", "--auto"], options).catch(() => undefined); const merged = await gitIn(dir, ["merge", "--ff-only", "--quiet", "@{u}"], options); if (merged.code !== 0) { @@ -267,12 +400,16 @@ export async function refreshProjectSource( return warnings; } -export async function readReposJson(dir: string): Promise { - try { - return await readFile(path.join(dir, REPOS_FILE), "utf8"); - } catch { - return null; - } +export async function readReposJson( + dir: string, + layout: ProjectLayout = "standalone", + signal?: AbortSignal, +): Promise { + const read = await readCommittedFiles(dir, [REPOS_FILE], 200_000, signal, { + prefix: projectDefinitionPrefix(layout), + }); + if (read.error !== null || read.files.length === 0) return null; + return read.files[0].content; } export interface CommittedFiles { @@ -305,13 +442,20 @@ export async function readCommittedFiles( paths: readonly string[], maxChars: number, signal?: AbortSignal, + options: { ref?: string | null; prefix?: string } = {}, ): Promise { for (const file of paths) { const problem = projectFilePathProblem(file); if (problem !== null) return { head: null, files: [], error: problem }; } - const options = { timeoutMs: GIT_TIMEOUT_MS, ...(signal === undefined ? {} : { signal }) }; - const head = await gitLine(dir, ["rev-parse", "--verify", "--quiet", "HEAD^{commit}"], options); + const prefix = options.prefix ?? ""; + const gitOptions = { timeoutMs: GIT_TIMEOUT_MS, ...(signal === undefined ? {} : { signal }) }; + const ref = + options.ref === undefined + ? await resolveDefaultBranchRef(dir, signal) + : options.ref; + const resolved = ref ?? "HEAD"; + const head = await gitLine(dir, ["rev-parse", "--verify", "--quiet", `${resolved}^{commit}`], gitOptions); if (head === null || head.length === 0) { return { head: null, files: [], error: `${dir} is not a git repo with a commit to read.` }; } @@ -320,8 +464,8 @@ export async function readCommittedFiles( // Literal pathspecs, so a name with `*` or a leading `:(` is only a name. const listed = await gitIn( dir, - ["--literal-pathspecs", "ls-tree", "-z", "--full-tree", head, "--", ...paths], - options, + ["--literal-pathspecs", "ls-tree", "-z", "--full-tree", head, "--", ...paths.map((file) => `${prefix}${file}`)], + gitOptions, ); if (listed.code !== 0) return { head, files: [], error: firstProblemLine(listed) }; const blobs = new Map(); @@ -334,14 +478,14 @@ export async function readCommittedFiles( const files: CommittedFiles["files"] = []; const tooLarge: string[] = []; for (const file of paths) { - const object = blobs.get(file); + const object = blobs.get(`${prefix}${file}`); if (object === undefined) { files.push({ path: file, content: null }); continue; } // Room for the limit in the widest UTF-8, plus one byte to tell "exactly // at the limit" from "over it". - const read = await gitIn(dir, ["cat-file", "blob", object], { ...options, maxBytes: maxChars * 3 + 1 }); + const read = await gitIn(dir, ["cat-file", "blob", object], { ...gitOptions, maxBytes: maxChars * 3 + 1 }); if (read.code !== 0) return { head, files: [], error: firstProblemLine(read) }; if (read.truncated || read.stdout.length > maxChars) { tooLarge.push(file); @@ -368,9 +512,13 @@ export async function writeReposJson( text: string, message: string, signal?: AbortSignal, + layout: ProjectLayout = "standalone", ): Promise<{ committed: boolean; head: string | null; message: string | null }> { - await writeFile(path.join(dir, REPOS_FILE), text, "utf8"); - await gitIn(dir, ["add", "--", REPOS_FILE], { + const prefix = projectDefinitionPrefix(layout); + const relative = `${prefix}${REPOS_FILE}`; + await mkdir(path.join(dir, prefix.length > 0 ? prefix : "."), { recursive: true }); + await writeFile(path.join(dir, relative), text, "utf8"); + await gitIn(dir, ["add", "--", relative], { timeoutMs: GIT_TIMEOUT_MS, ...(signal === undefined ? {} : { signal }), }); @@ -393,11 +541,30 @@ export async function pushGuidance( workspaceBbPath: string, threadId: string, signal?: AbortSignal, + definitionBase?: string | null, +): Promise<{ ok: boolean; ref: string | null; message: string }> { + const layout = await detectProjectLayout(projectSourcePath, signal); + return layout === "primary" + ? pushGuidancePrimary(projectSourcePath, workspaceBbPath, threadId, signal, definitionBase ?? null) + : pushGuidanceStandalone(projectSourcePath, workspaceBbPath, threadId, signal); +} + +async function pushGuidanceStandalone( + projectSourcePath: string, + workspaceBbPath: string, + threadId: string, + signal?: AbortSignal, ): Promise<{ ok: boolean; ref: string | null; message: string }> { const safeId = threadId.replace(/[^A-Za-z0-9._-]+/gu, "-").slice(0, 60); const ref = `refs/heads/guidance-${safeId}`; const options = { timeoutMs: FETCH_TIMEOUT_MS, ...(signal === undefined ? {} : { signal }) }; + // Commit whatever the agent left uncommitted. Publishing must not depend on + // the agent remembering a `git commit`: in the primary layout `.bb/` has no + // git repo at all, and the tool has to mean the same thing in both layouts. + await gitIn(workspaceBbPath, ["add", "-A"], options); + await commitAll(workspaceBbPath, `Guidance from ${threadId}`, signal); + const head = await gitLine(workspaceBbPath, ["rev-parse", "HEAD"], options); if (head === null) { return { ok: false, ref: null, message: `${workspaceBbPath} is not a git repo.` }; @@ -416,6 +583,82 @@ export async function pushGuidance( }; } +/** + * Publish the workspace's `.bb` edits as a `guidance-` branch of the + * primary repo, without touching the default branch. + * + * The default branch is reviewed like code, so it is not fast-forwarded here. + * A throwaway worktree at the default branch gets the workspace's `.bb` + * content in place of the checkout's, one commit is made on it, and the ref is + * written into the canonical checkout. A person (or BB-108) turns that branch + * into a pull request; the default branch only ever moves by merge. + */ +async function pushGuidancePrimary( + projectSourcePath: string, + workspaceBbPath: string, + threadId: string, + signal?: AbortSignal, + definitionBase?: string | null, +): Promise<{ ok: boolean; ref: string | null; message: string }> { + const ref = guidanceRef(threadId); + const options = { timeoutMs: FETCH_TIMEOUT_MS, ...(signal === undefined ? {} : { signal }) }; + const base = await resolveDefaultBranchRef(projectSourcePath, signal); + if (base === null) { + return { ok: false, ref: null, message: `${projectSourcePath} has no default branch to publish against.` }; + } + const warnings: string[] = []; + // A `.bb/` change merged on the default branch after this workspace was + // provisioned would be reverted by replacing `.bb/` wholesale, so say so + // loudly. The guidance branch is still published; the reviewer sees it. + if (definitionBase !== undefined && definitionBase !== null) { + const baseTree = await gitLine(projectSourcePath, ["rev-parse", `${definitionBase}:${PROJECT_SOURCE_DIR}`], options); + const headTree = await gitLine(projectSourcePath, ["rev-parse", `${base}:${PROJECT_SOURCE_DIR}`], options); + if (baseTree !== null && headTree !== null && baseTree !== headTree) { + warnings.push( + ".bb/ on the default branch has changed since this workspace was provisioned; check the guidance branch does not revert those changes.", + ); + } + } + const worktree = await mkdtemp(path.join(tmpdir(), "bb-guidance-")); + try { + const added = await gitIn(projectSourcePath, ["worktree", "add", "--detach", "--force", worktree, base], options); + if (added.code !== 0) return { ok: false, ref: null, message: firstProblemLine(added) }; + await rm(path.join(worktree, PROJECT_SOURCE_DIR), { recursive: true, force: true }); + await cp(workspaceBbPath, path.join(worktree, PROJECT_SOURCE_DIR), { recursive: true }); + await gitIn(worktree, ["add", "-A", "--", PROJECT_SOURCE_DIR], options); + const staged = await gitIn(worktree, ["diff", "--cached", "--quiet"], options); + let sha: string | null; + if (staged.code === 0) { + // Nothing changed relative to the default branch: publish the base so + // the ref exists, and say so rather than failing. + sha = await gitLine(projectSourcePath, ["rev-parse", base], options); + if (sha === null) return { ok: false, ref: null, message: `Could not resolve ${base}.` }; + await gitIn(projectSourcePath, ["update-ref", ref, sha], options); + return { ok: true, ref, message: [`No .bb changes to publish; ${ref} points at the default branch.`, ...warnings].join(" ") }; + } + const commit = await commitAll(worktree, `Publish guidance from ${threadId}`, signal); + if (!commit.committed || commit.head === null) { + return { ok: false, ref: null, message: commit.message ?? "Could not commit the guidance." }; + } + sha = commit.head; + const updated = await gitIn(projectSourcePath, ["update-ref", ref, sha], options); + if (updated.code !== 0) return { ok: false, ref: null, message: firstProblemLine(updated) }; + // Publish the branch to origin so a person (or BB-108) can open the pull + // request without finding the host-data path. Best-effort: a protected or + // unreachable remote leaves the branch in the local canonical checkout. + const pushed = await gitIn(projectSourcePath, ["push", "--quiet", "origin", `${ref}:${ref}`], options); + const pushedNote = pushed.code === 0 ? " It is pushed to origin." : " Push it to origin to open a pull request."; + return { + ok: true, + ref, + message: [`Published ${sha.slice(0, 8)} to ${ref} in ${projectSourcePath}.${pushedNote}`, ...warnings].join(" "), + }; + } finally { + await gitIn(projectSourcePath, ["worktree", "remove", "--force", worktree], options).catch(() => undefined); + await rm(worktree, { recursive: true, force: true }).catch(() => undefined); + } +} + /** The `guidance-` ref a thread's published commits land on. */ export function guidanceRef(threadId: string): string { const safeId = threadId.replace(/[^A-Za-z0-9._-]+/gu, "-").slice(0, 60); @@ -451,6 +694,27 @@ export async function mergeGuidance( file: string, text: string, signal?: AbortSignal, +): Promise { + const layout = await detectProjectLayout(projectSourcePath, signal); + if (layout === "primary") { + return { + ok: false, + sha: null, + branch: guidanceRef(threadId), + message: + "This project's .bb lives on the primary repo's default branch, which is reviewed like code. " + + `A lesson needs a pull request: open one from ${guidanceRef(threadId)} and merge it there.`, + }; + } + return mergeGuidanceStandalone(projectSourcePath, threadId, file, text, signal); +} + +async function mergeGuidanceStandalone( + projectSourcePath: string, + threadId: string, + file: string, + text: string, + signal?: AbortSignal, ): Promise { const branch = guidanceRef(threadId); const options = { timeoutMs: GIT_TIMEOUT_MS, ...(signal === undefined ? {} : { signal }) }; @@ -490,3 +754,338 @@ export async function mergeGuidance( if (newHead === null) return refuse(`Fast-forwarded ${branch}, but the new HEAD could not be read.`); return { ok: true, sha: newHead, branch, message: `Fast-forwarded ${current} to ${newHead.slice(0, 8)} from ${branch}.` }; } + +/** + * Copy a project-definition tree, skipping any nested `.git`. + * + * The standalone checkout is a git repo; the destination is a directory inside + * the primary repo, which owns the git history. A `.git` copied in would make + * git treat `.bb/` as a nested repository and the `git add` would produce a + * gitlink instead of the files. + */ +async function copyDefinitionTree(src: string, dest: string): Promise { + await cp(src, dest, { + recursive: true, + filter: (source) => path.basename(source) !== ".git", + }); +} + +export interface AdoptProjectSourceArgs { + /** The standalone `.bb` checkout whose tree is copied into the primary. */ + standalonePath: string; + /** URL or local path of the primary repo. */ + primaryUrl: string; + /** Where the primary clone lives; cloned when absent, fetched when present. */ + primaryPath: string; + /** Branch the seed commit is pushed on. */ + branch: string; + /** Attempt `gh pr create` after the push. */ + openPullRequest: boolean; + signal?: AbortSignal; +} + +export interface AdoptProjectSourceResult { + status: "already" | "pull-request" | "failed"; + message: string; + prUrl: string | null; + warnings: string[]; + primaryPath: string; + defaultBranch: string | null; +} + +/** + * Adopt a standalone `.bb` repo into a primary repo (BB-106, Case A). + * + * Idempotent and per-machine, exactly as the ticket requires: + * + * - The primary's default branch already carries `.bb/` — a previous run, or a + * teammate's merge — so nothing is copied; the caller re-points the project + * source at this checkout. + * - It does not — the standalone tree is copied to `.bb/` on `branch`, pushed, + * and a pull request is opened. The project source is re-pointed on a later + * run, once the merge has landed `.bb/` on the default branch. + * + * The standalone checkout keeps working throughout; this only ever adds a + * branch to the primary and never touches the default branch. + */ +export async function adoptProjectSource(args: AdoptProjectSourceArgs): Promise { + const warnings: string[] = []; + const options = { timeoutMs: FETCH_TIMEOUT_MS, ...(args.signal === undefined ? {} : { signal: args.signal }) }; + const fail = (message: string): AdoptProjectSourceResult => ({ + status: "failed", + message, + prUrl: null, + warnings, + primaryPath: args.primaryPath, + defaultBranch: null, + }); + + if (await isGitRepo(args.primaryPath)) { + const fetched = await gitIn(args.primaryPath, ["fetch", "--quiet", "origin"], options); + if (fetched.code !== 0) warnings.push(`Could not fetch the primary: ${firstProblemLine(fetched)}`); + } else { + const cloned = await cloneProjectSource(args.primaryUrl, args.primaryPath, args.signal); + if (!cloned.ok) return fail(cloned.message ?? `Could not clone ${args.primaryUrl}.`); + } + + const base = await resolveDefaultBranchRef(args.primaryPath, args.signal); + if (base === null) return fail(`${args.primaryPath} has no default branch to adopt into.`); + const defaultBranch = base.replace(/^refs\/(heads|remotes\/origin)\//u, ""); + const existing = await gitIn( + args.primaryPath, + ["cat-file", "-e", `${base}:${PROJECT_SOURCE_DIR}/${REPOS_FILE}`], + options, + ); + if (existing.code === 0) { + return { + status: "already", + message: `${args.primaryPath} already carries .bb/ on ${defaultBranch}; re-point the project source at this checkout.`, + prUrl: null, + warnings, + primaryPath: args.primaryPath, + defaultBranch, + }; + } + + const worktree = await mkdtemp(path.join(tmpdir(), "bb-adopt-")); + try { + const added = await gitIn( + args.primaryPath, + ["worktree", "add", "--detach", "--force", worktree, base], + options, + ); + if (added.code !== 0) return fail(firstProblemLine(added)); + await rm(path.join(worktree, PROJECT_SOURCE_DIR), { recursive: true, force: true }); + await copyDefinitionTree(args.standalonePath, path.join(worktree, PROJECT_SOURCE_DIR)); + await gitIn(worktree, ["add", "-A", "--", PROJECT_SOURCE_DIR], options); + const commit = await commitAll(worktree, "Move the project definition into .bb/ on the primary repo", args.signal); + if (!commit.committed || commit.head === null) { + return fail(commit.message ?? "There was nothing to adopt."); + } + const head = commit.head; + const recorded = await gitIn(args.primaryPath, ["update-ref", `refs/heads/${args.branch}`, head], options); + if (recorded.code !== 0) return fail(firstProblemLine(recorded)); + + const pushed = await gitIn( + args.primaryPath, + ["push", "--quiet", "--force-with-lease", "origin", `refs/heads/${args.branch}`], + options, + ); + if (pushed.code !== 0) { + warnings.push(`The adopt branch was created locally but not pushed: ${firstProblemLine(pushed)}`); + } + + let prUrl: string | null = null; + if (args.openPullRequest && pushed.code === 0) { + const title = "Move the project definition into .bb/ on the primary repo"; + const body = + "Adopts the standalone `.bb` project definition into `.bb/` on this repo's default branch (BB-106). " + + "`repos.json` lists only the other repos; the primary is derived from this repo's origin. " + + "Merge this to make the project source this repo."; + const pr = await run( + "gh", + [ + "pr", + "create", + "--base", + defaultBranch, + "--head", + args.branch, + "--title", + title, + "--body", + body, + ], + { cwd: args.primaryPath, timeoutMs: FETCH_TIMEOUT_MS, ...(args.signal === undefined ? {} : { signal: args.signal }) }, + ); + if (pr.code === 0) prUrl = pr.stdout.trim().split("\n").pop() ?? null; + else warnings.push(`Could not open the pull request: ${firstProblemLine(pr)}`); + } + + return { + status: "pull-request", + message: prUrl === null + ? `Copied the standalone .bb tree to ${args.branch} in ${args.primaryPath}. Push it and open a pull request, then run adopt again.` + : `Opened ${prUrl}. Merge it, then run adopt again to re-point the project source.`, + prUrl, + warnings, + primaryPath: args.primaryPath, + defaultBranch, + }; + } finally { + await gitIn(args.primaryPath, ["worktree", "remove", "--force", worktree], options).catch(() => undefined); + await rm(worktree, { recursive: true, force: true }).catch(() => undefined); + } +} + +export interface PreparePrimaryProjectSourceArgs { + /** URL or local path of the primary repo. */ + url: string; + /** Where the primary clone lives; cloned when absent, fetched when present. */ + path: string; + /** The other repos to seed `.bb/repos.json` with when it is absent. */ + seed: ReposFile; + /** Branch the seed commit is published on. */ + branch: string; + openPullRequest: boolean; + signal?: AbortSignal; +} + +export interface PreparePrimaryProjectSourceResult { + ok: boolean; + layout: ProjectLayout; + seeded: boolean; + prUrl: string | null; + warnings: string[]; + message: string; +} + +/** + * Stand up a project whose source is a primary repo (BB-106, item 3). + * + * Clone the repo if it is not on this machine, then: + * + * - it already carries `.bb/` (or is a standalone `.bb` repo) — use it as it + * is; + * - it does not — seed `.bb/` with `AGENTS.md`, `skills/` and a `repos.json` + * listing the other repos, commit it on the checkout's default branch so the + * first thread can provision, and publish a `guidance-` branch with a + * pull request so the seed is reviewed like code. + */ +export async function preparePrimaryProjectSource( + args: PreparePrimaryProjectSourceArgs, +): Promise { + const warnings: string[] = []; + const options = { timeoutMs: FETCH_TIMEOUT_MS, ...(args.signal === undefined ? {} : { signal: args.signal }) }; + const fail = (message: string): PreparePrimaryProjectSourceResult => ({ + ok: false, + layout: "standalone", + seeded: false, + prUrl: null, + warnings, + message, + }); + + if (await isGitRepo(args.path)) { + const fetched = await gitIn(args.path, ["fetch", "--quiet", "origin"], options); + if (fetched.code !== 0) warnings.push(`Could not fetch the primary: ${firstProblemLine(fetched)}`); + } else { + const cloned = await cloneProjectSource(args.url, args.path, args.signal); + if (!cloned.ok) return fail(cloned.message ?? `Could not clone ${args.url}.`); + } + + const base = await resolveDefaultBranchRef(args.path, args.signal); + if (base === null) return fail(`${args.path} has no default branch.`); + const defaultBranch = base.replace(/^refs\/(heads|remotes\/origin)\//u, ""); + + const rootDefinition = await gitIn(args.path, ["cat-file", "-e", `${base}:${REPOS_FILE}`], options); + if (rootDefinition.code === 0) { + return { + ok: true, + layout: "standalone", + seeded: false, + prUrl: null, + warnings, + message: `${args.path} is a standalone .bb repo.`, + }; + } + const existing = await gitIn( + args.path, + ["cat-file", "-e", `${base}:${PROJECT_SOURCE_DIR}/${REPOS_FILE}`], + options, + ); + if (existing.code === 0) { + return { + ok: true, + layout: "primary", + seeded: false, + prUrl: null, + warnings, + message: `${args.path} already carries .bb/.`, + }; + } + + // Build the seed commit in a detached worktree at the default-branch base, + // never on the checkout's current branch: a checkout the user points at may + // sit on a feature branch, and seeding there would publish their unpushed + // work on the seed branch. `adoptProjectSource` uses the same pattern. + const worktree = await mkdtemp(path.join(tmpdir(), "bb-seed-")); + let seedSha: string | null = null; + try { + const added = await gitIn(args.path, ["worktree", "add", "--detach", "--force", worktree, base], options); + if (added.code !== 0) return fail(firstProblemLine(added)); + const seeded = await seedPrimaryDefinition(worktree, args.seed, args.signal); + warnings.push(...seeded.warnings); + if (!seeded.bootstrapped) return fail(warnings.join("; ") || "Could not seed .bb/."); + seedSha = await gitLine(worktree, ["rev-parse", "HEAD"], options); + } finally { + await gitIn(args.path, ["worktree", "remove", "--force", worktree], options).catch(() => undefined); + await rm(worktree, { recursive: true, force: true }).catch(() => undefined); + } + if (seedSha === null) return fail("Could not commit the seed."); + + const branchCreated = await gitIn(args.path, ["update-ref", `refs/heads/${args.branch}`, seedSha], options); + if (branchCreated.code !== 0) warnings.push(`Could not create ${args.branch}: ${firstProblemLine(branchCreated)}`); + const pushed = await gitIn( + args.path, + ["push", "--quiet", "--force-with-lease", "origin", `refs/heads/${args.branch}`], + options, + ); + if (pushed.code !== 0) { + warnings.push(`The seed branch was committed locally but not pushed: ${firstProblemLine(pushed)}`); + } + + // Make the seed visible to provisioning without touching a feature branch: + // fast-forward the checked-out default branch, or advance the local default + // branch when it is not checked out and the seed descends from it. A diverged + // default branch is left alone — the seed waits on its pull request. + const checkedOut = await gitLine(args.path, ["rev-parse", "--abbrev-ref", "HEAD"], options); + if (checkedOut === defaultBranch) { + const merged = await gitIn(args.path, ["merge", "--ff-only", "--quiet", seedSha], options); + if (merged.code !== 0) warnings.push(`Could not fast-forward ${defaultBranch} to the seed: ${firstProblemLine(merged)}`); + } else { + const localRef = `refs/heads/${defaultBranch}`; + const present = await refExists(args.path, localRef, args.signal); + const ancestor = + present && (await gitIn(args.path, ["merge-base", "--is-ancestor", localRef, seedSha], options)).code === 0; + if (!present || ancestor) { + const updated = await gitIn(args.path, ["update-ref", localRef, seedSha], options); + if (updated.code !== 0) warnings.push(`Could not advance ${defaultBranch} to the seed: ${firstProblemLine(updated)}`); + } else { + warnings.push(`${defaultBranch} has diverged from origin; the seed is only on ${args.branch} until it merges.`); + } + } + + let prUrl: string | null = null; + if (args.openPullRequest && pushed.code === 0) { + const pr = await run( + "gh", + [ + "pr", + "create", + "--base", + defaultBranch, + "--head", + args.branch, + "--title", + "Add .bb/ to the primary repo", + "--body", + "Seeds `.bb/` (the repo set, cross-repo guidance and project skills) on the primary repo's default branch (BB-106).", + ], + { cwd: args.path, timeoutMs: FETCH_TIMEOUT_MS, ...(args.signal === undefined ? {} : { signal: args.signal }) }, + ); + if (pr.code === 0) prUrl = pr.stdout.trim().split("\n").pop() ?? null; + else warnings.push(`Could not open the seed pull request: ${firstProblemLine(pr)}`); + } + + return { + ok: true, + layout: "primary", + seeded: true, + prUrl, + warnings, + message: prUrl === null + ? `Seeded .bb/ in ${args.path} on ${args.branch}; open a pull request to land it.` + : `Opened ${prUrl}.`, + }; +} diff --git a/plugins/multi-repo/workspace.ts b/plugins/multi-repo/workspace.ts index 91988506..7da167a1 100644 --- a/plugins/multi-repo/workspace.ts +++ b/plugins/multi-repo/workspace.ts @@ -17,14 +17,16 @@ * leaves the object store in the source repo, outside `cwd`, where every * commit would fail on a sandboxed write. */ -import { mkdir, readdir, rm, writeFile, access } from "node:fs/promises"; +import { mkdir, mkdtemp, readdir, rm, writeFile, access, cp } from "node:fs/promises"; +import { tmpdir } from "node:os"; import path from "node:path"; import { experimental_killProcessesWithCwdUnder as killProcessesUnder } from "@get-bb/plugin-sdk/host"; import { cacheDefaultBranch, cacheHasBranch, ensureCache, type CacheProgress } from "./cache.js"; import { git, gitIn, gitLine, firstProblemLine } from "./git.js"; import type { RepoRequest } from "./contract.js"; import type { WorkspaceRepo } from "./layout.js"; -import { PROJECT_SOURCE_DIR, assertSafeSegment } from "./paths.js"; +import { PROJECT_SOURCE_DIR, assertSafeSegment, type ProjectLayout } from "./paths.js"; +import { resolveDefaultBranchRef } from "./source.js"; export const WORKSPACES_DIR_NAME = "workspaces"; export const STATE_DIR_NAME = "workspace-state"; @@ -268,6 +270,13 @@ export interface ProvisionArgs { dataDir: string; pathKey: string; projectSourcePath: string; + /** Which shape the project source has; decides how `.bb/` is materialized. */ + layout?: ProjectLayout; + /** + * The directory of the primary work clone inside the workspace. Present only + * for the `primary` layout, and the repo `.bb/` is copied out of. + */ + primaryDir?: string | null; repos: readonly RepoRequest[]; branchName: string; fetchTtlMs: number; @@ -276,9 +285,85 @@ export interface ProvisionArgs { signal?: AbortSignal; } +/** + * Materialize `/.bb` from the primary work clone. + * + * Core reads `.bb/AGENTS.md` and `.bb/skills` from the workspace root only + * (`host.read_workspace_agent_context` joins them onto `workspacePath`), so + * the primary's `.bb/` cannot be read in place at `//.bb` + * — it is copied to `/.bb`. A plain copy, not a clone: `.bb` is a + * directory in the primary repo now, and the workspace clone of the primary is + * on the thread branch, where the agent's code work lives. Keeping `.bb` a + * separate copy is what lets guidance be published from it without dragging + * the thread's code commits along. + */ +export async function materializePrimaryDefinition(args: { + root: string; + projectSourcePath: string; + primaryDir?: string | null; + signal?: AbortSignal; +}): Promise<{ repo: WorkspaceRepo; base: string | null }> { + const dest = path.join(args.root, PROJECT_SOURCE_DIR); + const remote = args.primaryDir === undefined || args.primaryDir === null + ? args.projectSourcePath + : path.join(args.root, args.primaryDir); + const options = { timeoutMs: GIT_TIMEOUT_MS, ...(args.signal === undefined ? {} : { signal: args.signal }) }; + const failed = (message: string): { repo: WorkspaceRepo; base: string | null } => ({ + repo: { + dir: PROJECT_SOURCE_DIR, + path: dest, + branch: "", + baseBranch: "", + remote, + status: "failed", + message: message.slice(0, 600), + }, + base: null, + }); + const ready = (base: string | null): { repo: WorkspaceRepo; base: string | null } => ({ + repo: { dir: PROJECT_SOURCE_DIR, path: dest, branch: "default", baseBranch: "default", remote, status: "ready" }, + base, + }); + // Idempotent per path key: a re-entered `create()` leaves the copy alone. + if (await exists(path.join(dest, "repos.json"))) return ready(null); + await rm(dest, { recursive: true, force: true }); + + // Read the default branch's committed tip, not the canonical checkout's + // working tree: the plugin's own seed and `repos.json` commits live on the + // local default branch before they are pushed, while an uncommitted edit in + // the checkout is not the project's. A throwaway shared clone gives exactly + // that tree without a worktree lock that two concurrent thread starts would + // contend on; it runs host-side, outside the agent's sandbox. + const base = await resolveDefaultBranchRef(args.projectSourcePath, args.signal); + if (base === null) return failed(`${args.projectSourcePath} is not a git repo with a default branch to read.`); + const baseSha = await gitLine(args.projectSourcePath, ["rev-parse", `${base}^{commit}`], options); + const temp = await mkdtemp(path.join(tmpdir(), "bb-definition-")); + try { + const cloned = await git( + ["clone", "--quiet", "--shared", "--no-checkout", args.projectSourcePath, temp], + options, + ); + if (cloned.code !== 0) return failed(firstProblemLine(cloned)); + const checked = await gitIn(temp, ["checkout", base, "--", PROJECT_SOURCE_DIR], options); + if (checked.code !== 0) { + return failed(`${args.projectSourcePath} does not carry .bb/ on the default branch; merge the .bb pull request first.`); + } + const source = path.join(temp, PROJECT_SOURCE_DIR); + if (!(await exists(source))) { + return failed(`${args.projectSourcePath} does not carry .bb/ on the default branch; merge the .bb pull request first.`); + } + await cp(source, dest, { recursive: true, filter: (entry) => path.basename(entry) !== ".git" }); + } catch (error) { + return failed(error instanceof Error ? error.message : String(error)); + } finally { + await rm(temp, { recursive: true, force: true }).catch(() => undefined); + } + return ready(baseSha); +} + export async function provisionWorkspace( args: ProvisionArgs, -): Promise<{ root: string; repos: WorkspaceRepo[] }> { +): Promise<{ root: string; repos: WorkspaceRepo[]; definitionBase: string | null }> { const root = workspaceRoot(args.dataDir, args.pathKey); await mkdir(root, { recursive: true }); @@ -291,14 +376,27 @@ export async function provisionWorkspace( } args.report?.step("Preparing the project definition"); - const source = await materializeProjectSource({ - root, - projectSourcePath: args.projectSourcePath, - ...(args.signal === undefined ? {} : { signal: args.signal }), - }); + let source: WorkspaceRepo; + let definitionBase: string | null = null; + if (args.layout === "primary") { + const materialized = await materializePrimaryDefinition({ + root, + projectSourcePath: args.projectSourcePath, + primaryDir: args.primaryDir ?? null, + ...(args.signal === undefined ? {} : { signal: args.signal }), + }); + source = materialized.repo; + definitionBase = materialized.base; + } else { + source = await materializeProjectSource({ + root, + projectSourcePath: args.projectSourcePath, + ...(args.signal === undefined ? {} : { signal: args.signal }), + }); + } repos.push(source); - return { root, repos }; + return { root, repos, definitionBase }; } export async function addWorkspaceRepo(args: {