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 if (existing) return existing;
92
93 await $`git config --file ${path.join(repoPath(name), "config")} core.bare true`;
94
95 const branch = await git.defaultBranch(name);
96 const now = new Date().toISOString();
97 return await db
98 .insertInto("repositories")
99 .values({
100 name,
101 description: null,
102 is_private: 0,
103 default_branch: branch,
104 created_at: now,
105 })
106 .returningAll()
107 .executeTakeFirstOrThrow();
108}
109
110export async function syncStartup(): Promise<void> {
111 await convertNonBareRepos();
112 const diskNames = new Set(listDiskRepoNames());
113 const dbRepos = await db
114 .selectFrom("repositories")
115 .select(["id", "name"])
116 .execute();
117 // Ensure all on-disk repos have DB records (e.g. repos pushed externally).
118 // Skip names that fail validation — they can't be served anyway.
119 const validDiskNames = [...diskNames].filter((n) =>
120 VALID_REPO_NAME_RE.test(n),
121 );
122 await Promise.all(validDiskNames.map((name) => ensureRepoRecord(name)));
123
124 const stale = dbRepos.filter((r) => !diskNames.has(r.name));
125 if (stale.length > 0) {
126 await db
127 .deleteFrom("repositories")
128 .where(
129 "id",
130 "in",
131 stale.map((r) => r.id),
132 )
133 .execute();
134 console.log(
135 `Removed ${stale.length} stale repo record(s): ${stale.map((r) => r.name).join(", ")}`,
136 );
137 }
138
139 // Release cleanup: sync DB records against on-disk release directories.
140 // Orphaned directories (no DB record) arise when the server crashes after
141 // files are written but before the transaction commits. Stale DB records
142 // (directory missing) arise when the server crashes after rmSync but before
143 // the DB delete during release deletion.
144 //
145 // Note: releases with no source code and no assets have no on-disk
146 // directory, so we only apply the stale-record check to releases that
147 // should have a directory (include_source_code=1 or has release_assets).
148 const allReleaseIds = new Set(
149 (await db.selectFrom("releases").select("id").execute()).map(
150 (r) => r.id,
151 ),
152 );
153
154 try {
155 for (const entry of readdirSync(RELEASES_DIR, {
156 withFileTypes: true,
157 })) {
158 if (!entry.isDirectory()) continue;
159 const id = Number(entry.name);
160 if (!Number.isNaN(id) && !allReleaseIds.has(id)) {
161 rmSync(path.join(RELEASES_DIR, entry.name), {
162 recursive: true,
163 force: true,
164 });
165 console.log(
166 `Removed orphaned release directory: ${entry.name}`,
167 );
168 }
169 }
170 } catch {
171 // RELEASES_DIR may not exist yet on first run
172 }
173
174 // Only check for missing dirs on releases that should have one.
175 const releasesWithDirs = await db
176 .selectFrom("releases")
177 .select("releases.id")
178 .where((eb) =>
179 eb.or([
180 eb("releases.include_source_code", "=", 1),
181 eb.exists(
182 eb
183 .selectFrom("release_assets")
184 .select("release_assets.id")
185 .whereRef(
186 "release_assets.release_id",
187 "=",
188 "releases.id",
189 ),
190 ),
191 ]),
192 )
193 .execute();
194
195 const staleReleases = releasesWithDirs.filter(
196 (r) => !existsSync(path.join(RELEASES_DIR, String(r.id))),
197 );
198 if (staleReleases.length > 0) {
199 await db
200 .deleteFrom("releases")
201 .where(
202 "id",
203 "in",
204 staleReleases.map((r) => r.id),
205 )
206 .execute();
207 console.log(
208 `Removed ${staleReleases.length} stale release record(s): ${staleReleases.map((r) => r.id).join(", ")}`,
209 );
210 }
211}
212