8.3 KB264 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 } 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 // Get latest git commit message for the import commit
172 let lastCommitMsg = "Import from git";
173 try {
174 lastCommitMsg = execSync("git log -1 --format=%s", { cwd: dir, stdio: "pipe" }).toString().trim();
175 } catch {}
176
177 // Count commits for user feedback
178 let commitCount = "?";
179 try {
180 commitCount = execSync("git rev-list --count HEAD", { cwd: dir, stdio: "pipe" }).toString().trim();
181 } catch {}
182 log.info(`Found git repository with ${commitCount} commits on ${gitBranch}`);
183
184 // Create Grove repo without seeding (we'll push the current tree)
185 const repo = await createRepo(name, owner, description, isPrivate, true);
186 const hub = await getHub();
187 const config = buildSlConfig({ ...repo, default_branch: gitBranch }, hub);
188
189 // Rename .git out of the way so sl init doesn't conflict
190 const s1 = spinner();
191 s1.start("Converting to Sapling repository");
192 const gitBackup = join(dir, ".git.bak");
193 renameSync(join(dir, ".git"), gitBackup);
194
195 // Init Sapling and commit all current files
196 execSync(`sl init --config init.prefer-git=false --config format.use-remotefilelog=true "${dir}"`, { stdio: "pipe" });
197 writeFileSync(join(dir, ".sl", "config"), config);
198 execSync(`sl add`, { cwd: dir, stdio: "pipe" });
199 execSync(`sl commit -m "Import from git (${commitCount} commits)\n\nLast git commit: ${lastCommitMsg}"`, { cwd: dir, stdio: "pipe" });
200 s1.stop("Sapling repository initialized with current tree");
201
202 // Push to Grove
203 const s2 = spinner();
204 s2.start(`Pushing to ${gitBranch}`);
205 try {
206 execSync(`sl push --to ${gitBranch} --create`, { cwd: dir, stdio: "pipe" });
207 } catch (e: any) {
208 s2.stop("Push failed");
209 log.error(e.stderr?.toString() || e.message);
210 // Restore .git so the user isn't left in a broken state
211 rmSync(join(dir, ".sl"), { recursive: true, force: true });
212 renameSync(gitBackup, join(dir, ".git"));
213 process.exit(1);
214 }
215 s2.stop(`Pushed to ${gitBranch}`);
216
217 log.info(`Git data saved to .git.bak (safe to delete)`);
218
219 outro(`Imported ${repo.owner_name}/${repo.name} from git`);
220}
221
222async function initFresh(dir: string, name: string, owner: string | undefined, description: string | undefined, isPrivate: boolean) {
223 const repo = await createRepo(name, owner, description, isPrivate, false);
224
225 // Init Sapling repo if not already one
226 if (!existsSync(join(dir, ".sl"))) {
227 mkdirSync(dir, { recursive: true });
228 const s = spinner();
229 s.start("Initializing Sapling repository");
230 execSync(`sl init --config init.prefer-git=false --config format.use-remotefilelog=true ${dir}`, { stdio: "pipe" });
231 s.stop("Sapling repository initialized");
232 }
233
234 // Configure remote, auth, and push settings
235 const s2 = spinner();
236 s2.start("Configuring Grove remote");
237 const hub = await getHub();
238 writeFileSync(join(dir, ".sl", "config"), buildSlConfig(repo, hub));
239 s2.stop("Remote configured");
240
241 // Pull the initial commit seeded by the API
242 const s3 = spinner();
243 s3.start("Pulling initial commit");
244 try {
245 execSync(`sl pull`, { cwd: dir, stdio: "pipe" });
246 execSync(`sl goto ${repo.default_branch}`, { cwd: dir, stdio: "pipe" });
247 s3.stop("Pulled initial commit");
248 } catch {
249 // If pull fails (e.g. seed didn't complete), create a local initial commit
250 s3.stop("Creating initial commit locally");
251 if (!existsSync(join(dir, "README.md"))) {
252 writeFileSync(join(dir, "README.md"), `# ${name}\n`);
253 }
254 execSync(`sl add`, { cwd: dir, stdio: "pipe" });
255 execSync(`sl commit -m "Initial commit"`, { cwd: dir, stdio: "pipe" });
256 const s4 = spinner();
257 s4.start(`Pushing to ${repo.default_branch}`);
258 execSync(`sl push --to ${repo.default_branch} --create`, { cwd: dir, stdio: "pipe" });
259 s4.stop(`Pushed to ${repo.default_branch}`);
260 }
261
262 outro(`Initialized ${repo.owner_name}/${repo.name}`);
263}
264