fix(host-service): fold duplicate project rows and make repo_path unique
What & why
Fixes #7702.
fraser-langton restarted into 1.30.0 and found nine extra project headings at the top of
the Projects sidebar, each duplicating an entry still present further down with the same
local path and repo URL but a different project id.
The rows were not new. host.db had carried two project rows per repo path since the v1
import on 7 September. The sidebar only renders projects that have a placement row, so the
twins sat invisible. 1.30.0 added usePlaceProjectsInSidebar (#7485), which places every
row-less project the local host serves, and all the long-hidden twins were placed at once.
The August work on this (plans/done/2026-08-10-v1-import-duplicate-projects-*) closed the
importer's write paths but deliberately shipped neither a dedupe nor a unique index,
because existing profiles already held twins. This migration does both, in order:
- re-point each twin's
workspacesandpull_requestsat the oldest row for that path - drop the twin's
tag_folder_settingsrows, asproject.removedoes - delete the twin
- make
projects.repo_patha unique index
Deleting the twin also retires its sidebar placement rows on the next project.list, which
is what removes the extra headings.
How it works
Children move rather than die: the twin's workspaces and PR rows are the user's.
pull_requests is already unique per repo and number with no project id in the key, so
moving PR rows cannot collide, and since 0035 several local workspace rows per project are
allowed, so the twin's local checkout row lands safely beside the keeper's.
Both re-points skip a row whose project row is already gone. That guard is load-bearing:
the db runner tolerates legacy orphans by design ("long-lived DBs can carry legacy
orphans"), and pull_requests.project_id is NOT NULL, so an unguarded re-point would
write the subquery's NULL there, throw, and leave host-service refusing to serve the
database — the runner deliberately never serves a half-migrated DB.
How I tested it
Live in the dev desktop app against the reporter's shape: two project rows sharing one
repo_path, created ~30s apart, as the v1 importer wrote them. Same database file
throughout; only the presence of migration 0036 differs.
rows in projects | sidebar | |
|---|---|---|
| 0036 withheld (boots at 0035) | 2 | two identical headings — ![]() |
| 0036 restored, app restarted | 1, the oldest kept | one heading — ![]() |
CREATE UNIQUE INDEX projects_repo_path_idx ON projects (repo_path) confirmed present
afterwards. Full transcript: live-migration-capture.txt.
Automated:
bun run lint # 8168 files, clean
bun run typecheck # 42 tasks
bun test packages/host-service # full suite green
bun test src/db/migrations.test.ts # 8 pass
The migration tests cover the fold, rejection of a later duplicate, a three-way fold rather
than a pair, and one orphan of each kind.
bun run check:i18n not run: no user-facing strings change.
A full bun run test on macOS also reports workspace-fs > watch-backend > recovers a recreated root. It is unrelated (this branch touches no file in that package), reproduces
on unmodified code, and CI does not see it because every job runs on ubuntu-latest while
that assertion depends on inotify delete semantics fsevents does not match.
Risks / rollout / rollback
- Blast radius. Runs once at host-service boot on every existing profile. Profiles
without duplicates do the four statements as no-ops and gain the index. - Forward-only. These migrations have no down step. The twin project rows are deleted;
their children are moved first, and only the twin'stag_folder_settingsrows are
discarded, matching whatproject.removealready does. - Rolling back the code does not roll back the database, and does not need to. An older
build simply does not ship 0036; a database that already has the index keeps working,
because the importer paths that used to mint duplicates were closed in August and nothing
else inserts a second row for one path. - If the index creation ever fails it means duplicates survived the fold, and the runner
throws rather than serving a half-migrated database. Recovery is to fix the fold, not to
ship the index alone.
Known limitations
- Rows whose
repo_pathdiffers only by case or a trailing slash are not folded. They are
distinct strings, so the unique index accepts them and the sidebar would still show two
headings. Out of scope here; the reporter's rows are byte-identical. - The keeper is the oldest row by
created_at, thenid. If a user had customised the
newer twin (icon, colour, naming instructions), those settings are lost with it. - Multi-repo projects (#7699) group in the renderer and do not touch
repo_path, so one row
per path still holds there.
Checklist
- PR title follows conventional commits (
type(scope): subject) -
bun run lintandbun run typecheckpass (CI fails on lint warnings too) - "Allow edits from maintainers" is checked on fork PRs
Summary by cubic
Fixes duplicate project headings in the Projects sidebar (#7702). The v1 importer wrote multiple project rows per repo path, and 1.30.0's usePlaceProjectsInSidebar began placing every row-less project, so the long-invisible duplicates all appeared at once.
Migration 0036 folds each set of duplicates into the oldest row by created_at, then makes projects.repo_path unique so the importer's mistake can't recur.
Migration details
- Twin workspaces and pull requests are re-pointed to the keeper; twins'
tag_folder_settingsare dropped likeproject.removedoes, and any custom settings on the twin (icon, colour, naming) are lost. - Children whose project row is already gone are left untouched, so legacy orphans can't abort the migration.
- Forward-only with no down step; an older build against an already-migrated database keeps working.
Summary by CodeRabbit
-
Bug Fixes
- Consolidated duplicate projects that reference the same repository path, preserving associated workspaces, pull requests, and settings under the oldest project.
- Improved migration handling for legacy orphaned references.
-
Data Integrity
- Repository paths are now required to be unique, preventing duplicate project entries for the same path.
-
Tests
- Added coverage for duplicate projects, multiple duplicates, orphaned references, and enforcement of repository-path uniqueness.

