Skip to content

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

Draft
harshithmullapudi wants to merge 14 commits into
mainfrom
multi-repo-projects-v0
Draft

harshithmullapudi wants to merge 14 commits into
mainfrom
multi-repo-projects-v0

Conversation

@harshithmullapudi

@harshithmullapudi harshithmullapudi commented Sep 21, 2026 •

Copy link
Copy Markdown
Collaborator

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

  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.

Written for commit f22770d. Summary will update on new commits.

Review in cubic

@coderabbitai

coderabbitai Bot commented Sep 21, 2026

Copy link
Copy Markdown
Contributor

Important

Draft PR not reviewed

Draft PRs are not automatically reviewed by default.

  • Trigger a manual review

To automatically review draft PRs, update your CodeRabbit configuration:

reviews:
  auto_review:
    drafts: true

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@expo

expo Bot commented Sep 21, 2026 •

Copy link
Copy Markdown

No installed development build can load this pull request as an update: its native fingerprint is 3e54f10bb2b3 and no development build has it. Add the mobile-build label to build one.

@github-actions

Copy link
Copy Markdown
Contributor

🚀 Preview Deployment

🔗 Preview Links

Service Status Link
Neon Database (Neon) ✅ View Branch
Vercel API (Vercel) ✅ Open Preview
Vercel Web (Vercel) ✅ Open Preview
Vercel Marketing (Vercel) ✅ Open Preview
Vercel Admin (Vercel) ✅ Open Preview
Vercel Docs (Vercel) ✅ Open Preview

Preview updates automatically with new commits

…pace

A project keeps an ordered folder list (`project_folders`), and creating a
workspace checks every folder out into one container, one worktree per
folder, recorded in `workspace_repos`. Position 0 stays mirrored onto
`projects.repo_path` and `workspaces.worktree_path`, so every single-repo
surface keeps reading exactly the row it always did and a project with one
repository behaves identically either way.

`git.*` procedures take an optional `repo` naming a folder; omitting it
means the primary. A backfill materializes the position-0 rows that every
existing project and workspace implicitly had.

Creation is all-or-nothing: the first failed folder rolls back the
worktrees already added, deletes the branches the run minted, and removes
the container, so a partial workspace can never reach the database.
Deletion walks every checkout and only removes the container once all of
them are gone.

Behind the `multi-repo-projects` flag, which gates adding a second
repository, not what the host can store.
They are published as Superset pages and linked from the PR. Keeping ~2,900
lines of HTML in the diff buries the code, and the implementation page's
claim that file read/write is unaffected was disproved by the workspace-root
fix in this branch.

Claude-Session: https://claude.ai/code/session_011GHUNf4G6WuLNxx4ivYAon
…ary checkout

A multi-repo workspace opens at a container whose secondary checkouts are
siblings of the primary, so anything scoped to `worktreePath` rejected them.
Saving any file outside the primary failed with "Path is outside workspace
root".

Split the two meanings that were sharing one accessor: `resolveWorkspaceRoot`
returns the container for the filesystem sandbox, `resolveWorkspacePrimaryWorktree`
returns the primary for anything reaching for git. Pointing the single accessor
at the container fixed saving but broke git watching, because `watchPath`
accepts only the exact service root.

Also: attribute agent usage by container so work in a secondary is not
misattributed, give `listGitIgnoredDirs` a ceiling so it cannot walk out of a
container into an enclosing repo, and refuse `project.remove` while another
project's live workspace still checks that repo out.

Claude-Session: https://claude.ai/code/session_011GHUNf4G6WuLNxx4ivYAon
`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, and one repo could
anchor exactly one project. Codex models a project as its own identity with a
name and an ordered folder list, and lets the same folder back any number of
projects.

Add that container rather than changing what a project is: `project_groups`
holds the name, `project_group_members` holds the ordered membership, and
`projects` is untouched — all 173 readers of `repo_path` keep their invariant.

`ON DELETE restrict` on the member's project reference is the load-bearing
part: the database refuses to delete a repository a group still references,
so the cascade that could strand a live workspace's checkout is unreachable
rather than guarded.

Ships dark. Nothing reads these tables yet.

Claude-Session: https://claude.ai/code/session_011GHUNf4G6WuLNxx4ivYAon
Read projects from `projectGroups` when the flag is on: create a Project and
add every source folder identically, edit its folders in settings, and show a
repo-count badge in the sidebar. Flag off reads the repository list exactly as
before — the sidebar overlay is simply not applied, and the equivalence for a
backfilled single-member group is asserted in a test rather than assumed.

Follows Codex's vocabulary: the container is a "Project", its members are
"Source folders", and a repository stops being user-facing.

Claude-Session: https://claude.ai/code/session_011GHUNf4G6WuLNxx4ivYAon
The project container was only applied in the sidebar, so every other
project chooser still listed repositories: a Project over two folders
appeared as its primary repo's name, and its other folders appeared as
projects of their own. The overlay moves out of the sidebar hook into
`useGroupedProjects`, and the new-workspace composer, the recent-project
list behind the tasks and automations pickers, and the sidebar all read
through it.

A repository adopted as another Project's first source folder is the
primary of that Project *and* of its own backfilled one — `project.create`
reuses the row for an already-imported path — so the group that owns
several folders now wins the tie. Picking the older backfilled group
rendered the wrong name and, host-side, checked out one folder instead of
all of them.

The picker's items key on project id so same-named projects stay
individually selectable, with a filter that scores only the name: ids in
the searchable value made every hex-ish query subsequence-match a UUID.
Settings listed repositories, so a Project appeared under its primary
repo's name and its other folders sat beside it as peers — the page you
landed on mixed the container's settings (name, source folders) with one
repository's (location, sparse checkout, lifecycle scripts).

The list now shows one row per Project with its folders indented beneath,
and the two kinds of settings split: `/settings/projects/group/$groupId`
owns the name, icon, source folders, and the settings that describe the
whole checkout — branch prefix, naming instructions, the container's base
directory — while a folder row opens the repository page it always was,
headed by a link back to the Project it belongs to. A Project over one
folder still renders as the single row it did before, and its page keeps
everything.

Deleting a Project removes the container only; `ON DELETE restrict` on the
membership rows means its repositories could not have followed it anyway.
Adding a second folder from a single-folder page lands on the Project page
that now owns those settings, rather than watching them unmount in place.
…rap each

`workspaces.create` resolved folders from `project_folders`, which for a
project assembled out of several repositories holds only its own — so
creating a workspace from it produced a single-repo checkout and none of
the folders the settings page listed. It now appends the rest of the
Project's membership, additively: a backfilled group can hold fewer members
than the folder list it came from, so a member whose repository a folder
row already covers is skipped rather than replacing it, and the primary
checkout takes the directory name its membership row advertises.

Lifecycle scripts follow the same shape. Setup and teardown resolve per
checkout and chain in folder order in one shell, so each repository
bootstraps by its own conventions and a failure still short-circuits the
rest. A workspace whose only script is the primary's resolves to exactly
the command it did before. The agent is sent back to the primary checkout
after a chained setup, since the chain ends wherever the last folder ran.
Dogfooding the container needs the flag on, and the local dev account is
not in the PostHog release condition — the same reason the plugins flag
falls back to the dev build a few lines above it.
Three surfaces still described the primary checkout only.

The Run button ran the project's `run` script from the workspace's primary
worktree. It now resolves one per checkout and starts them together —
`cd <checkout> && …` per folder, backgrounded and waited on, because a run
script is usually a server and chaining would leave all but the first
unstarted. One folder resolves to the command a single-repo workspace has
always run.

`GitWatcher` attached one watcher per workspace, off `workspaces.worktree_path`,
so editing a file in any other checkout emitted no `git:changed` at all —
per-folder status went stale until a window focus or the 30s refetch. Watch
state is now keyed per checkout (`.git/` watcher, worktree subscription and
gitignored-dir set each), while batching, debouncing and the emitted event
stay per workspace: consumers still see one coalesced signal. A secondary
checkout contributes a broad refresh rather than paths, which are read as
primary-relative downstream.

The top-bar Changes pill describes the selected folder, so a dirty sibling
was invisible. It now carries a dot when another folder has changes, and
renders even when the selected folder is clean.
…pace is stored

A workspace over several repositories is one `workspaces` row — `worktree_path`
the primary checkout, `root_path` the container — plus a `workspace_repos` row
per checkout. That is what the repo picker, per-folder git status, the `repo`
argument on `git.*`, the Run button and the watcher all read. The sandbox seed
wrote a different shape: a project AND a workspace row per repository, the
secondaries under ids derived from the workspace's, and no `workspace_repos`
rows at all — so every one of those surfaces degraded to the primary checkout
on a cloud box, and per-checkout watching attached once.

The box now writes the local shape from the same environment it already reads:
one workspace row with `root_path` set when there is more than one repository,
one project per repository (a box has no base repo, so the checkout is still
the repo), and one repo row per checkout in the environment's order, carrying
`baseBranch` where the control plane sent one. Nothing in the sandbox contract
moves; `SUPERSET_SANDBOX_REPOSITORIES` already carried everything needed.

Boxes seeded the old way are in the wild, so the seed converges them on the
next boot rather than leaving two shapes alive: it materializes the repo rows
against the project rows that already exist (matched by checkout path, so the
ids agent and terminal state reference survive) and deletes the derived
workspace rows. Deleting rather than keeping them, because the derived ids were
never addressable — the desktop filters sandbox-host rows out of its list, and
`terminal_sessions.origin_workspace_id` is ON DELETE SET NULL, so an orphan
degrades to a session with no origin instead of a dangling row.

A single-repository box is left exactly as it was: one workspace, null
`root_path`, no repo rows — `listWorkspaceRepos` synthesizes its primary, and a
test asserts the rows byte for byte.

The repo rows are rewritten whole rather than merged when the environment's
list stops matching (its hooks repository decides which checkout is primary,
and an environment can be edited), because moving a repository between
positions row by row trips the (workspace, position) unique index. Only when it
actually differs, so a restart is still a no-op.

Out of scope, and noted in docs/cloud-sandbox-mismatches.md: mapping a local
Project's folder list onto a cloud workspace (cloud repositories must be
connected GitHub repos in one installation, so a local-only source folder has
no cloud counterpart — a product decision, not a mapping), and per-folder
lifecycle scripts on the box, which still run from the single hooks checkout.
…wn id

Keying every checkout's watch state by `workspaceId + worktreePath` moved the
primary's entry out from under the id that `watched`, `ignoredDirs` and their
readers have always addressed it by, so the lazy-registration coverage for
#6729 stopped finding it — six tests, timing out rather than failing an
assertion, which is what hid it behind the debounce windows.

Only extra checkouts need a composite key. The primary keeps the workspace's
id, so the map reads exactly as it did before a workspace could hold more than
one checkout.

The one genuine seam change stays: the ignore-set swap is per checkout
(`refreshCheckoutWatcherIgnores`), since the native watcher it re-derives is
attached at the checkout, not at the container that `resolveWorkspaceRoot`
returns for a multi-repo workspace.
The folder switcher in the workspace sidebar was a folder glyph and a line of
text, which reads as a path control rather than as the repository switcher it
is. Each row and the trigger now carry the checkout's GitHub avatar — the same
thumbnail the sidebar and the project pickers use — falling back to the letter
tile for a checkout with no remote.

A project assembled out of local folders has no remote of its own, so its
avatar now falls through to its source folders before giving up on the letter
tile: the sidebar, the settings list and the Project page all resolve it the
same way.
…main

The rebase conflicted on every catalog — main and this branch each added
messages — and each replayed commit took main's side, which dropped the
translations this branch had written. Regenerated from source and refilled
from the pre-rebase tip, so both sides' messages are present and translated.

This branch was previously deployed

1 inactive deployment
preview — f22770d9 Deployed Sep 22, 2026 by harshithmullapudi via Deploy Admin #10189
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant