| 0fc3247 | | | 1 | import { intro, outro, select, spinner, isCancel, cancel, log } from "@clack/prompts"; |
| 8d8e815 | | | 2 | import { execSync } from "node:child_process"; |
| 0fc3247 | | | 3 | import { writeFileSync, existsSync, mkdirSync, renameSync, rmSync } from "node:fs"; |
| 8d8e815 | | | 4 | import { join, basename, resolve } from "node:path"; |
| 90d5eb8 | | | 5 | import { hubRequest, hubUploadStream } from "../api.js"; |
| 8d8e815 | | | 6 | import { getHub } from "../config.js"; |
| 0d9d723 | | | 7 | import { buildSlConfig } from "../sl-config.js"; |
| 8d8e815 | | | 8 | |
| 8d8e815 | | | 9 | interface Repo { |
| 8d8e815 | | | 10 | id: number; |
| 8d8e815 | | | 11 | owner_name: string; |
| 8d8e815 | | | 12 | name: string; |
| 8d8e815 | | | 13 | default_branch: string; |
| 8d8e815 | | | 14 | } |
| 8d8e815 | | | 15 | |
| 59e6667 | | | 16 | interface User { |
| 59e6667 | | | 17 | id: number; |
| 59e6667 | | | 18 | username: string; |
| 59e6667 | | | 19 | display_name: string; |
| 59e6667 | | | 20 | } |
| 59e6667 | | | 21 | |
| 59e6667 | | | 22 | interface Org { |
| 59e6667 | | | 23 | id: number; |
| 59e6667 | | | 24 | name: string; |
| 59e6667 | | | 25 | display_name: string; |
| 59e6667 | | | 26 | } |
| 59e6667 | | | 27 | |
| 0fc3247 | | | 28 | async function resolveOwner(args: string[]): Promise<string | undefined> { |
| 8d8e815 | | | 29 | const ownerIdx = args.indexOf("--owner"); |
| 0fc3247 | | | 30 | if (ownerIdx !== -1) return args[ownerIdx + 1]; |
| 8d8e815 | | | 31 | |
| 0fc3247 | | | 32 | const s = spinner(); |
| 0fc3247 | | | 33 | s.start("Fetching user and organizations from Grove"); |
| 0fc3247 | | | 34 | const [{ user }, { orgs }] = await Promise.all([ |
| 0fc3247 | | | 35 | hubRequest<{ user: User }>("/api/auth/me"), |
| 0fc3247 | | | 36 | hubRequest<{ orgs: Org[] }>("/api/orgs"), |
| 0fc3247 | | | 37 | ]); |
| 0fc3247 | | | 38 | s.stop(`Logged in as ${user.username}` + (orgs.length > 0 ? ` (${orgs.length} org${orgs.length !== 1 ? "s" : ""})` : "")); |
| 8d8e815 | | | 39 | |
| 0fc3247 | | | 40 | const choices = [user.username, ...orgs.map((o) => o.name)]; |
| 8d8e815 | | | 41 | |
| 0fc3247 | | | 42 | if (choices.length > 1) { |
| 0fc3247 | | | 43 | const selected = await select({ |
| 0fc3247 | | | 44 | message: "Where should this repository live?", |
| 0fc3247 | | | 45 | options: choices.map((c, i) => ({ |
| 0fc3247 | | | 46 | value: c, |
| 0fc3247 | | | 47 | label: i === 0 ? `${c} (personal)` : c, |
| 0fc3247 | | | 48 | })), |
| 0fc3247 | | | 49 | }); |
| 59e6667 | | | 50 | |
| 0fc3247 | | | 51 | if (isCancel(selected)) { |
| 0fc3247 | | | 52 | cancel("Operation cancelled."); |
| 0fc3247 | | | 53 | process.exit(0); |
| 0fc3247 | | | 54 | } |
| 0fc3247 | | | 55 | |
| 0fc3247 | | | 56 | // Only set owner for orgs (not the personal account) |
| 0fc3247 | | | 57 | if (selected !== choices[0]) { |
| 0fc3247 | | | 58 | return selected as string; |
| 59e6667 | | | 59 | } |
| 59e6667 | | | 60 | } |
| 59e6667 | | | 61 | |
| 0fc3247 | | | 62 | return undefined; |
| 0fc3247 | | | 63 | } |
| 0fc3247 | | | 64 | |
| 0d9d723 | | | 65 | async function createRepo(name: string, owner: string | undefined, description: string | undefined, isPrivate: boolean, skipSeed: boolean, defaultBranch?: string) { |
| 0fc3247 | | | 66 | const s = spinner(); |
| 0fc3247 | | | 67 | s.start("Creating repository and provisioning Mononoke"); |
| 59e6667 | | | 68 | const messages = [ |
| 59e6667 | | | 69 | "Writing repo config", |
| 59e6667 | | | 70 | "Restarting source control services", |
| 59e6667 | | | 71 | "Waiting for health check", |
| 59e6667 | | | 72 | ]; |
| 59e6667 | | | 73 | let msgIdx = 0; |
| 59e6667 | | | 74 | const ticker = setInterval(() => { |
| 59e6667 | | | 75 | if (msgIdx < messages.length) { |
| 0fc3247 | | | 76 | s.message(messages[msgIdx]); |
| 59e6667 | | | 77 | msgIdx++; |
| 59e6667 | | | 78 | } |
| 59e6667 | | | 79 | }, 3000); |
| 8d8e815 | | | 80 | const { repo } = await hubRequest<{ repo: Repo }>("/api/repos", { |
| 8d8e815 | | | 81 | method: "POST", |
| 8d8e815 | | | 82 | body: JSON.stringify({ |
| 8d8e815 | | | 83 | name, |
| 8d8e815 | | | 84 | description, |
| 8d8e815 | | | 85 | owner, |
| 8d8e815 | | | 86 | is_private: isPrivate, |
| 0fc3247 | | | 87 | skip_seed: skipSeed, |
| 0d9d723 | | | 88 | default_branch: defaultBranch, |
| 8d8e815 | | | 89 | }), |
| 8d8e815 | | | 90 | }); |
| 59e6667 | | | 91 | clearInterval(ticker); |
| 0fc3247 | | | 92 | s.stop(`Created ${repo.owner_name}/${repo.name}`); |
| 0fc3247 | | | 93 | return repo; |
| 0fc3247 | | | 94 | } |
| 8d8e815 | | | 95 | |
| 0fc3247 | | | 96 | function parsePositional(args: string[]): string | undefined { |
| 0d9d723 | | | 97 | const flagsWithValues = new Set(["--owner", "--description", "--branch"]); |
| 0fc3247 | | | 98 | for (let i = 0; i < args.length; i++) { |
| 0fc3247 | | | 99 | if (flagsWithValues.has(args[i])) { i++; continue; } |
| 0fc3247 | | | 100 | if (args[i].startsWith("--")) continue; |
| 0fc3247 | | | 101 | return args[i]; |
| 8d8e815 | | | 102 | } |
| 0fc3247 | | | 103 | return undefined; |
| 0fc3247 | | | 104 | } |
| 8d8e815 | | | 105 | |
| 0fc3247 | | | 106 | export async function init(args: string[]) { |
| 0fc3247 | | | 107 | const dir = resolve(parsePositional(args) ?? "."); |
| 0fc3247 | | | 108 | const name = basename(dir); |
| 0fc3247 | | | 109 | const isGitRepo = existsSync(join(dir, ".git")); |
| 8d8e815 | | | 110 | |
| 0fc3247 | | | 111 | const descIdx = args.indexOf("--description"); |
| 0fc3247 | | | 112 | const description = descIdx !== -1 ? args[descIdx + 1] : undefined; |
| 0d9d723 | | | 113 | const branchIdx = args.indexOf("--branch"); |
| 0d9d723 | | | 114 | const defaultBranch = branchIdx !== -1 ? args[branchIdx + 1] : undefined; |
| 0fc3247 | | | 115 | const isPrivate = args.includes("--private"); |
| 8d8e815 | | | 116 | |
| 0fc3247 | | | 117 | intro(`grove init ${name}${isGitRepo ? " (importing from git)" : ""}`); |
| 8d8e815 | | | 118 | |
| 0fc3247 | | | 119 | const owner = await resolveOwner(args); |
| 8d8e815 | | | 120 | |
| 0fc3247 | | | 121 | if (isGitRepo) { |
| 0fc3247 | | | 122 | await initFromGit(dir, name, owner, description, isPrivate); |
| 0fc3247 | | | 123 | } else { |
| 0d9d723 | | | 124 | await initFresh(dir, name, owner, description, isPrivate, defaultBranch); |
| 0fc3247 | | | 125 | } |
| 0fc3247 | | | 126 | } |
| 8d8e815 | | | 127 | |
| 0fc3247 | | | 128 | async function initFromGit(dir: string, name: string, owner: string | undefined, description: string | undefined, isPrivate: boolean) { |
| 0fc3247 | | | 129 | // Detect default branch from the git repo |
| 0fc3247 | | | 130 | let gitBranch = "main"; |
| 0fc3247 | | | 131 | try { |
| 0fc3247 | | | 132 | gitBranch = execSync("git symbolic-ref --short HEAD", { cwd: dir, stdio: "pipe" }).toString().trim(); |
| 0fc3247 | | | 133 | } catch { |
| 0fc3247 | | | 134 | // fall back to main |
| 0fc3247 | | | 135 | } |
| 8d8e815 | | | 136 | |
| 0fc3247 | | | 137 | // Count commits for user feedback |
| 0fc3247 | | | 138 | let commitCount = "?"; |
| 0fc3247 | | | 139 | try { |
| 0fc3247 | | | 140 | commitCount = execSync("git rev-list --count HEAD", { cwd: dir, stdio: "pipe" }).toString().trim(); |
| 0fc3247 | | | 141 | } catch {} |
| 0fc3247 | | | 142 | log.info(`Found git repository with ${commitCount} commits on ${gitBranch}`); |
| 8d8e815 | | | 143 | |
| 90d5eb8 | | | 144 | // Create Grove repo without seeding |
| 0fc3247 | | | 145 | const repo = await createRepo(name, owner, description, isPrivate, true); |
| 0fc3247 | | | 146 | const hub = await getHub(); |
| 90d5eb8 | | | 147 | const ownerName = repo.owner_name; |
| 90d5eb8 | | | 148 | |
| 90d5eb8 | | | 149 | // Create bare clone and tar it up for upload |
| 90d5eb8 | | | 150 | const tmpDir = join(dir, "..", `.grove-import-${name}-${Date.now()}`); |
| 90d5eb8 | | | 151 | const bareDir = join(tmpDir, "bare.git"); |
| 90d5eb8 | | | 152 | const tarPath = join(tmpDir, "bare.tar.gz"); |
| 8d8e815 | | | 153 | |
| 0fc3247 | | | 154 | const s1 = spinner(); |
| 90d5eb8 | | | 155 | s1.start("Preparing git history for import"); |
| 90d5eb8 | | | 156 | try { |
| 90d5eb8 | | | 157 | mkdirSync(tmpDir, { recursive: true }); |
| 90d5eb8 | | | 158 | execSync(`git clone --bare "${dir}" "${bareDir}"`, { stdio: "pipe" }); |
| 90d5eb8 | | | 159 | execSync(`tar czf "${tarPath}" -C "${tmpDir}" bare.git`, { stdio: "pipe" }); |
| 90d5eb8 | | | 160 | s1.stop("Bare clone ready"); |
| 90d5eb8 | | | 161 | } catch (e: any) { |
| 90d5eb8 | | | 162 | s1.stop("Failed to create bare clone"); |
| 90d5eb8 | | | 163 | log.error(e.stderr?.toString() || e.message); |
| 90d5eb8 | | | 164 | rmSync(tmpDir, { recursive: true, force: true }); |
| 90d5eb8 | | | 165 | process.exit(1); |
| 90d5eb8 | | | 166 | } |
| 90d5eb8 | | | 167 | |
| 90d5eb8 | | | 168 | // Upload to server and run gitimport |
| 0fc3247 | | | 169 | const s2 = spinner(); |
| 90d5eb8 | | | 170 | s2.start("Importing git history into Grove"); |
| 0fc3247 | | | 171 | try { |
| 90d5eb8 | | | 172 | await hubUploadStream( |
| 90d5eb8 | | | 173 | `/api/repos/${ownerName}/${name}/import-bundle`, |
| 90d5eb8 | | | 174 | tarPath, |
| 90d5eb8 | | | 175 | (event, data) => { |
| 90d5eb8 | | | 176 | if (event === "progress" && data.message) { |
| 90d5eb8 | | | 177 | s2.message(data.message); |
| 90d5eb8 | | | 178 | } |
| 90d5eb8 | | | 179 | }, |
| 90d5eb8 | | | 180 | ); |
| 90d5eb8 | | | 181 | s2.stop("Git history imported"); |
| 90d5eb8 | | | 182 | } catch (e: any) { |
| 90d5eb8 | | | 183 | s2.stop("Import failed"); |
| 90d5eb8 | | | 184 | log.error(e.message); |
| 90d5eb8 | | | 185 | rmSync(tmpDir, { recursive: true, force: true }); |
| 90d5eb8 | | | 186 | process.exit(1); |
| 90d5eb8 | | | 187 | } |
| 90d5eb8 | | | 188 | |
| 90d5eb8 | | | 189 | // Remove temp upload files |
| 90d5eb8 | | | 190 | rmSync(tmpDir, { recursive: true, force: true }); |
| 90d5eb8 | | | 191 | |
| 56f671e | | | 192 | // Set up Sapling working copy: init, configure, pull, checkout |
| 90d5eb8 | | | 193 | const s3 = spinner(); |
| 90d5eb8 | | | 194 | s3.start("Setting up Sapling working copy"); |
| 90d5eb8 | | | 195 | try { |
| 56f671e | | | 196 | // Remove .git and init Sapling in place |
| 90d5eb8 | | | 197 | rmSync(join(dir, ".git"), { recursive: true, force: true }); |
| 56f671e | | | 198 | execSync(`sl init --config init.prefer-git=false --config format.use-remotefilelog=true "${dir}"`, { stdio: "pipe" }); |
| 90d5eb8 | | | 199 | |
| 56f671e | | | 200 | // Write config with correct remote and auth settings |
| 90d5eb8 | | | 201 | const config = buildSlConfig({ ...repo, default_branch: gitBranch }, hub); |
| 90d5eb8 | | | 202 | writeFileSync(join(dir, ".sl", "config"), config); |
| 90d5eb8 | | | 203 | |
| 56f671e | | | 204 | // Pull commits and checkout (pull may warn about "master" prefetch, which is non-fatal) |
| 56f671e | | | 205 | try { execSync(`sl pull`, { cwd: dir, stdio: "pipe" }); } catch {} |
| 56f671e | | | 206 | execSync(`sl goto ${gitBranch}`, { cwd: dir, stdio: "pipe" }); |
| 6d52207 | | | 207 | |
| 90d5eb8 | | | 208 | s3.stop("Sapling working copy ready"); |
| 0fc3247 | | | 209 | } catch (e: any) { |
| 56f671e | | | 210 | s3.stop("Failed to set up working copy"); |
| 0fc3247 | | | 211 | log.error(e.stderr?.toString() || e.message); |
| 0fc3247 | | | 212 | process.exit(1); |
| 0fc3247 | | | 213 | } |
| 0fc3247 | | | 214 | |
| 90d5eb8 | | | 215 | outro(`Imported ${ownerName}/${name} with full git history`); |
| 0fc3247 | | | 216 | } |
| 0fc3247 | | | 217 | |
| 0d9d723 | | | 218 | async function initFresh(dir: string, name: string, owner: string | undefined, description: string | undefined, isPrivate: boolean, defaultBranch?: string) { |
| 0d9d723 | | | 219 | const repo = await createRepo(name, owner, description, isPrivate, false, defaultBranch); |
| 0fc3247 | | | 220 | |
| 0fc3247 | | | 221 | // Init Sapling repo if not already one |
| 0fc3247 | | | 222 | if (!existsSync(join(dir, ".sl"))) { |
| 0fc3247 | | | 223 | mkdirSync(dir, { recursive: true }); |
| 0fc3247 | | | 224 | const s = spinner(); |
| 0fc3247 | | | 225 | s.start("Initializing Sapling repository"); |
| 0fc3247 | | | 226 | execSync(`sl init --config init.prefer-git=false --config format.use-remotefilelog=true ${dir}`, { stdio: "pipe" }); |
| 0fc3247 | | | 227 | s.stop("Sapling repository initialized"); |
| 0fc3247 | | | 228 | } |
| 0fc3247 | | | 229 | |
| 0fc3247 | | | 230 | // Configure remote, auth, and push settings |
| 0fc3247 | | | 231 | const s2 = spinner(); |
| 0fc3247 | | | 232 | s2.start("Configuring Grove remote"); |
| 0fc3247 | | | 233 | const hub = await getHub(); |
| 0fc3247 | | | 234 | writeFileSync(join(dir, ".sl", "config"), buildSlConfig(repo, hub)); |
| 0fc3247 | | | 235 | s2.stop("Remote configured"); |
| 59e6667 | | | 236 | |
| c5a8edf | | | 237 | // Pull the initial commit seeded by the API |
| 0fc3247 | | | 238 | const s3 = spinner(); |
| 0fc3247 | | | 239 | s3.start("Pulling initial commit"); |
| c5a8edf | | | 240 | try { |
| c5a8edf | | | 241 | execSync(`sl pull`, { cwd: dir, stdio: "pipe" }); |
| c5a8edf | | | 242 | execSync(`sl goto ${repo.default_branch}`, { cwd: dir, stdio: "pipe" }); |
| 0fc3247 | | | 243 | s3.stop("Pulled initial commit"); |
| c5a8edf | | | 244 | } catch { |
| c5a8edf | | | 245 | // If pull fails (e.g. seed didn't complete), create a local initial commit |
| 0fc3247 | | | 246 | s3.stop("Creating initial commit locally"); |
| c5a8edf | | | 247 | if (!existsSync(join(dir, "README.md"))) { |
| 0fc3247 | | | 248 | writeFileSync(join(dir, "README.md"), `# ${name}\n`); |
| c5a8edf | | | 249 | } |
| c5a8edf | | | 250 | execSync(`sl add`, { cwd: dir, stdio: "pipe" }); |
| c5a8edf | | | 251 | execSync(`sl commit -m "Initial commit"`, { cwd: dir, stdio: "pipe" }); |
| 0fc3247 | | | 252 | const s4 = spinner(); |
| 0fc3247 | | | 253 | s4.start(`Pushing to ${repo.default_branch}`); |
| c5a8edf | | | 254 | execSync(`sl push --to ${repo.default_branch} --create`, { cwd: dir, stdio: "pipe" }); |
| 0fc3247 | | | 255 | s4.stop(`Pushed to ${repo.default_branch}`); |
| 59e6667 | | | 256 | } |
| 59e6667 | | | 257 | |
| 59e6667 | | | 258 | outro(`Initialized ${repo.owner_name}/${repo.name}`); |
| 8d8e815 | | | 259 | } |