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 if (existing) return existing;
97
98 // First time this repo is seen (pushed externally, manually imported, or
99 // freshly created): make sure git treats it as bare before we record and
100 // serve it. Doing this only on discovery — not on every read — keeps repo
101 // page views free of a per-request subprocess spawn and config write.
102 await $`git config --file ${path.join(repoPath(name), "config")} core.bare true`;
103 const branch = await git.defaultBranch(name);
104 const now = new Date().toISOString();
105 return await db
106 .insertInto("repositories")
107 .values({
108 name,
109 description: null,
110 is_private: config.SCANNED_REPO_PRIVATE ? 1 : 0,
111 default_branch: branch,
112 created_at: now,
113 })
114 .returningAll()
115 .executeTakeFirstOrThrow();
116}
117
118async function ensureSigningSetup(): Promise<void> {
119 const hostname = new URL(config.BASE_URL).hostname;
120 const pubKeyPath = `${paths.SSH_HOST_KEY_PATH}.pub`;
121
122 if (!existsSync(paths.SSH_HOST_KEY_PATH)) {
123 const { spawnSync } = await import("node:child_process");
124 spawnSync(
125 "ssh-keygen",
126 [
127 "-t",
128 "ed25519",
129 "-N",
130 "",
131 "-f",
132 paths.SSH_HOST_KEY_PATH,
133 "-C",
134 hostname,
135 ],
136 { stdio: "ignore" },
137 );
138 console.log("Generated SSH host key at", paths.SSH_HOST_KEY_PATH);
139 } else {
140 try {
141 const existing = readFileSync(pubKeyPath, "utf8").trim();
142 const keyHostname = existing.split(/\s+/)[2] ?? "";
143 if (keyHostname !== hostname) {
144 console.warn(
145 `Warning: SSH host key comment "${keyHostname}" does not match` +
146 ` current hostname "${hostname}". The key was likely generated` +
147 ` for a different BASE_URL. Commit signatures may show an` +
148 ` unexpected identity.`,
149 );
150 }
151 } catch {
152 // .pub file missing or unreadable — handled below
153 }
154 }
155
156 let pubKey: string;
157 try {
158 pubKey = readFileSync(pubKeyPath, "utf8").trim();
159 } catch {
160 console.warn("Could not read SSH public key at", pubKeyPath);
161 return;
162 }
163
164 let content = `* namespaces="git" ${pubKey}\n`;
165
166 if (config.EXTRA_ALLOWED_SIGNERS_PATH) {
167 try {
168 const extra = readFileSync(
169 config.EXTRA_ALLOWED_SIGNERS_PATH,
170 "utf8",
171 );
172 content += extra.endsWith("\n") ? extra : `${extra}\n`;
173 } catch {
174 console.warn(
175 "Could not read config.EXTRA_ALLOWED_SIGNERS_PATH:",
176 config.EXTRA_ALLOWED_SIGNERS_PATH,
177 );
178 }
179 }
180
181 await Bun.write(paths.ALLOWED_SIGNERS_PATH, content);
182}
183
184export async function syncStartup(): Promise<void> {
185 await ensureSigningSetup();
186 await convertNonBareRepos();
187 const diskNames = new Set(listDiskRepoNames());
188 const dbRepos = await db
189 .selectFrom("repositories")
190 .select(["id", "name"])
191 .execute();
192 // Ensure all on-disk repos have DB records (e.g. repos pushed externally).
193 // Skip names that fail validation — they can't be served anyway.
194 const validDiskNames = [...diskNames].filter((n) =>
195 VALID_REPO_NAME_RE.test(n),
196 );
197 await Promise.all(validDiskNames.map((name) => ensureRepoRecord(name)));
198
199 const stale = dbRepos.filter((r) => !diskNames.has(r.name));
200 if (stale.length > 0) {
201 await db
202 .deleteFrom("repositories")
203 .where(
204 "id",
205 "in",
206 stale.map((r) => r.id),
207 )
208 .execute();
209 console.log(
210 `Removed ${stale.length} stale repo record(s): ${stale.map((r) => r.name).join(", ")}`,
211 );
212 }
213
214 // Release cleanup: sync DB records against on-disk release directories.
215 // Orphaned directories (no DB record) arise when the server crashes after
216 // files are written but before the transaction commits. Stale DB records
217 // (directory missing) arise when the server crashes after rmSync but before
218 // the DB delete during release deletion.
219 //
220 // Note: releases with no source code and no assets have no on-disk
221 // directory, so we only apply the stale-record check to releases that
222 // should have a directory (include_source_code=1 or has release_assets).
223 const allReleaseIds = new Set(
224 (await db.selectFrom("releases").select("id").execute()).map(
225 (r) => r.id,
226 ),
227 );
228
229 try {
230 for (const entry of readdirSync(paths.RELEASES_DIR, {
231 withFileTypes: true,
232 })) {
233 if (!entry.isDirectory()) continue;
234 const id = Number(entry.name);
235 if (!Number.isNaN(id) && !allReleaseIds.has(id)) {
236 rmSync(path.join(paths.RELEASES_DIR, entry.name), {
237 recursive: true,
238 force: true,
239 });
240 console.log(
241 `Removed orphaned release directory: ${entry.name}`,
242 );
243 }
244 }
245 } catch {
246 // paths.RELEASES_DIR may not exist yet on first run
247 }
248
249 // Only check for missing dirs on releases that should have one.
250 const releasesWithDirs = await db
251 .selectFrom("releases")
252 .select("releases.id")
253 .where((eb) =>
254 eb.or([
255 eb("releases.include_source_code", "=", 1),
256 eb.exists(
257 eb
258 .selectFrom("release_assets")
259 .select("release_assets.id")
260 .whereRef(
261 "release_assets.release_id",
262 "=",
263 "releases.id",
264 ),
265 ),
266 ]),
267 )
268 .execute();
269
270 const staleReleases = releasesWithDirs.filter(
271 (r) => !existsSync(path.join(paths.RELEASES_DIR, String(r.id))),
272 );
273 if (staleReleases.length > 0) {
274 await db
275 .deleteFrom("releases")
276 .where(
277 "id",
278 "in",
279 staleReleases.map((r) => r.id),
280 )
281 .execute();
282 console.log(
283 `Removed ${staleReleases.length} stale release record(s): ${staleReleases.map((r) => r.id).join(", ")}`,
284 );
285 }
286}
287