| 72 | 72 | | (!owner.is_empty() && !repo.is_empty()).then(|| (owner.to_owned(), repo.to_owned(), git_ref.to_owned())) |
| 73 | 73 | | } |
| 74 | 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 |
| 142 | + | } |
| 143 | + | |
| 75 | 144 | | impl Job { |
| 76 | 145 | | /// Runs a git command, logging it; the credential header is never logged. |
| 77 | 146 | | fn git(&mut self, dir: &Path, args: &[&str], auth: Option<&str>) -> bool { |
| ⋯ |
| 103 | 172 | | false |
| 104 | 173 | | } |
| 105 | 174 | | |
| 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. |
| 107 | 193 | | fn checkout(&mut self, with: &BTreeMap<String, String>) -> (bool, BTreeMap<String, String>) { |
| 108 | 194 | | let checkout = self.spec["checkout"].clone(); |
| 109 | 195 | | 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()); |
| 112 | 200 | | 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(); |
| 114 | 202 | | 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(); |
| 118 | 209 | | let auth = (!token.is_empty()).then(|| format!("AUTHORIZATION: basic {}", STANDARD.encode(format!("x-access-token:{token}")))); |
| 210 | + | let header = auth.as_deref(); |
| 119 | 211 | | |
| 120 | | − | self.log.line(&format!("Checking out {repository} into {}", path.display())); |
| 212 | + | self.log.line(&format!("Syncing repository: {repository}")); |
| 121 | 213 | | if path.exists() { |
| 122 | 214 | | let _ = std::fs::remove_dir_all(&path); |
| 123 | 215 | | } |
| ⋯ |
| 125 | 217 | | self.log.line(&format!("##[error]Could not make {}: {error}", path.display())); |
| 126 | 218 | | return (false, BTreeMap::new()); |
| 127 | 219 | | } |
| 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 | + | } |
| 129 | 224 | | if !self.git(&path, &["init", "--quiet"], None) || !self.git(&path, &["remote", "add", "origin", &url], None) { |
| 130 | 225 | | return (false, BTreeMap::new()); |
| 131 | 226 | | } |
| 227 | + | let _ = Command::new("git").current_dir(&path).args(["config", "--local", "gc.auto", "0"]).status(); |
| 132 | 228 | | |
| 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, |
| 146 | 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 | + | ); |
| 147 | 241 | | 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); |
| 166 | 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()); |
| 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)) { |
| 176 | 268 | | 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}.")); |
| 179 | 276 | | return (false, BTreeMap::new()); |
| 180 | 277 | | } |
| 278 | + | } else if !fetched { |
| 279 | + | self.log.line(&format!("##[error]Could not fetch {} from {repository}.", plan.refspecs.join(" "))); |
| 280 | + | return (false, BTreeMap::new()); |
| 181 | 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 | + | } |
| 182 | 304 | | let checked_out = match &branch { |
| 183 | 305 | | Some(branch) => self.git(&path, &["checkout", "--quiet", "--force", "-B", branch, &target], None), |
| 184 | 306 | | None => self.git(&path, &["checkout", "--quiet", "--force", "--detach", &target], None), |
| ⋯ |
| 186 | 308 | | if !checked_out { |
| 187 | 309 | | return (false, BTreeMap::new()); |
| 188 | 310 | | } |
| 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 { |
| 193 | 314 | | let _ = Command::new("git").current_dir(&path).args(["config", "--local", &key, header]).status(); |
| 194 | 315 | | } |
| 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"); |
| 199 | 330 | | } |
| 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()); |
| 202 | 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 | + | } |
| 203 | 343 | | } |
| 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."); |
| 206 | 346 | | } |
| 207 | 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(); |
| 208 | 348 | | self.log.line(&format!("Checked out {commit}")); |
| 209 | 349 | | let mut outputs = BTreeMap::new(); |
| 210 | | − | outputs.insert("ref".into(), wanted_ref.unwrap_or(run_ref)); |
| 350 | + | outputs.insert("ref".into(), plan.output_ref); |
| 211 | 351 | | outputs.insert("commit".into(), commit); |
| 212 | 352 | | (true, outputs) |
| 213 | 353 | | } |
| 214 | 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 | + | |
| 215 | 370 | | /// Fetches another repository's action, once per job: from g1t when |
| 216 | 371 | | /// g1t has the repository and this one may use it, otherwise from |
| 217 | 372 | | /// GitHub. A private repository on g1t that may not be used here fails |
| ⋯ |
| 909 | 1064 | | } |
| 910 | 1065 | | |
| 911 | 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); |
| 1186 | + | } |
| 1187 | + | |
| 1188 | + | #[test] |
| 912 | 1189 | | fn unexpected_inputs_are_named_with_the_valid_ones() { |
| 913 | 1190 | | let declared = vec!["token".to_owned(), "path".to_owned()]; |
| 914 | 1191 | | assert_eq!(unexpected_inputs(&declared, &["Token".into()], false), None); |