From 0b1bed4bd9e4eea4f3daae2042b4cc371678db0d Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 10 Apr 2026 15:05:35 -0700 Subject: [PATCH 1/6] docs: add design doc for v2 host project paths Outlines the approach for allowing users to import existing local repos or clone to a chosen location instead of auto-cloning to a fixed path. Covers local-only storage decision, throw-on-create pattern, and phased implementation plan. --- docs/design/v2-host-project-paths.md | 245 +++++++++++++++++++++++++++ 1 file changed, 245 insertions(+) create mode 100644 docs/design/v2-host-project-paths.md diff --git a/docs/design/v2-host-project-paths.md b/docs/design/v2-host-project-paths.md new file mode 100644 index 00000000000..830d3948846 --- /dev/null +++ b/docs/design/v2-host-project-paths.md @@ -0,0 +1,245 @@ +# V2 Host Project Paths + +## Problem + +The v2 architecture has **no per-host project path mapping**. When a workspace is created on a host, the system either: + +1. Finds the project in the host-service local SQLite and reuses its `repoPath` +2. Or auto-clones to a hardcoded path: `~/.superset/repos/{projectId}` + +A user who already has `~/work/my-project` checked out locally gets a **duplicate clone**. There's no way to say "use my existing checkout." + +### Current State + +| Layer | What it knows | What's missing | +|-------|--------------|----------------| +| **Cloud** (`v2_projects`) | Project name, slug, GitHub repo | Where it lives on any machine | +| **Cloud** (`v2_workspaces`) | Which project + which host + branch | The filesystem path on that host | +| **Cloud** (`v2_hosts`) | Machine ID, name, online status | Which projects are set up locally | +| **Host-service local DB** | `projects.repoPath` per project | No import flow; auto-clones to fixed path | + +### Relevant Files + +| File | Purpose | +|------|---------| +| `packages/db/src/schema/schema.ts` (L380-547) | Cloud schema: `v2_projects`, `v2_hosts`, `v2_workspaces` | +| `packages/host-service/src/db/schema.ts` | Host-service local SQLite: `projects` (has `repoPath`), `workspaces` | +| `packages/host-service/src/trpc/router/workspace/workspace.ts` | Workspace creation — auto-clones to `~/.superset/repos/{projectId}` if missing | +| `packages/host-service/src/trpc/router/project/project.ts` | Project removal (local cleanup) | +| `packages/trpc/src/router/v2-project/v2-project.ts` | Cloud v2 project CRUD | +| `packages/trpc/src/router/v2-workspace/v2-workspace.ts` | Cloud v2 workspace CRUD | +| `packages/trpc/src/router/device/device.ts` | Host/device registration (`ensureV2Host`, `ensureV2Client`) | +| `packages/shared/src/device-info.ts` | Machine ID derivation (platform-specific) | +| `apps/desktop/.../CollectionsProvider/collections.ts` | Electric SQL shape subscriptions (cloud → desktop sync) | +| `apps/desktop/.../DashboardNewWorkspaceModal/` | V2 workspace creation UI | +| `apps/desktop/.../v2-workspaces/hooks/useAccessibleV2Workspaces/` | Discovery page query logic | + +--- + +## Decision: Local-Only Storage + +**The project path mapping lives in the host-service local SQLite DB only — no new cloud table.** + +The host-service already has a `projects` table with `repoPath`. The path is inherently machine-local: other devices can't act on knowing `~/work/my-project` exists on your MacBook. The cloud already knows *which* host a workspace is on (via `v2_workspaces.hostId`), which is sufficient for the discovery page to show "this project has workspaces on your device." + +What changes is the **flow for populating** the local `projects.repoPath` — allowing import of existing repos instead of only auto-cloning. + +### What stays the same + +``` +host-service local SQLite +└── projects + ├── id text PK (matches cloud v2_projects.id) + ├── repoPath text NOT NULL ← this is the path mapping + ├── repoProvider, repoOwner, repoName, repoUrl, remoteName + └── createdAt +``` + +No new tables. No cloud migration. The local `projects` table is the single source of truth for "where does this project live on this machine." + +--- + +## Decision: Throw-on-Create, Not Check-First + +**`workspace.create` throws `PROJECT_NOT_SETUP` or `PROJECT_PATH_MISSING` when the project isn't ready. The client catches these and prompts the user to import or clone. No separate preflight check endpoint for the creation flow.** + +### Why throw-on-create wins + +1. **Setup is a one-time event.** A project gets set up once per machine. After that, every `workspace.create` is a single call with no preflight overhead. A check-first approach pays the cost of a status query on every creation — even though setup is already done 99% of the time. + +2. **Handles drift naturally.** If a path vanishes between sessions (user moves/deletes the repo), the next `workspace.create` catches it at the exact moment it matters. No stale "ready" status sitting in the UI from a check that ran minutes ago. + +3. **Single source of truth.** The create call itself is the authoritative answer to "can I create a workspace right now." No possibility of a check and create disagreeing due to a race condition. + +4. **Project creation is a separate flow.** Users create projects in a dedicated flow (cloud-only, no local path). By the time they're creating workspaces, the project exists — the only question is local setup. The throw is a natural redirect, not an unexpected error. + +### The flow + +``` +User opens "new workspace" modal + → selects project, fills branch/name, submits + → client calls host-service: workspace.create(projectId, name, branch) + → SUCCESS: workspace created ✓ + → throws PROJECT_NOT_SETUP or PROJECT_PATH_MISSING: + → client catches, shows setup UI (import/clone) + → user completes setup via project.setup + → client retries workspace.create + → done — every subsequent create is 1 call +``` + +--- + +## New Host-Service Procedures + +### `project.setup` + +```typescript +project.setup({ + projectId: string, + mode: "import" | "clone", + localPath: string, // import: existing repo path; clone: parent dir +}) +→ { repoPath: string } +``` + +**Import mode:** +1. Validate `localPath` exists and is a directory +2. Find git root (`git rev-parse --show-toplevel`) +3. Run `git remote -v` → extract remote URLs +4. Fetch project's GitHub repo info from cloud (`v2Project.get` → `repoCloneUrl`) +5. Compare — check all remotes, not just `origin` +6. If match → upsert local `projects` row with `repoPath = gitRoot` +7. If mismatch → return error with expected vs. actual remote details + +**Clone mode:** +1. Fetch repo clone URL from cloud (`v2Project.get`) +2. Clone to `{localPath}/{repoName}` +3. Upsert local `projects` row with resulting path + +--- + +## Updated Workspace Creation Flow (Deferred) + +Changes to `workspace.create` are **deferred** — other workspace create updates need to land first. The auto-clone logic stays for now. + +### Current (`workspace.ts:29-133`) + +``` +workspace.create(projectId, name, branch) + → local project exists? → YES → create worktree from repoPath + → NO → auto-clone to ~/.superset/repos/{projectId} + → insert local project row + → create worktree +``` + +### Future (after workspace create refactor lands) + +``` +workspace.create(projectId, name, branch) + → local project exists? + → YES → path exists on disk? + → YES → create worktree from repoPath ✓ + → NO → throw PROJECT_PATH_MISSING + → NO → throw PROJECT_NOT_SETUP +``` + +Auto-clone is removed. The setup responsibility moves to `project.setup`, triggered by the client when it catches a throw. `workspace.create` assumes setup is done and fails fast if not. + +--- + +## Desktop UI Changes + +### New Workspace Modal — Setup Redirect on Throw + +The normal flow is: user selects project, fills branch/name, submits. If `workspace.create` throws `PROJECT_NOT_SETUP` or `PROJECT_PATH_MISSING`, the modal catches it and shows the setup step: + +``` +┌─────────────────────────────────────────┐ +│ Set up "my-project" on this device │ +│ │ +│ ○ Use existing directory │ +│ [~/work/my-project ] [Browse] │ +│ ✓ Matches github.com/org/my-project │ +│ │ +│ ○ Clone repository │ +│ [~/.superset/repos ] [Browse] │ +│ │ +│ [Set Up & Create] │ +└─────────────────────────────────────────┘ +``` + +On submit, the client calls `project.setup`, then retries `workspace.create` automatically. The user sees a single flow — setup + workspace creation feels like one action. + +**Validation for "Use existing directory":** +- Path exists and is a directory +- Contains a `.git` folder (is a git repo) +- A git remote URL matches the project's GitHub repository +- Show green checkmark or red X with mismatch details + +--- + +## Data Flow + +``` +Desktop ──► host-service: workspace.create(projectId, name, branch) + │ + ├─ local project exists + path valid → create worktree, upsert cloud v2_workspace ✓ + │ + └─ throws PROJECT_NOT_SETUP or PROJECT_PATH_MISSING + │ + ▼ + Client shows setup UI (import/clone) + │ + ▼ +Desktop ──► host-service: project.setup(projectId, mode, path) + │ validates git remote + │ clones if needed + ▼ + projects.repoPath stored in local SQLite + │ + ▼ +Desktop ──► host-service: workspace.create(projectId, name, branch) [retry] + → succeeds ✓ +``` + +No Electric sync needed for paths. The desktop talks to the local host-service for path operations and talks to the cloud (via Electric) for project/workspace metadata. + +--- + +## Implementation Checklist + +### Phase 1: Project Setup Endpoint (now) + +- [ ] `packages/host-service/src/trpc/router/project/utils/git-remote.ts` — New file: git remote extraction, URL normalization (SSH/HTTPS → `owner/repo`), matching utility +- [ ] `packages/host-service/src/trpc/router/project/project.ts` — Add `setup` mutation (import + clone modes; upserts, so re-running with import mode handles re-pointing) + +### Phase 2: Workspace Create Throws (after workspace create refactor) + +- [ ] `packages/host-service/src/trpc/router/workspace/workspace.ts` — Remove auto-clone logic +- [ ] `packages/host-service/src/trpc/router/workspace/workspace.ts` — Throw `PROJECT_NOT_SETUP` if no local project entry +- [ ] `packages/host-service/src/trpc/router/workspace/workspace.ts` — Throw `PROJECT_PATH_MISSING` if path exists in DB but gone from disk + +### Phase 3: Desktop UI (after phase 2) + +- [ ] `useCreateDashboardWorkspace` — Catch `PROJECT_NOT_SETUP` / `PROJECT_PATH_MISSING` from `workspace.create` +- [ ] New setup step component (import/clone radio, path picker, git remote validation feedback) +- [ ] On setup complete: call `project.setup`, then automatically retry `workspace.create` + +--- + +## Edge Cases + +### Path becomes stale +User moves or deletes the local repo after setup. Next `workspace.create` throws `PROJECT_PATH_MISSING`. Client catches it and shows the setup UI again — same flow as first-time setup. + +### Multiple remotes +A local repo may have multiple git remotes (origin, upstream, fork). Import validation should check **all** remotes for a match, not just `origin`. The match logic compares the GitHub `owner/repo` slug extracted from the URL. + +### Repo at different path on same machine +User re-clones to a new location. They re-run `project.setup` with import mode pointing at the new path. The upsert overwrites the existing `projects` row (keyed by `projectId`). + +### Host not yet registered +If the current machine hasn't called `ensureV2Host` yet, `workspace.create` already handles this. The setup flow doesn't need the cloud host — it only touches the local SQLite DB. + +### SSH vs HTTPS clone URLs +When validating git remotes, normalize URLs before comparing. `git@github.com:org/repo.git` and `https://github.com/org/repo.git` should both match a project linked to `org/repo`. From 3cc23af6a35e5ba53550d83458cc0248a20f7b85 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 10 Apr 2026 15:07:20 -0700 Subject: [PATCH 2/6] feat(host-service): add project.setup endpoint for importing/cloning repos Adds a `project.setup` mutation that lets users either import an existing local repo or clone to a chosen directory. Validates git remotes against the cloud project's GitHub repo (handles SSH + HTTPS). Upserts the local projects table so re-running handles re-pointing. --- .../src/trpc/router/project/project.ts | 175 +++++++++++++++++- .../trpc/router/project/utils/git-remote.ts | 63 +++++++ 2 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 packages/host-service/src/trpc/router/project/utils/git-remote.ts diff --git a/packages/host-service/src/trpc/router/project/project.ts b/packages/host-service/src/trpc/router/project/project.ts index 3bad3cbc35c..fa9be8b0554 100644 --- a/packages/host-service/src/trpc/router/project/project.ts +++ b/packages/host-service/src/trpc/router/project/project.ts @@ -1,10 +1,101 @@ -import { rmSync } from "node:fs"; +import { existsSync, rmSync, statSync } from "node:fs"; +import { basename, join } from "node:path"; +import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; +import simpleGit from "simple-git"; import { z } from "zod"; import { projects, workspaces } from "../../../db/schema"; import { protectedProcedure, router } from "../../index"; +import { + extractGitHubSlug, + findMatchingRemote, + getAllRemoteUrls, +} from "./utils/git-remote"; export const projectRouter = router({ + setup: protectedProcedure + .input( + z.object({ + projectId: z.string(), + mode: z.enum(["import", "clone"]), + localPath: z.string().min(1), + }), + ) + .mutation(async ({ ctx, input }) => { + if (!ctx.api) { + throw new TRPCError({ + code: "PRECONDITION_FAILED", + message: "Cloud API not configured", + }); + } + + const cloudProject = await ctx.api.v2Project.get.query({ + organizationId: ctx.organizationId, + id: input.projectId, + }); + + if (!cloudProject.repoCloneUrl) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: "Project has no linked GitHub repository — cannot set up", + }); + } + + const expectedSlug = extractGitHubSlug(cloudProject.repoCloneUrl); + if (!expectedSlug) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Could not parse GitHub slug from ${cloudProject.repoCloneUrl}`, + }); + } + + let repoPath: string; + + if (input.mode === "import") { + repoPath = await importExistingRepo(input.localPath, expectedSlug); + } else { + repoPath = await cloneRepo(cloudProject.repoCloneUrl, input.localPath); + } + + // Extract repo metadata from the resolved path + const git = simpleGit(repoPath); + const remotes = await getAllRemoteUrls(git); + const matchingRemote = findMatchingRemote(remotes, expectedSlug); + const remoteUrl = matchingRemote + ? remotes.get(matchingRemote) + : undefined; + const repoFullName = remoteUrl + ? extractGitHubSlug(remoteUrl) + : expectedSlug; + const [repoOwner, repoName] = repoFullName?.split("/") ?? []; + + ctx.db + .insert(projects) + .values({ + id: input.projectId, + repoPath, + repoProvider: "github", + repoOwner, + repoName, + repoUrl: remoteUrl, + remoteName: matchingRemote, + }) + .onConflictDoUpdate({ + target: projects.id, + set: { + repoPath, + repoProvider: "github", + repoOwner, + repoName, + repoUrl: remoteUrl, + remoteName: matchingRemote, + }, + }) + .run(); + + return { repoPath }; + }), + // TODO: remove remove: protectedProcedure .input(z.object({ projectId: z.string() })) @@ -51,3 +142,85 @@ export const projectRouter = router({ return { success: true }; }), }); + +async function importExistingRepo( + localPath: string, + expectedSlug: string, +): Promise { + if (!existsSync(localPath)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Path does not exist: ${localPath}`, + }); + } + + const stat = statSync(localPath); + if (!stat.isDirectory()) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Path is not a directory: ${localPath}`, + }); + } + + const git = simpleGit(localPath); + + let gitRoot: string; + try { + gitRoot = (await git.revparse(["--show-toplevel"])).trim(); + } catch { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Not a git repository: ${localPath}`, + }); + } + + const remotes = await getAllRemoteUrls(simpleGit(gitRoot)); + const matchingRemote = findMatchingRemote(remotes, expectedSlug); + + if (!matchingRemote) { + const found = [...remotes.entries()] + .map(([name, url]) => `${name}: ${url}`) + .join(", "); + throw new TRPCError({ + code: "BAD_REQUEST", + message: `No remote matches ${expectedSlug}. Found: ${found || "no remotes"}`, + }); + } + + return gitRoot; +} + +async function cloneRepo( + repoCloneUrl: string, + parentDir: string, +): Promise { + if (!existsSync(parentDir)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Parent directory does not exist: ${parentDir}`, + }); + } + + const repoName = extractRepoNameFromUrl(repoCloneUrl); + const targetPath = join(parentDir, repoName); + + if (existsSync(targetPath)) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Directory already exists: ${targetPath}`, + }); + } + + await simpleGit().clone(repoCloneUrl, targetPath); + + return targetPath; +} + +function extractRepoNameFromUrl(url: string): string { + // Handle both https://github.com/owner/repo.git and git@github.com:owner/repo.git + const slug = extractGitHubSlug(url); + if (slug) { + return slug.split("/")[1] ?? basename(url, ".git"); + } + return basename(url, ".git"); +} diff --git a/packages/host-service/src/trpc/router/project/utils/git-remote.ts b/packages/host-service/src/trpc/router/project/utils/git-remote.ts new file mode 100644 index 00000000000..f1b0d8a1b3d --- /dev/null +++ b/packages/host-service/src/trpc/router/project/utils/git-remote.ts @@ -0,0 +1,63 @@ +import type { SimpleGit } from "simple-git"; + +/** + * Get all fetch remote URLs from a git repository. + * Returns a map of remote name → fetch URL. + */ +export async function getAllRemoteUrls( + git: SimpleGit, +): Promise> { + const remotes = new Map(); + const output = await git.remote(["-v"]); + if (!output) return remotes; + + for (const line of output.trim().split("\n")) { + const match = line.match(/^(\S+)\s+(\S+)\s+\(fetch\)$/); + if (match?.[1] && match[2]) { + remotes.set(match[1], match[2]); + } + } + + return remotes; +} + +/** + * Extract the GitHub owner/repo slug from a git remote URL. + * Handles both SSH and HTTPS formats: + * - git@github.com:org/repo.git → org/repo + * - https://github.com/org/repo.git → org/repo + * - https://github.com/org/repo → org/repo + */ +export function extractGitHubSlug(remoteUrl: string): string | null { + // SSH format: git@github.com:owner/repo.git + const sshMatch = remoteUrl.match( + /^[\w.-]+@github\.com:([^/]+\/[^/]+?)(?:\.git)?$/, + ); + if (sshMatch?.[1]) return sshMatch[1]; + + // HTTPS format: https://github.com/owner/repo.git + const httpsMatch = remoteUrl.match( + /^https?:\/\/github\.com\/([^/]+\/[^/]+?)(?:\.git)?$/, + ); + if (httpsMatch?.[1]) return httpsMatch[1]; + + return null; +} + +/** + * Check if any remote in the map matches the expected GitHub owner/repo slug. + * Returns the name of the matching remote, or null if none match. + */ +export function findMatchingRemote( + remotes: Map, + expectedSlug: string, +): string | null { + const normalized = expectedSlug.toLowerCase(); + for (const [name, url] of remotes) { + const slug = extractGitHubSlug(url); + if (slug && slug.toLowerCase() === normalized) { + return name; + } + } + return null; +} From fa04f7a6f21a211ed45ea97af7866fb05c0eba11 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 10 Apr 2026 16:32:11 -0700 Subject: [PATCH 3/6] fix(host-service): address PR review feedback on project.setup - Reuse existing parseGitHubRemote helper instead of custom regex, adds ssh:// URI support and credential-safe URL storage - Validate parentDir is a directory in cloneRepo, not just exists - Wrap simpleGit().clone() in try/catch for actionable error messages - Return remotes from importExistingRepo to avoid double git remote -v - Use path.resolve for absolute clone paths - Fix design doc: git rev-parse instead of .git folder check --- docs/design/v2-host-project-paths.md | 2 +- .../src/trpc/router/project/project.ts | 128 +++++++++++------- .../trpc/router/project/utils/git-remote.ts | 53 ++++---- 3 files changed, 108 insertions(+), 75 deletions(-) diff --git a/docs/design/v2-host-project-paths.md b/docs/design/v2-host-project-paths.md index 830d3948846..c64378428d1 100644 --- a/docs/design/v2-host-project-paths.md +++ b/docs/design/v2-host-project-paths.md @@ -172,7 +172,7 @@ On submit, the client calls `project.setup`, then retries `workspace.create` aut **Validation for "Use existing directory":** - Path exists and is a directory -- Contains a `.git` folder (is a git repo) +- Resolves to a git repository via `git rev-parse --show-toplevel` - A git remote URL matches the project's GitHub repository - Show green checkmark or red X with mismatch details diff --git a/packages/host-service/src/trpc/router/project/project.ts b/packages/host-service/src/trpc/router/project/project.ts index fa9be8b0554..423cc6b0a61 100644 --- a/packages/host-service/src/trpc/router/project/project.ts +++ b/packages/host-service/src/trpc/router/project/project.ts @@ -1,17 +1,24 @@ import { existsSync, rmSync, statSync } from "node:fs"; -import { basename, join } from "node:path"; +import { basename, join, resolve } from "node:path"; import { TRPCError } from "@trpc/server"; import { eq } from "drizzle-orm"; import simpleGit from "simple-git"; import { z } from "zod"; import { projects, workspaces } from "../../../db/schema"; +import { parseGitHubRemote } from "../../../runtime/pull-requests/utils/parse-github-remote"; import { protectedProcedure, router } from "../../index"; import { - extractGitHubSlug, findMatchingRemote, - getAllRemoteUrls, + getGitHubRemotes, + type ParsedGitHubRemote, } from "./utils/git-remote"; +interface ResolvedRepo { + repoPath: string; + matchingRemote: string; + parsed: ParsedGitHubRemote; +} + export const projectRouter = router({ setup: protectedProcedure .input( @@ -41,59 +48,53 @@ export const projectRouter = router({ }); } - const expectedSlug = extractGitHubSlug(cloudProject.repoCloneUrl); - if (!expectedSlug) { + const expectedParsed = parseGitHubRemote(cloudProject.repoCloneUrl); + if (!expectedParsed) { throw new TRPCError({ code: "BAD_REQUEST", - message: `Could not parse GitHub slug from ${cloudProject.repoCloneUrl}`, + message: `Could not parse GitHub remote from ${cloudProject.repoCloneUrl}`, }); } - let repoPath: string; + const expectedSlug = `${expectedParsed.owner}/${expectedParsed.name}`; + + let resolved: ResolvedRepo; if (input.mode === "import") { - repoPath = await importExistingRepo(input.localPath, expectedSlug); + resolved = await importExistingRepo(input.localPath, expectedSlug); } else { - repoPath = await cloneRepo(cloudProject.repoCloneUrl, input.localPath); + resolved = await cloneRepo( + cloudProject.repoCloneUrl, + input.localPath, + expectedSlug, + ); } - // Extract repo metadata from the resolved path - const git = simpleGit(repoPath); - const remotes = await getAllRemoteUrls(git); - const matchingRemote = findMatchingRemote(remotes, expectedSlug); - const remoteUrl = matchingRemote - ? remotes.get(matchingRemote) - : undefined; - const repoFullName = remoteUrl - ? extractGitHubSlug(remoteUrl) - : expectedSlug; - const [repoOwner, repoName] = repoFullName?.split("/") ?? []; - ctx.db .insert(projects) .values({ id: input.projectId, - repoPath, + repoPath: resolved.repoPath, repoProvider: "github", - repoOwner, - repoName, - repoUrl: remoteUrl, - remoteName: matchingRemote, + repoOwner: resolved.parsed.owner, + repoName: resolved.parsed.name, + repoUrl: resolved.parsed.url, + remoteName: resolved.matchingRemote, }) .onConflictDoUpdate({ target: projects.id, set: { - repoPath, + repoPath: resolved.repoPath, repoProvider: "github", - repoOwner, - repoName, - repoUrl: remoteUrl, - remoteName: matchingRemote, + repoOwner: resolved.parsed.owner, + repoName: resolved.parsed.name, + repoUrl: resolved.parsed.url, + remoteName: resolved.matchingRemote, }, }) .run(); - return { repoPath }; + return { repoPath: resolved.repoPath }; }), // TODO: remove @@ -146,7 +147,7 @@ export const projectRouter = router({ async function importExistingRepo( localPath: string, expectedSlug: string, -): Promise { +): Promise { if (!existsSync(localPath)) { throw new TRPCError({ code: "BAD_REQUEST", @@ -154,8 +155,7 @@ async function importExistingRepo( }); } - const stat = statSync(localPath); - if (!stat.isDirectory()) { + if (!statSync(localPath).isDirectory()) { throw new TRPCError({ code: "BAD_REQUEST", message: `Path is not a directory: ${localPath}`, @@ -174,12 +174,12 @@ async function importExistingRepo( }); } - const remotes = await getAllRemoteUrls(simpleGit(gitRoot)); + const remotes = await getGitHubRemotes(simpleGit(gitRoot)); const matchingRemote = findMatchingRemote(remotes, expectedSlug); if (!matchingRemote) { const found = [...remotes.entries()] - .map(([name, url]) => `${name}: ${url}`) + .map(([name, parsed]) => `${name}: ${parsed.owner}/${parsed.name}`) .join(", "); throw new TRPCError({ code: "BAD_REQUEST", @@ -187,22 +187,34 @@ async function importExistingRepo( }); } - return gitRoot; + const parsed = remotes.get(matchingRemote)!; + + return { repoPath: gitRoot, matchingRemote, parsed }; } async function cloneRepo( repoCloneUrl: string, parentDir: string, -): Promise { - if (!existsSync(parentDir)) { + expectedSlug: string, +): Promise { + const resolvedParentDir = resolve(parentDir); + + if (!existsSync(resolvedParentDir)) { throw new TRPCError({ code: "BAD_REQUEST", - message: `Parent directory does not exist: ${parentDir}`, + message: `Parent directory does not exist: ${resolvedParentDir}`, + }); + } + + if (!statSync(resolvedParentDir).isDirectory()) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Parent path is not a directory: ${resolvedParentDir}`, }); } const repoName = extractRepoNameFromUrl(repoCloneUrl); - const targetPath = join(parentDir, repoName); + const targetPath = join(resolvedParentDir, repoName); if (existsSync(targetPath)) { throw new TRPCError({ @@ -211,16 +223,34 @@ async function cloneRepo( }); } - await simpleGit().clone(repoCloneUrl, targetPath); + try { + await simpleGit().clone(repoCloneUrl, targetPath); + } catch (err) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Failed to clone repository: ${err instanceof Error ? err.message : String(err)}`, + }); + } + + const remotes = await getGitHubRemotes(simpleGit(targetPath)); + const matchingRemote = findMatchingRemote(remotes, expectedSlug); - return targetPath; + if (!matchingRemote) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: "Cloned repo does not match expected GitHub remote", + }); + } + + return { + repoPath: targetPath, + matchingRemote, + parsed: remotes.get(matchingRemote)!, + }; } function extractRepoNameFromUrl(url: string): string { - // Handle both https://github.com/owner/repo.git and git@github.com:owner/repo.git - const slug = extractGitHubSlug(url); - if (slug) { - return slug.split("/")[1] ?? basename(url, ".git"); - } + const parsed = parseGitHubRemote(url); + if (parsed) return parsed.name; return basename(url, ".git"); } diff --git a/packages/host-service/src/trpc/router/project/utils/git-remote.ts b/packages/host-service/src/trpc/router/project/utils/git-remote.ts index f1b0d8a1b3d..35d8624f012 100644 --- a/packages/host-service/src/trpc/router/project/utils/git-remote.ts +++ b/packages/host-service/src/trpc/router/project/utils/git-remote.ts @@ -1,4 +1,10 @@ import type { SimpleGit } from "simple-git"; +import { + type ParsedGitHubRemote, + parseGitHubRemote, +} from "../../../../runtime/pull-requests/utils/parse-github-remote"; + +export type { ParsedGitHubRemote }; /** * Get all fetch remote URLs from a git repository. @@ -11,8 +17,8 @@ export async function getAllRemoteUrls( const output = await git.remote(["-v"]); if (!output) return remotes; - for (const line of output.trim().split("\n")) { - const match = line.match(/^(\S+)\s+(\S+)\s+\(fetch\)$/); + for (const line of output.trim().split(/\r?\n/)) { + const match = line.trim().match(/^(\S+)\s+(\S+)\s+\(fetch\)$/); if (match?.[1] && match[2]) { remotes.set(match[1], match[2]); } @@ -22,40 +28,37 @@ export async function getAllRemoteUrls( } /** - * Extract the GitHub owner/repo slug from a git remote URL. - * Handles both SSH and HTTPS formats: - * - git@github.com:org/repo.git → org/repo - * - https://github.com/org/repo.git → org/repo - * - https://github.com/org/repo → org/repo + * Parse all fetch remotes and return only GitHub ones as parsed objects. + * Returns a map of remote name → ParsedGitHubRemote. */ -export function extractGitHubSlug(remoteUrl: string): string | null { - // SSH format: git@github.com:owner/repo.git - const sshMatch = remoteUrl.match( - /^[\w.-]+@github\.com:([^/]+\/[^/]+?)(?:\.git)?$/, - ); - if (sshMatch?.[1]) return sshMatch[1]; - - // HTTPS format: https://github.com/owner/repo.git - const httpsMatch = remoteUrl.match( - /^https?:\/\/github\.com\/([^/]+\/[^/]+?)(?:\.git)?$/, - ); - if (httpsMatch?.[1]) return httpsMatch[1]; +export async function getGitHubRemotes( + git: SimpleGit, +): Promise> { + const rawRemotes = await getAllRemoteUrls(git); + const parsed = new Map(); - return null; + for (const [name, url] of rawRemotes) { + const result = parseGitHubRemote(url); + if (result) { + parsed.set(name, result); + } + } + + return parsed; } /** - * Check if any remote in the map matches the expected GitHub owner/repo slug. + * Check if any remote matches the expected GitHub owner/repo slug. * Returns the name of the matching remote, or null if none match. */ export function findMatchingRemote( - remotes: Map, + remotes: Map, expectedSlug: string, ): string | null { const normalized = expectedSlug.toLowerCase(); - for (const [name, url] of remotes) { - const slug = extractGitHubSlug(url); - if (slug && slug.toLowerCase() === normalized) { + for (const [name, parsed] of remotes) { + const slug = `${parsed.owner}/${parsed.name}`; + if (slug.toLowerCase() === normalized) { return name; } } From 30cbf5c5c92f98be4d1cc905a688c0afdb81f003 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 10 Apr 2026 16:38:02 -0700 Subject: [PATCH 4/6] fix(host-service): replace non-null assertions with explicit guards --- .../src/trpc/router/project/project.ts | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/packages/host-service/src/trpc/router/project/project.ts b/packages/host-service/src/trpc/router/project/project.ts index 423cc6b0a61..5f7e020a894 100644 --- a/packages/host-service/src/trpc/router/project/project.ts +++ b/packages/host-service/src/trpc/router/project/project.ts @@ -187,7 +187,13 @@ async function importExistingRepo( }); } - const parsed = remotes.get(matchingRemote)!; + const parsed = remotes.get(matchingRemote); + if (!parsed) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Remote "${matchingRemote}" matched but has no parsed data`, + }); + } return { repoPath: gitRoot, matchingRemote, parsed }; } @@ -242,11 +248,15 @@ async function cloneRepo( }); } - return { - repoPath: targetPath, - matchingRemote, - parsed: remotes.get(matchingRemote)!, - }; + const parsed = remotes.get(matchingRemote); + if (!parsed) { + throw new TRPCError({ + code: "INTERNAL_SERVER_ERROR", + message: `Remote "${matchingRemote}" matched but has no parsed data`, + }); + } + + return { repoPath: targetPath, matchingRemote, parsed }; } function extractRepoNameFromUrl(url: string): string { From 9eb7dc73a7e223d2215d7a0217173c1a18defcd6 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 10 Apr 2026 16:39:13 -0700 Subject: [PATCH 5/6] fix(host-service): clean up cloned directory on post-clone validation failure --- packages/host-service/src/trpc/router/project/project.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/host-service/src/trpc/router/project/project.ts b/packages/host-service/src/trpc/router/project/project.ts index 5f7e020a894..a201668a797 100644 --- a/packages/host-service/src/trpc/router/project/project.ts +++ b/packages/host-service/src/trpc/router/project/project.ts @@ -232,6 +232,9 @@ async function cloneRepo( try { await simpleGit().clone(repoCloneUrl, targetPath); } catch (err) { + if (existsSync(targetPath)) { + rmSync(targetPath, { recursive: true, force: true }); + } throw new TRPCError({ code: "BAD_REQUEST", message: `Failed to clone repository: ${err instanceof Error ? err.message : String(err)}`, @@ -242,6 +245,7 @@ async function cloneRepo( const matchingRemote = findMatchingRemote(remotes, expectedSlug); if (!matchingRemote) { + rmSync(targetPath, { recursive: true, force: true }); throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: "Cloned repo does not match expected GitHub remote", From c2b92ba57a92cd101215a242f781d184c3778e40 Mon Sep 17 00:00:00 2001 From: Kiet Ho Date: Fri, 10 Apr 2026 16:55:28 -0700 Subject: [PATCH 6/6] fix(host-service): add cleanup on all cloneRepo error paths --- packages/host-service/src/trpc/router/project/project.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/host-service/src/trpc/router/project/project.ts b/packages/host-service/src/trpc/router/project/project.ts index a201668a797..1a9b23b7d41 100644 --- a/packages/host-service/src/trpc/router/project/project.ts +++ b/packages/host-service/src/trpc/router/project/project.ts @@ -254,6 +254,7 @@ async function cloneRepo( const parsed = remotes.get(matchingRemote); if (!parsed) { + rmSync(targetPath, { recursive: true, force: true }); throw new TRPCError({ code: "INTERNAL_SERVER_ERROR", message: `Remote "${matchingRemote}" matched but has no parsed data`,