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