import { rename, rm, mkdir } from "node:fs/promises";
import path from "node:path";
import { count, db, filter, findById, findFirst, isUniqueViolation, orNull, type RecordModel } from "./db";
import {
cloneBare,
defaultBranch,
GitError,
initBareRepo,
isValidName,
lastActivity,
normalizeRepoName,
pathExists,
repoPath,
wikiPath,
} from "./git";
import { commitFiles } from "./git-write";
import { deleteRepoImages } from "./registry";
import { deleteRepoReleaseFiles } from "./releases";
import type { AgentPush } from "./branch-rules";
import { orgRole, repoAccessOfRole } from "./orgs";
import { getPersonByUsername, type User } from "./users";
export type Visibility = "public" | "private";
export type Access = "admin" | "write" | "read" | null;
/** The repository a fork was made from. */
export type ForkParent = { id: string; ownerId: string; owner: string; name: string; visibility: Visibility };
export type RepoRecord = {
id: string;
ownerId: string;
owner: string;
/** Owned by an organization, whose members' roles apply to it (see lib/orgs.ts). */
ownerIsOrg: boolean;
name: string;
description: string;
visibility: Visibility;
createdAt: number;
pushedAt: number | null;
/** When a custom image was uploaded (used to bust caches), or null to show the owner's avatar. */
imageVersion: number | null;
wikiEnabled: boolean;
/** Where agents may push (see lib/branch-rules.ts). */
agentPush: AgentPush;
/** Set for forks, unless the repository they were made from was deleted. */
forkOf: ForkParent | null;
dir: string;
/** The wiki's git repository; it only exists once the first page is written. */
wikiDir: string;
};
const EXPAND = "owner,fork_of.owner";
function toRepo(r: RecordModel): RepoRecord {
const ownerRecord = r.expand?.owner as RecordModel;
const owner = ownerRecord.username as string;
const parent = r.expand?.fork_of as RecordModel | undefined;
return {
id: r.id,
ownerId: r.owner,
owner,
ownerIsOrg: ownerRecord.kind === "org",
name: r.name,
description: r.description,
visibility: r.visibility,
createdAt: r.created_at,
pushedAt: orNull(r.pushed_at),
imageVersion: r.image ? orNull(r.image_updated_at) : null,
wikiEnabled: !r.wiki_disabled,
agentPush: r.agent_push || "any",
forkOf: parent
? { id: parent.id, ownerId: parent.owner, owner: (parent.expand?.owner as RecordModel).username, name: parent.name, visibility: parent.visibility }
: null,
dir: repoPath(owner, r.name),
wikiDir: wikiPath(r.id),
};
}
export class RepoError extends Error {}
const repos = async () => (await db()).collection("repos");
export async function findRepo(owner: string, name: string) {
const row = await findFirst(
"repos",
filter("owner.username:lower = {:owner} && name:lower = {:name}", { owner: owner.toLowerCase(), name: name.toLowerCase() }),
{ expand: EXPAND },
);
return row ? toRepo(row) : null;
}
export async function findRepoById(id: string) {
const row = await findById("repos", id, { expand: EXPAND });
return row ? toRepo(row) : null;
}
/**
* What a git URL names: a repository, or the wiki of one as <name>.wiki.git (like GitHub). A
* repository that is itself named "<name>.wiki" (from before such names were refused) wins.
*/
export async function findGitTarget(owner: string, repoParam: string) {
const name = normalizeRepoName(repoParam);
const repo = await findRepo(owner, name);
if (repo) return { repo, dir: repo.dir, wiki: false };
if (!name.toLowerCase().endsWith(".wiki")) return null;
const parent = await findRepo(owner, name.slice(0, -5));
return parent?.wikiEnabled ? { repo: parent, dir: parent.wikiDir, wiki: true } : null;
}
/** Creates the wiki's git repository if nothing has been written to it yet. */
export async function ensureWikiRepo(repo: RepoRecord) {
if (await pathExists(repo.wikiDir)) return;
await mkdir(path.dirname(repo.wikiDir), { recursive: true });
await initBareRepo(repo.wikiDir);
}
const ACCESS_LEVELS: Access[] = [null, "read", "write", "admin"];
const highest = (...levels: Access[]) => levels.reduce((a, b) => (ACCESS_LEVELS.indexOf(b) > ACCESS_LEVELS.indexOf(a) ? b : a), null);
/**
* What a user may do in a repository: its owner administers it; collaborators push to it; members
* of the organization that owns it get what their role gives them (see lib/orgs.ts).
*/
export async function repoAccess(repo: Pick<RepoRecord, "id" | "ownerId" | "visibility">, userId: string | null | undefined): Promise<Access> {
const everyone: Access = repo.visibility === "public" ? "read" : null;
if (!userId) return everyone;
if (repo.ownerId === userId) return "admin";
const [collaborator, role] = await Promise.all([
findFirst("collaborators", filter("repo = {:repo} && user = {:userId}", { repo: repo.id, userId }), { fields: "id" }),
orgRole(repo.ownerId, userId),
]);
return highest(everyone, collaborator ? "write" : null, role && repoAccessOfRole(role));
}
export const canWrite = (access: Access) => access === "write" || access === "admin";
/** The repository a fork was made from, if the viewer may see it (a fork of a private repository could outlive their access to it). */
export async function visibleParent(repo: RepoRecord, viewerId: string | null | undefined) {
return repo.forkOf && (await repoAccess(repo.forkOf, viewerId)) ? repo.forkOf : null;
}
/** Repos a user may see: public ones, their own, ones they collaborate on, and their organizations'. */
const visibleTo = (viewerId: string | null) =>
viewerId
? filter(
"(visibility = 'public' || owner = {:viewer} || collaborators_via_repo.user ?= {:viewer} || owner.org_members_via_org.user ?= {:viewer})",
{ viewer: viewerId },
)
: "visibility = 'public'";
async function list(where: string, limit?: number) {
const options = { filter: where, sort: "-activity_at", expand: EXPAND };
const rows = limit ? (await (await repos()).getList(1, limit, { ...options, skipTotal: true })).items : await (await repos()).getFullList(options);
return rows.map(toRepo);
}
export async function listReposByOwner(ownerId: string, viewerId: string | null) {
return list(`${filter("owner = {:owner}", { owner: ownerId })} && ${visibleTo(viewerId)}`);
}
/** Repositories the user owns or collaborates on, and those of their organizations. */
export async function listReposForUser(userId: string) {
return list(filter("owner = {:u} || collaborators_via_repo.user ?= {:u} || owner.org_members_via_org.user ?= {:u}", { u: userId }));
}
export async function searchRepos(query: string, viewerId: string | null, limit = 50) {
return list(`${visibleTo(viewerId)} && ${filter("(name ~ {:q} || description ~ {:q} || owner.username ~ {:q})", { q: query })}`, limit);
}
// <name>.wiki.git is the clone URL of a repository's wiki.
const isWikiName = (name: string) => name.toLowerCase().endsWith(".wiki");
const alreadyHas = (owner: Pick<User, "username" | "isOrg">, name: string) =>
`${owner.isOrg ? `${owner.username} already has` : "You already have"} a repository named ${name}.`;
/** Checks a name for a new repository of `owner` and returns where it goes on disk. */
async function newRepoDir(owner: User, rawName: string) {
const name = rawName.trim();
if (!isValidName(name)) {
throw new RepoError("Repository names may only contain letters, digits, '.', '-' and '_', and may not end in .git.");
}
if (isWikiName(name)) throw new RepoError("Repository names may not end in .wiki: that's where a repository's wiki is cloned from.");
if (await findRepo(owner.username, name)) throw new RepoError(alreadyHas(owner, name));
const dir = repoPath(owner.username, name);
if (await pathExists(dir)) throw new RepoError(`A repository directory for ${name} already exists on disk.`);
await mkdir(path.dirname(dir), { recursive: true });
return { name, dir };
}
/** Saves the record for a repository whose directory was just made, removing the directory if that fails. */
async function createRecord(owner: User, dir: string, data: Record<string, unknown> & { name: string }) {
const now = Date.now();
try {
return await (await repos()).create({ owner: owner.id, created_at: now, activity_at: now, ...data });
} catch (e) {
await rm(dir, { recursive: true, force: true });
if (isUniqueViolation(e)) throw new RepoError(alreadyHas(owner, data.name));
throw e;
}
}
export async function createRepo(
owner: User,
input: { name: string; description: string; visibility: Visibility; readme: boolean },
by: {
/** Who writes the README: the owner, or the person creating a repository for their organization. */
author?: User;
/** Who commits it, if not the author: an agent creating the repository for them. */
committer?: { name: string; email: string };
} = {},
) {
const author = by.author ?? owner;
const { name, dir } = await newRepoDir(owner, input.name);
await initBareRepo(dir);
const record = await createRecord(owner, dir, { name, description: input.description.trim().slice(0, 350), visibility: input.visibility });
if (input.readme) {
const body = `# ${name}\n${input.description ? `\n${input.description.trim()}\n` : ""}`;
await commitFiles(dir, {
branch: "main",
parent: null,
changes: [{ path: "README.md", content: Buffer.from(body) }],
message: "Initial commit",
author: { name: author.name || author.username, email: author.email },
committer: by.committer,
});
await markPushed(record.id);
}
return (await findRepoById(record.id))!;
}
/**
* Forks `source` for `owner`: a copy of its branches (or only its default branch) and tags, linked
* to it. Issues, pull requests, releases and the wiki stay with the source. A fork of a private
* repository is private too.
*/
export async function forkRepo(owner: User, source: RepoRecord, input: { name: string; description: string; defaultBranchOnly: boolean }) {
const { name, dir } = await newRepoDir(owner, input.name);
const branch = input.defaultBranchOnly ? await defaultBranch(source.dir) : null;
try {
await cloneBare(source.dir, dir, branch);
} catch (e) {
await rm(dir, { recursive: true, force: true });
throw e;
}
const empty = (await lastActivity(dir)) === null;
const record = await createRecord(owner, dir, {
name,
description: input.description.trim().slice(0, 350),
visibility: source.visibility,
fork_of: source.id,
...(empty ? {} : { pushed_at: Date.now() }),
});
return (await findRepoById(record.id))!;
}
/** Forks of a repository the viewer may see, most recently active first. */
export async function listForks(repoId: string, viewerId: string | null) {
return list(`${filter("fork_of = {:repoId}", { repoId })} && ${visibleTo(viewerId)}`);
}
export async function countForks(repoId: string, viewerId: string | null) {
return count("repos", `${filter("fork_of = {:repoId}", { repoId })} && ${visibleTo(viewerId)}`);
}
/**
* The repositories a pull request can come from or go to: everything forked from the same
* original, directly or not, that the viewer may see. The original comes first.
*/
export async function forkNetwork(repo: RepoRecord, viewerId: string | null, limit = 100) {
let root = repo;
for (let i = 0; i < 20 && root.forkOf; i++) {
const parent = await findRepoById(root.forkOf.id);
if (!parent) break;
root = parent;
}
const out: RepoRecord[] = [];
const queue = [root];
const seen = new Set<string>();
while (queue.length && out.length < limit) {
const next = queue.shift()!;
if (seen.has(next.id)) continue;
seen.add(next.id);
if (await repoAccess(next, viewerId)) out.push(next);
// Forks of a repository the viewer can't see may still be visible to them.
queue.push(...(await list(filter("fork_of = {:id}", { id: next.id }))));
}
return out;
}
/** Whether two repositories are forks of the same original (or one of the other). */
export async function sameNetwork(a: RepoRecord, b: RepoRecord) {
const root = async (r: RepoRecord) => {
for (let i = 0; i < 20 && r.forkOf; i++) {
const parent = await findRepoById(r.forkOf.id);
if (!parent) break;
r = parent;
}
return r.id;
};
return a.id === b.id || (await root(a)) === (await root(b));
}
export async function markPushed(repoId: string) {
const now = Date.now();
await (await repos()).update(repoId, { pushed_at: now, activity_at: now });
}
export async function updateRepo(repoId: string, data: { description: string; visibility: Visibility }) {
const repo = await findRepoById(repoId);
if (repo?.forkOf?.visibility === "private" && data.visibility === "public") {
throw new RepoError(`This is a fork of ${repo.forkOf.owner}/${repo.forkOf.name}, which is private, so it stays private too.`);
}
await (await repos()).update(repoId, { description: data.description.trim().slice(0, 350), visibility: data.visibility });
}
export async function setAgentPush(repoId: string, agentPush: AgentPush) {
await (await repos()).update(repoId, { agent_push: agentPush === "any" ? "" : agentPush });
}
/** Turning the wiki off hides it and refuses git access to it, but keeps its pages. */
export async function setWikiEnabled(repoId: string, enabled: boolean) {
await (await repos()).update(repoId, { wiki_disabled: !enabled });
}
export async function renameRepo(repo: RepoRecord, newName: string) {
newName = newName.trim();
if (!isValidName(newName)) throw new RepoError("That repository name is invalid.");
if (isWikiName(newName)) throw new RepoError("Repository names may not end in .wiki: that's where a repository's wiki is cloned from.");
if (newName === repo.name) return repo;
const existing = await findRepo(repo.owner, newName);
if (existing && existing.id !== repo.id) throw new RepoError(`You already have a repository named ${newName}.`);
const newDir = repoPath(repo.owner, newName);
if (newDir !== repo.dir) await rename(repo.dir, newDir);
try {
await (await repos()).update(repo.id, { name: newName });
} catch (e) {
if (newDir !== repo.dir) await rename(newDir, repo.dir);
if (isUniqueViolation(e)) throw new RepoError(`You already have a repository named ${newName}.`);
throw e;
}
return (await findRepoById(repo.id))!;
}
/** Deletes the repository and its wiki; PocketBase deletes its issues, runs, releases, collaborators and so on with it. */
export async function deleteRepo(repo: RepoRecord) {
// Pull requests from it to other repositories can't change anymore. Loaded when needed: lib/pulls.ts needs this module.
const { closePullsFromRepo } = await import("./pulls");
await closePullsFromRepo(repo);
// Its forks stay in the network: they become forks of what it was forked from (or stand alone).
for (const fork of await (await repos()).getFullList({ filter: filter("fork_of = {:id}", { id: repo.id }), fields: "id" })) {
await (await repos()).update(fork.id, { fork_of: repo.forkOf?.id ?? "" });
}
await (await repos()).delete(repo.id);
// Frees the container images and the blobs no other repository uses.
deleteRepoImages(repo.id);
await deleteRepoReleaseFiles(repo.id);
await rm(repo.dir, { recursive: true, force: true });
await rm(repo.wikiDir, { recursive: true, force: true });
}
// ---------------------------------------------------------------------------
// Repository image
export const MAX_REPO_IMAGE_BYTES = 1024 * 1024;
const IMAGE_TYPES: Record<string, string> = { "image/png": "png", "image/jpeg": "jpg", "image/gif": "gif", "image/webp": "webp" };
/** Recognizes the raster formats we accept by their magic bytes; the browser-reported type isn't trusted. */
function sniffImageType(data: Uint8Array) {
const starts = (...bytes: number[]) => bytes.every((b, i) => data[i] === b);
if (starts(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a)) return "image/png";
if (starts(0xff, 0xd8, 0xff)) return "image/jpeg";
if (starts(0x47, 0x49, 0x46, 0x38)) return "image/gif";
if (starts(0x52, 0x49, 0x46, 0x46) && String.fromCharCode(...data.subarray(8, 12)) === "WEBP") return "image/webp";
return null;
}
export async function setRepoImage(repoId: string, data: Uint8Array) {
if (data.byteLength === 0) throw new RepoError("Choose an image to upload.");
if (data.byteLength > MAX_REPO_IMAGE_BYTES) throw new RepoError("Images can be at most 1 MB.");
const contentType = sniffImageType(data);
if (!contentType) throw new RepoError("Use a PNG, JPEG, GIF or WebP image.");
await (await repos()).update(repoId, {
image: new File([new Uint8Array(data)], `image.${IMAGE_TYPES[contentType]}`, { type: contentType }),
image_type: contentType,
image_updated_at: Date.now(),
});
}
export async function removeRepoImage(repoId: string) {
await (await repos()).update(repoId, { image: null, image_type: "", image_updated_at: 0 });
}
export async function getRepoImage(repoId: string) {
const row = await findById("repos", repoId);
if (!row?.image) return null;
const pb = await db();
// The file field is protected: downloads need a short-lived file token.
const res = await fetch(pb.files.getURL(row, row.image, { token: await pb.files.getToken() }));
if (!res.ok) return null;
return { contentType: row.image_type as string, data: new Uint8Array(await res.arrayBuffer()) };
}
// ---------------------------------------------------------------------------
// Collaborators
export async function listCollaborators(repoId: string) {
const rows = await (await db())
.collection("collaborators")
.getFullList({ filter: filter("repo = {:repoId}", { repoId }), expand: "user" });
return rows
.map((r) => {
const user = r.expand?.user as RecordModel;
return { id: user.id as string, username: user.username as string, name: user.name as string, addedAt: r.created_at as number };
})
.sort((a, b) => a.username.localeCompare(b.username));
}
/** Returns the person, and whether they weren't a collaborator yet. */
export async function addCollaborator(repo: RepoRecord, username: string) {
const user = await getPersonByUsername(username.trim());
if (!user) throw new RepoError(`No user named ${username}.`);
if (user.id === repo.ownerId) throw new RepoError("The owner already has full access.");
try {
await (await db()).collection("collaborators").create({ repo: repo.id, user: user.id, created_at: Date.now() });
return { user, added: true };
} catch (e) {
// Already a collaborator.
if (!isUniqueViolation(e)) throw e;
return { user, added: false };
}
}
export async function removeCollaborator(repoId: string, userId: string) {
const row = await findFirst("collaborators", filter("repo = {:repoId} && user = {:userId}", { repoId, userId }), { fields: "id" });
if (row) await (await db()).collection("collaborators").delete(row.id);
}
/** Last commit time for display, falling back to the creation time. */
export async function repoUpdatedAt(repo: RepoRecord) {
return (await lastActivity(repo.dir)) ?? repo.createdAt;
}
export { GitError };