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";
90d5eb85import { hubRequest, hubUploadStream } from "../api.js";
8d8e8156import { getHub } from "../config.js";
0d9d7237import { buildSlConfig } from "../sl-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
0fc324728async function resolveOwner(args: string[]): Promise<string | undefined> {
8d8e81529 const ownerIdx = args.indexOf("--owner");
0fc324730 if (ownerIdx !== -1) return args[ownerIdx + 1];
8d8e81531
0fc324732 const s = spinner();
0fc324733 s.start("Fetching user and organizations from Grove");
0fc324734 const [{ user }, { orgs }] = await Promise.all([
0fc324735 hubRequest<{ user: User }>("/api/auth/me"),
0fc324736 hubRequest<{ orgs: Org[] }>("/api/orgs"),
0fc324737 ]);
0fc324738 s.stop(`Logged in as ${user.username}` + (orgs.length > 0 ? ` (${orgs.length} org${orgs.length !== 1 ? "s" : ""})` : ""));
8d8e81539
0fc324740 const choices = [user.username, ...orgs.map((o) => o.name)];
8d8e81541
0fc324742 if (choices.length > 1) {
0fc324743 const selected = await select({
0fc324744 message: "Where should this repository live?",
0fc324745 options: choices.map((c, i) => ({
0fc324746 value: c,
0fc324747 label: i === 0 ? `${c} (personal)` : c,
0fc324748 })),
0fc324749 });
59e666750
0fc324751 if (isCancel(selected)) {
0fc324752 cancel("Operation cancelled.");
0fc324753 process.exit(0);
0fc324754 }
0fc324755
0fc324756 // Only set owner for orgs (not the personal account)
0fc324757 if (selected !== choices[0]) {
0fc324758 return selected as string;
59e666759 }
59e666760 }
59e666761
0fc324762 return undefined;
0fc324763}
0fc324764
0d9d72365async function createRepo(name: string, owner: string | undefined, description: string | undefined, isPrivate: boolean, skipSeed: boolean, defaultBranch?: string) {
0fc324766 const s = spinner();
0fc324767 s.start("Creating repository and provisioning Mononoke");
59e666768 const messages = [
59e666769 "Writing repo config",
59e666770 "Restarting source control services",
59e666771 "Waiting for health check",
59e666772 ];
59e666773 let msgIdx = 0;
59e666774 const ticker = setInterval(() => {
59e666775 if (msgIdx < messages.length) {
0fc324776 s.message(messages[msgIdx]);
59e666777 msgIdx++;
59e666778 }
59e666779 }, 3000);
8d8e81580 const { repo } = await hubRequest<{ repo: Repo }>("/api/repos", {
8d8e81581 method: "POST",
8d8e81582 body: JSON.stringify({
8d8e81583 name,
8d8e81584 description,
8d8e81585 owner,
8d8e81586 is_private: isPrivate,
0fc324787 skip_seed: skipSeed,
0d9d72388 default_branch: defaultBranch,
8d8e81589 }),
8d8e81590 });
59e666791 clearInterval(ticker);
0fc324792 s.stop(`Created ${repo.owner_name}/${repo.name}`);
0fc324793 return repo;
0fc324794}
8d8e81595
0fc324796function parsePositional(args: string[]): string | undefined {
0d9d72397 const flagsWithValues = new Set(["--owner", "--description", "--branch"]);
0fc324798 for (let i = 0; i < args.length; i++) {
0fc324799 if (flagsWithValues.has(args[i])) { i++; continue; }
0fc3247100 if (args[i].startsWith("--")) continue;
0fc3247101 return args[i];
8d8e815102 }
0fc3247103 return undefined;
0fc3247104}
8d8e815105
0fc3247106export async function init(args: string[]) {
0fc3247107 const dir = resolve(parsePositional(args) ?? ".");
0fc3247108 const name = basename(dir);
0fc3247109 const isGitRepo = existsSync(join(dir, ".git"));
8d8e815110
0fc3247111 const descIdx = args.indexOf("--description");
0fc3247112 const description = descIdx !== -1 ? args[descIdx + 1] : undefined;
0d9d723113 const branchIdx = args.indexOf("--branch");
0d9d723114 const defaultBranch = branchIdx !== -1 ? args[branchIdx + 1] : undefined;
0fc3247115 const isPrivate = args.includes("--private");
8d8e815116
0fc3247117 intro(`grove init ${name}${isGitRepo ? " (importing from git)" : ""}`);
8d8e815118
0fc3247119 const owner = await resolveOwner(args);
8d8e815120
0fc3247121 if (isGitRepo) {
0fc3247122 await initFromGit(dir, name, owner, description, isPrivate);
0fc3247123 } else {
0d9d723124 await initFresh(dir, name, owner, description, isPrivate, defaultBranch);
0fc3247125 }
0fc3247126}
8d8e815127
0fc3247128async function initFromGit(dir: string, name: string, owner: string | undefined, description: string | undefined, isPrivate: boolean) {
0fc3247129 // Detect default branch from the git repo
0fc3247130 let gitBranch = "main";
0fc3247131 try {
0fc3247132 gitBranch = execSync("git symbolic-ref --short HEAD", { cwd: dir, stdio: "pipe" }).toString().trim();
0fc3247133 } catch {
0fc3247134 // fall back to main
0fc3247135 }
8d8e815136
0fc3247137 // Count commits for user feedback
0fc3247138 let commitCount = "?";
0fc3247139 try {
0fc3247140 commitCount = execSync("git rev-list --count HEAD", { cwd: dir, stdio: "pipe" }).toString().trim();
0fc3247141 } catch {}
0fc3247142 log.info(`Found git repository with ${commitCount} commits on ${gitBranch}`);
8d8e815143
90d5eb8144 // Create Grove repo without seeding
0fc3247145 const repo = await createRepo(name, owner, description, isPrivate, true);
0fc3247146 const hub = await getHub();
90d5eb8147 const ownerName = repo.owner_name;
90d5eb8148
90d5eb8149 // Create bare clone and tar it up for upload
90d5eb8150 const tmpDir = join(dir, "..", `.grove-import-${name}-${Date.now()}`);
90d5eb8151 const bareDir = join(tmpDir, "bare.git");
90d5eb8152 const tarPath = join(tmpDir, "bare.tar.gz");
8d8e815153
0fc3247154 const s1 = spinner();
90d5eb8155 s1.start("Preparing git history for import");
90d5eb8156 try {
90d5eb8157 mkdirSync(tmpDir, { recursive: true });
90d5eb8158 execSync(`git clone --bare "${dir}" "${bareDir}"`, { stdio: "pipe" });
90d5eb8159 execSync(`tar czf "${tarPath}" -C "${tmpDir}" bare.git`, { stdio: "pipe" });
90d5eb8160 s1.stop("Bare clone ready");
90d5eb8161 } catch (e: any) {
90d5eb8162 s1.stop("Failed to create bare clone");
90d5eb8163 log.error(e.stderr?.toString() || e.message);
90d5eb8164 rmSync(tmpDir, { recursive: true, force: true });
90d5eb8165 process.exit(1);
90d5eb8166 }
90d5eb8167
90d5eb8168 // Upload to server and run gitimport
0fc3247169 const s2 = spinner();
90d5eb8170 s2.start("Importing git history into Grove");
0fc3247171 try {
90d5eb8172 await hubUploadStream(
90d5eb8173 `/api/repos/${ownerName}/${name}/import-bundle`,
90d5eb8174 tarPath,
90d5eb8175 (event, data) => {
90d5eb8176 if (event === "progress" && data.message) {
90d5eb8177 s2.message(data.message);
90d5eb8178 }
90d5eb8179 },
90d5eb8180 );
90d5eb8181 s2.stop("Git history imported");
90d5eb8182 } catch (e: any) {
90d5eb8183 s2.stop("Import failed");
90d5eb8184 log.error(e.message);
90d5eb8185 rmSync(tmpDir, { recursive: true, force: true });
90d5eb8186 process.exit(1);
90d5eb8187 }
90d5eb8188
90d5eb8189 // Remove temp upload files
90d5eb8190 rmSync(tmpDir, { recursive: true, force: true });
90d5eb8191
56f671e192 // Set up Sapling working copy: init, configure, pull, checkout
90d5eb8193 const s3 = spinner();
90d5eb8194 s3.start("Setting up Sapling working copy");
90d5eb8195 try {
56f671e196 // Remove .git and init Sapling in place
90d5eb8197 rmSync(join(dir, ".git"), { recursive: true, force: true });
56f671e198 execSync(`sl init --config init.prefer-git=false --config format.use-remotefilelog=true "${dir}"`, { stdio: "pipe" });
90d5eb8199
56f671e200 // Write config with correct remote and auth settings
90d5eb8201 const config = buildSlConfig({ ...repo, default_branch: gitBranch }, hub);
90d5eb8202 writeFileSync(join(dir, ".sl", "config"), config);
90d5eb8203
56f671e204 // Pull commits and checkout (pull may warn about "master" prefetch, which is non-fatal)
56f671e205 try { execSync(`sl pull`, { cwd: dir, stdio: "pipe" }); } catch {}
56f671e206 execSync(`sl goto ${gitBranch}`, { cwd: dir, stdio: "pipe" });
6d52207207
90d5eb8208 s3.stop("Sapling working copy ready");
0fc3247209 } catch (e: any) {
56f671e210 s3.stop("Failed to set up working copy");
0fc3247211 log.error(e.stderr?.toString() || e.message);
0fc3247212 process.exit(1);
0fc3247213 }
0fc3247214
90d5eb8215 outro(`Imported ${ownerName}/${name} with full git history`);
0fc3247216}
0fc3247217
0d9d723218async function initFresh(dir: string, name: string, owner: string | undefined, description: string | undefined, isPrivate: boolean, defaultBranch?: string) {
0d9d723219 const repo = await createRepo(name, owner, description, isPrivate, false, defaultBranch);
0fc3247220
0fc3247221 // Init Sapling repo if not already one
0fc3247222 if (!existsSync(join(dir, ".sl"))) {
0fc3247223 mkdirSync(dir, { recursive: true });
0fc3247224 const s = spinner();
0fc3247225 s.start("Initializing Sapling repository");
0fc3247226 execSync(`sl init --config init.prefer-git=false --config format.use-remotefilelog=true ${dir}`, { stdio: "pipe" });
0fc3247227 s.stop("Sapling repository initialized");
0fc3247228 }
0fc3247229
0fc3247230 // Configure remote, auth, and push settings
0fc3247231 const s2 = spinner();
0fc3247232 s2.start("Configuring Grove remote");
0fc3247233 const hub = await getHub();
0fc3247234 writeFileSync(join(dir, ".sl", "config"), buildSlConfig(repo, hub));
0fc3247235 s2.stop("Remote configured");
59e6667236
c5a8edf237 // Pull the initial commit seeded by the API
0fc3247238 const s3 = spinner();
0fc3247239 s3.start("Pulling initial commit");
c5a8edf240 try {
c5a8edf241 execSync(`sl pull`, { cwd: dir, stdio: "pipe" });
c5a8edf242 execSync(`sl goto ${repo.default_branch}`, { cwd: dir, stdio: "pipe" });
0fc3247243 s3.stop("Pulled initial commit");
c5a8edf244 } catch {
c5a8edf245 // If pull fails (e.g. seed didn't complete), create a local initial commit
0fc3247246 s3.stop("Creating initial commit locally");
c5a8edf247 if (!existsSync(join(dir, "README.md"))) {
0fc3247248 writeFileSync(join(dir, "README.md"), `# ${name}\n`);
c5a8edf249 }
c5a8edf250 execSync(`sl add`, { cwd: dir, stdio: "pipe" });
c5a8edf251 execSync(`sl commit -m "Initial commit"`, { cwd: dir, stdio: "pipe" });
0fc3247252 const s4 = spinner();
0fc3247253 s4.start(`Pushing to ${repo.default_branch}`);
c5a8edf254 execSync(`sl push --to ${repo.default_branch} --create`, { cwd: dir, stdio: "pipe" });
0fc3247255 s4.stop(`Pushed to ${repo.default_branch}`);
59e6667256 }
59e6667257
59e6667258 outro(`Initialized ${repo.owner_name}/${repo.name}`);
8d8e815259}