9.3 KB302 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 { homedir } from "node:os";
6import { hubRequest, hubUploadStream } from "../api.js";
7import { getHub } from "../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
28function buildSlConfig(repo: Repo, hub: string) {
29 const tlsDir = join(homedir(), ".grove", "tls");
30 return `[paths]
31default = slapi:${repo.name}
32
33[remotefilelog]
34reponame = ${repo.name}
35
36[grove]
37owner = ${repo.owner_name}
38
39[auth]
40grove.prefix = ${hub}
41grove.cert = ${tlsDir}/client.crt
42grove.key = ${tlsDir}/client.key
43grove.cacerts = ${tlsDir}/ca.crt
44
45grove-mononoke.prefix = mononoke://grove.host
46grove-mononoke.cert = ${tlsDir}/client.crt
47grove-mononoke.key = ${tlsDir}/client.key
48grove-mononoke.cacerts = ${tlsDir}/ca.crt
49
50[edenapi]
51url = ${hub.replace(/\/$/, "")}:8443/edenapi/
52
53[clone]
54use-commit-graph = true
55
56[remotenames]
57selectivepulldefault = ${repo.default_branch}
58
59[push]
60edenapi = true
61to = ${repo.default_branch}
62`;
63}
64
65async 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
102async 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
132function 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
142export 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
162async 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 by cloning from Grove
227 const s3 = spinner();
228 s3.start("Setting up Sapling working copy");
229 const cloneTmp = join(dir, "..", `.grove-clone-${name}-${Date.now()}`);
230 try {
231 // Clone into a temp dir, then move .sl into the working dir
232 // Pass selectivepulldefault so sl clone pulls the right bookmark (default is "master")
233 execSync(`sl clone --config remotenames.selectivepulldefault=${gitBranch} slapi:${name} "${cloneTmp}"`, { stdio: "pipe" });
234
235 // Remove .git and replace with .sl
236 rmSync(join(dir, ".git"), { recursive: true, force: true });
237 renameSync(join(cloneTmp, ".sl"), join(dir, ".sl"));
238
239 // Write config with correct remote settings
240 const config = buildSlConfig({ ...repo, default_branch: gitBranch }, hub);
241 writeFileSync(join(dir, ".sl", "config"), config);
242
243 // Reset dirstate to match existing working copy files
244 execSync(`sl goto ${gitBranch} --clean`, { cwd: dir, stdio: "pipe" });
245
246 s3.stop("Sapling working copy ready");
247 } catch (e: any) {
248 s3.stop("Clone failed");
249 log.error(e.stderr?.toString() || e.message);
250 rmSync(cloneTmp, { recursive: true, force: true });
251 process.exit(1);
252 }
253
254 // Clean up clone temp dir
255 rmSync(cloneTmp, { recursive: true, force: true });
256
257 outro(`Imported ${ownerName}/${name} with full git history`);
258}
259
260async function initFresh(dir: string, name: string, owner: string | undefined, description: string | undefined, isPrivate: boolean) {
261 const repo = await createRepo(name, owner, description, isPrivate, false);
262
263 // Init Sapling repo if not already one
264 if (!existsSync(join(dir, ".sl"))) {
265 mkdirSync(dir, { recursive: true });
266 const s = spinner();
267 s.start("Initializing Sapling repository");
268 execSync(`sl init --config init.prefer-git=false --config format.use-remotefilelog=true ${dir}`, { stdio: "pipe" });
269 s.stop("Sapling repository initialized");
270 }
271
272 // Configure remote, auth, and push settings
273 const s2 = spinner();
274 s2.start("Configuring Grove remote");
275 const hub = await getHub();
276 writeFileSync(join(dir, ".sl", "config"), buildSlConfig(repo, hub));
277 s2.stop("Remote configured");
278
279 // Pull the initial commit seeded by the API
280 const s3 = spinner();
281 s3.start("Pulling initial commit");
282 try {
283 execSync(`sl pull`, { cwd: dir, stdio: "pipe" });
284 execSync(`sl goto ${repo.default_branch}`, { cwd: dir, stdio: "pipe" });
285 s3.stop("Pulled initial commit");
286 } catch {
287 // If pull fails (e.g. seed didn't complete), create a local initial commit
288 s3.stop("Creating initial commit locally");
289 if (!existsSync(join(dir, "README.md"))) {
290 writeFileSync(join(dir, "README.md"), `# ${name}\n`);
291 }
292 execSync(`sl add`, { cwd: dir, stdio: "pipe" });
293 execSync(`sl commit -m "Initial commit"`, { cwd: dir, stdio: "pipe" });
294 const s4 = spinner();
295 s4.start(`Pushing to ${repo.default_branch}`);
296 execSync(`sl push --to ${repo.default_branch} --create`, { cwd: dir, stdio: "pipe" });
297 s4.stop(`Pushed to ${repo.default_branch}`);
298 }
299
300 outro(`Initialized ${repo.owner_name}/${repo.name}`);
301}
302