Skip to content

Commit

Merge branch 'worktree-agent-a1eb74e8a85291bbb' into claude/cool-newton-7nm8r9

Claudeagentcommitted Parents92112899f33effBrowse files
7 files+398−810/7 viewed
+5−8
127127 request.json().await.unwrap_or(Value::Null)
128128 }
129129
130−/// Who a request's `Authorization: Bearer g1t_…` names. A missing token is
131−/// an anonymous viewer; a wrong one is refused, so that a typo does not
132−/// silently look signed out.
130+/// Who a request's `Authorization: Bearer g1t_…` (or `token g1t_…`, as
131+/// Octokit sends it) names. A missing token is an anonymous viewer; a
132+/// wrong one is refused, so that a typo does not silently look signed out.
133133 async fn authenticate(
134134 request: &Request,
135135 services: &Services,
136136 ) -> Result<std::result::Result<Viewer, Response>> {
137137 let header = request.headers().get("authorization")?.unwrap_or_default();
138− let token = match header.split_once(' ') {
139− Some((scheme, token)) if scheme.eq_ignore_ascii_case("bearer") && !token.is_empty() => {
140− token.trim()
141− }
142− _ => return Ok(Ok(None)),
138+ let Some(token) = limits::bearer(&header) else {
139+ return Ok(Ok(None));
143140 };
144141 let viewer: Viewer = g1t_kit::call(
145142 &services.identity,
+12−3
5555 Sha256::digest(token.as_bytes())[..8].iter().map(|byte| format!("{byte:02x}")).collect()
5656 }
5757
58−/// The bearer token of an `Authorization` header, if it has one.
58+/// The access token of an `Authorization` header, if it has one: `Bearer
59+/// <token>`, or `token <token>`, the form GitHub also takes and Octokit
60+/// (`@actions/github`, so `actions/checkout` and `actions/github-script`)
61+/// sends.
5962 pub fn bearer(header: &str) -> Option<&str> {
6063 match header.split_once(' ') {
61− Some((scheme, token)) if scheme.eq_ignore_ascii_case("bearer") => Some(token.trim()).filter(|t| !t.is_empty()),
64+ Some((scheme, token)) if scheme.eq_ignore_ascii_case("bearer") || scheme.eq_ignore_ascii_case("token") => {
65+ Some(token.trim()).filter(|t| !t.is_empty())
66+ }
6267 _ => None,
6368 }
6469 }
133138 }
134139
135140 #[test]
136− fn only_bearer_tokens_are_read() {
141+ fn bearer_and_token_schemes_are_read() {
137142 assert_eq!(bearer("Bearer g1t_abc "), Some("g1t_abc"));
138143 assert_eq!(bearer("bearer g1t_abc"), Some("g1t_abc"));
144+ // Octokit's form, as `actions/checkout` sends it for a default branch.
145+ assert_eq!(bearer("token g1t_abc"), Some("g1t_abc"));
146+ assert_eq!(bearer("Token g1t_abc"), Some("g1t_abc"));
147+ assert_eq!(bearer("token "), None);
139148 assert_eq!(bearer("Basic dXNlcjpwYXNz"), None);
140149 assert_eq!(bearer("Bearer "), None);
141150 assert_eq!(bearer(""), None);
+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.
615619 language images have both). In an image without them, such as Alpine,
616620 they run beside the container, on g1t's runner, with the same files,
617621 and the log says so.
618−- `actions/checkout`, `actions/cache` and the artifact actions run on
619− g1t's runner, with the same files.
622+- `actions/checkout` and the artifact actions run on g1t's runner, with
623+ the same files.
620624
621625 ### Building and pushing images
622626
+4−0
4747 -H "Authorization: Bearer $G1T_TOKEN"
4848 ```
4949
50+`Authorization: token $G1T_TOKEN`, the other form GitHub takes, works the
51+same, so Octokit and the actions built on it, such as `actions/checkout`
52+and `actions/github-script`, are signed in with the token they are given.
53+
5054 Public data can be read without a token. A token that is not valid is
5155 rejected with `401` rather than treated as anonymous. Each endpoint's page
5256 says whether it needs a token.
+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
7272 (!owner.is_empty() && !repo.is_empty()).then(|| (owner.to_owned(), repo.to_owned(), git_ref.to_owned()))
7373 }
7474
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
142+}
143+
75144 impl Job {
76145 /// Runs a git command, logging it; the credential header is never logged.
77146 fn git(&mut self, dir: &Path, args: &[&str], auth: Option<&str>) -> bool {
103172 false
104173 }
105174
106− /// `actions/checkout`, against g1t.
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)))
188+ }
189+
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.
107193 fn checkout(&mut self, with: &BTreeMap<String, String>) -> (bool, BTreeMap<String, String>) {
108194 let checkout = self.spec["checkout"].clone();
109195 let own = checkout["repository"].as_str().unwrap_or_default().to_owned();
110− let server = self.contexts["github"]["server_url"].as_str().unwrap_or("https://g1t.sh").to_owned();
111− let repository = with.get("repository").filter(|r| !r.is_empty()).cloned().unwrap_or(own.clone());
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());
112200 let same = repository.eq_ignore_ascii_case(&own);
113− let token = with.get("token").filter(|t| !t.is_empty()).cloned().or_else(|| checkout["token"].as_str().map(str::to_owned)).unwrap_or_default();
201+ let token = input("token").or_else(|| checkout["token"].as_str().map(str::to_owned)).unwrap_or_default();
114202 let url = if same { checkout["url"].as_str().unwrap_or_default().to_owned() } else { format!("{server}/{repository}.git") };
115− let path = with.get("path").filter(|p| !p.is_empty()).map_or(self.workspace.clone(), |p| self.workspace.join(p));
116− let depth: u32 = with.get("fetch-depth").and_then(|d| d.parse().ok()).unwrap_or(1);
117− let wanted_ref = with.get("ref").filter(|r| !r.is_empty()).cloned();
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();
118209 let auth = (!token.is_empty()).then(|| format!("AUTHORIZATION: basic {}", STANDARD.encode(format!("x-access-token:{token}"))));
210+ let header = auth.as_deref();
119211
120− self.log.line(&format!("Checking out {repository} into {}", path.display()));
212+ self.log.line(&format!("Syncing repository: {repository}"));
121213 if path.exists() {
122214 let _ = std::fs::remove_dir_all(&path);
123215 }
125217 self.log.line(&format!("##[error]Could not make {}: {error}", path.display()));
126218 return (false, BTreeMap::new());
127219 }
128− let _ = Command::new("git").args(["config", "--global", "--add", "safe.directory", "*"]).status();
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+ }
129224 if !self.git(&path, &["init", "--quiet"], None) || !self.git(&path, &["remote", "add", "origin", &url], None) {
130225 return (false, BTreeMap::new());
131226 }
227+ let _ = Command::new("git").current_dir(&path).args(["config", "--local", "gc.auto", "0"]).status();
132228
133− // What to fetch, and which commit to end up on.
134− let run_ref = checkout["ref"].as_str().unwrap_or_default().to_owned();
135− let run_sha = checkout["sha"].as_str().unwrap_or_default().to_owned();
136− let is_sha = |r: &str| r.len() == 40 && r.chars().all(|c| c.is_ascii_hexdigit());
137− let (fetch, sha, branch): (String, Option<String>, Option<String>) = match &wanted_ref {
138− Some(r) if is_sha(r) => ("HEAD".into(), Some(r.clone()), None),
139− Some(r) if r.starts_with("refs/") => (r.clone(), None, r.strip_prefix("refs/heads/").map(str::to_owned)),
140− Some(r) => (r.clone(), None, Some(r.clone())),
141− // A pull request's merge ref, or no ref at all (a deployment of
142− // a bare commit): the commit itself.
143− None if same && (run_ref.starts_with("refs/pull/") || run_ref.is_empty()) => ("HEAD".into(), Some(run_sha.clone()), None),
144− None if same => (run_ref.clone(), Some(run_sha.clone()), run_ref.strip_prefix("refs/heads/").map(str::to_owned)),
145− None => ("HEAD".into(), None, None),
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,
146234 };
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+ );
147241 let depth_arg = format!("--depth={depth}");
148− let mut args = vec!["fetch", "--no-tags", "--prune", "--quiet"];
149− if depth > 0 {
150− args.push(&depth_arg);
151− }
152− if with.get("fetch-tags").is_some_and(|t| t == "true") {
153− args.retain(|a| *a != "--no-tags");
154− }
155− args.push("origin");
156− args.push(&fetch);
157− if !self.fetch_retrying(&path, &args, auth.as_deref()) {
158− self.log.line(&format!("##[error]Could not fetch {fetch} from {repository}."));
159− return (false, BTreeMap::new());
160− }
161− let target = sha.clone().unwrap_or_else(|| "FETCH_HEAD".into());
162− // The commit may be further back than a shallow fetch reaches: the
163− // branch moved on after the run began, say. Ask for the commit
164− // itself, and failing that the whole history; a plain fetch never
165− // reaches past a shallow boundary.
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);
166263 let has = |target: &str| Command::new("git").current_dir(&path).args(["cat-file", "-e", &format!("{target}^{{commit}}")]).status().is_ok_and(|s| s.success());
167− if !has(&target) {
168− let by_sha = sha.as_deref().is_some_and(|sha| {
169− let mut args = vec!["fetch", "--no-tags", "--quiet"];
170− if depth > 0 {
171− args.push(&depth_arg);
172− }
173− args.extend(["origin", sha]);
174− self.git(&path, &args, auth.as_deref()) && has(sha)
175− });
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)) {
176268 let shallow = path.join(".git").join("shallow").exists();
177− let deepen: &[&str] = if shallow { &["fetch", "--no-tags", "--quiet", "--unshallow", "origin"] } else { &["fetch", "--no-tags", "--quiet", "origin"] };
178− if !by_sha && !self.fetch_retrying(&path, deepen, auth.as_deref()) {
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}."));
179276 return (false, BTreeMap::new());
180277 }
278+ } else if !fetched {
279+ self.log.line(&format!("##[error]Could not fetch {} from {repository}.", plan.refspecs.join(" ")));
280+ return (false, BTreeMap::new());
181281 }
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+ }
182304 let checked_out = match &branch {
183305 Some(branch) => self.git(&path, &["checkout", "--quiet", "--force", "-B", branch, &target], None),
184306 None => self.git(&path, &["checkout", "--quiet", "--force", "--detach", &target], None),
186308 if !checked_out {
187309 return (false, BTreeMap::new());
188310 }
189− if with.get("persist-credentials").is_none_or(|p| p != "false")
190− && let Some(header) = &auth
191− {
192− let key = format!("http.{server}/.extraheader");
311+ let persist = yes("persist-credentials", true);
312+ let key = format!("http.{server}/.extraheader");
313+ if persist && let Some(header) = header {
193314 let _ = Command::new("git").current_dir(&path).args(["config", "--local", &key, header]).status();
194315 }
195− if let Some(submodules) = with.get("submodules").filter(|s| *s == "true" || *s == "recursive") {
196− let mut args = vec!["submodule", "update", "--init", "--quiet"];
197− if submodules == "recursive" {
198− args.push("--recursive");
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");
199330 }
200− if !self.git(&path, &args, auth.as_deref()) {
201− self.log.line("##[warning]Submodules could not all be checked out; only those hosted on g1t can be.");
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());
202334 }
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+ }
203343 }
204− if with.get("lfs").is_some_and(|l| l == "true") {
205− self.log.line("##[warning]Git LFS files are not fetched on g1t yet.");
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.");
206346 }
207347 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();
208348 self.log.line(&format!("Checked out {commit}"));
209349 let mut outputs = BTreeMap::new();
210− outputs.insert("ref".into(), wanted_ref.unwrap_or(run_ref));
350+ outputs.insert("ref".into(), plan.output_ref);
211351 outputs.insert("commit".into(), commit);
212352 (true, outputs)
213353 }
214354
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+
215370 /// Fetches another repository's action, once per job: from g1t when
216371 /// g1t has the repository and this one may use it, otherwise from
217372 /// GitHub. A private repository on g1t that may not be used here fails
9091064 }
9101065
9111066 #[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);
1186+ }
1187+
1188+ #[test]
9121189 fn unexpected_inputs_are_named_with_the_valid_ones() {
9131190 let declared = vec!["token".to_owned(), "path".to_owned()];
9141191 assert_eq!(unexpected_inputs(&declared, &["Token".into()], false), None);