cli/src/commands/init.tsblame
View source
0fc32471import { intro, outro, select, spinner, isCancel, cancel, log } from "@clack/prompts";
8d8e8152import { execSync } from "node:child_process";
0fc32473import { writeFileSync, existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
8d8e8154import { join, basename, resolve } from "node:path";
8d8e8155import { homedir } from "node:os";
90d5eb86import { hubRequest, hubUploadStream } from "../api.js";
8d8e8157import { getHub } from "../config.js";
8d8e8158
8d8e8159interface Repo {
8d8e81510 id: number;
8d8e81511 owner_name: string;
8d8e81512 name: string;
8d8e81513 default_branch: string;
8d8e81514}
8d8e81515
59e666716interface User {
59e666717 id: number;
59e666718 username: string;
59e666719 display_name: string;
59e666720}
59e666721
59e666722interface Org {
59e666723 id: number;
59e666724 name: string;
59e666725 display_name: string;
59e666726}
59e666727
0fc324728function buildSlConfig(repo: Repo, hub: string) {
0fc324729 const tlsDir = join(homedir(), ".grove", "tls");
0fc324730 return `[paths]
0fc324731default = slapi:${repo.name}
0fc324732
0fc324733[remotefilelog]
0fc324734reponame = ${repo.name}
0fc324735
0fc324736[grove]
0fc324737owner = ${repo.owner_name}
0fc324738
0fc324739[auth]
0fc324740grove.prefix = ${hub}
0fc324741grove.cert = ${tlsDir}/client.crt
0fc324742grove.key = ${tlsDir}/client.key
0fc324743grove.cacerts = ${tlsDir}/ca.crt
0fc324744
0fc324745grove-mononoke.prefix = mononoke://grove.host
0fc324746grove-mononoke.cert = ${tlsDir}/client.crt
0fc324747grove-mononoke.key = ${tlsDir}/client.key
0fc324748grove-mononoke.cacerts = ${tlsDir}/ca.crt
0fc324749
0fc324750[edenapi]
0fc324751url = ${hub.replace(/\/$/, "")}:8443/edenapi/
0fc324752
0fc324753[clone]
0fc324754use-commit-graph = true
0fc324755
0fc324756[remotenames]
0fc324757selectivepulldefault = ${repo.default_branch}
0fc324758
0fc324759[push]
0fc324760edenapi = true
0fc324761to = ${repo.default_branch}
0fc324762`;
0fc324763}
8d8e81564
0fc324765async function resolveOwner(args: string[]): Promise<string | undefined> {
8d8e81566 const ownerIdx = args.indexOf("--owner");
0fc324767 if (ownerIdx !== -1) return args[ownerIdx + 1];
8d8e81568
0fc324769 const s = spinner();
0fc324770 s.start("Fetching user and organizations from Grove");
0fc324771 const [{ user }, { orgs }] = await Promise.all([
0fc324772 hubRequest<{ user: User }>("/api/auth/me"),
0fc324773 hubRequest<{ orgs: Org[] }>("/api/orgs"),
0fc324774 ]);
0fc324775 s.stop(`Logged in as ${user.username}` + (orgs.length > 0 ? ` (${orgs.length} org${orgs.length !== 1 ? "s" : ""})` : ""));
8d8e81576
0fc324777 const choices = [user.username, ...orgs.map((o) => o.name)];
8d8e81578
0fc324779 if (choices.length > 1) {
0fc324780 const selected = await select({
0fc324781 message: "Where should this repository live?",
0fc324782 options: choices.map((c, i) => ({
0fc324783 value: c,
0fc324784 label: i === 0 ? `${c} (personal)` : c,
0fc324785 })),
0fc324786 });
59e666787
0fc324788 if (isCancel(selected)) {
0fc324789 cancel("Operation cancelled.");
0fc324790 process.exit(0);
0fc324791 }
0fc324792
0fc324793 // Only set owner for orgs (not the personal account)
0fc324794 if (selected !== choices[0]) {
0fc324795 return selected as string;
59e666796 }
59e666797 }
59e666798
0fc324799 return undefined;
0fc3247100}
0fc3247101
0fc3247102async function createRepo(name: string, owner: string | undefined, description: string | undefined, isPrivate: boolean, skipSeed: boolean) {
0fc3247103 const s = spinner();
0fc3247104 s.start("Creating repository and provisioning Mononoke");
59e6667105 const messages = [
59e6667106 "Writing repo config",
59e6667107 "Restarting source control services",
59e6667108 "Waiting for health check",
59e6667109 ];
59e6667110 let msgIdx = 0;
59e6667111 const ticker = setInterval(() => {
59e6667112 if (msgIdx < messages.length) {
0fc3247113 s.message(messages[msgIdx]);
59e6667114 msgIdx++;
59e6667115 }
59e6667116 }, 3000);
8d8e815117 const { repo } = await hubRequest<{ repo: Repo }>("/api/repos", {
8d8e815118 method: "POST",
8d8e815119 body: JSON.stringify({
8d8e815120 name,
8d8e815121 description,
8d8e815122 owner,
8d8e815123 is_private: isPrivate,
0fc3247124 skip_seed: skipSeed,
8d8e815125 }),
8d8e815126 });
59e6667127 clearInterval(ticker);
0fc3247128 s.stop(`Created ${repo.owner_name}/${repo.name}`);
0fc3247129 return repo;
0fc3247130}
8d8e815131
0fc3247132function parsePositional(args: string[]): string | undefined {
0fc3247133 const flagsWithValues = new Set(["--owner", "--description"]);
0fc3247134 for (let i = 0; i < args.length; i++) {
0fc3247135 if (flagsWithValues.has(args[i])) { i++; continue; }
0fc3247136 if (args[i].startsWith("--")) continue;
0fc3247137 return args[i];
8d8e815138 }
0fc3247139 return undefined;
0fc3247140}
8d8e815141
0fc3247142export async function init(args: string[]) {
0fc3247143 const dir = resolve(parsePositional(args) ?? ".");
0fc3247144 const name = basename(dir);
0fc3247145 const isGitRepo = existsSync(join(dir, ".git"));
8d8e815146
0fc3247147 const descIdx = args.indexOf("--description");
0fc3247148 const description = descIdx !== -1 ? args[descIdx + 1] : undefined;
0fc3247149 const isPrivate = args.includes("--private");
8d8e815150
0fc3247151 intro(`grove init ${name}${isGitRepo ? " (importing from git)" : ""}`);
8d8e815152
0fc3247153 const owner = await resolveOwner(args);
8d8e815154
0fc3247155 if (isGitRepo) {
0fc3247156 await initFromGit(dir, name, owner, description, isPrivate);
0fc3247157 } else {
0fc3247158 await initFresh(dir, name, owner, description, isPrivate);
0fc3247159 }
0fc3247160}
8d8e815161
0fc3247162async function initFromGit(dir: string, name: string, owner: string | undefined, description: string | undefined, isPrivate: boolean) {
0fc3247163 // Detect default branch from the git repo
0fc3247164 let gitBranch = "main";
0fc3247165 try {
0fc3247166 gitBranch = execSync("git symbolic-ref --short HEAD", { cwd: dir, stdio: "pipe" }).toString().trim();
0fc3247167 } catch {
0fc3247168 // fall back to main
0fc3247169 }
8d8e815170
0fc3247171 // Count commits for user feedback
0fc3247172 let commitCount = "?";
0fc3247173 try {
0fc3247174 commitCount = execSync("git rev-list --count HEAD", { cwd: dir, stdio: "pipe" }).toString().trim();
0fc3247175 } catch {}
0fc3247176 log.info(`Found git repository with ${commitCount} commits on ${gitBranch}`);
8d8e815177
90d5eb8178 // Create Grove repo without seeding
0fc3247179 const repo = await createRepo(name, owner, description, isPrivate, true);
0fc3247180 const hub = await getHub();
90d5eb8181 const ownerName = repo.owner_name;
90d5eb8182
90d5eb8183 // Create bare clone and tar it up for upload
90d5eb8184 const tmpDir = join(dir, "..", `.grove-import-${name}-${Date.now()}`);
90d5eb8185 const bareDir = join(tmpDir, "bare.git");
90d5eb8186 const tarPath = join(tmpDir, "bare.tar.gz");
8d8e815187
0fc3247188 const s1 = spinner();
90d5eb8189 s1.start("Preparing git history for import");
90d5eb8190 try {
90d5eb8191 mkdirSync(tmpDir, { recursive: true });
90d5eb8192 execSync(`git clone --bare "${dir}" "${bareDir}"`, { stdio: "pipe" });
90d5eb8193 execSync(`tar czf "${tarPath}" -C "${tmpDir}" bare.git`, { stdio: "pipe" });
90d5eb8194 s1.stop("Bare clone ready");
90d5eb8195 } catch (e: any) {
90d5eb8196 s1.stop("Failed to create bare clone");
90d5eb8197 log.error(e.stderr?.toString() || e.message);
90d5eb8198 rmSync(tmpDir, { recursive: true, force: true });
90d5eb8199 process.exit(1);
90d5eb8200 }
90d5eb8201
90d5eb8202 // Upload to server and run gitimport
0fc3247203 const s2 = spinner();
90d5eb8204 s2.start("Importing git history into Grove");
0fc3247205 try {
90d5eb8206 await hubUploadStream(
90d5eb8207 `/api/repos/${ownerName}/${name}/import-bundle`,
90d5eb8208 tarPath,
90d5eb8209 (event, data) => {
90d5eb8210 if (event === "progress" && data.message) {
90d5eb8211 s2.message(data.message);
90d5eb8212 }
90d5eb8213 },
90d5eb8214 );
90d5eb8215 s2.stop("Git history imported");
90d5eb8216 } catch (e: any) {
90d5eb8217 s2.stop("Import failed");
90d5eb8218 log.error(e.message);
90d5eb8219 rmSync(tmpDir, { recursive: true, force: true });
90d5eb8220 process.exit(1);
90d5eb8221 }
90d5eb8222
90d5eb8223 // Remove temp upload files
90d5eb8224 rmSync(tmpDir, { recursive: true, force: true });
90d5eb8225
90d5eb8226 // Set up Sapling working copy by cloning from Grove
90d5eb8227 const s3 = spinner();
90d5eb8228 s3.start("Setting up Sapling working copy");
90d5eb8229 const cloneTmp = join(dir, "..", `.grove-clone-${name}-${Date.now()}`);
90d5eb8230 try {
90d5eb8231 // Clone into a temp dir, then move .sl into the working dir
90d5eb8232 execSync(`sl clone slapi:${name} "${cloneTmp}"`, { stdio: "pipe" });
90d5eb8233
90d5eb8234 // Remove .git and replace with .sl
90d5eb8235 rmSync(join(dir, ".git"), { recursive: true, force: true });
90d5eb8236 renameSync(join(cloneTmp, ".sl"), join(dir, ".sl"));
90d5eb8237
90d5eb8238 // Write config with correct remote settings
90d5eb8239 const config = buildSlConfig({ ...repo, default_branch: gitBranch }, hub);
90d5eb8240 writeFileSync(join(dir, ".sl", "config"), config);
90d5eb8241
90d5eb8242 s3.stop("Sapling working copy ready");
0fc3247243 } catch (e: any) {
90d5eb8244 s3.stop("Clone failed");
0fc3247245 log.error(e.stderr?.toString() || e.message);
90d5eb8246 rmSync(cloneTmp, { recursive: true, force: true });
0fc3247247 process.exit(1);
0fc3247248 }
0fc3247249
90d5eb8250 // Clean up clone temp dir
90d5eb8251 rmSync(cloneTmp, { recursive: true, force: true });
0fc3247252
90d5eb8253 outro(`Imported ${ownerName}/${name} with full git history`);
0fc3247254}
0fc3247255
0fc3247256async function initFresh(dir: string, name: string, owner: string | undefined, description: string | undefined, isPrivate: boolean) {
0fc3247257 const repo = await createRepo(name, owner, description, isPrivate, false);
0fc3247258
0fc3247259 // Init Sapling repo if not already one
0fc3247260 if (!existsSync(join(dir, ".sl"))) {
0fc3247261 mkdirSync(dir, { recursive: true });
0fc3247262 const s = spinner();
0fc3247263 s.start("Initializing Sapling repository");
0fc3247264 execSync(`sl init --config init.prefer-git=false --config format.use-remotefilelog=true ${dir}`, { stdio: "pipe" });
0fc3247265 s.stop("Sapling repository initialized");
0fc3247266 }
0fc3247267
0fc3247268 // Configure remote, auth, and push settings
0fc3247269 const s2 = spinner();
0fc3247270 s2.start("Configuring Grove remote");
0fc3247271 const hub = await getHub();
0fc3247272 writeFileSync(join(dir, ".sl", "config"), buildSlConfig(repo, hub));
0fc3247273 s2.stop("Remote configured");
59e6667274
c5a8edf275 // Pull the initial commit seeded by the API
0fc3247276 const s3 = spinner();
0fc3247277 s3.start("Pulling initial commit");
c5a8edf278 try {
c5a8edf279 execSync(`sl pull`, { cwd: dir, stdio: "pipe" });
c5a8edf280 execSync(`sl goto ${repo.default_branch}`, { cwd: dir, stdio: "pipe" });
0fc3247281 s3.stop("Pulled initial commit");
c5a8edf282 } catch {
c5a8edf283 // If pull fails (e.g. seed didn't complete), create a local initial commit
0fc3247284 s3.stop("Creating initial commit locally");
c5a8edf285 if (!existsSync(join(dir, "README.md"))) {
0fc3247286 writeFileSync(join(dir, "README.md"), `# ${name}\n`);
c5a8edf287 }
c5a8edf288 execSync(`sl add`, { cwd: dir, stdio: "pipe" });
c5a8edf289 execSync(`sl commit -m "Initial commit"`, { cwd: dir, stdio: "pipe" });
0fc3247290 const s4 = spinner();
0fc3247291 s4.start(`Pushing to ${repo.default_branch}`);
c5a8edf292 execSync(`sl push --to ${repo.default_branch} --create`, { cwd: dir, stdio: "pipe" });
0fc3247293 s4.stop(`Pushed to ${repo.default_branch}`);
59e6667294 }
59e6667295
59e6667296 outro(`Initialized ${repo.owner_name}/${repo.name}`);
8d8e815297}