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.tsBlame263 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 {
2cd3bb4ccantynz-alt95 // Lowercase on create — repo slugs are canonically lowercase to avoid the
96 // "Vapron" vs "vapron" confusion. Imported names are normalized here.
97 const cleaned = name
98 .replace(/[^A-Za-z0-9._-]/g, "-")
99 .replace(/^-+|-+$/g, "")
100 .toLowerCase();
80bed05Claude101 return cleaned || "imported-repo";
102}
103
104/**
105 * Build the clone URL that `git clone --bare --mirror` will use. When a
106 * token is supplied we inject it so private repos are reachable.
107 */
108export function buildCloneUrl(cloneUrl: string, token: string | null): string {
109 if (!token) return cloneUrl;
110 return cloneUrl.replace("https://github.com/", `https://${token}@github.com/`);
111}
14c3cc8Claude112
113/**
114 * Strip any secret (token) from a string before it gets returned to the UI
115 * or written to logs. Defense in depth: we already avoid putting tokens into
116 * messages, but git/HTTP errors may echo the URL we passed in.
117 */
118export function scrubSecrets(input: string, token: string | null): string {
119 if (!input) return input;
120 let out = input;
121 if (token) out = out.split(token).join("***");
122 // Also redact any `https://<creds>@github.com/...` form the URL may leak.
123 out = out.replace(
124 /https:\/\/[^@\s]+@github\.com/gi,
125 "https://***@github.com"
126 );
127 return out;
128}
129
130export interface ImportOneRepoInput {
131 cloneUrl: string;
132 targetName: string;
133 ownerId: string;
134 ownerUsername: string;
135 token?: string | null;
136 description?: string | null;
137 isPrivate?: boolean;
138 defaultBranch?: string;
139}
140
141export type ImportOneRepoStatus = "success" | "skipped-exists" | "failed";
142
143export interface ImportOneRepoResult {
144 status: ImportOneRepoStatus;
145 name: string;
146 notes: string;
147}
148
149/**
150 * Clone one GitHub repo into this user's namespace and insert the DB row.
151 *
152 * Resilient: returns a result object instead of throwing, so bulk callers
153 * can continue past a failure. Never includes the token in the returned
154 * notes — all output is passed through `scrubSecrets`.
155 */
156export async function importOneRepo(
157 input: ImportOneRepoInput
158): Promise<ImportOneRepoResult> {
159 const {
160 cloneUrl,
161 targetName,
162 ownerId,
163 ownerUsername,
164 token = null,
165 description = null,
166 isPrivate = false,
167 defaultBranch = "main",
168 } = input;
169
170 const safeName = sanitizeRepoName(targetName);
171
172 try {
173 // Uniqueness in the caller's namespace (owner+name).
174 const [existing] = await db
175 .select()
176 .from(repositories)
177 .where(
178 and(eq(repositories.ownerId, ownerId), eq(repositories.name, safeName))
179 )
180 .limit(1);
181
182 if (existing) {
9bc1aedccantynz-alt183 // A prior import can leave an EMPTY repo behind (see countRepoCommits).
184 // Don't cheerfully report "already exists" for a broken empty shell —
185 // tell the user it's empty so they can delete + re-import to populate it.
186 const existingCommits = await countRepoCommits(existing.diskPath);
187 if (existingCommits === 0) {
188 return {
189 status: "failed",
190 name: safeName,
191 notes:
192 "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.",
193 };
194 }
14c3cc8Claude195 return {
196 status: "skipped-exists",
197 name: safeName,
9bc1aedccantynz-alt198 notes: `Already exists in your namespace (${existingCommits} commits)`,
14c3cc8Claude199 };
200 }
201
202 const destPath = join(config.gitReposPath, ownerUsername, `${safeName}.git`);
203 await mkdir(join(config.gitReposPath, ownerUsername), { recursive: true });
204
205 const authedCloneUrl = buildCloneUrl(cloneUrl, token);
206
207 const proc = Bun.spawn(
208 ["git", "clone", "--bare", "--mirror", authedCloneUrl, destPath],
209 {
210 stdout: "pipe",
211 stderr: "pipe",
212 env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
213 }
214 );
215 const stderr = await new Response(proc.stderr).text();
216 const exitCode = await proc.exited;
217
218 if (exitCode !== 0) {
219 return {
220 status: "failed",
221 name: safeName,
222 notes: `git clone failed: ${scrubSecrets(stderr, token).slice(0, 200)}`,
223 };
224 }
225
9bc1aedccantynz-alt226 // Gate success on the clone having actually transferred history. `git
227 // clone --mirror` can exit 0 yet leave an empty repo; without this check
228 // that got a DB row + "success" — a phantom empty repo. Clean up the
229 // empty dir (so a retry isn't blocked by "already exists") and fail loud.
230 const commitCount = await countRepoCommits(destPath);
231 if (commitCount === 0) {
232 await rm(destPath, { recursive: true, force: true }).catch(() => {});
233 return {
234 status: "failed",
235 name: safeName,
236 notes:
237 "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.",
238 };
239 }
240
14c3cc8Claude241 await db.insert(repositories).values({
242 name: safeName,
243 ownerId,
244 description,
245 isPrivate,
246 defaultBranch: defaultBranch || "main",
247 diskPath: destPath,
248 starCount: 0,
249 });
250
9bc1aedccantynz-alt251 return {
252 status: "success",
253 name: safeName,
254 notes: `Cloned + indexed (${commitCount} commits)`,
255 };
14c3cc8Claude256 } catch (err) {
257 return {
258 status: "failed",
259 name: safeName,
260 notes: scrubSecrets(String(err), token).slice(0, 200),
261 };
262 }
263}