Diffinery

codician/diffinerypublic

Open-source, agent-first GitHub and GitHub Actions alternative that runs on your own Cloudflare account: git hosting, pull requests, code review and drop-in GitHub Actions YAML on Workers, Durable Objects, R2 and Containers.

250 lines · 12.2 KBRaw History
1import { existsSync } from "node:fs";
2import { mkdir, readdir, rm } from "node:fs/promises";
3import path from "node:path";
4import { failure } from "../job.js";
5import { truthyInput } from "../util.js";
6import type { Builtin, BuiltinContext } from "./index.js";
7
8export function basicAuthHeader(token: string): string {
9 return `AUTHORIZATION: basic ${Buffer.from(`x-access-token:${token}`).toString("base64")}`;
10}
11
12/** Derives the clone URL of `owner/repo` from the job's own clone URL. */
13export function cloneUrlFor(spec: { repo: { owner: string; name: string; cloneUrl: string } }, repository: string): string {
14 const own = `${spec.repo.owner}/${spec.repo.name}`;
15 if (repository.toLowerCase() === own.toLowerCase()) return spec.repo.cloneUrl;
16 const u = spec.repo.cloneUrl;
17 const idx = u.toLowerCase().lastIndexOf(own.toLowerCase());
18 if (idx >= 0) return u.slice(0, idx) + repository + u.slice(idx + own.length);
19 try {
20 const parsed = new URL(u);
21 return `${parsed.origin}/${repository}.git`;
22 } catch {
23 return u;
24 }
25}
26
27function isHttp(url: string): boolean {
28 return /^https?:\/\//i.test(url);
29}
30
31export const checkout: Builtin = async (c) => {
32 const spec = c.job.spec;
33 const ownRepo = `${spec.repo.owner}/${spec.repo.name}`;
34 const repository = c.input("repository", ownRepo);
35 if (!/^[\w.-]+\/[\w.-]+$/.test(repository)) throw failure(`Invalid repository '${repository}'. Expected format {owner}/{repo}.`);
36 const isOwn = repository.toLowerCase() === ownRepo.toLowerCase();
37 const refInput = c.input("ref");
38 const depthRaw = Number(c.input("fetch-depth", "1"));
39 const fetchDepth = Number.isFinite(depthRaw) && depthRaw >= 0 ? Math.floor(depthRaw) : 1;
40 const dest = path.resolve(c.job.workspace, c.input("path", "."));
41 if (dest !== c.job.workspace && !dest.startsWith(c.job.workspace + path.sep)) throw failure(`Repository path '${dest}' is not under '${c.job.workspace}'`);
42 const clean = truthyInput(c.input("clean"), true);
43 const submodules = c.input("submodules", "false").toLowerCase();
44 const persist = truthyInput(c.input("persist-credentials"), true);
45 const fetchTags = truthyInput(c.input("fetch-tags"), false);
46 const showProgress = truthyInput(c.input("show-progress"), true);
47 const lfs = truthyInput(c.input("lfs"), false);
48 const sparse = c.input("sparse-checkout").split("\n").map((l) => l.trim()).filter(Boolean);
49 const coneMode = truthyInput(c.input("sparse-checkout-cone-mode"), true);
50 // Like actions/checkout, sparse checkouts fetch without blobs unless a filter is given.
51 const filter = c.input("filter") || (sparse.length ? "blob:none" : "");
52
53 const server = c.input("github-server-url").replace(/\/+$/, "");
54 const ownOrigin = isHttp(spec.repo.cloneUrl) ? new URL(spec.repo.cloneUrl).origin : "";
55 const url = server && server !== ownOrigin ? `${server}/${repository}.git` : cloneUrlFor(spec, repository);
56 const origin = isHttp(url) ? new URL(url).origin : "";
57 // The job's own token only ever goes to this server; any other host needs an explicit `token:`.
58 const token = c.input("token") || (origin && origin === ownOrigin ? spec.token : "");
59 const header = token ? basicAuthHeader(token) : "";
60 if (token) {
61 c.job.masker.add(token);
62 c.job.masker.add(header.slice("AUTHORIZATION: basic ".length));
63 }
64 const authKey = `http.${origin}/.extraheader`;
65 const env = c.toolEnv({ GIT_TERMINAL_PROMPT: "0", ...(lfs ? {} : { GIT_LFS_SKIP_SMUDGE: "1" }) });
66 /** Configuration for the git commands run here (and the git, git-lfs and submodule processes they start). */
67 const withConfig = (key: string, value: string) => {
68 const n = Number(env.GIT_CONFIG_COUNT) || 0;
69 Object.assign(env, { GIT_CONFIG_COUNT: String(n + 1), [`GIT_CONFIG_KEY_${n}`]: key, [`GIT_CONFIG_VALUE_${n}`]: value });
70 };
71 // Like actions/checkout's temporary global config: trust the checkout for these git commands only.
72 if (truthyInput(c.input("set-safe-directory"), true)) withConfig("safe.directory", dest);
73 // The token reaches git through the environment while fetching, so a checkout that fails half
74 // way leaves nothing in .git/config; it is written there at the end, only to persist it.
75 if (origin && header) {
76 withConfig(authKey, header);
77 withConfig(`url.${origin}/.insteadOf`, `git@${new URL(origin).host}:`);
78 }
79 const git = (args: string[], o: { silent?: boolean; allowFail?: boolean } = {}) =>
80 o.allowFail ? c.exec("git", args, { cwd: dest, env, capture: true, silent: o.silent }) : c.run("git", args, { cwd: dest, env, silent: o.silent });
81
82 c.log(`Syncing repository: ${repository}`);
83 await mkdir(dest, { recursive: true });
84 const hasGit = existsSync(path.join(dest, ".git"));
85 if (hasGit && clean) {
86 await git(["clean", "-ffdx"], { allowFail: true });
87 await git(["reset", "--hard", "HEAD"], { allowFail: true, silent: true });
88 } else if (!hasGit) {
89 const entries = await readdir(dest);
90 if (entries.length && clean) {
91 c.log(`Deleting the contents of '${dest}'`);
92 await Promise.all(entries.map((e) => rm(path.join(dest, e), { recursive: true, force: true })));
93 }
94 }
95 if (!hasGit) await git(["init", "-q", dest]);
96 const setUrl = await git(["remote", "set-url", "origin", url], { allowFail: true, silent: true });
97 if (setUrl.exitCode !== 0) await git(["remote", "add", "origin", url]);
98 await git(["config", "--local", "gc.auto", "0"]);
99 // A reused workspace may still hold another job's credentials; they would be sent alongside ours.
100 if (origin) await git(["config", "--local", "--unset-all", authKey], { allowFail: true, silent: true });
101 if (lfs) await git(["lfs", "install", "--local"]);
102
103 // Decide what to fetch
104 let sha: string | undefined;
105 let ref: string | undefined;
106 if (refInput) {
107 if (/^[0-9a-f]{40}$/i.test(refInput)) sha = refInput;
108 else ref = refInput;
109 } else if (isOwn) {
110 sha = spec.sha;
111 ref = spec.ref;
112 } else {
113 ref = "HEAD";
114 }
115
116 const depthArgs = fetchDepth > 0 ? [`--depth=${fetchDepth}`] : [];
117 const tagArgs = fetchDepth === 0 || fetchTags ? [] : ["--no-tags"];
118 const extraArgs = [...(filter ? [`--filter=${filter}`] : []), ...(showProgress ? ["--progress"] : [])];
119 const fetch = (refspecs: string[], allowFail = false) => git(["fetch", ...tagArgs, "--prune", "--no-recurse-submodules", ...extraArgs, ...depthArgs, "origin", ...refspecs], { allowFail });
120
121 let checkoutTarget: string | undefined;
122 let branch: string | undefined;
123 if (ref?.startsWith("refs/heads/")) branch = ref.slice("refs/heads/".length);
124
125 if (fetchDepth === 0) {
126 c.log("Fetching the repository (full history)");
127 await fetch(["+refs/heads/*:refs/remotes/origin/*", "+refs/tags/*:refs/tags/*"]);
128 if (ref && !ref.startsWith("refs/heads/") && !ref.startsWith("refs/tags/") && ref !== "HEAD") {
129 if (ref.startsWith("refs/")) await fetch([`+${ref}:${ref.replace(/^refs\//, "refs/remotes/")}`], true);
130 }
131 checkoutTarget = sha ?? (await resolveRef(git, ref!, (b) => (branch = b)));
132 } else {
133 c.log(`Fetching the repository (depth ${fetchDepth})`);
134 let fetched = false;
135 if (sha) {
136 const r = await fetch([`+${sha}:refs/remotes/origin/${branch ?? "diffinery-checkout"}`], true);
137 fetched = r.exitCode === 0;
138 if (!fetched) c.log("Fetching by commit SHA failed; falling back to fetching the ref.");
139 }
140 if (!fetched && ref) {
141 const specs = refspecsFor(ref);
142 let ok = false;
143 for (const rs of specs) {
144 const r = await fetch([rs.refspec], true);
145 if (r.exitCode === 0) {
146 ok = true;
147 if (rs.branch) branch = rs.branch;
148 break;
149 }
150 }
151 if (!ok) throw failure(`Failed to fetch ref '${ref}' from ${repository}. Check that the ref exists and the token has read access.`);
152 if (!sha) {
153 const target = specs.find((s) => s.branch === branch)?.local ?? "FETCH_HEAD";
154 checkoutTarget = target;
155 }
156 } else if (!fetched) {
157 throw failure(`Failed to fetch commit ${sha} from ${repository}.`);
158 }
159 checkoutTarget ??= sha ?? "FETCH_HEAD";
160 }
161
162 if (lfs) {
163 c.log("Fetching LFS objects");
164 await git(["lfs", "fetch", "origin", checkoutTarget!]);
165 }
166 if (sparse.length) {
167 c.log(`Setting up sparse checkout${coneMode ? "" : " (non-cone mode)"}`);
168 await git(["sparse-checkout", "set", ...(coneMode ? [] : ["--no-cone"]), "--", ...sparse]);
169 } else if (hasGit) {
170 await git(["sparse-checkout", "disable"], { allowFail: true, silent: true });
171 }
172
173 const progress = showProgress ? ["--progress"] : [];
174 if (branch) await git(["checkout", ...progress, "--force", "-B", branch, checkoutTarget!]);
175 else await git(["checkout", ...progress, "--force", checkoutTarget!]);
176 const head = (await git(["rev-parse", "HEAD"], { silent: true })).stdout.trim();
177 if (sha && /^[0-9a-f]{40}$/i.test(sha) && head.toLowerCase() !== sha.toLowerCase()) {
178 throw failure(`Checked out ${head} but expected ${sha}.`);
179 }
180 c.log(`HEAD is now at ${head}`);
181 c.setOutput("commit", head);
182 c.setOutput("ref", ref && ref !== "HEAD" ? ref : branch ? `refs/heads/${branch}` : "");
183
184 if (submodules === "true" || submodules === "recursive") {
185 const recursive = submodules === "recursive" ? ["--recursive"] : [];
186 c.log("Fetching submodules");
187 await git(["submodule", "sync", ...recursive]);
188 await git(["submodule", "update", "--init", "--force", ...(fetchDepth > 0 ? [`--depth=${fetchDepth}`] : []), ...recursive]);
189 if (lfs) await git(["submodule", "foreach", ...recursive, "git lfs pull"]);
190 }
191
192 if (!origin || !header || !persist) return;
193 // persist-credentials: later steps' git commands authenticate; the post step removes it again.
194 await git(["config", "--local", authKey, header], { silent: true });
195 if (submodules === "true" || submodules === "recursive") {
196 await git(["submodule", "foreach", ...(submodules === "recursive" ? ["--recursive"] : []), `git config --local '${authKey}' '${header}'`], { silent: true });
197 }
198 c.registerPost(`Post ${c.name}`, "always()", async (pc: BuiltinContext) => {
199 if (!existsSync(path.join(dest, ".git"))) return;
200 pc.log("Removing persisted credentials");
201 await pc.exec("git", ["config", "--local", "--unset-all", authKey], { cwd: dest, capture: true, silent: true });
202 if (submodules === "true" || submodules === "recursive") {
203 await pc.exec("git", ["submodule", "foreach", "--recursive", `git config --local --unset-all '${authKey}' || :`], { cwd: dest, capture: true, silent: true });
204 }
205 });
206};
207
208function refspecsFor(ref: string): Array<{ refspec: string; local: string; branch?: string }> {
209 if (ref === "HEAD") return [{ refspec: "HEAD", local: "FETCH_HEAD" }];
210 if (ref.startsWith("refs/heads/")) {
211 const b = ref.slice(11);
212 return [{ refspec: `+${ref}:refs/remotes/origin/${b}`, local: `refs/remotes/origin/${b}`, branch: b }];
213 }
214 if (ref.startsWith("refs/tags/")) return [{ refspec: `+${ref}:${ref}`, local: ref }];
215 if (ref.startsWith("refs/")) {
216 const local = ref.replace(/^refs\//, "refs/remotes/");
217 return [{ refspec: `+${ref}:${local}`, local }];
218 }
219 return [
220 { refspec: `+refs/heads/${ref}:refs/remotes/origin/${ref}`, local: `refs/remotes/origin/${ref}`, branch: ref },
221 { refspec: `+refs/tags/${ref}:refs/tags/${ref}`, local: `refs/tags/${ref}` },
222 ];
223}
224
225async function resolveRef(
226 git: (args: string[], o?: { silent?: boolean; allowFail?: boolean }) => Promise<{ exitCode: number; stdout: string }>,
227 ref: string,
228 setBranch: (b: string) => void,
229): Promise<string> {
230 if (ref === "HEAD") {
231 const r = await git(["symbolic-ref", "refs/remotes/origin/HEAD"], { allowFail: true, silent: true });
232 if (r.exitCode === 0) return r.stdout.trim();
233 }
234 const short = ref.replace(/^refs\/heads\//, "");
235 const candidates = ref.startsWith("refs/tags/")
236 ? [ref]
237 : ref.startsWith("refs/heads/")
238 ? [`refs/remotes/origin/${short}`]
239 : ref.startsWith("refs/")
240 ? [ref.replace(/^refs\//, "refs/remotes/")]
241 : [`refs/remotes/origin/${ref}`, `refs/tags/${ref}`];
242 for (const cand of candidates) {
243 const r = await git(["rev-parse", "--verify", "--quiet", cand], { allowFail: true, silent: true });
244 if (r.exitCode === 0) {
245 if (cand.startsWith("refs/remotes/origin/")) setBranch(cand.slice("refs/remotes/origin/".length));
246 return cand;
247 }
248 }
249 throw failure(`A branch or tag with the name '${ref}' could not be found`);
250}