grove init: auto-detect and import existing git repos

- Detects .git directory and imports current tree as initial Sapling commit
- Creates Grove repo with skip_seed, pushes all files to remote
- Preserves git data as .git.bak, restores on failure
- Fixed arg parsing to skip flag values (--owner, --description)
Anton Kaminsky29d ago0fc3247a0408parent 953e989
1 file changed+179-91
cli/src/commands/init.ts
@@ -1,6 +1,6 @@
1import { intro, outro, select, spinner, isCancel, cancel } from "@clack/prompts";
1import { intro, outro, select, spinner, isCancel, cancel, log } from "@clack/prompts";
22import { execSync } from "node:child_process";
3import { writeFileSync, existsSync, mkdirSync } from "node:fs";
3import { writeFileSync, existsSync, mkdirSync, renameSync, rmSync } from "node:fs";
44import { join, basename, resolve } from "node:path";
55import { homedir } from "node:os";
66import { hubRequest } from "../api.js";
@@ -25,56 +25,83 @@
2525 display_name: string;
2626}
2727
28export async function init(args: string[]) {
29 const dir = resolve(args.find((a) => !a.startsWith("--")) ?? ".");
30 const name = basename(dir);
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}
3164
65async function resolveOwner(args: string[]): Promise<string | undefined> {
3266 const ownerIdx = args.indexOf("--owner");
33 let owner = ownerIdx !== -1 ? args[ownerIdx + 1] : undefined;
67 if (ownerIdx !== -1) return args[ownerIdx + 1];
3468
35 const descIdx = args.indexOf("--description");
36 const description = descIdx !== -1 ? args[descIdx + 1] : undefined;
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" : ""})` : ""));
3776
38 const isPrivate = args.includes("--private");
77 const choices = [user.username, ...orgs.map((o) => o.name)];
3978
40 intro(`grove init ${name}`);
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 });
4187
42 // If no --owner, fetch user + orgs and ask
43 if (!owner) {
44 const s = spinner();
45 s.start("Fetching user and organizations from Grove");
46 const [{ user }, { orgs }] = await Promise.all([
47 hubRequest<{ user: User }>("/api/auth/me"),
48 hubRequest<{ orgs: Org[] }>("/api/orgs"),
49 ]);
50 s.stop(`Logged in as ${user.username}` + (orgs.length > 0 ? ` (${orgs.length} org${orgs.length !== 1 ? "s" : ""})` : ""));
51
52 const choices = [user.username, ...orgs.map((o) => o.name)];
53
54 if (choices.length > 1) {
55 const selected = await select({
56 message: "Where should this repository live?",
57 options: choices.map((c, i) => ({
58 value: c,
59 label: i === 0 ? `${c} (personal)` : c,
60 })),
61 });
62
63 if (isCancel(selected)) {
64 cancel("Operation cancelled.");
65 process.exit(0);
66 }
67
68 // Only set owner for orgs (not the personal account)
69 if (selected !== choices[0]) {
70 owner = selected as string;
71 }
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;
7296 }
7397 }
7498
75 // Create the repo on Grove (this restarts Mononoke services, takes ~10s)
76 const s2 = spinner();
77 s2.start("Creating repository and provisioning Mononoke");
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");
78105 const messages = [
79106 "Writing repo config",
80107 "Restarting source control services",
@@ -83,7 +110,7 @@
83110 let msgIdx = 0;
84111 const ticker = setInterval(() => {
85112 if (msgIdx < messages.length) {
86 s2.message(messages[msgIdx]);
113 s.message(messages[msgIdx]);
87114 msgIdx++;
88115 }
89116 }, 3000);
@@ -94,81 +121,142 @@
94121 description,
95122 owner,
96123 is_private: isPrivate,
124 skip_seed: skipSeed,
97125 }),
98126 });
99127 clearInterval(ticker);
100 s2.stop(`Created ${repo.owner_name}/${repo.name}`);
128 s.stop(`Created ${repo.owner_name}/${repo.name}`);
129 return repo;
130}
101131
102 // Init Sapling repo if not already one
103 if (!existsSync(join(dir, ".sl"))) {
104 mkdirSync(dir, { recursive: true });
105 const s3 = spinner();
106 s3.start("Initializing Sapling repository");
107 execSync(`sl init --config init.prefer-git=false --config format.use-remotefilelog=true ${dir}`, { stdio: "pipe" });
108 s3.stop("Sapling repository initialized");
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];
109138 }
139 return undefined;
140}
110141
111 // Configure remote, auth, and push settings
112 const s4 = spinner();
113 s4.start("Configuring Grove remote");
114 const hub = await getHub();
115 const tlsDir = join(homedir(), ".grove", "tls");
116 const config = `[paths]
117default = slapi:${repo.name}
142export async function init(args: string[]) {
143 const dir = resolve(parsePositional(args) ?? ".");
144 const name = basename(dir);
145 const isGitRepo = existsSync(join(dir, ".git"));
118146
119[remotefilelog]
120reponame = ${repo.name}
147 const descIdx = args.indexOf("--description");
148 const description = descIdx !== -1 ? args[descIdx + 1] : undefined;
149 const isPrivate = args.includes("--private");
121150
122[grove]
123owner = ${repo.owner_name}
151 intro(`grove init ${name}${isGitRepo ? " (importing from git)" : ""}`);
124152
125[auth]
126grove.prefix = ${hub}
127grove.cert = ${tlsDir}/client.crt
128grove.key = ${tlsDir}/client.key
129grove.cacerts = ${tlsDir}/ca.crt
153 const owner = await resolveOwner(args);
130154
131grove-mononoke.prefix = mononoke://grove.host
132grove-mononoke.cert = ${tlsDir}/client.crt
133grove-mononoke.key = ${tlsDir}/client.key
134grove-mononoke.cacerts = ${tlsDir}/ca.crt
155 if (isGitRepo) {
156 await initFromGit(dir, name, owner, description, isPrivate);
157 } else {
158 await initFresh(dir, name, owner, description, isPrivate);
159 }
160}
135161
136[edenapi]
137url = ${hub.replace(/\/$/, "")}:8443/edenapi/
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 }
138170
139[clone]
140use-commit-graph = true
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 {}
141176
142[remotenames]
143selectivepulldefault = ${repo.default_branch}
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}`);
144183
145[push]
146edenapi = true
147to = ${repo.default_branch}
148`;
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);
149188
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" });
150197 writeFileSync(join(dir, ".sl", "config"), config);
151 s4.stop("Remote configured");
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");
152240
153241 // Pull the initial commit seeded by the API
154 const s5 = spinner();
155 s5.start("Pulling initial commit");
242 const s3 = spinner();
243 s3.start("Pulling initial commit");
156244 try {
157245 execSync(`sl pull`, { cwd: dir, stdio: "pipe" });
158246 execSync(`sl goto ${repo.default_branch}`, { cwd: dir, stdio: "pipe" });
159 s5.stop("Pulled initial commit");
247 s3.stop("Pulled initial commit");
160248 } catch {
161249 // If pull fails (e.g. seed didn't complete), create a local initial commit
162 s5.stop("Creating initial commit locally");
250 s3.stop("Creating initial commit locally");
163251 if (!existsSync(join(dir, "README.md"))) {
164 writeFileSync(join(dir, "README.md"), `# ${repo.name}\n`);
252 writeFileSync(join(dir, "README.md"), `# ${name}\n`);
165253 }
166254 execSync(`sl add`, { cwd: dir, stdio: "pipe" });
167255 execSync(`sl commit -m "Initial commit"`, { cwd: dir, stdio: "pipe" });
168 const s6 = spinner();
169 s6.start(`Pushing to ${repo.default_branch}`);
256 const s4 = spinner();
257 s4.start(`Pushing to ${repo.default_branch}`);
170258 execSync(`sl push --to ${repo.default_branch} --create`, { cwd: dir, stdio: "pipe" });
171 s6.stop(`Pushed to ${repo.default_branch}`);
259 s4.stop(`Pushed to ${repo.default_branch}`);
172260 }
173261
174262 outro(`Initialized ${repo.owner_name}/${repo.name}`);
175263