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";
8d8e8156import { hubRequest } 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 // Get latest git commit message for the import commit
0fc3247172 let lastCommitMsg = "Import from git";
0fc3247173 try {
0fc3247174 lastCommitMsg = execSync("git log -1 --format=%s", { cwd: dir, stdio: "pipe" }).toString().trim();
0fc3247175 } catch {}
8d8e815176
0fc3247177 // Count commits for user feedback
0fc3247178 let commitCount = "?";
0fc3247179 try {
0fc3247180 commitCount = execSync("git rev-list --count HEAD", { cwd: dir, stdio: "pipe" }).toString().trim();
0fc3247181 } catch {}
0fc3247182 log.info(`Found git repository with ${commitCount} commits on ${gitBranch}`);
8d8e815183
0fc3247184 // Create Grove repo without seeding (we'll push the current tree)
0fc3247185 const repo = await createRepo(name, owner, description, isPrivate, true);
0fc3247186 const hub = await getHub();
0fc3247187 const config = buildSlConfig({ ...repo, default_branch: gitBranch }, hub);
8d8e815188
0fc3247189 // Rename .git out of the way so sl init doesn't conflict
0fc3247190 const s1 = spinner();
0fc3247191 s1.start("Converting to Sapling repository");
0fc3247192 const gitBackup = join(dir, ".git.bak");
0fc3247193 renameSync(join(dir, ".git"), gitBackup);
0fc3247194
0fc3247195 // Init Sapling and commit all current files
0fc3247196 execSync(`sl init --config init.prefer-git=false --config format.use-remotefilelog=true "${dir}"`, { stdio: "pipe" });
8d8e815197 writeFileSync(join(dir, ".sl", "config"), config);
0fc3247198 execSync(`sl add`, { cwd: dir, stdio: "pipe" });
0fc3247199 execSync(`sl commit -m "Import from git (${commitCount} commits)\n\nLast git commit: ${lastCommitMsg}"`, { cwd: dir, stdio: "pipe" });
0fc3247200 s1.stop("Sapling repository initialized with current tree");
0fc3247201
0fc3247202 // Push to Grove
0fc3247203 const s2 = spinner();
0fc3247204 s2.start(`Pushing to ${gitBranch}`);
0fc3247205 try {
0fc3247206 execSync(`sl push --to ${gitBranch} --create`, { cwd: dir, stdio: "pipe" });
0fc3247207 } catch (e: any) {
0fc3247208 s2.stop("Push failed");
0fc3247209 log.error(e.stderr?.toString() || e.message);
0fc3247210 // Restore .git so the user isn't left in a broken state
0fc3247211 rmSync(join(dir, ".sl"), { recursive: true, force: true });
0fc3247212 renameSync(gitBackup, join(dir, ".git"));
0fc3247213 process.exit(1);
0fc3247214 }
0fc3247215 s2.stop(`Pushed to ${gitBranch}`);
0fc3247216
0fc3247217 log.info(`Git data saved to .git.bak (safe to delete)`);
0fc3247218
0fc3247219 outro(`Imported ${repo.owner_name}/${repo.name} from git`);
0fc3247220}
0fc3247221
0fc3247222async function initFresh(dir: string, name: string, owner: string | undefined, description: string | undefined, isPrivate: boolean) {
0fc3247223 const repo = await createRepo(name, owner, description, isPrivate, false);
0fc3247224
0fc3247225 // Init Sapling repo if not already one
0fc3247226 if (!existsSync(join(dir, ".sl"))) {
0fc3247227 mkdirSync(dir, { recursive: true });
0fc3247228 const s = spinner();
0fc3247229 s.start("Initializing Sapling repository");
0fc3247230 execSync(`sl init --config init.prefer-git=false --config format.use-remotefilelog=true ${dir}`, { stdio: "pipe" });
0fc3247231 s.stop("Sapling repository initialized");
0fc3247232 }
0fc3247233
0fc3247234 // Configure remote, auth, and push settings
0fc3247235 const s2 = spinner();
0fc3247236 s2.start("Configuring Grove remote");
0fc3247237 const hub = await getHub();
0fc3247238 writeFileSync(join(dir, ".sl", "config"), buildSlConfig(repo, hub));
0fc3247239 s2.stop("Remote configured");
59e6667240
c5a8edf241 // Pull the initial commit seeded by the API
0fc3247242 const s3 = spinner();
0fc3247243 s3.start("Pulling initial commit");
c5a8edf244 try {
c5a8edf245 execSync(`sl pull`, { cwd: dir, stdio: "pipe" });
c5a8edf246 execSync(`sl goto ${repo.default_branch}`, { cwd: dir, stdio: "pipe" });
0fc3247247 s3.stop("Pulled initial commit");
c5a8edf248 } catch {
c5a8edf249 // If pull fails (e.g. seed didn't complete), create a local initial commit
0fc3247250 s3.stop("Creating initial commit locally");
c5a8edf251 if (!existsSync(join(dir, "README.md"))) {
0fc3247252 writeFileSync(join(dir, "README.md"), `# ${name}\n`);
c5a8edf253 }
c5a8edf254 execSync(`sl add`, { cwd: dir, stdio: "pipe" });
c5a8edf255 execSync(`sl commit -m "Initial commit"`, { cwd: dir, stdio: "pipe" });
0fc3247256 const s4 = spinner();
0fc3247257 s4.start(`Pushing to ${repo.default_branch}`);
c5a8edf258 execSync(`sl push --to ${repo.default_branch} --create`, { cwd: dir, stdio: "pipe" });
0fc3247259 s4.stop(`Pushed to ${repo.default_branch}`);
59e6667260 }
59e6667261
59e6667262 outro(`Initialized ${repo.owner_name}/${repo.name}`);
8d8e815263}