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