Commit9bc1aed
fix(import): never report a silent-empty clone as a successful import
fix(import): never report a silent-empty clone as a successful import The GitHub-import feature could create a phantom empty repo and report success. `git clone --bare --mirror` of an empty, unreachable, or private-without-a-valid-token source EXITS 0 yet produces a repo with zero objects — and both import paths (importOneRepo for bulk, the inline single-repo path in import.tsx) inserted a DB row + returned "success" immediately after the exit-0 check, with no content verification. That's how a Vapron import "succeeded" while transferring nothing; combined with a user then deleting the GitHub source trusting the import worked, it's a data-loss trap. A verifier (verifyMigration, checks commit count) already existed but was only wired to a separate optional /migrations button — never gating the import itself. Fix: add countRepoCommits(destPath) (rev-list --all --count; 0 for empty/error) and gate BOTH import paths on it. If the clone produced zero commits: delete the empty dir (so a retry isn't blocked by "already exists") and fail loudly with a clear reason — no DB row, no false success. Also: when an import finds a repo that already exists but is EMPTY (a prior broken import), it now reports that honestly instead of a cheerful "already exists" skip. Success notes now include the commit count. Tests: src/__tests__/import-empty-guard.test.ts — empty bare repo → 0, non-git path → 0, mirror-clone of a repo with history → > 0. Full suite 3110 pass, same 4 pre-existing unrelated fails.
3 files changed+139−59bc1aeded741487060753bbe2c58616804363740
3 changed files+139−5
Addedsrc/__tests__/import-empty-guard.test.ts+56−0View fileUnifiedSplit
@@ -0,0 +1,56 @@
1/**
2 * Regression coverage for the silent-empty-import bug: a `git clone --bare
3 * --mirror` of an empty / unreachable / token-less-private source exits 0 yet
4 * produces a repo with zero commits, which the importer used to register as a
5 * successful import (DB row + "success"). A Vapron import "succeeded" this way
6 * while transferring nothing — dangerous once the GitHub source is deleted.
7 *
8 * countRepoCommits() is the gate that makes an import trustworthy: it must
9 * return 0 for an empty bare repo and > 0 once history exists.
10 */
11import { describe, it, expect, beforeAll, afterAll } from "bun:test";
12import { mkdtempSync, rmSync } from "node:fs";
13import { tmpdir } from "node:os";
14import { join } from "node:path";
15import { countRepoCommits } from "../lib/import-helper";
16
17function git(cwd: string, ...args: string[]) {
18 const p = Bun.spawnSync(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
19 if (p.exitCode !== 0) {
20 throw new Error(`git ${args.join(" ")} failed: ${new TextDecoder().decode(p.stderr)}`);
21 }
22}
23
24let root = "";
25beforeAll(() => {
26 root = mkdtempSync(join(tmpdir(), "import-empty-guard-"));
27});
28afterAll(() => {
29 if (root) rmSync(root, { recursive: true, force: true });
30});
31
32describe("countRepoCommits — the import empty-guard", () => {
33 it("returns 0 for a freshly-init'd EMPTY bare repo (the phantom-import case)", async () => {
34 const empty = join(root, "empty.git");
35 git(root, "init", "--bare", empty);
36 expect(await countRepoCommits(empty)).toBe(0);
37 });
38
39 it("returns 0 for a path that isn't a git repo at all", async () => {
40 expect(await countRepoCommits(join(root, "does-not-exist"))).toBe(0);
41 });
42
43 it("returns > 0 once the repo actually has history", async () => {
44 // Build a work repo with one commit, then mirror-clone it bare (exactly
45 // what the importer does) and confirm the guard sees the history.
46 const work = join(root, "work");
47 git(root, "init", "-b", "main", work);
48 git(work, "config", "user.email", "t@t.com");
49 git(work, "config", "user.name", "t");
50 Bun.spawnSync(["git", "-C", work, "commit", "--allow-empty", "-m", "seed"], { stdout: "pipe", stderr: "pipe" });
51
52 const mirror = join(root, "mirror.git");
53 git(root, "clone", "--bare", "--mirror", work, mirror);
54 expect(await countRepoCommits(mirror)).toBeGreaterThan(0);
55 });
56});
Modifiedsrc/lib/import-helper.ts+66−3View fileUnifiedSplit
@@ -7,12 +7,44 @@
77 */
88
99import { and, eq } from "drizzle-orm";
10import { mkdir } from "fs/promises";
10import { mkdir, rm } from "fs/promises";
1111import { join } from "path";
1212import { db } from "../db";
1313import { repositories } from "../db/schema";
1414import { config } from "../lib/config";
1515
16/**
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
1648export interface ParsedGithubUrl {
1749 owner: string;
1850 repo: string;
@@ -143,10 +175,22 @@ export async function importOneRepo(
143175 .limit(1);
144176
145177 if (existing) {
178 // 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 }
146190 return {
147191 status: "skipped-exists",
148192 name: safeName,
149 notes: "Already exists in your namespace",
193 notes: `Already exists in your namespace (${existingCommits} commits)`,
150194 };
151195 }
152196
@@ -174,6 +218,21 @@ export async function importOneRepo(
174218 };
175219 }
176220
221 // 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
177236 await db.insert(repositories).values({
178237 name: safeName,
179238 ownerId,
@@ -184,7 +243,11 @@ export async function importOneRepo(
184243 starCount: 0,
185244 });
186245
187 return { status: "success", name: safeName, notes: "Cloned + indexed" };
246 return {
247 status: "success",
248 name: safeName,
249 notes: `Cloned + indexed (${commitCount} commits)`,
250 };
188251 } catch (err) {
189252 return {
190253 status: "failed",
Modifiedsrc/routes/import.tsx+17−2View fileUnifiedSplit
@@ -14,13 +14,14 @@ import { Layout } from "../views/layout";
1414import { softAuth, requireAuth } from "../middleware/auth";
1515import type { AuthEnv } from "../middleware/auth";
1616import { config } from "../lib/config";
17import { mkdir } from "fs/promises";
17import { mkdir, rm } from "fs/promises";
1818import { join } from "path";
1919import {
2020 parseGithubUrl,
2121 sanitizeRepoName,
2222 buildCloneUrl,
2323 scrubSecrets,
24 countRepoCommits,
2425} from "../lib/import-helper";
2526
2627const importRoutes = new Hono<AuthEnv>();
@@ -984,6 +985,18 @@ async function importSingleRepo(
984985 throw new Error(`git clone failed: ${sanitized.slice(0, 400)}`);
985986 }
986987
988 // Gate success on the clone actually transferring history. `git clone
989 // --mirror` exits 0 even for an empty / unreachable / token-less-private
990 // source, leaving an empty repo that previously got a DB row + "imported
991 // successfully" — a silent phantom import. Clean up + fail loudly instead.
992 const commitCount = await countRepoCommits(destPath);
993 if (commitCount === 0) {
994 await rm(destPath, { recursive: true, force: true }).catch(() => {});
995 throw new Error(
996 "Clone produced an empty repository — nothing was imported. The source may be empty, or private and the access token lacks permission. No repo was created."
997 );
998 }
999
9871000 // Insert into database
9881001 await db.insert(repositories).values({
9891002 name: safeName,
@@ -995,7 +1008,9 @@ async function importSingleRepo(
9951008 starCount: 0,
9961009 });
9971010
998 console.log(`[import] ${ghRepo.full_name} imported successfully`);
1011 console.log(
1012 `[import] ${ghRepo.full_name} imported successfully (${commitCount} commits)`
1013 );
9991014}
10001015
10011016export default importRoutes;
10021017