8.6 KB260 lines
Blame
1import { intro, outro, select, spinner, isCancel, cancel, log } from "@clack/prompts";
2import { execSync } from "node:child_process";
3import { writeFileSync, existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
4import { join, basename, resolve } from "node:path";
5import { hubRequest, hubUploadStream } from "../api.js";
6import { getHub } from "../config.js";
7import { buildSlConfig } from "../sl-config.js";
8
9interface Repo {
10 id: number;
11 owner_name: string;
12 name: string;
13 default_branch: string;
14}
15
16interface User {
17 id: number;
18 username: string;
19 display_name: string;
20}
21
22interface Org {
23 id: number;
24 name: string;
25 display_name: string;
26}
27
28async 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
65async 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
96function 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
106export 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
128async 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
218async 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