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