| 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 | 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 | |
| 208 | function 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 | |
| 225 | async 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 | } |