repoSync.ts
⎇
Raw
1import {
2 type Dirent,
3 existsSync,
4 readdirSync,
5 readFileSync,
6 renameSync,
7 rmSync,
8 statSync,
9 unlinkSync,
10} from "node:fs";
11import path from "node:path";
12import config from "../config.ts";
13import { paths, STALE_LOCK_MS, VALID_REPO_NAME_RE } from "../constants.ts";
14import type { RepositoryRow } from "../db/index.ts";
15import { db } from "../db/index.ts";
16import { git, repoPath } from "../services/git.ts";
17
18// Converts a non-bare repo to bare in-place by extracting the .git directory.
19// Case 1: "myrepo/" — move myrepo/.git → myrepo.git/, delete myrepo/
20// Case 2: "myrepo.git/" — move .git/ to a temp dir, delete myrepo.git/, rename temp → myrepo.git/
21async function convertNonBareRepo(
22 entryName: string,
23 entryPath: string,
24): Promise<void> {
25 const dotGitPath = path.join(entryPath, ".git");
26 const hasGitSuffix = entryName.endsWith(".git");
27 const baseName = hasGitSuffix ? entryName : `${entryName}.git`;
28 const targetPath = path.join(paths.REPOS_DIR, baseName);
29
30 try {
31 if (hasGitSuffix) {
32 // Case 2: source and target path are the same dir — use a temp location.
33 const tmpPath = path.join(
34 paths.REPOS_DIR,
35 `.${entryName}.bare_tmp`,
36 );
37 renameSync(dotGitPath, tmpPath);
38 rmSync(entryPath, { recursive: true, force: true });
39 renameSync(tmpPath, targetPath);
40 } else {
41 // Case 1: simple move.
42 renameSync(dotGitPath, targetPath);
43 rmSync(entryPath, { recursive: true, force: true });
44 }
45
46 const worktreesPath = path.join(targetPath, "worktrees");
47 if (existsSync(worktreesPath)) {
48 rmSync(worktreesPath, { recursive: true, force: true });
49 }
50
51 console.log(`Converted non-bare repo to bare: ${baseName}`);
52 } catch (err) {
53 console.error(`Failed to convert non-bare repo ${entryName}:`, err);
54 }
55}
56
57// Scans paths.REPOS_DIR for non-bare repos and converts them before the main sync.
58async function convertNonBareRepos(): Promise<void> {
59 let entries: Dirent[];
60 try {
61 entries = readdirSync(paths.REPOS_DIR, { withFileTypes: true });
62 } catch {
63 return;
64 }
65
66 const conversions: Promise<void>[] = [];
67 for (const entry of entries) {
68 if (!entry.isDirectory()) continue;
69 const entryPath = path.join(paths.REPOS_DIR, entry.name);
70 if (existsSync(path.join(entryPath, ".git"))) {
71 conversions.push(convertNonBareRepo(entry.name, entryPath));
72 }
73 }
74 await Promise.all(conversions);
75}
76
77function clearStaleConfigLocks(): void {
78 let entries: Dirent[];
79 try {
80 entries = readdirSync(paths.REPOS_DIR, { withFileTypes: true });
81 } catch {
82 return;
83 }
84
85 for (const entry of entries) {
86 if (!entry.isDirectory() || !entry.name.endsWith(".git")) continue;
87 const lockPath = path.join(paths.REPOS_DIR, entry.name, "config.lock");
88 try {
89 if (Date.now() - statSync(lockPath).mtimeMs < STALE_LOCK_MS)
90 continue;
91 unlinkSync(lockPath);
92 console.log(`Removed stale config lock: ${entry.name}`);
93 } catch {
94 // No lock file (the normal case), or it vanished meanwhile.
95 }
96 }
97}
98
99export function listDiskRepoNames(): string[] {
100 try {
101 return readdirSync(paths.REPOS_DIR, { withFileTypes: true })
102 .filter((e) => e.isDirectory() && e.name.endsWith(".git"))
103 .map((e) => e.name.slice(0, -4));
104 } catch {
105 return [];
106 }
107}
108
109export function repoDiskExists(name: string): boolean {
110 return existsSync(repoPath(name));
111}
112
113export async function ensureRepoRecord(name: string): Promise<RepositoryRow> {
114 const existing = await db
115 .selectFrom("repositories")
116 .selectAll()
117 .where("name", "=", name)
118 .executeTakeFirst();
119 if (existing) return existing;
120
121 // First time this repo is seen (pushed externally, manually imported, or
122 // freshly created): make sure git treats it as bare before we record and
123 // serve it. Doing this only on discovery — not on every read — keeps repo
124 // page views free of a per-request subprocess spawn and config write.
125 //
126 // ensureBare never throws. It must not be able to block the insert below:
127 // without a DB record every later request lands here again, so one failed
128 // config write would turn into a permanent per-request failure loop.
129 await git.ensureBare(name);
130 const branch = await git.defaultBranch(name);
131 const now = new Date().toISOString();
132 return await db
133 .insertInto("repositories")
134 .values({
135 name,
136 description: null,
137 is_private: config.SCANNED_REPO_PRIVATE ? 1 : 0,
138 default_branch: branch,
139 created_at: now,
140 })
141 .returningAll()
142 .executeTakeFirstOrThrow();
143}
144
145async function ensureSigningSetup(): Promise<void> {
146 const hostname = new URL(config.BASE_URL).hostname;
147 const pubKeyPath = `${paths.SSH_HOST_KEY_PATH}.pub`;
148
149 if (!existsSync(paths.SSH_HOST_KEY_PATH)) {
150 const { spawnSync } = await import("node:child_process");
151 spawnSync(
152 "ssh-keygen",
153 [
154 "-t",
155 "ed25519",
156 "-N",
157 "",
158 "-f",
159 paths.SSH_HOST_KEY_PATH,
160 "-C",
161 hostname,
162 ],
163 { stdio: "ignore" },
164 );
165 console.log("Generated SSH host key at", paths.SSH_HOST_KEY_PATH);
166 } else {
167 try {
168 const existing = readFileSync(pubKeyPath, "utf8").trim();
169 const keyHostname = existing.split(/\s+/)[2] ?? "";
170 if (keyHostname !== hostname) {
171 console.warn(
172 `Warning: SSH host key comment "${keyHostname}" does not match` +
173 ` current hostname "${hostname}". The key was likely generated` +
174 ` for a different BASE_URL. Commit signatures may show an` +
175 ` unexpected identity.`,
176 );
177 }
178 } catch {
179 // .pub file missing or unreadable — handled below
180 }
181 }
182
183 let pubKey: string;
184 try {
185 pubKey = readFileSync(pubKeyPath, "utf8").trim();
186 } catch {
187 console.warn("Could not read SSH public key at", pubKeyPath);
188 return;
189 }
190
191 let content = `* namespaces="git" ${pubKey}\n`;
192
193 if (config.EXTRA_ALLOWED_SIGNERS_PATH) {
194 try {
195 const extra = readFileSync(
196 config.EXTRA_ALLOWED_SIGNERS_PATH,
197 "utf8",
198 );
199 content += extra.endsWith("\n") ? extra : `${extra}\n`;
200 } catch {
201 console.warn(
202 "Could not read config.EXTRA_ALLOWED_SIGNERS_PATH:",
203 config.EXTRA_ALLOWED_SIGNERS_PATH,
204 );
205 }
206 }
207
208 await Bun.write(paths.ALLOWED_SIGNERS_PATH, content);
209}
210
211export async function syncStartup(): Promise<void> {
212 await ensureSigningSetup();
213 clearStaleConfigLocks();
214 await convertNonBareRepos();
215 const diskNames = new Set(listDiskRepoNames());
216 const dbRepos = await db
217 .selectFrom("repositories")
218 .select(["id", "name"])
219 .execute();
220 // Ensure all on-disk repos have DB records (e.g. repos pushed externally).
221 // Skip names that fail validation — they can't be served anyway.
222 const validDiskNames = [...diskNames].filter((n) =>
223 VALID_REPO_NAME_RE.test(n),
224 );
225 await Promise.all(validDiskNames.map((name) => ensureRepoRecord(name)));
226
227 const stale = dbRepos.filter((r) => !diskNames.has(r.name));
228 if (stale.length > 0) {
229 await db
230 .deleteFrom("repositories")
231 .where(
232 "id",
233 "in",
234 stale.map((r) => r.id),
235 )
236 .execute();
237 console.log(
238 `Removed ${stale.length} stale repo record(s): ${stale.map((r) => r.name).join(", ")}`,
239 );
240 }
241
242 // Release cleanup: sync DB records against on-disk release directories.
243 // Orphaned directories (no DB record) arise when the server crashes after
244 // files are written but before the transaction commits. Stale DB records
245 // (directory missing) arise when the server crashes after rmSync but before
246 // the DB delete during release deletion.
247 //
248 // Note: releases with no source code and no assets have no on-disk
249 // directory, so we only apply the stale-record check to releases that
250 // should have a directory (include_source_code=1 or has release_assets).
251 const allReleaseIds = new Set(
252 (await db.selectFrom("releases").select("id").execute()).map(
253 (r) => r.id,
254 ),
255 );
256
257 try {
258 for (const entry of readdirSync(paths.RELEASES_DIR, {
259 withFileTypes: true,
260 })) {
261 if (!entry.isDirectory()) continue;
262 const id = Number(entry.name);
263 if (!Number.isNaN(id) && !allReleaseIds.has(id)) {
264 rmSync(path.join(paths.RELEASES_DIR, entry.name), {
265 recursive: true,
266 force: true,
267 });
268 console.log(
269 `Removed orphaned release directory: ${entry.name}`,
270 );
271 }
272 }
273 } catch {
274 // paths.RELEASES_DIR may not exist yet on first run
275 }
276
277 // Only check for missing dirs on releases that should have one.
278 const releasesWithDirs = await db
279 .selectFrom("releases")
280 .select("releases.id")
281 .where((eb) =>
282 eb.or([
283 eb("releases.include_source_code", "=", 1),
284 eb.exists(
285 eb
286 .selectFrom("release_assets")
287 .select("release_assets.id")
288 .whereRef(
289 "release_assets.release_id",
290 "=",
291 "releases.id",
292 ),
293 ),
294 ]),
295 )
296 .execute();
297
298 const staleReleases = releasesWithDirs.filter(
299 (r) => !existsSync(path.join(paths.RELEASES_DIR, String(r.id))),
300 );
301 if (staleReleases.length > 0) {
302 await db
303 .deleteFrom("releases")
304 .where(
305 "id",
306 "in",
307 staleReleases.map((r) => r.id),
308 )
309 .execute();
310 console.log(
311 `Removed ${staleReleases.length} stale release record(s): ${staleReleases.map((r) => r.id).join(", ")}`,
312 );
313 }
314}
315