| 1 | import { existsSync } from "node:fs"; |
| 2 | import { mkdir, readdir, rm } from "node:fs/promises"; |
| 3 | import path from "node:path"; |
| 4 | import { failure } from "../job.js"; |
| 5 | import { truthyInput } from "../util.js"; |
| 6 | import type { Builtin, BuiltinContext } from "./index.js"; |
| 7 | |
| 8 | export 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. */ |
| 13 | export 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 | |
| 27 | function isHttp(url: string): boolean { |
| 28 | return /^https?:\/\//i.test(url); |
| 29 | } |
| 30 | |
| 31 | export 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 | const isOwn = repository.toLowerCase() === ownRepo.toLowerCase(); |
| 36 | const token = c.input("token", spec.token); |
| 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 | const clean = truthyInput(c.input("clean"), true); |
| 42 | const submodules = c.input("submodules", "false").toLowerCase(); |
| 43 | const persist = truthyInput(c.input("persist-credentials"), true); |
| 44 | const fetchTags = truthyInput(c.input("fetch-tags"), false); |
| 45 | if (truthyInput(c.input("lfs"), false)) c.warn("Git LFS is not supported by the Diffinery checkout built-in; LFS pointers will not be resolved."); |
| 46 | if (c.input("sparse-checkout")) c.warn("sparse-checkout is not supported by the Diffinery checkout built-in; doing a full checkout."); |
| 47 | |
| 48 | const url = cloneUrlFor(spec, repository); |
| 49 | const header = basicAuthHeader(token); |
| 50 | c.job.masker.add(token); |
| 51 | c.job.masker.add(header.slice("AUTHORIZATION: basic ".length)); |
| 52 | const authArgs = isHttp(url) && token ? ["-c", `http.extraheader=${header}`] : []; |
| 53 | const env = c.toolEnv({ GIT_TERMINAL_PROMPT: "0", GIT_LFS_SKIP_SMUDGE: "1" }); |
| 54 | const git = (args: string[], o: { silent?: boolean; allowFail?: boolean } = {}) => |
| 55 | o.allowFail ? c.exec("git", args, { cwd: dest, env, capture: true, silent: o.silent }) : c.run("git", args, { cwd: dest, env, silent: o.silent }); |
| 56 | |
| 57 | c.log(`Syncing repository: ${repository}`); |
| 58 | await mkdir(dest, { recursive: true }); |
| 59 | const hasGit = existsSync(path.join(dest, ".git")); |
| 60 | if (hasGit && clean) { |
| 61 | await git(["clean", "-ffdx"], { allowFail: true }); |
| 62 | await git(["reset", "--hard", "HEAD"], { allowFail: true, silent: true }); |
| 63 | } else if (!hasGit) { |
| 64 | const entries = await readdir(dest); |
| 65 | if (entries.length && clean) { |
| 66 | c.log(`Deleting the contents of '${dest}'`); |
| 67 | await Promise.all(entries.map((e) => rm(path.join(dest, e), { recursive: true, force: true }))); |
| 68 | } |
| 69 | } |
| 70 | if (!hasGit) await git(["init", "-q", dest]); |
| 71 | const setUrl = await git(["remote", "set-url", "origin", url], { allowFail: true, silent: true }); |
| 72 | if (setUrl.exitCode !== 0) await git(["remote", "add", "origin", url]); |
| 73 | await git(["config", "--local", "gc.auto", "0"]); |
| 74 | |
| 75 | // Decide what to fetch |
| 76 | let sha: string | undefined; |
| 77 | let ref: string | undefined; |
| 78 | if (refInput) { |
| 79 | if (/^[0-9a-f]{40}$/i.test(refInput)) sha = refInput; |
| 80 | else ref = refInput; |
| 81 | } else if (isOwn) { |
| 82 | sha = spec.sha; |
| 83 | ref = spec.ref; |
| 84 | } else { |
| 85 | ref = "HEAD"; |
| 86 | } |
| 87 | |
| 88 | const depthArgs = fetchDepth > 0 ? [`--depth=${fetchDepth}`] : []; |
| 89 | const tagArgs = fetchDepth === 0 || fetchTags ? [] : ["--no-tags"]; |
| 90 | const fetch = (refspecs: string[], allowFail = false) => |
| 91 | git([...authArgs, "fetch", ...tagArgs, "--prune", "--no-recurse-submodules", ...depthArgs, "origin", ...refspecs], { allowFail }); |
| 92 | |
| 93 | let checkoutTarget: string | undefined; |
| 94 | let branch: string | undefined; |
| 95 | if (ref?.startsWith("refs/heads/")) branch = ref.slice("refs/heads/".length); |
| 96 | |
| 97 | if (fetchDepth === 0) { |
| 98 | c.log("Fetching the repository (full history)"); |
| 99 | await fetch(["+refs/heads/*:refs/remotes/origin/*", "+refs/tags/*:refs/tags/*"]); |
| 100 | if (ref && !ref.startsWith("refs/heads/") && !ref.startsWith("refs/tags/") && ref !== "HEAD") { |
| 101 | if (ref.startsWith("refs/")) await fetch([`+${ref}:${ref.replace(/^refs\//, "refs/remotes/")}`], true); |
| 102 | } |
| 103 | checkoutTarget = sha ?? (await resolveRef(git, ref!, (b) => (branch = b))); |
| 104 | } else { |
| 105 | c.log(`Fetching the repository (depth ${fetchDepth})`); |
| 106 | let fetched = false; |
| 107 | if (sha) { |
| 108 | const r = await fetch([`+${sha}:refs/remotes/origin/${branch ?? "diffinery-checkout"}`], true); |
| 109 | fetched = r.exitCode === 0; |
| 110 | if (!fetched) c.log("Fetching by commit SHA failed; falling back to fetching the ref."); |
| 111 | } |
| 112 | if (!fetched && ref) { |
| 113 | const specs = refspecsFor(ref); |
| 114 | let ok = false; |
| 115 | for (const rs of specs) { |
| 116 | const r = await fetch([rs.refspec], true); |
| 117 | if (r.exitCode === 0) { |
| 118 | ok = true; |
| 119 | if (rs.branch) branch = rs.branch; |
| 120 | break; |
| 121 | } |
| 122 | } |
| 123 | if (!ok) throw failure(`Failed to fetch ref '${ref}' from ${repository}. Check that the ref exists and the token has read access.`); |
| 124 | if (!sha) { |
| 125 | const target = specs.find((s) => s.branch === branch)?.local ?? "FETCH_HEAD"; |
| 126 | checkoutTarget = target; |
| 127 | } |
| 128 | } else if (!fetched) { |
| 129 | throw failure(`Failed to fetch commit ${sha} from ${repository}.`); |
| 130 | } |
| 131 | checkoutTarget ??= sha ?? "FETCH_HEAD"; |
| 132 | } |
| 133 | |
| 134 | if (branch) await git(["checkout", "--progress", "--force", "-B", branch, checkoutTarget!]); |
| 135 | else await git(["checkout", "--progress", "--force", checkoutTarget!]); |
| 136 | const head = (await git(["rev-parse", "HEAD"], { silent: true })).stdout.trim(); |
| 137 | if (sha && /^[0-9a-f]{40}$/i.test(sha) && head.toLowerCase() !== sha.toLowerCase()) { |
| 138 | throw failure(`Checked out ${head} but expected ${sha}.`); |
| 139 | } |
| 140 | c.log(`HEAD is now at ${head}`); |
| 141 | c.setOutput("commit", head); |
| 142 | c.setOutput("ref", ref && ref !== "HEAD" ? ref : branch ? `refs/heads/${branch}` : ""); |
| 143 | |
| 144 | if (submodules === "true" || submodules === "recursive") { |
| 145 | c.log("Fetching submodules"); |
| 146 | await git(["submodule", "sync", ...(submodules === "recursive" ? ["--recursive"] : [])], { allowFail: true }); |
| 147 | const r = await git( |
| 148 | [...authArgs, "submodule", "update", "--init", "--force", ...(fetchDepth > 0 ? [`--depth=${fetchDepth}`] : []), ...(submodules === "recursive" ? ["--recursive"] : [])], |
| 149 | { allowFail: true }, |
| 150 | ); |
| 151 | if (r.exitCode !== 0) c.warn("Failed to update submodules (best effort)."); |
| 152 | } |
| 153 | |
| 154 | if (persist && authArgs.length) { |
| 155 | const key = `http.${new URL(url).origin}/.extraheader`; |
| 156 | await git(["config", "--local", key, header], { silent: true }); |
| 157 | c.registerPost(`Post ${c.name}`, "always()", async (pc: BuiltinContext) => { |
| 158 | if (!existsSync(path.join(dest, ".git"))) return; |
| 159 | pc.log("Removing persisted credentials"); |
| 160 | await pc.exec("git", ["config", "--local", "--unset-all", key], { cwd: dest, capture: true, silent: true }); |
| 161 | }); |
| 162 | } |
| 163 | }; |
| 164 | |
| 165 | function refspecsFor(ref: string): Array<{ refspec: string; local: string; branch?: string }> { |
| 166 | if (ref === "HEAD") return [{ refspec: "HEAD", local: "FETCH_HEAD" }]; |
| 167 | if (ref.startsWith("refs/heads/")) { |
| 168 | const b = ref.slice(11); |
| 169 | return [{ refspec: `+${ref}:refs/remotes/origin/${b}`, local: `refs/remotes/origin/${b}`, branch: b }]; |
| 170 | } |
| 171 | if (ref.startsWith("refs/tags/")) return [{ refspec: `+${ref}:${ref}`, local: ref }]; |
| 172 | if (ref.startsWith("refs/")) { |
| 173 | const local = ref.replace(/^refs\//, "refs/remotes/"); |
| 174 | return [{ refspec: `+${ref}:${local}`, local }]; |
| 175 | } |
| 176 | return [ |
| 177 | { refspec: `+refs/heads/${ref}:refs/remotes/origin/${ref}`, local: `refs/remotes/origin/${ref}`, branch: ref }, |
| 178 | { refspec: `+refs/tags/${ref}:refs/tags/${ref}`, local: `refs/tags/${ref}` }, |
| 179 | ]; |
| 180 | } |
| 181 | |
| 182 | async function resolveRef( |
| 183 | git: (args: string[], o?: { silent?: boolean; allowFail?: boolean }) => Promise<{ exitCode: number; stdout: string }>, |
| 184 | ref: string, |
| 185 | setBranch: (b: string) => void, |
| 186 | ): Promise<string> { |
| 187 | if (ref === "HEAD") { |
| 188 | const r = await git(["symbolic-ref", "refs/remotes/origin/HEAD"], { allowFail: true, silent: true }); |
| 189 | if (r.exitCode === 0) return r.stdout.trim(); |
| 190 | } |
| 191 | const short = ref.replace(/^refs\/heads\//, ""); |
| 192 | const candidates = ref.startsWith("refs/tags/") |
| 193 | ? [ref] |
| 194 | : ref.startsWith("refs/heads/") |
| 195 | ? [`refs/remotes/origin/${short}`] |
| 196 | : ref.startsWith("refs/") |
| 197 | ? [ref.replace(/^refs\//, "refs/remotes/")] |
| 198 | : [`refs/remotes/origin/${ref}`, `refs/tags/${ref}`]; |
| 199 | for (const cand of candidates) { |
| 200 | const r = await git(["rev-parse", "--verify", "--quiet", cand], { allowFail: true, silent: true }); |
| 201 | if (r.exitCode === 0) { |
| 202 | if (cand.startsWith("refs/remotes/origin/")) setBranch(cand.slice("refs/remotes/origin/".length)); |
| 203 | return cand; |
| 204 | } |
| 205 | } |
| 206 | throw failure(`A branch or tag with the name '${ref}' could not be found`); |
| 207 | } |