Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

g1t.sh

Commit

`actions/checkout` on g1t fetches what versions 4 and 5 of the action fetch and ends where they end. `fetch-depth: 0` brings every branch and tag, so `git describe` and tag-based versioning work; a `ref` that names a tag checks the tag out detached instead of making a branch of that name, and a name that is neither a branch nor a tag fails saying so; another `repository` with no `ref` is checked out on its default branch. `filter`, `sparse-checkout` with `sparse-checkout-cone-mode`, and `set-safe-directory: false` are read. Submodules are fetched to the step's `fetch-depth`, with the job's token sent only to g1t (it went to every submodule's host before, GitHub's included) and with g1t's SSH addresses fetched over HTTPS, and `persist-credentials` signs them in for later steps too. The step is still g1t's own rather than GitHub's: the real action fetches a pull request's commit from the workflow's repository, where commits from a fork or from the copy made for a pull request opened from an issue are not kept yet. The Actions guide says what the checkout reads, and the limitations say why it is not the real action yet and what it lacks: `lfs`, `ssh-key`, `clean: false` and a post step.

Claude-Session:
https://claude.ai/code/session_01PnwTRSNaC7vEHTAuBzjhrn
Claudeagent and Claudeagentcommitted Parent3211eccBrowse files
4 files+377−700/4 viewed
+23−0
273273 register with `--no-job-docker`.
274274 - **Status.** Not scheduled.
275275
276+### `actions/checkout` is g1t's own
277+
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.
284+
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.
298+
276299 ### No `gh` command in jobs
277300
278301 The runner does not include the `gh` command, and pointing it at g1t
+7−3
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` | Checks out from g1t, with `ref`, `fetch-depth`, `path`, `repository`, `token` and `submodules`. |
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). |
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.
108112 - **Background steps.** A step with `background: true`, or a `wait`,
109113 `wait-all`, `cancel` or `parallel` step, is refused: g1t does not run
110114 them yet.
613617 language images have both). In an image without them, such as Alpine,
614618 they run beside the container, on g1t's runner, with the same files,
615619 and the log says so.
616−- `actions/checkout`, `actions/cache` and the artifact actions run on
617− g1t's runner, with the same files.
620+- `actions/checkout` and the artifact actions run on g1t's runner, with
621+ the same files.
618622
619623 ### Building and pushing images
620624
+6−3
785785 named("ACTIONS_STEP_DEBUG") || variables.get("RUNNER_DEBUG").is_some_and(|v| expr::to_text(v) == "1")
786786 }
787787
788−fn setup(mut spec: Value, api: Api) -> Result<Job> {
788+fn setup(spec: Value, api: Api) -> Result<Job> {
789+ setup_in(spec, api, paths::under_home(WORKSPACE), paths::under_home(TEMP))
790+}
791+
792+/// [`setup`] with the workspace and temporary folder given: a test's own.
793+fn setup_in(mut spec: Value, api: Api, workspace: PathBuf, temp: PathBuf) -> Result<Job> {
789794 let masks: Vec<String> = spec["masks"].as_array().map(|m| m.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect()).unwrap_or_default();
790795 let mut log = Log::new(api, masks);
791796 // Variables GitHub sets that a g1t from before them does not send.
796801 paths::relocate(&mut spec[part]);
797802 }
798803 paths::relocate(&mut spec["contexts"]["runner"]);
799− let workspace = paths::under_home(WORKSPACE);
800− let temp = paths::under_home(TEMP);
801804 log.matchers.workspace = workspace.clone();
802805 std::fs::create_dir_all(&workspace).context("could not make the workspace")?;
803806 std::fs::create_dir_all(&temp).context("could not make the temporary folder")?;
+341−64
6969 (!owner.is_empty() && !repo.is_empty()).then(|| (owner.to_owned(), repo.to_owned(), git_ref.to_owned()))
7070 }
7171
72+/// Every branch, as a checkout with all history fetches them.
73+const HEADS_REFSPEC: &str = "+refs/heads/*:refs/remotes/origin/*";
74+/// Every tag, likewise.
75+const TAGS_REFSPEC: &str = "+refs/tags/*:refs/tags/*";
76+
77+/// What a checkout fetches and where it ends, as `actions/checkout`
78+/// decides it (its `getRefSpec`, `getRefSpecForAllHistory` and
79+/// `getCheckoutInfo`).
80+#[derive(Debug, PartialEq, Eq)]
81+struct CheckoutPlan {
82+ refspecs: Vec<String>,
83+ /// The commit to end on, when it is known before the fetch.
84+ sha: Option<String>,
85+ /// The branch made there (or at its remote-tracking branch).
86+ branch: Option<String>,
87+ /// Where to end with neither: a tag, or another ref.
88+ start: Option<String>,
89+ /// A name that may be a branch or a tag; the fetch tells which.
90+ unqualified: Option<String>,
91+ /// The step's `ref` output.
92+ output_ref: String,
93+}
94+
95+/// `wanted` is the step's `ref` (or another repository's default branch);
96+/// without one, the run's own ref and commit for its own repository.
97+fn checkout_plan(wanted: Option<&str>, same: bool, run_ref: &str, run_sha: &str) -> CheckoutPlan {
98+ let (git_ref, commit) = match wanted {
99+ Some(r) if is_sha(r) => (String::new(), r.to_owned()),
100+ Some(r) => (r.to_owned(), String::new()),
101+ // Some events name a branch without `refs/heads/`.
102+ 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()),
103+ None if same => (run_ref.to_owned(), run_sha.to_owned()),
104+ None => (String::new(), String::new()),
105+ };
106+ let upper = git_ref.to_ascii_uppercase();
107+ let rest = |prefix: &str| git_ref[prefix.len()..].to_owned();
108+ let mut plan = CheckoutPlan { refspecs: Vec::new(), sha: None, branch: None, start: None, unqualified: None, output_ref: git_ref.clone() };
109+ if !commit.is_empty() {
110+ plan.refspecs.push(if upper.starts_with("REFS/HEADS/") {
111+ plan.branch = Some(rest("refs/heads/"));
112+ format!("+{commit}:refs/remotes/origin/{}", rest("refs/heads/"))
113+ } else if upper.starts_with("REFS/PULL/") {
114+ format!("+{commit}:refs/remotes/pull/{}", rest("refs/pull/"))
115+ } else if upper.starts_with("REFS/TAGS/") {
116+ format!("+{commit}:{git_ref}")
117+ } else {
118+ commit.clone()
119+ });
120+ plan.sha = Some(commit);
121+ } else if git_ref.is_empty() {
122+ plan.refspecs.push("HEAD".into());
123+ } else if !upper.starts_with("REFS/") {
124+ plan.refspecs.push(format!("+refs/heads/{git_ref}*:refs/remotes/origin/{git_ref}*"));
125+ plan.refspecs.push(format!("+refs/tags/{git_ref}*:refs/tags/{git_ref}*"));
126+ plan.unqualified = Some(git_ref.clone());
127+ } else if upper.starts_with("REFS/HEADS/") {
128+ plan.branch = Some(rest("refs/heads/"));
129+ plan.refspecs.push(format!("+{git_ref}:refs/remotes/origin/{}", rest("refs/heads/")));
130+ } else if upper.starts_with("REFS/PULL/") {
131+ let local = format!("refs/remotes/pull/{}", rest("refs/pull/"));
132+ plan.refspecs.push(format!("+{git_ref}:{local}"));
133+ plan.start = Some(local);
134+ } else {
135+ plan.refspecs.push(format!("+{git_ref}:{git_ref}"));
136+ plan.start = Some(git_ref.clone());
137+ }
138+ plan
139+}
140+
72141 impl Job {
73142 /// Runs a git command, logging it; the credential header is never logged.
74143 fn git(&mut self, dir: &Path, args: &[&str], auth: Option<&str>) -> bool {
100169 false
101170 }
102171
103− /// `actions/checkout`, against g1t.
172+ /// A git command with `header` sent only to `server` (`http.<server>/.extraheader`),
173+ /// so a submodule on another host never sees the job's token. Logged
174+ /// without the header.
175+ fn git_scoped(&mut self, dir: &Path, args: &[&str], server: &str, header: Option<&str>) -> bool {
176+ self.log.line(&format!("[command]git {}", args.join(" ")));
177+ let mut command = Command::new("git");
178+ command.current_dir(dir);
179+ if let Some(header) = header {
180+ command.args(["-c", &format!("http.{server}/.extraheader={header}")]);
181+ }
182+ command.args(args).env("GIT_TERMINAL_PROMPT", "0");
183+ let mut commands = Commands::default();
184+ matches!(process::run(command, Duration::from_secs(600).min(self.remaining_time()), &mut self.log, &mut commands), Ok(Ended::Exited(0)))
185+ }
186+
187+ /// `actions/checkout`, against g1t: the inputs of its version 4 and 5
188+ /// but `ssh-key`, `lfs` and `clean: false`, fetching what it fetches
189+ /// and ending on the same branch or commit.
104190 fn checkout(&mut self, with: &BTreeMap<String, String>) -> (bool, BTreeMap<String, String>) {
105191 let checkout = self.spec["checkout"].clone();
106192 let own = checkout["repository"].as_str().unwrap_or_default().to_owned();
107− let server = self.contexts["github"]["server_url"].as_str().unwrap_or("https://g1t.sh").to_owned();
108− let repository = with.get("repository").filter(|r| !r.is_empty()).cloned().unwrap_or(own.clone());
193+ let server = self.contexts["github"]["server_url"].as_str().unwrap_or("https://g1t.sh").trim_end_matches('/').to_owned();
194+ let input = |name: &str| with.get(name).map(|v| v.trim().to_owned()).filter(|v| !v.is_empty());
195+ let yes = |name: &str, default: bool| input(name).map_or(default, |v| v.eq_ignore_ascii_case("true"));
196+ let repository = input("repository").unwrap_or(own.clone());
109197 let same = repository.eq_ignore_ascii_case(&own);
110− let token = with.get("token").filter(|t| !t.is_empty()).cloned().or_else(|| checkout["token"].as_str().map(str::to_owned)).unwrap_or_default();
198+ let token = input("token").or_else(|| checkout["token"].as_str().map(str::to_owned)).unwrap_or_default();
111199 let url = if same { checkout["url"].as_str().unwrap_or_default().to_owned() } else { format!("{server}/{repository}.git") };
112− let path = with.get("path").filter(|p| !p.is_empty()).map_or(self.workspace.clone(), |p| self.workspace.join(p));
113− let depth: u32 = with.get("fetch-depth").and_then(|d| d.parse().ok()).unwrap_or(1);
114− let wanted_ref = with.get("ref").filter(|r| !r.is_empty()).cloned();
200+ let path = input("path").map_or(self.workspace.clone(), |p| self.workspace.join(p));
201+ // As the action reads it: not a number, or below zero, is all history.
202+ 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));
203+ let wanted_ref = input("ref");
204+ let filter = input("filter");
205+ 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();
115206 let auth = (!token.is_empty()).then(|| format!("AUTHORIZATION: basic {}", STANDARD.encode(format!("x-access-token:{token}"))));
207+ let header = auth.as_deref();
116208
117− self.log.line(&format!("Checking out {repository} into {}", path.display()));
209+ self.log.line(&format!("Syncing repository: {repository}"));
118210 if path.exists() {
119211 let _ = std::fs::remove_dir_all(&path);
120212 }
122214 self.log.line(&format!("##[error]Could not make {}: {error}", path.display()));
123215 return (false, BTreeMap::new());
124216 }
125− let _ = Command::new("git").args(["config", "--global", "--add", "safe.directory", "*"]).status();
217+ // So git works on it in a container run as another user.
218+ if yes("set-safe-directory", true) {
219+ let _ = Command::new("git").args(["config", "--global", "--add", "safe.directory", "*"]).status();
220+ }
126221 if !self.git(&path, &["init", "--quiet"], None) || !self.git(&path, &["remote", "add", "origin", &url], None) {
127222 return (false, BTreeMap::new());
128223 }
224+ let _ = Command::new("git").current_dir(&path).args(["config", "--local", "gc.auto", "0"]).status();
129225
130− // What to fetch, and which commit to end up on.
131− let run_ref = checkout["ref"].as_str().unwrap_or_default().to_owned();
132− let run_sha = checkout["sha"].as_str().unwrap_or_default().to_owned();
133− let is_sha = |r: &str| r.len() == 40 && r.chars().all(|c| c.is_ascii_hexdigit());
134− let (fetch, sha, branch): (String, Option<String>, Option<String>) = match &wanted_ref {
135− Some(r) if is_sha(r) => ("HEAD".into(), Some(r.clone()), None),
136− Some(r) if r.starts_with("refs/") => (r.clone(), None, r.strip_prefix("refs/heads/").map(str::to_owned)),
137− Some(r) => (r.clone(), None, Some(r.clone())),
138− // A pull request's merge ref, or no ref at all (a deployment of
139− // a bare commit): the commit itself.
140− None if same && (run_ref.starts_with("refs/pull/") || run_ref.is_empty()) => ("HEAD".into(), Some(run_sha.clone()), None),
141− None if same => (run_ref.clone(), Some(run_sha.clone()), run_ref.strip_prefix("refs/heads/").map(str::to_owned)),
142− None => ("HEAD".into(), None, None),
226+ // Another repository with no ref: its default branch, as the
227+ // action asks the API for it.
228+ let default_branch = match (&wanted_ref, same) {
229+ (None, false) => self.default_branch(&path, &server, header),
230+ _ => None,
143231 };
232+ let plan = checkout_plan(
233+ wanted_ref.as_deref().or(default_branch.as_deref()),
234+ same,
235+ checkout["ref"].as_str().unwrap_or_default(),
236+ checkout["sha"].as_str().unwrap_or_default(),
237+ );
144238 let depth_arg = format!("--depth={depth}");
145− let mut args = vec!["fetch", "--no-tags", "--prune", "--quiet"];
146− if depth > 0 {
147− args.push(&depth_arg);
148− }
149− if with.get("fetch-tags").is_some_and(|t| t == "true") {
150− args.retain(|a| *a != "--no-tags");
151− }
152− args.push("origin");
153− args.push(&fetch);
154− if !self.fetch_retrying(&path, &args, auth.as_deref()) {
155− self.log.line(&format!("##[error]Could not fetch {fetch} from {repository}."));
156− return (false, BTreeMap::new());
157− }
158− let target = sha.clone().unwrap_or_else(|| "FETCH_HEAD".into());
159− // The commit may be further back than a shallow fetch reaches: the
160− // branch moved on after the run began, say. Ask for the commit
161− // itself, and failing that the whole history; a plain fetch never
162− // reaches past a shallow boundary.
239+ let filter_arg = filter.clone().or_else(|| (!sparse.is_empty()).then(|| "blob:none".to_owned())).map(|f| format!("--filter={f}"));
240+ let fetch_args = |refspecs: &[String], tags: bool| {
241+ let mut args: Vec<String> = ["-c", "protocol.version=2", "fetch", "--prune", "--no-recurse-submodules", "--quiet"].map(String::from).into();
242+ if !tags {
243+ args.push("--no-tags".into());
244+ }
245+ args.extend(filter_arg.clone());
246+ if depth > 0 {
247+ args.push(depth_arg.clone());
248+ }
249+ args.push("origin".into());
250+ args.extend(refspecs.iter().cloned());
251+ args
252+ };
253+ // All history: every branch and tag, then what the run names, which
254+ // may be a commit its branch has since moved on from.
255+ let everything = fetch_args(&[HEADS_REFSPEC.into(), TAGS_REFSPEC.into()], true);
256+ let everything: Vec<&str> = everything.iter().map(String::as_str).collect();
257+ let named = fetch_args(&plan.refspecs, yes("fetch-tags", false));
258+ let named: Vec<&str> = named.iter().map(String::as_str).collect();
259+ let fetched = (depth > 0 || self.fetch_retrying(&path, &everything, header)) && self.fetch_retrying(&path, &named, header);
163260 let has = |target: &str| Command::new("git").current_dir(&path).args(["cat-file", "-e", &format!("{target}^{{commit}}")]).status().is_ok_and(|s| s.success());
164− if !has(&target) {
165− let by_sha = sha.as_deref().is_some_and(|sha| {
166− let mut args = vec!["fetch", "--no-tags", "--quiet"];
167− if depth > 0 {
168− args.push(&depth_arg);
169− }
170− args.extend(["origin", sha]);
171− self.git(&path, &args, auth.as_deref()) && has(sha)
172− });
261+ // The commit may be further back than a shallow fetch reaches (the
262+ // branch moved on after the run began), or not one the server
263+ // gives by itself: then the whole history.
264+ if let Some(sha) = plan.sha.as_deref().filter(|sha| !has(sha)) {
173265 let shallow = path.join(".git").join("shallow").exists();
174− let deepen: &[&str] = if shallow { &["fetch", "--no-tags", "--quiet", "--unshallow", "origin"] } else { &["fetch", "--no-tags", "--quiet", "origin"] };
175− if !by_sha && !self.fetch_retrying(&path, deepen, auth.as_deref()) {
266+ let mut deepen = vec!["-c", "protocol.version=2", "fetch", "--no-tags", "--quiet"];
267+ if shallow {
268+ deepen.push("--unshallow");
269+ }
270+ deepen.extend(["origin", HEADS_REFSPEC]);
271+ if !self.fetch_retrying(&path, &deepen, header) || !has(sha) {
272+ self.log.line(&format!("##[error]Could not fetch {sha} from {repository}."));
176273 return (false, BTreeMap::new());
177274 }
275+ } else if !fetched {
276+ self.log.line(&format!("##[error]Could not fetch {} from {repository}.", plan.refspecs.join(" ")));
277+ return (false, BTreeMap::new());
178278 }
279+
280+ // Where to end up: a branch made at its start point, or detached.
281+ 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());
282+ let (branch, target) = match (&plan.sha, &plan.branch, &plan.unqualified) {
283+ (Some(sha), branch, _) => (branch.clone(), sha.clone()),
284+ (None, Some(branch), _) => (Some(branch.clone()), format!("refs/remotes/origin/{branch}")),
285+ (None, None, Some(name)) if ref_exists(&format!("refs/remotes/origin/{name}")) => (Some(name.clone()), format!("refs/remotes/origin/{name}")),
286+ (None, None, Some(name)) if ref_exists(&format!("refs/tags/{name}")) => (None, format!("refs/tags/{name}")),
287+ (None, None, Some(name)) => {
288+ self.log.line(&format!("##[error]A branch or tag with the name '{name}' could not be found"));
289+ return (false, BTreeMap::new());
290+ }
291+ (None, None, None) => (None, plan.start.clone().unwrap_or_else(|| "FETCH_HEAD".into())),
292+ };
293+ if !sparse.is_empty() {
294+ let mut args = vec!["sparse-checkout", "set"];
295+ args.push(if yes("sparse-checkout-cone-mode", true) { "--cone" } else { "--no-cone" });
296+ args.extend(sparse.iter().map(String::as_str));
297+ if !self.git(&path, &args, None) {
298+ return (false, BTreeMap::new());
299+ }
300+ }
179301 let checked_out = match &branch {
180302 Some(branch) => self.git(&path, &["checkout", "--quiet", "--force", "-B", branch, &target], None),
181303 None => self.git(&path, &["checkout", "--quiet", "--force", "--detach", &target], None),
183305 if !checked_out {
184306 return (false, BTreeMap::new());
185307 }
186− if with.get("persist-credentials").is_none_or(|p| p != "false")
187− && let Some(header) = &auth
188− {
189− let key = format!("http.{server}/.extraheader");
308+ let persist = yes("persist-credentials", true);
309+ let key = format!("http.{server}/.extraheader");
310+ if persist && let Some(header) = header {
190311 let _ = Command::new("git").current_dir(&path).args(["config", "--local", &key, header]).status();
191312 }
192− if let Some(submodules) = with.get("submodules").filter(|s| *s == "true" || *s == "recursive") {
193− let mut args = vec!["submodule", "update", "--init", "--quiet"];
194− if submodules == "recursive" {
195− args.push("--recursive");
313+ if let Some(submodules) = input("submodules").map(|s| s.to_ascii_lowercase()).filter(|s| s == "true" || s == "recursive") {
314+ let recursive = submodules == "recursive";
315+ // A submodule on g1t named by its SSH address is fetched over
316+ // HTTPS with the token, as the action does.
317+ let host = server.split_once("://").map_or(server.as_str(), |(_, host)| host).to_owned();
318+ let instead = format!("url.{server}/.insteadOf=git@{host}:");
319+ let mut sync = vec!["submodule", "sync", "--quiet"];
320+ let mut update = vec!["-c", &instead, "-c", "protocol.version=2", "submodule", "update", "--init", "--force", "--quiet"];
321+ if depth > 0 {
322+ update.push(&depth_arg);
323+ }
324+ if recursive {
325+ sync.push("--recursive");
326+ update.push("--recursive");
196327 }
197− if !self.git(&path, &args, auth.as_deref()) {
198− self.log.line("##[warning]Submodules could not all be checked out; only those hosted on g1t can be.");
328+ if !self.git(&path, &sync, None) || !self.git_scoped(&path, &update, &server, header) {
329+ self.log.line("##[error]The submodules could not all be checked out.");
330+ return (false, BTreeMap::new());
199331 }
332+ if persist && let Some(header) = header {
333+ let mut foreach = vec!["submodule", "foreach", "--quiet"];
334+ if recursive {
335+ foreach.push("--recursive");
336+ }
337+ foreach.push(r#"git config --local "$G1T_KEY" "$G1T_HEADER" && git config --local gc.auto 0"#);
338+ let _ = Command::new("git").current_dir(&path).args(&foreach).env("G1T_KEY", &key).env("G1T_HEADER", header).status();
339+ }
200340 }
201− if with.get("lfs").is_some_and(|l| l == "true") {
202− self.log.line("##[warning]Git LFS files are not fetched on g1t yet.");
341+ if yes("lfs", false) {
342+ self.log.line("##[warning]Git LFS files are not fetched on g1t: g1t does not keep LFS objects yet.");
203343 }
204344 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();
205345 self.log.line(&format!("Checked out {commit}"));
206346 let mut outputs = BTreeMap::new();
207− outputs.insert("ref".into(), wanted_ref.unwrap_or(run_ref));
347+ outputs.insert("ref".into(), plan.output_ref);
208348 outputs.insert("commit".into(), commit);
209349 (true, outputs)
210350 }
211351
352+ /// Another repository's default branch, from what its remote says
353+ /// `HEAD` is. None when it says nothing: the fetch then takes `HEAD`.
354+ fn default_branch(&mut self, dir: &Path, server: &str, header: Option<&str>) -> Option<String> {
355+ let mut command = Command::new("git");
356+ command.current_dir(dir);
357+ if let Some(header) = header {
358+ command.args(["-c", &format!("http.{server}/.extraheader={header}")]);
359+ }
360+ let output = command.args(["ls-remote", "--quiet", "--symref", "origin", "HEAD"]).env("GIT_TERMINAL_PROMPT", "0").output().ok()?;
361+ let text = String::from_utf8_lossy(&output.stdout);
362+ let branch = text.lines().find_map(|line| line.strip_prefix("ref: ")?.split_once('\t')?.0.strip_prefix("refs/heads/").map(str::to_owned))?;
363+ self.log.line(&format!("Default branch '{branch}'"));
364+ Some(format!("refs/heads/{branch}"))
365+ }
366+
212367 /// Fetches another repository's action, once per job: from g1t when
213368 /// g1t has the repository and this one may use it, otherwise from
214369 /// GitHub. A private repository on g1t that may not be used here fails
8901045 }
8911046
8921047 #[test]
1048+ fn a_checkout_fetches_what_actions_checkout_fetches() {
1049+ let sha = "0123456789abcdef0123456789abcdef01234567";
1050+ let specs = |plan: &CheckoutPlan| plan.refspecs.clone();
1051+ // A push: the run's commit, as its branch.
1052+ let push = checkout_plan(None, true, "refs/heads/main", sha);
1053+ assert_eq!(specs(&push), [format!("+{sha}:refs/remotes/origin/main")]);
1054+ assert_eq!((push.sha.as_deref(), push.branch.as_deref(), push.output_ref.as_str()), (Some(sha), Some("main"), "refs/heads/main"));
1055+ // An unqualified branch from the event is qualified first.
1056+ assert_eq!(checkout_plan(None, true, "main", sha).output_ref, "refs/heads/main");
1057+ // A pull request: its commit under refs/remotes/pull, detached.
1058+ let pull = checkout_plan(None, true, "refs/pull/7/merge", sha);
1059+ assert_eq!(specs(&pull), [format!("+{sha}:refs/remotes/pull/7/merge")]);
1060+ assert_eq!(pull.branch, None);
1061+ // A tag push, and a deployment of a bare commit.
1062+ assert_eq!(specs(&checkout_plan(None, true, "refs/tags/v1", sha)), [format!("+{sha}:refs/tags/v1")]);
1063+ assert_eq!(specs(&checkout_plan(None, true, "", sha)), [sha]);
1064+ // `ref:` a commit, a branch, a tag or a name that may be either.
1065+ let by_sha = checkout_plan(Some(sha), true, "refs/heads/main", "ffff");
1066+ assert_eq!((specs(&by_sha), by_sha.sha.as_deref(), by_sha.output_ref.as_str()), (vec![sha.to_owned()], Some(sha), ""));
1067+ let branch = checkout_plan(Some("refs/heads/dev"), true, "refs/heads/main", sha);
1068+ assert_eq!((specs(&branch), branch.sha, branch.branch.as_deref()), (vec!["+refs/heads/dev:refs/remotes/origin/dev".to_owned()], None, Some("dev")));
1069+ let tag = checkout_plan(Some("refs/tags/v2"), true, "", "");
1070+ assert_eq!((specs(&tag), tag.start.as_deref()), (vec!["+refs/tags/v2:refs/tags/v2".to_owned()], Some("refs/tags/v2")));
1071+ let name = checkout_plan(Some("v2"), false, "", "");
1072+ assert_eq!(specs(&name), ["+refs/heads/v2*:refs/remotes/origin/v2*", "+refs/tags/v2*:refs/tags/v2*"]);
1073+ assert_eq!(name.unqualified.as_deref(), Some("v2"));
1074+ // Another repository whose default branch is not known: its HEAD.
1075+ assert_eq!(specs(&checkout_plan(None, false, "refs/heads/main", sha)), ["HEAD"]);
1076+ }
1077+
1078+ /// Checks out repositories of its own over `file://`, where git is
1079+ /// installed: the run's commit behind its branch's tip, all history,
1080+ /// a tag and a branch by name, a sparse checkout, and another
1081+ /// repository's default branch.
1082+ #[test]
1083+ fn a_checkout_ends_where_actions_checkout_ends() {
1084+ let git = |dir: &Path, args: &[&str]| {
1085+ let out = Command::new("git").current_dir(dir).args(args).output().expect("git");
1086+ assert!(out.status.success(), "git {args:?}: {}", String::from_utf8_lossy(&out.stderr));
1087+ String::from_utf8_lossy(&out.stdout).trim().to_owned()
1088+ };
1089+ if Command::new("git").arg("--version").output().is_err() {
1090+ return;
1091+ }
1092+ let root = std::env::temp_dir().join(format!("g1t-checkout-test-{}", std::process::id()));
1093+ let _ = std::fs::remove_dir_all(&root);
1094+ let make = |name: &str, branch: &str| {
1095+ let dir = root.join("src").join(name);
1096+ std::fs::create_dir_all(&dir).unwrap();
1097+ git(&dir, &["init", "--quiet", "-b", branch]);
1098+ for (key, value) in [("user.name", "t"), ("user.email", "t@example.com"), ("commit.gpgsign", "false"), ("tag.gpgsign", "false")] {
1099+ git(&dir, &["config", key, value]);
1100+ }
1101+ dir
1102+ };
1103+ let commit = |dir: &Path, file: &str, message: &str| {
1104+ std::fs::create_dir_all(dir.join(file).parent().unwrap()).unwrap();
1105+ std::fs::write(dir.join(file), message).unwrap();
1106+ git(dir, &["add", "."]);
1107+ git(dir, &["commit", "--quiet", "-m", message]);
1108+ };
1109+ let app = make("app", "main");
1110+ commit(&app, "docs/a.md", "one");
1111+ commit(&app, "src/main.rs", "two");
1112+ git(&app, &["tag", "v1"]);
1113+ git(&app, &["checkout", "--quiet", "-b", "feature"]);
1114+ commit(&app, "feature.txt", "feature");
1115+ git(&app, &["checkout", "--quiet", "main"]);
1116+ commit(&app, "README.md", "three");
1117+ let other = make("other", "trunk");
1118+ commit(&other, "o.txt", "o");
1119+ let served = root.join("srv").join("acme");
1120+ std::fs::create_dir_all(&served).unwrap();
1121+ for name in ["app", "other"] {
1122+ let src = root.join("src").join(name);
1123+ git(&served, &["clone", "--quiet", "--bare", &src.display().to_string(), &format!("{name}.git")]);
1124+ git(&served.join(format!("{name}.git")), &["config", "uploadpack.allowFilter", "true"]);
1125+ }
1126+ let server = format!("file://{}", root.join("srv").display());
1127+ let behind = git(&app, &["rev-parse", "main~1"]);
1128+ let spec = json!({
1129+ "checkout": { "repository": "acme/app", "url": format!("{server}/acme/app.git"), "ref": "refs/heads/main", "sha": behind, "token": "" },
1130+ "github": { "server_url": server },
1131+ "variables": {},
1132+ "contexts": {},
1133+ });
1134+ let workspace = root.join("work");
1135+ let mut job = super::super::setup_in(spec, super::super::report::Api::offline(), workspace.clone(), root.join("temp")).unwrap();
1136+ let mut run = |inputs: &[(&str, &str)]| {
1137+ let mut with: BTreeMap<String, String> = inputs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect();
1138+ with.insert("set-safe-directory".into(), "false".into());
1139+ job.checkout(&with)
1140+ };
1141+
1142+ // A push whose branch moved on: the run's commit, on its branch.
1143+ let (ok, outputs) = run(&[]);
1144+ assert!(ok);
1145+ assert_eq!((git(&workspace, &["rev-parse", "HEAD"]), git(&workspace, &["branch", "--show-current"])), (behind.clone(), "main".to_owned()));
1146+ assert_eq!((outputs["ref"].as_str(), outputs["commit"].as_str()), ("refs/heads/main", behind.as_str()));
1147+ // All history: every branch and tag.
1148+ assert!(run(&[("fetch-depth", "0")]).0);
1149+ assert!(!workspace.join(".git/shallow").exists());
1150+ assert_eq!(git(&workspace, &["branch", "-r"]).split_whitespace().collect::<Vec<_>>(), ["origin/feature", "origin/main"]);
1151+ assert_eq!(git(&workspace, &["tag"]), "v1");
1152+ // A tag by name, detached; a branch by name, as the branch.
1153+ assert!(run(&[("ref", "v1")]).0);
1154+ assert_eq!((git(&workspace, &["describe", "--tags"]), git(&workspace, &["branch", "--show-current"])), ("v1".to_owned(), String::new()));
1155+ assert!(run(&[("ref", "feature")]).0);
1156+ assert_eq!(git(&workspace, &["branch", "--show-current"]), "feature");
1157+ // Sparse: only the folders named, from a blobless fetch.
1158+ assert!(run(&[("sparse-checkout", "docs\n")]).0);
1159+ assert!(workspace.join("docs/a.md").exists() && !workspace.join("src").exists());
1160+ assert_eq!(git(&workspace, &["config", "remote.origin.partialclonefilter"]), "blob:none");
1161+ // Another repository without a ref: its default branch.
1162+ let (ok, outputs) = run(&[("repository", "acme/other"), ("path", "other")]);
1163+ assert!(ok);
1164+ assert_eq!(git(&workspace.join("other"), &["branch", "--show-current"]), "trunk");
1165+ assert_eq!(outputs["ref"], "refs/heads/trunk");
1166+ let _ = std::fs::remove_dir_all(&root);
1167+ }
1168+
1169+ #[test]
8931170 fn unexpected_inputs_are_named_with_the_valid_ones() {
8941171 let declared = vec!["token".to_owned(), "path".to_owned()];
8951172 assert_eq!(unexpected_inputs(&declared, &["Token".into()], false), None);