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