Skip to content

refactor(workspaces): split workspace_repos into git_repos + workspace_projects#42

Merged
dvcdsys merged 11 commits into
developfrom
feat/workspaces-link-local-projects
May 14, 2026
Merged

refactor(workspaces): split workspace_repos into git_repos + workspace_projects#42
dvcdsys merged 11 commits into
developfrom
feat/workspaces-link-local-projects

Conversation

@dvcdsys
Copy link
Copy Markdown
Owner

@dvcdsys dvcdsys commented May 14, 2026

Rewrites the workspaces feature so the data model matches the words
operators use about it: projects are the canonical entity, workspaces
are pure aggregations of projects. The previous workspace_repos table
conflated clone metadata, workspace membership, and an owned-vs-linked
flag into one row, which forced increasingly awkward workarounds for
standalone repos and local-path projects.

What changed

Data model

projects              (canonical entity — unchanged)
git_repos             NEW — clone + webhook metadata, 1:1 with projects
                      for external repos only. Local projects have no
                      git_repos row at all.
workspace_projects    NEW — many-to-many junction (workspace_id ↔ project_path).
                      Adding to a workspace = INSERT here, removing =
                      DELETE. The project itself is untouched.
workspace_repos       DROPPED

projects.host_path is the single identity. Deleting a project
cascades to git_repos AND workspace_projects via FK ON DELETE
CASCADE.

API

Endpoint Purpose
POST /api/v1/git-repos Create an external project (clones + indexes; standalone, no workspace)
GET /api/v1/projects/{hash}/git-repo Clone+webhook metadata
GET /api/v1/projects/{hash}/webhook-info Webhook URL + secret
POST /api/v1/projects/{hash}/reindex Manual re-clone+reindex
GET /api/v1/workspaces/{id}/projects List projects in workspace
POST /api/v1/workspaces/{id}/projects Link an indexed project
DELETE /api/v1/workspaces/{id}/projects/{hash} Unlink (does not delete project)
POST /api/v1/webhooks/github/{hash} Webhook now keyed by projects.path_hash

All previous /workspaces/{id}/repos[/...] endpoints are gone.

Webhook clean break

Webhook URLs now use projects.path_hash (stable across the system
and meaningful in logs). Operators with auto-registered GitHub hooks
must re-register or update the URL in GitHub's webhook settings.
On-disk clone directories are renamed during the migration from
{DataDir}/repos/{workspace_repos.id} to {DataDir}/repos/{path_hash},
so the indexed content survives.

Migration

migrateSplitWorkspaceRepos runs once at startup. For every legacy
workspace_repos row it:

  1. Pre-seeds the matching projects row if absent (covers
    pending/cloning rows whose projects row hadn't been written yet).
  2. Inserts a workspace_projects membership.
  3. Inserts a git_repos row for owned external rows only (linked
    rows reuse the canonical owner; local rows have no git_repos at all).
  4. Renames the on-disk clone dir.
  5. DROPs the legacy table.

Idempotent — re-running boots after a successful migration is a no-op.

Dashboard

  • WorkspaceDetailPage reads projects via /workspaces/{id}/projects.
    Project rows render via the new WorkspaceProjectRow with a single
    action: Unlink. Reindex / webhook config / delete live on the
    project's own detail page.
  • AddRepoDialog.workspaceID is optional:
    • Provided → chains POST /git-repos with POST /workspaces/{id}/projects
      so the new project is linked into the current workspace.
    • Omitted → just creates a standalone project (mounted on /projects).
  • AddExistingProjectDialog no longer disables local-path projects —
    they're regular workspace_projects rows now.

Tests

  • go test ./... — all packages green.
  • New gitrepos package tests (Create + UNIQUE + GetByHash + cascade).
  • New workspaceprojects package tests (Link + duplicate + non-indexed +
    cascade).
  • New HTTP tests: TestAddGitRepo_Succeeds, _Duplicate,
    TestReindexProject_RequiresGitRepo,
    TestDeleteProject_CascadesGitRepoAndMembership,
    TestLinkProjectToWorkspace_*, TestUnlinkProject.
  • TestMigrate_SplitWorkspaceRepos seeds the legacy table (owned +
    linked + local rows) plus on-disk clone dirs, opens via
    OpenWith(DataDir), and asserts the table is dropped, git_repos +
    workspace_projects populated correctly, and the clone dir renamed.
  • Existing webhooks_test.go + workspacesearch_test.go ported to
    the new model.
  • Dashboard npm run build clean against regenerated generated.ts.

E2E (manual)

  • Boot against a snapshot of the current dev DB. Confirm boot log
    reports the split migration ran, PRAGMA table_info(git_repos)
    and (workspace_projects) show the new shape, workspace_repos
    is gone, and ls {DataDir}/repos/ shows renamed directories.
  • /projectsAdd repo → walk through token/account/repo/branch
    → submit → new project appears, clone+index runs to completion.
  • Open the project detail page → use Add to workspace to link
    it into a workspace.
  • Open the workspace → see the project in its list (rendered as a
    Project row, not a RepoCard) with status badge; Unlink
    removes it from the workspace but /projects still shows it.
  • Workspace search returns hits from the linked project.
  • Delete the project from /projects/{hash} → workspaces' lists
    update, git_repos row is gone, the same upstream can be re-added.
  • Re-register the GitHub webhook URL to the new
    /api/v1/webhooks/github/{path_hash} route, push a commit → the
    clone_repo job fires.

🤖 Generated with Claude Code

@dvcdsys dvcdsys changed the title feat(workspaces): allow linking local projects feat(workspaces): link local projects + Add repo on /projects May 14, 2026
…e_projects

The previous schema conflated three concerns inside `workspace_repos`:
clone+webhook metadata, workspace membership, and the
"owned vs linked" distinction. This made it impossible to talk about a
project as a first-class entity — every operation was workspace-coupled,
which forced workarounds like the singleton "Personal" workspace for
standalone repos and the synthetic `is_local` flag for CLI-indexed
projects.

The new model matches how operators actually think about the system:

- **projects** is the canonical entity (unchanged shape). Local and
  external projects live here side by side, identified by host_path.
- **git_repos** carries clone + webhook metadata (1:1 with projects for
  external projects only — local projects have no git_repos row).
- **workspace_projects** is the many-to-many junction. Adding a project
  to a workspace = INSERT here; removing = DELETE. The project itself
  is untouched.

Deleting a project cascades to git_repos and workspace_projects via
FK ON DELETE CASCADE.

## API rewrite

- `POST /api/v1/git-repos` — create an external project (clone + index).
- `GET  /api/v1/projects/{hash}/git-repo` — read git_repos metadata.
- `GET  /api/v1/projects/{hash}/webhook-info` — webhook URL + secret.
- `POST /api/v1/projects/{hash}/reindex` — re-trigger clone+index.
- `GET  /api/v1/workspaces/{id}/projects` — list projects in workspace.
- `POST /api/v1/workspaces/{id}/projects` — link an indexed project.
- `DELETE /api/v1/workspaces/{id}/projects/{hash}` — unlink.
- `POST /api/v1/webhooks/github/{hash}` — webhook URL now uses
  projects.path_hash. Existing GitHub-side hooks need re-registering
  (clean break).

All previous `/workspaces/{id}/repos[/...]` endpoints are gone.

## Migration

`migrateSplitWorkspaceRepos` runs once at startup. For every
`workspace_repos` row it: (a) pre-seeds the matching projects row if
absent, (b) inserts a workspace_projects membership, (c) inserts a
git_repos row for owned external rows only, (d) renames the on-disk
clone dir from `{DataDir}/repos/{workspace_repos.id}` to
`{DataDir}/repos/{path_hash}` so existing clones survive. Finally it
DROPs the legacy table.

## Code reorg

- `internal/gitrepos/` — new service package (Create, GetByPath,
  GetByHash, SetClone, SetWebhookID, Delete).
- `internal/workspaceprojects/` — new service package (Link, Unlink,
  ListByWorkspace, ListByProject).
- `internal/workspacerepos/` — deleted.
- `internal/workspacejobs/` — payload identifier is now project_path;
  clone dir naming uses path_hash; status writes go to projects.status
  (single source of truth).
- `internal/httpapi/gitrepos.go` + `workspaceprojects.go` — new HTTP
  handlers replacing `workspacerepos.go`.
- Webhook lookup uses GitRepos.GetByHash (path_hash) instead of
  workspace_repos.id.

## Dashboard

- `WorkspaceDetailPage` reads projects via `/workspaces/{id}/projects`.
- New `WorkspaceProjectRow` replaces `RepoCard`. The row's only action
  is Unlink (reindex / webhook config / delete live on the project's
  own detail page).
- `AddRepoDialog.workspaceID` is optional. With it: POST `/git-repos`
  then `POST /workspaces/{id}/projects`. Without it: just create a
  standalone project (mounted on `/projects`).
- `AddExistingProjectDialog` no longer disables local projects —
  local linkages are a regular workspace_projects row.

## Tests

- New gitrepos package tests (Create + UNIQUE + GetByHash + cascade).
- New workspaceprojects package tests (Link + duplicate + non-indexed
  precondition + cascade).
- New HTTP tests: TestAddGitRepo_Succeeds, _Duplicate,
  TestReindexProject_RequiresGitRepo, TestDeleteProject_CascadesGitRepoAndMembership,
  TestLinkProjectToWorkspace_*, TestUnlinkProject.
- Migration test TestMigrate_SplitWorkspaceRepos seeds the legacy table
  (owned + linked + local rows) plus on-disk clone dirs, opens via
  OpenWith(DataDir), and asserts the table is dropped, git_repos +
  workspace_projects populated, and the clone dir renamed.
- Existing webhooks_test.go + workspacesearch_test.go ported.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@dvcdsys dvcdsys force-pushed the feat/workspaces-link-local-projects branch from 94ca785 to 763154a Compare May 14, 2026 12:32
@dvcdsys dvcdsys changed the title feat(workspaces): link local projects + Add repo on /projects refactor(workspaces): split workspace_repos into git_repos + workspace_projects May 14, 2026
dvcdsys and others added 10 commits May 14, 2026 13:35
This file is the TypeScript incremental-build cache (`tsc -b`). It
mutates on every local build and was producing noise in git status +
diffs. Same treatment as the existing `dist/` and `src/api/generated.ts`
rules — build artefacts don't belong in source control.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
After the workspace_repos → git_repos + workspace_projects split
(763154a), the CLI client still targeted /api/v1/workspaces/{id}/repos
— deleted. Three commands (`cix ws list -v`, `cix ws <name> list`,
`cix ws <name>` describe) returned 404 or silently lost data.

- Replace WorkspaceRepo + ListWorkspaceRepos with WorkspaceProject +
  ListWorkspaceProjects against the new endpoint.
- Update three call sites in cli/cmd/workspace.go to use the new
  payload shape (project_path / status / path_hash instead of
  github_url / branch / id).
- New cli/cmd/workspace_test.go covers status badge formatting,
  empty-list rendering, and case-insensitive name resolution.

Resolves Fix #1 + #17 in docs/code-review-workspaces-link-local-projects.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ProjectDetailPage rendered "@undefined" and warned about duplicate
React keys because the TS type still declared repo_id, branch,
status, is_linked — fields the server stopped returning after the
workspace_repos split (project_workspaces.go now sends only
workspace_id, workspace_name, added_at).

- Trim ProjectWorkspaceEntry to the three real fields.
- Key on workspace_id; drop the "linked vs owned" UI (concept
  removed — every membership is just a link now).
- Tooltip + chip body show workspace name + added_at only.

Resolves Fix #2.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two related migration-safety fixes:

1. migrateSplitWorkspaceRepos used to commit the DB tx first and
   then rename clone dirs — a kill -9 in that window left old
   {workspace_repos.id} dirs orphaned and forced a re-clone. Now
   the rename runs BEFORE the transaction and an error aborts the
   migration (leaves workspace_repos in place so the next run
   retries). Counters for renamed / skipped_missing_source /
   skipped_target_exists / failed are logged on completion.

2. Add schema_migrations(version, name, applied_at). Open() reads
   MAX(version) and skips already-applied migrations. Existing
   prod DBs bootstrap by detecting which legacy tables are present.

3. Migration test suite expanded with subtests covering partial
   rename, pre-existing target, missing source dir, duplicate
   project_path rows, and idempotent re-runs.

Resolves Fix #3, #7, #14.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
After the workspace_repos → git_repos split, webhook URLs moved from
/webhooks/github/{uuid} to /webhooks/github/{path_hash}. Existing
manual-mode GitHub-side hooks silently 404'd. Nothing signalled the
break to operators.

- New webhook_audit.go runs after db.Open(): counts manual-mode
  git_repos rows and logs a WARN with the new URL pattern.
- Test covers the count + log emission paths.

Resolves Fix #4a. Dashboard banner (4b) and auto-reregister endpoint
(4c) remain follow-ups per docs/code-review-workspaces-link-local-projects.md.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…projects

Two related correctness fixes:

1. POST /git-repos was not transactional — a failed gitrepos insert
   (e.g. UNIQUE violation under concurrent posts) left an orphan
   projects row in 'pending' that the dashboard couldn't surface for
   cleanup. The handler now tracks whether it created the project
   row and runs a compensating DeleteByHash on gitrepos failure.
   TestAddGitRepo_ConcurrentDuplicate_NoOrphan asserts the
   invariant: SELECT COUNT(*) FROM projects WHERE host_path = ?
   == 1 after two parallel posts.

2. workspaceprojects.Link checked precondition + did INSERT in two
   separate queries — race window where the project could be deleted
   between the SELECT and INSERT surfaced as a 500 instead of 404.
   Rewritten as a single INSERT ... SELECT ... WHERE EXISTS, with a
   follow-up diagnostic SELECT when RowsAffected == 0 to return the
   right 404/422 reason.

3. TestDeleteProject_CascadesGitRepoAndMembership now explicitly
   asserts SELECT COUNT(*) FROM workspace_projects WHERE
   project_path = ? == 0 (instead of relying on UNIQUE-retry
   inference).

Resolves Fix #5, #6, #15, #16.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- validHMAC now rejects empty-secret signatures upfront. The
  WebhookSecret column is NOT NULL, but the explicit check guards
  against accidental empty strings in future regressions. New
  TestValidHMAC_RejectsEmptySecret.
- workspacejobs zeroes the decrypted PAT after the
  repocloner.CloneOrFetch call via deferred clear. Best-effort
  documentation of intent — Go GC may still hold copies.
- OpenAPI: document that GET /projects/{hash}/webhook-info returns
  404 for local projects (no git_repos row), so callers can
  distinguish "project missing" from "expected — local project".

Resolves Fix #11, #12, #13.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
After the workspace_repos → git_repos + workspace_projects split,
docs and stale comments still referenced concepts that no longer
exist.

- workspaces.md + doc/WORKSPACES.md: rewrite lifecycle, REST API
  reference, webhook URL examples, and clone-dir paths to use
  projects.path_hash, /git-repos, /workspaces/{id}/projects, and
  /projects/{hash}/reindex.
- Both skills/cix-workspace/SKILL.md and
  plugins/cix/skills/cix-workspace/SKILL.md: replace
  /workspaces/{id}/repos/{repo_id}/reindex with
  /projects/{hash}/reindex. New plugins/cix/scripts/sync-skills.sh
  keeps the duplicate copies byte-identical going forward.
- Code comments updated across config.go, callgraph.go,
  repocloner.go, githubapi.go, workspaces.go, and
  AddExistingProjectDialog.tsx to point at gitrepos /
  workspace_projects / projects.path_hash instead of the dead
  workspacerepos.* names.

Resolves Fix #8, #9, #10, #19.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`go build ./cmd/cix-server` run directly from server/ (without -o)
writes the binary to server/cix-server, which the existing
/cmd/cix-server/cix-server rule did not cover. Add a rooted
/cix-server pattern matching only server/cix-server — keeps the
same full-path style introduced in c058970 so the cmd/ source dir
stays trackable.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
GitHub-cloned projects had no UI affordance to trigger a reindex even
though the server-side POST /projects/{hash}/reindex endpoint already
exists. Wire it up: button appears next to Search/Delete for projects
whose host_path starts with github.com/ (local projects keep their
CLI-driven flow with the existing copy).

Also show progress: an "Indexing in progress" alert with a spinner
appears whenever status='indexing', and the useProject query polls the
server every 3s while in that state so the page auto-updates on
completion. The bottom "run cix reindex from terminal" alert now hides
for external projects, where it would be misleading.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@dvcdsys dvcdsys merged commit 596748e into develop May 14, 2026
5 checks passed
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