repoSync.ts
Raw
1import {
2 type Dirent,
3 existsSync,
4 readdirSync,
5 renameSync,
6 rmSync,
7} from "node:fs";
8import path from "node:path";
9import { $ } from "bun";
10import { RELEASES_DIR, REPOS_DIR, VALID_REPO_NAME_RE } from "../constants.ts";
11import type { RepositoryRow } from "../db/index.ts";
12import { db } from "../db/index.ts";
13import { git, repoPath } from "../services/git.ts";
14
15// Converts a non-bare repo to bare in-place by extracting the .git directory.
16// Case 1: "myrepo/" — move myrepo/.git → myrepo.git/, delete myrepo/
17// Case 2: "myrepo.git/" — move .git/ to a temp dir, delete myrepo.git/, rename temp → myrepo.git/
18async function convertNonBareRepo(
19 entryName: string,
20 entryPath: string,
21): Promise<void> {
22 const dotGitPath = path.join(entryPath, ".git");
23 const hasGitSuffix = entryName.endsWith(".git");
24 const baseName = hasGitSuffix ? entryName : `${entryName}.git`;
25 const targetPath = path.join(REPOS_DIR, baseName);
26
27 try {
28 if (hasGitSuffix) {
29 // Case 2: source and target path are the same dir — use a temp location.
30 const tmpPath = path.join(REPOS_DIR, `.${entryName}.bare_tmp`);
31 renameSync(dotGitPath, tmpPath);
32 rmSync(entryPath, { recursive: true, force: true });
33 renameSync(tmpPath, targetPath);
34 } else {
35 // Case 1: simple move.
36 renameSync(dotGitPath, targetPath);
37 rmSync(entryPath, { recursive: true, force: true });
38 }
39
40 const worktreesPath = path.join(targetPath, "worktrees");
41 if (existsSync(worktreesPath)) {
42 rmSync(worktreesPath, { recursive: true, force: true });
43 }
44
45 console.log(`Converted non-bare repo to bare: ${baseName}`);
46 } catch (err) {
47 console.error(`Failed to convert non-bare repo ${entryName}:`, err);
48 }
49}
50
51// Scans REPOS_DIR for non-bare repos and converts them before the main sync.
52async function convertNonBareRepos(): Promise<void> {
53 let entries: Dirent[];
54 try {
55 entries = readdirSync(REPOS_DIR, { withFileTypes: true });
56 } catch {
57 return;
58 }
59
60 const conversions: Promise<void>[] = [];
61 for (const entry of entries) {
62 if (!entry.isDirectory()) continue;
63 const entryPath = path.join(REPOS_DIR, entry.name);
64 if (existsSync(path.join(entryPath, ".git"))) {
65 conversions.push(convertNonBareRepo(entry.name, entryPath));
66 }
67 }
68 await Promise.all(conversions);
69}
70
71export function listDiskRepoNames(): string[] {
72 try {
73 return readdirSync(REPOS_DIR, { withFileTypes: true })
74 .filter((e) => e.isDirectory() && e.name.endsWith(".git"))
75 .map((e) => e.name.slice(0, -4));
76 } catch {
77 return [];
78 }
79}
80
81export function repoDiskExists(name: string): boolean {
82 return existsSync(repoPath(name));
83}
84
85export async function ensureRepoRecord(name: string): Promise<RepositoryRow> {
86 const existing = await db
87 .selectFrom("repositories")
88 .selectAll()
89 .where("name", "=", name)
90 .executeTakeFirst();
91 await $`git config --file ${path.join(repoPath(name), "config")} core.bare true`;
92 if (existing) return existing;
93
94 const branch = await git.defaultBranch(name);
95 const now = new Date().toISOString();
96 return await db
97 .insertInto("repositories")
98 .values({
99 name,
100 description: null,
101 is_private: 0,
102 default_branch: branch,
103 created_at: now,
104 })
105 .returningAll()
106 .executeTakeFirstOrThrow();
107}
108
109export async function syncStartup(): Promise<void> {
110 await convertNonBareRepos();
111 const diskNames = new Set(listDiskRepoNames());
112 const dbRepos = await db
113 .selectFrom("repositories")
114 .select(["id", "name"])
115 .execute();
116 // Ensure all on-disk repos have DB records (e.g. repos pushed externally).
117 // Skip names that fail validation — they can't be served anyway.
118 const validDiskNames = [...diskNames].filter((n) =>
119 VALID_REPO_NAME_RE.test(n),
120 );
121 await Promise.all(validDiskNames.map((name) => ensureRepoRecord(name)));
122
123 const stale = dbRepos.filter((r) => !diskNames.has(r.name));
124 if (stale.length > 0) {
125 await db
126 .deleteFrom("repositories")
127 .where(
128 "id",
129 "in",
130 stale.map((r) => r.id),
131 )
132 .execute();
133 console.log(
134 `Removed ${stale.length} stale repo record(s): ${stale.map((r) => r.name).join(", ")}`,
135 );
136 }
137
138 // Release cleanup: sync DB records against on-disk release directories.
139 // Orphaned directories (no DB record) arise when the server crashes after
140 // files are written but before the transaction commits. Stale DB records
141 // (directory missing) arise when the server crashes after rmSync but before
142 // the DB delete during release deletion.
143 //
144 // Note: releases with no source code and no assets have no on-disk
145 // directory, so we only apply the stale-record check to releases that
146 // should have a directory (include_source_code=1 or has release_assets).
147 const allReleaseIds = new Set(
148 (await db.selectFrom("releases").select("id").execute()).map(
149 (r) => r.id,
150 ),
151 );
152
153 try {
154 for (const entry of readdirSync(RELEASES_DIR, {
155 withFileTypes: true,
156 })) {
157 if (!entry.isDirectory()) continue;
158 const id = Number(entry.name);
159 if (!Number.isNaN(id) && !allReleaseIds.has(id)) {
160 rmSync(path.join(RELEASES_DIR, entry.name), {
161 recursive: true,
162 force: true,
163 });
164 console.log(
165 `Removed orphaned release directory: ${entry.name}`,
166 );
167 }
168 }
169 } catch {
170 // RELEASES_DIR may not exist yet on first run
171 }
172
173 // Only check for missing dirs on releases that should have one.
174 const releasesWithDirs = await db
175 .selectFrom("releases")
176 .select("releases.id")
177 .where((eb) =>
178 eb.or([
179 eb("releases.include_source_code", "=", 1),
180 eb.exists(
181 eb
182 .selectFrom("release_assets")
183 .select("release_assets.id")
184 .whereRef(
185 "release_assets.release_id",
186 "=",
187 "releases.id",
188 ),
189 ),
190 ]),
191 )
192 .execute();
193
194 const staleReleases = releasesWithDirs.filter(
195 (r) => !existsSync(path.join(RELEASES_DIR, String(r.id))),
196 );
197 if (staleReleases.length > 0) {
198 await db
199 .deleteFrom("releases")
200 .where(
201 "id",
202 "in",
203 staleReleases.map((r) => r.id),
204 )
205 .execute();
206 console.log(
207 `Removed ${staleReleases.length} stale release record(s): ${staleReleases.map((r) => r.id).join(", ")}`,
208 );
209 }
210}
211