Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

import-helper.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

import-helper.tsBlame258 lines · 2 contributors
80bed05Claude1/**
2 * Small helpers for the GitHub import flow (/import).
3 *
14c3cc8Claude4 * Pure parsing/normalization helpers live at the top of the file.
5 * `importOneRepo` at the bottom wraps the clone + DB insert so that both
6 * the single-repo and bulk importers can share one code path.
80bed05Claude7 */
8
14c3cc8Claude9import { and, eq } from "drizzle-orm";
9bc1aedccantynz-alt10import { mkdir, rm } from "fs/promises";
14c3cc8Claude11import { join } from "path";
12import { db } from "../db";
13import { repositories } from "../db/schema";
14import { config } from "../lib/config";
15
9bc1aedccantynz-alt16/**
17 * Count commits across ALL refs in a bare repo. Returns 0 for an empty repo
18 * (no refs / unborn HEAD) or on any git error.
19 *
20 * This is the gate that makes an import trustworthy: `git clone --bare
21 * --mirror` of an empty, unreachable, or private-without-a-valid-token
22 * source EXITS 0 yet produces a repo with zero objects. Without this check
23 * the import inserted a DB row and reported "success" for a phantom empty
24 * repo — which is how a Vapron import "succeeded" while transferring nothing,
25 * a silent failure that's actively dangerous once the user deletes the
26 * GitHub source trusting the import worked.
27 */
28export async function countRepoCommits(destPath: string): Promise<number> {
29 try {
30 const proc = Bun.spawn(
31 ["git", "-C", destPath, "rev-list", "--all", "--count"],
32 {
33 stdout: "pipe",
34 stderr: "pipe",
35 env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
36 }
37 );
38 const out = await new Response(proc.stdout).text();
39 const code = await proc.exited;
40 if (code !== 0) return 0;
41 const n = parseInt(out.trim(), 10);
42 return Number.isFinite(n) && n > 0 ? n : 0;
43 } catch {
44 return 0;
45 }
46}
47
80bed05Claude48export interface ParsedGithubUrl {
49 owner: string;
50 repo: string;
51}
52
53/**
54 * Parse a GitHub URL into { owner, repo }. Accepts:
55 * - https://github.com/foo/bar
56 * - https://github.com/foo/bar.git
57 * - http://github.com/foo/bar/
58 * - git@github.com:foo/bar.git
59 * - github.com/foo/bar
60 * - foo/bar
61 *
62 * Returns null if the URL cannot be parsed.
63 */
64export function parseGithubUrl(raw: string): ParsedGithubUrl | null {
65 const input = (raw || "").trim();
66 if (!input) return null;
67
68 // SSH form: git@github.com:owner/repo(.git)?
69 const ssh = input.match(/^git@github\.com:([^/]+)\/([^/\s]+?)(?:\.git)?\/?$/i);
70 if (ssh) return { owner: ssh[1], repo: stripDotGit(ssh[2]) };
71
72 // HTTP(S) / bare host form
73 const http = input.match(
74 /^(?:https?:\/\/)?(?:www\.)?github\.com\/([^/\s]+)\/([^/\s?#]+?)(?:\.git)?\/?(?:[?#].*)?$/i
75 );
76 if (http) return { owner: http[1], repo: stripDotGit(http[2]) };
77
78 // owner/repo shorthand
79 const short = input.match(/^([^/\s]+)\/([^/\s]+?)(?:\.git)?$/);
80 if (short) return { owner: short[1], repo: stripDotGit(short[2]) };
81
82 return null;
83}
84
85function stripDotGit(name: string): string {
86 return name.replace(/\.git$/i, "");
87}
88
89/**
90 * Repository names on gluecron follow GitHub's rough rules: letters,
91 * digits, hyphens, underscores, dots. We normalize by replacing anything
92 * else with a hyphen so an imported repo is always addressable.
93 */
94export function sanitizeRepoName(name: string): string {
95 const cleaned = name.replace(/[^A-Za-z0-9._-]/g, "-").replace(/^-+|-+$/g, "");
96 return cleaned || "imported-repo";
97}
98
99/**
100 * Build the clone URL that `git clone --bare --mirror` will use. When a
101 * token is supplied we inject it so private repos are reachable.
102 */
103export function buildCloneUrl(cloneUrl: string, token: string | null): string {
104 if (!token) return cloneUrl;
105 return cloneUrl.replace("https://github.com/", `https://${token}@github.com/`);
106}
14c3cc8Claude107
108/**
109 * Strip any secret (token) from a string before it gets returned to the UI
110 * or written to logs. Defense in depth: we already avoid putting tokens into
111 * messages, but git/HTTP errors may echo the URL we passed in.
112 */
113export function scrubSecrets(input: string, token: string | null): string {
114 if (!input) return input;
115 let out = input;
116 if (token) out = out.split(token).join("***");
117 // Also redact any `https://<creds>@github.com/...` form the URL may leak.
118 out = out.replace(
119 /https:\/\/[^@\s]+@github\.com/gi,
120 "https://***@github.com"
121 );
122 return out;
123}
124
125export interface ImportOneRepoInput {
126 cloneUrl: string;
127 targetName: string;
128 ownerId: string;
129 ownerUsername: string;
130 token?: string | null;
131 description?: string | null;
132 isPrivate?: boolean;
133 defaultBranch?: string;
134}
135
136export type ImportOneRepoStatus = "success" | "skipped-exists" | "failed";
137
138export interface ImportOneRepoResult {
139 status: ImportOneRepoStatus;
140 name: string;
141 notes: string;
142}
143
144/**
145 * Clone one GitHub repo into this user's namespace and insert the DB row.
146 *
147 * Resilient: returns a result object instead of throwing, so bulk callers
148 * can continue past a failure. Never includes the token in the returned
149 * notes — all output is passed through `scrubSecrets`.
150 */
151export async function importOneRepo(
152 input: ImportOneRepoInput
153): Promise<ImportOneRepoResult> {
154 const {
155 cloneUrl,
156 targetName,
157 ownerId,
158 ownerUsername,
159 token = null,
160 description = null,
161 isPrivate = false,
162 defaultBranch = "main",
163 } = input;
164
165 const safeName = sanitizeRepoName(targetName);
166
167 try {
168 // Uniqueness in the caller's namespace (owner+name).
169 const [existing] = await db
170 .select()
171 .from(repositories)
172 .where(
173 and(eq(repositories.ownerId, ownerId), eq(repositories.name, safeName))
174 )
175 .limit(1);
176
177 if (existing) {
9bc1aedccantynz-alt178 // A prior import can leave an EMPTY repo behind (see countRepoCommits).
179 // Don't cheerfully report "already exists" for a broken empty shell —
180 // tell the user it's empty so they can delete + re-import to populate it.
181 const existingCommits = await countRepoCommits(existing.diskPath);
182 if (existingCommits === 0) {
183 return {
184 status: "failed",
185 name: safeName,
186 notes:
187 "A repo by this name already exists but is EMPTY — a previous import left an empty shell. Delete it and re-import to populate it.",
188 };
189 }
14c3cc8Claude190 return {
191 status: "skipped-exists",
192 name: safeName,
9bc1aedccantynz-alt193 notes: `Already exists in your namespace (${existingCommits} commits)`,
14c3cc8Claude194 };
195 }
196
197 const destPath = join(config.gitReposPath, ownerUsername, `${safeName}.git`);
198 await mkdir(join(config.gitReposPath, ownerUsername), { recursive: true });
199
200 const authedCloneUrl = buildCloneUrl(cloneUrl, token);
201
202 const proc = Bun.spawn(
203 ["git", "clone", "--bare", "--mirror", authedCloneUrl, destPath],
204 {
205 stdout: "pipe",
206 stderr: "pipe",
207 env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
208 }
209 );
210 const stderr = await new Response(proc.stderr).text();
211 const exitCode = await proc.exited;
212
213 if (exitCode !== 0) {
214 return {
215 status: "failed",
216 name: safeName,
217 notes: `git clone failed: ${scrubSecrets(stderr, token).slice(0, 200)}`,
218 };
219 }
220
9bc1aedccantynz-alt221 // Gate success on the clone having actually transferred history. `git
222 // clone --mirror` can exit 0 yet leave an empty repo; without this check
223 // that got a DB row + "success" — a phantom empty repo. Clean up the
224 // empty dir (so a retry isn't blocked by "already exists") and fail loud.
225 const commitCount = await countRepoCommits(destPath);
226 if (commitCount === 0) {
227 await rm(destPath, { recursive: true, force: true }).catch(() => {});
228 return {
229 status: "failed",
230 name: safeName,
231 notes:
232 "Clone produced an EMPTY repository — nothing was imported. The source may be empty, or private and the token lacks access. No repo was created; fix access and retry.",
233 };
234 }
235
14c3cc8Claude236 await db.insert(repositories).values({
237 name: safeName,
238 ownerId,
239 description,
240 isPrivate,
241 defaultBranch: defaultBranch || "main",
242 diskPath: destPath,
243 starCount: 0,
244 });
245
9bc1aedccantynz-alt246 return {
247 status: "success",
248 name: safeName,
249 notes: `Cloned + indexed (${commitCount} commits)`,
250 };
14c3cc8Claude251 } catch (err) {
252 return {
253 status: "failed",
254 name: safeName,
255 notes: scrubSecrets(String(err), token).slice(0, 200),
256 };
257 }
258}