feat(multi-repo): projects that own several repositories

Draft
HA
2mo ago
+14738-755

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_groupsthe name — what a user calls a Project
project_group_membersordered membership, position 0 is the primary
projectsunchanged — 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

  1. c5e52d678 — the single-root fixes. Smallest, and explains the container/primary split everything else depends on.
  2. c22dacbdc — schema + router. Ships dark; nothing reads it.
  3. 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.createForWorkspace and the PR-metadata git procedures never got the repo argument. workspace_pull_requests is 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 — projectGroups has no member-rename procedure, so the menu item is hidden. The old path has it.
  • project_group_members carries no repo_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.create is machineOnly.
  • create-worktrees.test.ts:190 is 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

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_groups and project_group_members above the unchanged projects table (plus project_folders, workspace_repos, and a backfill that materializes position-0 rows); ON DELETE restrict makes the cascade that could strand a live checkout unreachable.
  • workspaces.create checks 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 optional repo that defaults to the primary.
  • The filesystem sandbox and git watching address the container and each checkout separately (resolveWorkspaceRoot vs primary worktree, per-checkout watch keying), so saving works in any folder and per-folder status stays live. Agent usage attributes to the container, and project.remove is 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_path set, one workspace_repos row per checkout — and boxes seeded the old way are converged on boot.

Desktop and CLI

  • The project chooser reads through one useGroupedProjects overlay, 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 --project repeatedly and prints a repos array.
  • 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.