feat(multi-repo): projects that own several repositories
Lets a project own several repositories, so one workspace checks them all out on one branch and one agent sees all of them.
Ships behind multi-repo-projects (PostHog). Flag off is byte-identical to today — asserted in a test, not assumed.
What you get
Add source folders to a project, and every workspace started in it gets one container with a checkout per folder on one branch name. A folder picker in the right sidebar re-roots Files and Changes. The CLI takes --project repeatedly and prints a repos array.
The model change, and why it's here
projects.repo_path is NOT NULL, so a project is required to be a repository. A multi-repo project was therefore its primary repo renamed — which means one repo could anchor exactly one project, ever, and deleting a folder-project could strand a live workspace.
Rather than change what a project is, this adds the container above it:
project_groups | the name — what a user calls a Project |
project_group_members | ordered membership, position 0 is the primary |
projects | unchanged — still one repo at one path |
All 173 readers of repo_path keep their invariant. ON DELETE restrict on the member's project reference is load-bearing: the database refuses to delete a repository a group still references, so the cascade that could strand a checkout is unreachable rather than guarded. Verified against a real SQLite db, not just the tests.
Vocabulary follows Codex, whose model this matches ({ id, name, rootPaths[] }, same folder in any number of projects — read out of its own state file): the container is a Project, members are Source folders, a repository stops being user-facing.
Review order
c5e52d678— the single-root fixes. Smallest, and explains the container/primary split everything else depends on.c22dacbdc— schema + router. Ships dark; nothing reads it.3829f7757— the UI. The only commit that can change existing behaviour.
Known gaps, deliberately not fixed here
- PR creation still addresses the primary only.
pullRequests.createForWorkspaceand the PR-metadata git procedures never got therepoargument.workspace_pull_requestsis already many-to-many, so this is additive later. - Folder selection doesn't survive restart. No host-side per-workspace UI-state store exists; it's renderer state scoped to the route.
- No folder rename in group mode —
projectGroupshas no member-rename procedure, so the menu item is hidden. The old path has it. project_group_memberscarries norepo_url, so portability into a cloud sandbox depends on joining to the member's project row. Worth adding before cloud depends on it.- Adding a source folder on a remote host falls back to a lookup, since
project.createismachineOnly. create-worktrees.test.ts:190is flaky — failed 1 of 3 runs.
Verification
typecheck 42/42. Desktop 4022 pass / 36 fail — 2 known terminal-ws-transport, 34 git/shell integration tests that pass in isolation and fail only under full-suite parallel load (spot-checked). host-service failures match the branch baseline, which swings ~32–44 run to run in the GitWatcher/timing family. check:i18n clean, all 16 locales translated. Zero comments added across the diff.
Context
- Competitive scan — what ten other tools do
- Implementation — blast radius, surface by surface
- Projects as containers — the model argument and Codex's schema
https://claude.ai/code/session_011GHUNf4G6WuLNxx4ivYAon
Summary by cubic
Lets a project own several repositories, so one workspace checks them all out into one container on one branch, and one agent sees all of them. Ships behind the multi-repo-projects flag (defaulted on in dev builds); with the flag off, behavior is byte-identical to today, asserted in a test.
Host changes
- Adds
project_groupsandproject_group_membersabove the unchangedprojectstable (plusproject_folders,workspace_repos, and a backfill that materializes position-0 rows);ON DELETE restrictmakes the cascade that could strand a live checkout unreachable. workspaces.createchecks every folder of a Project out together and walks them all on delete; creation is all-or-nothing — the first failed folder rolls back the worktrees, branches, and container, so a partial workspace never reaches the database.- Lifecycle scripts chain per checkout in folder order, and
git.*procedures take an optionalrepothat defaults to the primary. - The filesystem sandbox and git watching address the container and each checkout separately (
resolveWorkspaceRootvs primary worktree, per-checkout watch keying), so saving works in any folder and per-folder status stays live. Agent usage attributes to the container, andproject.removeis refused while another project's live workspace still checks that repo out. - Cloud sandboxes are seeded with the local shape — one workspace row with
root_pathset, oneworkspace_reposrow per checkout — and boxes seeded the old way are converged on boot.
Desktop and CLI
- The project chooser reads through one
useGroupedProjectsoverlay, so the sidebar, new-workspace composer, and recent-project lists all show the container's name and a repo-count badge. A repository adopted as another Project's first source folder resolves to the group that owns several folders. The CLI takes--projectrepeatedly and prints areposarray. - Settings split into a Project page (name, icon, source folders) and per-repository pages; a folder picker in the workspace sidebar re-roots Files, Changes, and diffs. The Run button starts a run script per folder in parallel, and the Changes pill dots when another folder is dirty. Folder picker rows and projects carry their checkouts' GitHub avatars, falling back to a letter tile when a project assembled from local folders has no remote.
Known gaps: PR creation still addresses the primary repository only, and folder selection lives in renderer memory, so it does not survive a restart. Cloud repositories must be connected GitHub repos in one installation, so a local-only source folder has no cloud counterpart.