refactor(workspaces): split workspace_repos into git_repos + workspace_projects#42
Merged
Merged
Conversation
3 tasks
…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>
94ca785 to
763154a
Compare
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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_repostableconflated 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.host_pathis the single identity. Deleting a projectcascades to
git_reposANDworkspace_projectsvia FK ON DELETECASCADE.
API
POST /api/v1/git-reposGET /api/v1/projects/{hash}/git-repoGET /api/v1/projects/{hash}/webhook-infoPOST /api/v1/projects/{hash}/reindexGET /api/v1/workspaces/{id}/projectsPOST /api/v1/workspaces/{id}/projectsDELETE /api/v1/workspaces/{id}/projects/{hash}POST /api/v1/webhooks/github/{hash}All previous
/workspaces/{id}/repos[/...]endpoints are gone.Webhook clean break
Webhook URLs now use
projects.path_hash(stable across the systemand 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
migrateSplitWorkspaceReposruns once at startup. For every legacyworkspace_reposrow it:projectsrow if absent (coverspending/cloning rows whose
projectsrow hadn't been written yet).workspace_projectsmembership.git_reposrow for owned external rows only (linkedrows reuse the canonical owner; local rows have no git_repos at all).
Idempotent — re-running boots after a successful migration is a no-op.
Dashboard
WorkspaceDetailPagereads projects via/workspaces/{id}/projects.Project rows render via the new
WorkspaceProjectRowwith a singleaction: Unlink. Reindex / webhook config / delete live on the
project's own detail page.
AddRepoDialog.workspaceIDis optional:POST /git-reposwithPOST /workspaces/{id}/projectsso the new project is linked into the current workspace.
/projects).AddExistingProjectDialogno longer disables local-path projects —they're regular
workspace_projectsrows now.Tests
go test ./...— all packages green.cascade).
TestAddGitRepo_Succeeds,_Duplicate,TestReindexProject_RequiresGitRepo,TestDeleteProject_CascadesGitRepoAndMembership,TestLinkProjectToWorkspace_*,TestUnlinkProject.TestMigrate_SplitWorkspaceReposseeds 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_projectspopulated correctly, and the clone dir renamed.webhooks_test.go+workspacesearch_test.goported tothe new model.
npm run buildclean against regeneratedgenerated.ts.E2E (manual)
reports the split migration ran,
PRAGMA table_info(git_repos)and
(workspace_projects)show the new shape,workspace_reposis gone, and
ls {DataDir}/repos/shows renamed directories./projects→ Add repo → walk through token/account/repo/branch→ submit → new project appears, clone+index runs to completion.
it into a workspace.
Project row, not a RepoCard) with status badge; Unlink
removes it from the workspace but
/projectsstill shows it./projects/{hash}→ workspaces' listsupdate,
git_reposrow is gone, the same upstream can be re-added./api/v1/webhooks/github/{path_hash}route, push a commit → theclone_repojob fires.🤖 Generated with Claude Code