Skip to content

Commit

`actions/checkout` is the real action now, at the version a step names: g1t's runner no longer checks out in its place. It fetches from g1t with the job's token, as on GitHub, including a pull request's test merge commit, which g1t now keeps in the repository as `refs/pull/<number>/merge` beside `refs/pull/<number>/head`, and its post step removes the credential it left. Every input and output is the action's own. In a job container whose image has no git, the action falls back to a tarball download g1t does not serve yet, and `lfs` finds no files: the limitations say so, with what to do instead.

Claude-Session:
https://claude.ai/code/session_01PnwTRSNaC7vEHTAuBzjhrn
Claudeagent and Claudeagentcommitted Parent5dee456Browse files
3 files+18−4190/3 viewed
+8−20
273273 register with `--no-job-docker`.
274274 - **Status.** Not scheduled.
275275
276−### `actions/checkout` is g1t's own
276+### `actions/checkout` without git, or with Git LFS
277277
278−A step with `uses: actions/checkout` runs g1t's checkout, not the action
279−from GitHub, whatever version it names. It reads the action's inputs and
280−sets its outputs (see [what runs](/guides/actions/#what-runs)), but not
281−`lfs`, `ssh-key` or `clean: false`, and it has no post step: the
282−credential `persist-credentials` leaves stays in `.git/config` until the
283−job ends.
278+`actions/checkout` is the action itself. In a job container whose image
279+has no git, it downloads the repository as a tarball from
280+`GET /repos/{owner}/{repo}/tarball/{ref}`, which g1t does not serve yet,
281+and fails. With `lfs: true`, it finds no LFS files: g1t keeps none.
284282
285−- **Why.** The action fetches the run's commit from the repository the
286− workflow is in. A pull request's commits from a fork, or from the copy
287− g1t makes for a pull request opened from an issue, are not in that
288− repository while the pull request is open: g1t keeps no
289− `refs/pull/<number>/head` there yet. g1t's checkout fetches them from
290− where they are. Without git in a job's container, the action downloads
291− a tarball from `GET /repos/{owner}/{repo}/tarball/{ref}`, which g1t does
292− not have, and g1t keeps no Git LFS files.
293−- **Instead.** Use a `run:` step with `git` for anything else, with the
294− job's token as `AUTHORIZATION: basic` of `x-access-token:<token>`, as
295− the checkout leaves it in `http.https://g1t.sh/.extraheader`.
296−- **Status.** Planned: the real action, once a pull request's commits
297− are kept in its repository.
283+- **Instead.** Use an image with git, or install git in a step before the
284+ checkout. Keep large files out of Git LFS for now.
285+- **Status.** Planned.
298286
299287 ### No `gh` command in jobs
300288
+5−7
6666 | JavaScript actions (`uses: owner/repo@v7`, `owner/repo/path@v7`) | Fetched from that repository on g1t when g1t has it and your repository may use it, otherwise from GitHub, and run as they are, on Node 24, the runtime current actions declare. An action that declares `node20` runs on Node 20 where the machine has one. See [actions and workflows from other repositories](#actions-and-workflows-from-other-repositories) and [an action's pre and post](#an-actions-pre-and-post). |
6767 | Composite actions | The same. |
6868 | Reusable workflows (`jobs.<id>.uses: ./.g1t/workflows/build.yml`, or `owner/repo/.g1t/workflows/build.yml@v1` in another repository) | The same: `with:` inputs checked against their `type`, `secrets:` by name or `secrets: inherit`, `on.workflow_call` outputs, `strategy.matrix` on the calling job (one call for each combination), `concurrency` on the calling job, and up to ten levels of workflows with at most 50 different reusable workflows in a run. `.github/workflows/…` finds the workflow under `.g1t/` after the move. See [actions and workflows from other repositories](#actions-and-workflows-from-other-repositories). |
69−| `actions/checkout` | g1t's runner checks out itself, at whatever version the step names, fetching what versions 4 and 5 fetch: the run's commit on its branch (detached for a pull request or a tag); `ref` as a branch, a tag or a commit; `repository`, at its default branch when there is no `ref`; `path`, `token`, `fetch-depth` (`0` fetches every branch and tag), `fetch-tags`, `filter`, `sparse-checkout` and `sparse-checkout-cone-mode`; `submodules` (`true` or `recursive`, with the token sent only to g1t); `persist-credentials`, on unless it is `false`, so later steps' `git fetch` and `git push` are signed in; and `set-safe-directory`. The outputs `ref` and `commit` are set. `lfs`, `ssh-key` and `clean: false` are not read. See [why it is g1t's own](/about/limitations/#actionscheckout-is-g1ts-own). |
69+| `actions/checkout` | The action itself, at the version the step names, fetching from g1t with the job's token: every input and output, its post step that removes the credential, and a pull request's [test merge commit](#what-a-pull-requests-run-builds), which g1t keeps in the repository. `lfs` finds no Git LFS files: g1t keeps none. |
7070 | `GITHUB_OUTPUT`, `GITHUB_ENV`, `GITHUB_PATH`, `GITHUB_STATE`, `GITHUB_STEP_SUMMARY` | The same. Step summaries show on the run's page; see [job summaries](#job-summaries). |
7171 | `::error::`, `::warning::`, `::notice::`, `::group::`, `::add-mask::`, `::echo::on` | The same: errors and warnings become annotations on the run, with their `file`, `line`, `col`, `endLine` and `endColumn`, and [masked](#masking-secrets) values stay hidden. |
7272 | Problem matchers (`::add-matcher::`, `::remove-matcher::`) | The same, so `actions/setup-node`, `setup-go` and `setup-python` turn compiler and linter output into annotations. See [problem matchers](#problem-matchers). |
105105 the `pull_request` types `unassigned`, `locked` and `unlocked`, `edited`
106106 for a new title or description, or the `issues` types `unassigned`,
107107 `locked`, `unlocked`, `pinned` and `unpinned`.
108−- **The `actions/checkout` from GitHub.** g1t's runner checks out
109− itself, with the action's inputs and outputs but `lfs`, `ssh-key` and
110− `clean: false`, until a pull request's commits are kept in its
111− repository.
108+- **`actions/checkout` in a job container without git.** The action
109+ then downloads a tarball through the API, which g1t does not serve yet.
110+ Use an image with git, or install it in an earlier step.
112111 - **Background steps.** A step with `background: true`, or a `wait`,
113112 `wait-all`, `cancel` or `parallel` step, is refused: g1t does not run
114113 them yet.
619618 language images have both). In an image without them, such as Alpine,
620619 they run beside the container, on g1t's runner, with the same files,
621620 and the log says so.
622−- `actions/checkout` and the artifact actions run on g1t's runner, with
623− the same files.
621+- The artifact actions run on g1t's runner, with the same files.
624622
625623 ### Building and pushing images
626624
+5−392
4848 fn native(name: &str, docker_hosted: bool) -> bool {
4949 matches!(
5050 name,
51− "actions/checkout"
52− | "actions/upload-artifact"
51+ "actions/upload-artifact"
5352 | "actions/upload-artifact/merge"
5453 | "actions/download-artifact"
5554 ) || (docker_hosted && name == "docker/setup-buildx-action")
7069 let mut parts = name.splitn(3, '/');
7170 let (owner, repo) = (parts.next()?, parts.next()?);
7271 (!owner.is_empty() && !repo.is_empty()).then(|| (owner.to_owned(), repo.to_owned(), git_ref.to_owned()))
73−}
74−
75−/// Every branch, as a checkout with all history fetches them.
76−const HEADS_REFSPEC: &str = "+refs/heads/*:refs/remotes/origin/*";
77−/// Every tag, likewise.
78−const TAGS_REFSPEC: &str = "+refs/tags/*:refs/tags/*";
79−
80−/// What a checkout fetches and where it ends, as `actions/checkout`
81−/// decides it (its `getRefSpec`, `getRefSpecForAllHistory` and
82−/// `getCheckoutInfo`).
83−#[derive(Debug, PartialEq, Eq)]
84−struct CheckoutPlan {
85− refspecs: Vec<String>,
86− /// The commit to end on, when it is known before the fetch.
87− sha: Option<String>,
88− /// The branch made there (or at its remote-tracking branch).
89− branch: Option<String>,
90− /// Where to end with neither: a tag, or another ref.
91− start: Option<String>,
92− /// A name that may be a branch or a tag; the fetch tells which.
93− unqualified: Option<String>,
94− /// The step's `ref` output.
95− output_ref: String,
96−}
97−
98−/// `wanted` is the step's `ref` (or another repository's default branch);
99−/// without one, the run's own ref and commit for its own repository.
100−fn checkout_plan(wanted: Option<&str>, same: bool, run_ref: &str, run_sha: &str) -> CheckoutPlan {
101− let (git_ref, commit) = match wanted {
102− Some(r) if is_sha(r) => (String::new(), r.to_owned()),
103− Some(r) => (r.to_owned(), String::new()),
104− // Some events name a branch without `refs/heads/`.
105− None if same && !run_sha.is_empty() && !run_ref.is_empty() && !run_ref.starts_with("refs/") => (format!("refs/heads/{run_ref}"), run_sha.to_owned()),
106− None if same => (run_ref.to_owned(), run_sha.to_owned()),
107− None => (String::new(), String::new()),
108− };
109− let upper = git_ref.to_ascii_uppercase();
110− let rest = |prefix: &str| git_ref[prefix.len()..].to_owned();
111− let mut plan = CheckoutPlan { refspecs: Vec::new(), sha: None, branch: None, start: None, unqualified: None, output_ref: git_ref.clone() };
112− if !commit.is_empty() {
113− plan.refspecs.push(if upper.starts_with("REFS/HEADS/") {
114− plan.branch = Some(rest("refs/heads/"));
115− format!("+{commit}:refs/remotes/origin/{}", rest("refs/heads/"))
116− } else if upper.starts_with("REFS/PULL/") {
117− format!("+{commit}:refs/remotes/pull/{}", rest("refs/pull/"))
118− } else if upper.starts_with("REFS/TAGS/") {
119− format!("+{commit}:{git_ref}")
120− } else {
121− commit.clone()
122− });
123− plan.sha = Some(commit);
124− } else if git_ref.is_empty() {
125− plan.refspecs.push("HEAD".into());
126− } else if !upper.starts_with("REFS/") {
127− plan.refspecs.push(format!("+refs/heads/{git_ref}*:refs/remotes/origin/{git_ref}*"));
128− plan.refspecs.push(format!("+refs/tags/{git_ref}*:refs/tags/{git_ref}*"));
129− plan.unqualified = Some(git_ref.clone());
130− } else if upper.starts_with("REFS/HEADS/") {
131− plan.branch = Some(rest("refs/heads/"));
132− plan.refspecs.push(format!("+{git_ref}:refs/remotes/origin/{}", rest("refs/heads/")));
133− } else if upper.starts_with("REFS/PULL/") {
134− let local = format!("refs/remotes/pull/{}", rest("refs/pull/"));
135− plan.refspecs.push(format!("+{git_ref}:{local}"));
136− plan.start = Some(local);
137− } else {
138− plan.refspecs.push(format!("+{git_ref}:{git_ref}"));
139− plan.start = Some(git_ref.clone());
140− }
141− plan
14272 }
14373
14474 impl Job {
170100 }
171101 }
172102 false
173− }
174−
175− /// A git command with `header` sent only to `server` (`http.<server>/.extraheader`),
176− /// so a submodule on another host never sees the job's token. Logged
177− /// without the header.
178− fn git_scoped(&mut self, dir: &Path, args: &[&str], server: &str, header: Option<&str>) -> bool {
179− self.log.line(&format!("[command]git {}", args.join(" ")));
180− let mut command = Command::new("git");
181− command.current_dir(dir);
182− if let Some(header) = header {
183− command.args(["-c", &format!("http.{server}/.extraheader={header}")]);
184− }
185− command.args(args).env("GIT_TERMINAL_PROMPT", "0");
186− let mut commands = Commands::default();
187− matches!(process::run(command, Duration::from_secs(600).min(self.remaining_time()), &mut self.log, &mut commands), Ok(Ended::Exited(0)))
188103 }
189104
190− /// `actions/checkout`, against g1t: the inputs of its version 4 and 5
191− /// but `ssh-key`, `lfs` and `clean: false`, fetching what it fetches
192− /// and ending on the same branch or commit.
193− fn checkout(&mut self, with: &BTreeMap<String, String>) -> (bool, BTreeMap<String, String>) {
194− let checkout = self.spec["checkout"].clone();
195− let own = checkout["repository"].as_str().unwrap_or_default().to_owned();
196− let server = self.contexts["github"]["server_url"].as_str().unwrap_or("https://g1t.sh").trim_end_matches('/').to_owned();
197− let input = |name: &str| with.get(name).map(|v| v.trim().to_owned()).filter(|v| !v.is_empty());
198− let yes = |name: &str, default: bool| input(name).map_or(default, |v| v.eq_ignore_ascii_case("true"));
199− let repository = input("repository").unwrap_or(own.clone());
200− let same = repository.eq_ignore_ascii_case(&own);
201− let token = input("token").or_else(|| checkout["token"].as_str().map(str::to_owned)).unwrap_or_default();
202− let url = if same { checkout["url"].as_str().unwrap_or_default().to_owned() } else { format!("{server}/{repository}.git") };
203− let path = input("path").map_or(self.workspace.clone(), |p| self.workspace.join(p));
204− // As the action reads it: not a number, or below zero, is all history.
205− let depth: u32 = input("fetch-depth").map_or(1, |d| d.parse::<f64>().ok().filter(|d| *d >= 0.0).map_or(0, |d| d as u32));
206− let wanted_ref = input("ref");
207− let filter = input("filter");
208− let sparse: Vec<String> = with.get("sparse-checkout").map(|s| s.lines().map(str::trim).filter(|l| !l.is_empty()).map(str::to_owned).collect()).unwrap_or_default();
209− let auth = (!token.is_empty()).then(|| format!("AUTHORIZATION: basic {}", STANDARD.encode(format!("x-access-token:{token}"))));
210− let header = auth.as_deref();
211−
212− self.log.line(&format!("Syncing repository: {repository}"));
213− if path.exists() {
214− let _ = std::fs::remove_dir_all(&path);
215− }
216− if let Err(error) = std::fs::create_dir_all(&path) {
217− self.log.line(&format!("##[error]Could not make {}: {error}", path.display()));
218− return (false, BTreeMap::new());
219− }
220− // So git works on it in a container run as another user.
221− if yes("set-safe-directory", true) {
222− let _ = Command::new("git").args(["config", "--global", "--add", "safe.directory", "*"]).status();
223− }
224− if !self.git(&path, &["init", "--quiet"], None) || !self.git(&path, &["remote", "add", "origin", &url], None) {
225− return (false, BTreeMap::new());
226− }
227− let _ = Command::new("git").current_dir(&path).args(["config", "--local", "gc.auto", "0"]).status();
228−
229− // Another repository with no ref: its default branch, as the
230− // action asks the API for it.
231− let default_branch = match (&wanted_ref, same) {
232− (None, false) => self.default_branch(&path, &server, header),
233− _ => None,
234− };
235− let plan = checkout_plan(
236− wanted_ref.as_deref().or(default_branch.as_deref()),
237− same,
238− checkout["ref"].as_str().unwrap_or_default(),
239− checkout["sha"].as_str().unwrap_or_default(),
240− );
241− let depth_arg = format!("--depth={depth}");
242− let filter_arg = filter.clone().or_else(|| (!sparse.is_empty()).then(|| "blob:none".to_owned())).map(|f| format!("--filter={f}"));
243− let fetch_args = |refspecs: &[String], tags: bool| {
244− let mut args: Vec<String> = ["-c", "protocol.version=2", "fetch", "--prune", "--no-recurse-submodules", "--quiet"].map(String::from).into();
245− if !tags {
246− args.push("--no-tags".into());
247− }
248− args.extend(filter_arg.clone());
249− if depth > 0 {
250− args.push(depth_arg.clone());
251− }
252− args.push("origin".into());
253− args.extend(refspecs.iter().cloned());
254− args
255− };
256− // All history: every branch and tag, then what the run names, which
257− // may be a commit its branch has since moved on from.
258− let everything = fetch_args(&[HEADS_REFSPEC.into(), TAGS_REFSPEC.into()], true);
259− let everything: Vec<&str> = everything.iter().map(String::as_str).collect();
260− let named = fetch_args(&plan.refspecs, yes("fetch-tags", false));
261− let named: Vec<&str> = named.iter().map(String::as_str).collect();
262− let fetched = (depth > 0 || self.fetch_retrying(&path, &everything, header)) && self.fetch_retrying(&path, &named, header);
263− let has = |target: &str| Command::new("git").current_dir(&path).args(["cat-file", "-e", &format!("{target}^{{commit}}")]).status().is_ok_and(|s| s.success());
264− // The commit may be further back than a shallow fetch reaches (the
265− // branch moved on after the run began), or not one the server
266− // gives by itself: then the whole history.
267− if let Some(sha) = plan.sha.as_deref().filter(|sha| !has(sha)) {
268− let shallow = path.join(".git").join("shallow").exists();
269− let mut deepen = vec!["-c", "protocol.version=2", "fetch", "--no-tags", "--quiet"];
270− if shallow {
271− deepen.push("--unshallow");
272− }
273− deepen.extend(["origin", HEADS_REFSPEC]);
274− if !self.fetch_retrying(&path, &deepen, header) || !has(sha) {
275− self.log.line(&format!("##[error]Could not fetch {sha} from {repository}."));
276− return (false, BTreeMap::new());
277− }
278− } else if !fetched {
279− self.log.line(&format!("##[error]Could not fetch {} from {repository}.", plan.refspecs.join(" ")));
280− return (false, BTreeMap::new());
281− }
282−
283− // Where to end up: a branch made at its start point, or detached.
284− let ref_exists = |name: &str| Command::new("git").current_dir(&path).args(["rev-parse", "--verify", "--quiet", name]).output().is_ok_and(|o| o.status.success());
285− let (branch, target) = match (&plan.sha, &plan.branch, &plan.unqualified) {
286− (Some(sha), branch, _) => (branch.clone(), sha.clone()),
287− (None, Some(branch), _) => (Some(branch.clone()), format!("refs/remotes/origin/{branch}")),
288− (None, None, Some(name)) if ref_exists(&format!("refs/remotes/origin/{name}")) => (Some(name.clone()), format!("refs/remotes/origin/{name}")),
289− (None, None, Some(name)) if ref_exists(&format!("refs/tags/{name}")) => (None, format!("refs/tags/{name}")),
290− (None, None, Some(name)) => {
291− self.log.line(&format!("##[error]A branch or tag with the name '{name}' could not be found"));
292− return (false, BTreeMap::new());
293− }
294− (None, None, None) => (None, plan.start.clone().unwrap_or_else(|| "FETCH_HEAD".into())),
295− };
296− if !sparse.is_empty() {
297− let mut args = vec!["sparse-checkout", "set"];
298− args.push(if yes("sparse-checkout-cone-mode", true) { "--cone" } else { "--no-cone" });
299− args.extend(sparse.iter().map(String::as_str));
300− if !self.git(&path, &args, None) {
301− return (false, BTreeMap::new());
302− }
303− }
304− let checked_out = match &branch {
305− Some(branch) => self.git(&path, &["checkout", "--quiet", "--force", "-B", branch, &target], None),
306− None => self.git(&path, &["checkout", "--quiet", "--force", "--detach", &target], None),
307− };
308− if !checked_out {
309− return (false, BTreeMap::new());
310− }
311− let persist = yes("persist-credentials", true);
312− let key = format!("http.{server}/.extraheader");
313− if persist && let Some(header) = header {
314− let _ = Command::new("git").current_dir(&path).args(["config", "--local", &key, header]).status();
315− }
316− if let Some(submodules) = input("submodules").map(|s| s.to_ascii_lowercase()).filter(|s| s == "true" || s == "recursive") {
317− let recursive = submodules == "recursive";
318− // A submodule on g1t named by its SSH address is fetched over
319− // HTTPS with the token, as the action does.
320− let host = server.split_once("://").map_or(server.as_str(), |(_, host)| host).to_owned();
321− let instead = format!("url.{server}/.insteadOf=git@{host}:");
322− let mut sync = vec!["submodule", "sync", "--quiet"];
323− let mut update = vec!["-c", &instead, "-c", "protocol.version=2", "submodule", "update", "--init", "--force", "--quiet"];
324− if depth > 0 {
325− update.push(&depth_arg);
326− }
327− if recursive {
328− sync.push("--recursive");
329− update.push("--recursive");
330− }
331− if !self.git(&path, &sync, None) || !self.git_scoped(&path, &update, &server, header) {
332− self.log.line("##[error]The submodules could not all be checked out.");
333− return (false, BTreeMap::new());
334− }
335− if persist && let Some(header) = header {
336− let mut foreach = vec!["submodule", "foreach", "--quiet"];
337− if recursive {
338− foreach.push("--recursive");
339− }
340− foreach.push(r#"git config --local "$G1T_KEY" "$G1T_HEADER" && git config --local gc.auto 0"#);
341− let _ = Command::new("git").current_dir(&path).args(&foreach).env("G1T_KEY", &key).env("G1T_HEADER", header).status();
342− }
343− }
344− if yes("lfs", false) {
345− self.log.line("##[warning]Git LFS files are not fetched on g1t: g1t does not keep LFS objects yet.");
346− }
347− let commit = Command::new("git").current_dir(&path).args(["rev-parse", "HEAD"]).output().ok().map(|o| String::from_utf8_lossy(&o.stdout).trim().to_owned()).unwrap_or_default();
348− self.log.line(&format!("Checked out {commit}"));
349− let mut outputs = BTreeMap::new();
350− outputs.insert("ref".into(), plan.output_ref);
351− outputs.insert("commit".into(), commit);
352− (true, outputs)
353− }
354−
355− /// Another repository's default branch, from what its remote says
356− /// `HEAD` is. None when it says nothing: the fetch then takes `HEAD`.
357− fn default_branch(&mut self, dir: &Path, server: &str, header: Option<&str>) -> Option<String> {
358− let mut command = Command::new("git");
359− command.current_dir(dir);
360− if let Some(header) = header {
361− command.args(["-c", &format!("http.{server}/.extraheader={header}")]);
362− }
363− let output = command.args(["ls-remote", "--quiet", "--symref", "origin", "HEAD"]).env("GIT_TERMINAL_PROMPT", "0").output().ok()?;
364− let text = String::from_utf8_lossy(&output.stdout);
365− let branch = text.lines().find_map(|line| line.strip_prefix("ref: ")?.split_once('\t')?.0.strip_prefix("refs/heads/").map(str::to_owned))?;
366− self.log.line(&format!("Default branch '{branch}'"));
367− Some(format!("refs/heads/{branch}"))
368− }
369−
370105 /// Fetches another repository's action, once per job: from g1t when
371106 /// g1t has the repository and this one may use it, otherwise from
372107 /// GitHub. A private repository on g1t that may not be used here fails
649384 return self.setup_buildx(with);
650385 }
651386 match lower.as_str() {
652− "actions/checkout" => return self.checkout(with),
653387 "actions/upload-artifact" => return self.upload_artifact(with),
654388 "actions/upload-artifact/merge" => return self.merge_artifacts(with),
655389 "actions/download-artifact" => return self.download_artifact(with),
1051785 let found = |uses: &str| remote_action(uses, true);
1052786 assert_eq!(found("actions/setup-node@v4"), Some(("actions".into(), "setup-node".into(), "v4".into())));
1053787 assert_eq!(found(" github/codeql-action/init@v3 "), Some(("github".into(), "codeql-action".into(), "v3".into())));
1054− // Done by the runner itself, local, an image, or no version.
1055− assert_eq!(found("actions/checkout@v4"), None);
1056− // actions/cache is the real action, fetched like any other.
788+ // actions/checkout and actions/cache are the real actions, fetched
789+ // like any other.
790+ assert_eq!(found("actions/checkout@v4"), Some(("actions".into(), "checkout".into(), "v4".into())));
1057791 assert_eq!(found("Actions/Cache/Restore@v4"), Some(("Actions".into(), "Cache".into(), "v4".into())));
792+ // Done by the runner itself, local, an image, or no version.
1058793 assert_eq!(found("docker/setup-buildx-action@v3"), None);
1059794 assert!(remote_action("docker/setup-buildx-action@v3", false).is_some());
1060795 assert_eq!(found("./.g1t/actions/setup"), None);
1061796 assert_eq!(found("docker://alpine:3"), None);
1062797 assert_eq!(found("actions/setup-node"), None);
1063798 assert_eq!(found("setup-node@v4"), None);
1064− }
1065−
1066− #[test]
1067− fn a_checkout_fetches_what_actions_checkout_fetches() {
1068− let sha = "0123456789abcdef0123456789abcdef01234567";
1069− let specs = |plan: &CheckoutPlan| plan.refspecs.clone();
1070− // A push: the run's commit, as its branch.
1071− let push = checkout_plan(None, true, "refs/heads/main", sha);
1072− assert_eq!(specs(&push), [format!("+{sha}:refs/remotes/origin/main")]);
1073− assert_eq!((push.sha.as_deref(), push.branch.as_deref(), push.output_ref.as_str()), (Some(sha), Some("main"), "refs/heads/main"));
1074− // An unqualified branch from the event is qualified first.
1075− assert_eq!(checkout_plan(None, true, "main", sha).output_ref, "refs/heads/main");
1076− // A pull request: its commit under refs/remotes/pull, detached.
1077− let pull = checkout_plan(None, true, "refs/pull/7/merge", sha);
1078− assert_eq!(specs(&pull), [format!("+{sha}:refs/remotes/pull/7/merge")]);
1079− assert_eq!(pull.branch, None);
1080− // A tag push, and a deployment of a bare commit.
1081− assert_eq!(specs(&checkout_plan(None, true, "refs/tags/v1", sha)), [format!("+{sha}:refs/tags/v1")]);
1082− assert_eq!(specs(&checkout_plan(None, true, "", sha)), [sha]);
1083− // `ref:` a commit, a branch, a tag or a name that may be either.
1084− let by_sha = checkout_plan(Some(sha), true, "refs/heads/main", "ffff");
1085− assert_eq!((specs(&by_sha), by_sha.sha.as_deref(), by_sha.output_ref.as_str()), (vec![sha.to_owned()], Some(sha), ""));
1086− let branch = checkout_plan(Some("refs/heads/dev"), true, "refs/heads/main", sha);
1087− assert_eq!((specs(&branch), branch.sha, branch.branch.as_deref()), (vec!["+refs/heads/dev:refs/remotes/origin/dev".to_owned()], None, Some("dev")));
1088− let tag = checkout_plan(Some("refs/tags/v2"), true, "", "");
1089− assert_eq!((specs(&tag), tag.start.as_deref()), (vec!["+refs/tags/v2:refs/tags/v2".to_owned()], Some("refs/tags/v2")));
1090− let name = checkout_plan(Some("v2"), false, "", "");
1091− assert_eq!(specs(&name), ["+refs/heads/v2*:refs/remotes/origin/v2*", "+refs/tags/v2*:refs/tags/v2*"]);
1092− assert_eq!(name.unqualified.as_deref(), Some("v2"));
1093− // Another repository whose default branch is not known: its HEAD.
1094− assert_eq!(specs(&checkout_plan(None, false, "refs/heads/main", sha)), ["HEAD"]);
1095− }
1096−
1097− /// Checks out repositories of its own over `file://`, where git is
1098− /// installed: the run's commit behind its branch's tip, all history,
1099− /// a tag and a branch by name, a sparse checkout, and another
1100− /// repository's default branch.
1101− #[test]
1102− fn a_checkout_ends_where_actions_checkout_ends() {
1103− let git = |dir: &Path, args: &[&str]| {
1104− let out = Command::new("git").current_dir(dir).args(args).output().expect("git");
1105− assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
1106− String::from_utf8_lossy(&out.stdout).trim().to_owned()
1107− };
1108− if Command::new("git").arg("--version").output().is_err() {
1109− return;
1110− }
1111− let root = std::env::temp_dir().join(format!("g1t-checkout-test-{}", std::process::id()));
1112− let _ = std::fs::remove_dir_all(&root);
1113− let make = |name: &str, branch: &str| {
1114− let dir = root.join("src").join(name);
1115− std::fs::create_dir_all(&dir).unwrap();
1116− git(&dir, &["init", "--quiet", "-b", branch]);
1117− for (key, value) in [("user.name", "t"), ("user.email", "t@example.com"), ("commit.gpgsign", "false"), ("tag.gpgsign", "false")] {
1118− git(&dir, &["config", key, value]);
1119− }
1120− dir
1121− };
1122− let commit = |dir: &Path, file: &str, message: &str| {
1123− std::fs::create_dir_all(dir.join(file).parent().unwrap()).unwrap();
1124− std::fs::write(dir.join(file), message).unwrap();
1125− git(dir, &["add", "."]);
1126− git(dir, &["commit", "--quiet", "-m", message]);
1127− };
1128− let app = make("app", "main");
1129− commit(&app, "docs/a.md", "one");
1130− commit(&app, "src/main.rs", "two");
1131− git(&app, &["tag", "v1"]);
1132− git(&app, &["checkout", "--quiet", "-b", "feature"]);
1133− commit(&app, "feature.txt", "feature");
1134− git(&app, &["checkout", "--quiet", "main"]);
1135− commit(&app, "README.md", "three");
1136− let other = make("other", "trunk");
1137− commit(&other, "o.txt", "o");
1138− let served = root.join("srv").join("acme");
1139− std::fs::create_dir_all(&served).unwrap();
1140− for name in ["app", "other"] {
1141− let src = root.join("src").join(name);
1142− git(&served, &["clone", "--quiet", "--bare", &src.display().to_string(), &format!("{name}.git")]);
1143− git(&served.join(format!("{name}.git")), &["config", "uploadpack.allowFilter", "true"]);
1144− }
1145− let server = format!("file://{}", root.join("srv").display());
1146− let behind = git(&app, &["rev-parse", "main~1"]);
1147− let spec = json!({
1148− "checkout": { "repository": "acme/app", "url": format!("{server}/acme/app.git"), "ref": "refs/heads/main", "sha": behind, "token": "" },
1149− "github": { "server_url": server },
1150− "variables": {},
1151− "contexts": {},
1152− });
1153− let workspace = root.join("work");
1154− let mut job = super::super::setup_in(spec, super::super::report::Api::offline(), workspace.clone(), root.join("temp")).unwrap();
1155− let mut run = |inputs: &[(&str, &str)]| {
1156− let mut with: BTreeMap<String, String> = inputs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
1157− with.insert("set-safe-directory".into(), "false".into());
1158− job.checkout(&with)
1159− };
1160−
1161− // A push whose branch moved on: the run's commit, on its branch.
1162− let (ok, outputs) = run(&[]);
1163− assert!(ok);
1164− assert_eq!((git(&workspace, &["rev-parse", "HEAD"]), git(&workspace, &["branch", "--show-current"])), (behind.clone(), "main".to_owned()));
1165− assert_eq!((outputs["ref"].as_str(), outputs["commit"].as_str()), ("refs/heads/main", behind.as_str()));
1166− // All history: every branch and tag.
1167− assert!(run(&[("fetch-depth", "0")]).0);
1168− assert!(!workspace.join(".git/shallow").exists());
1169− assert_eq!(git(&workspace, &["branch", "-r"]).split_whitespace().collect::<Vec<_>>(), ["origin/feature", "origin/main"]);
1170− assert_eq!(git(&workspace, &["tag"]), "v1");
1171− // A tag by name, detached; a branch by name, as the branch.
1172− assert!(run(&[("ref", "v1")]).0);
1173− assert_eq!((git(&workspace, &["describe", "--tags"]), git(&workspace, &["branch", "--show-current"])), ("v1".to_owned(), String::new()));
1174− assert!(run(&[("ref", "feature")]).0);
1175− assert_eq!(git(&workspace, &["branch", "--show-current"]), "feature");
1176− // Sparse: only the folders named, from a blobless fetch.
1177− assert!(run(&[("sparse-checkout", "docs\n")]).0);
1178− assert!(workspace.join("docs/a.md").exists() && !workspace.join("src").exists());
1179− assert_eq!(git(&workspace, &["config", "remote.origin.partialclonefilter"]), "blob:none");
1180− // Another repository without a ref: its default branch.
1181− let (ok, outputs) = run(&[("repository", "acme/other"), ("path", "other")]);
1182− assert!(ok);
1183− assert_eq!(git(&workspace.join("other"), &["branch", "--show-current"]), "trunk");
1184− assert_eq!(outputs["ref"], "refs/heads/trunk");
1185− let _ = std::fs::remove_dir_all(&root);
1186799 }
1187800
1188801 #[test]