Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge remote-tracking branch 'origin/claude/peaceful-franklin-7qtkwx' | 1 | //! Actions from GitHub, kept on g1t. |
| 2 | //! | |
| 3 | //! An action `uses:` names that g1t does not host (reach.rs) comes from | |
| 4 | //! GitHub. Before a job fetches it, g1t resolves its ref to a commit, as | |
| 5 | //! GitHub's runner does, from the repository's advertisement | |
| 6 | //! (`info/refs`, which no API rate limit applies to), and keeps that | |
| 7 | //! commit's archive in R2. Every later job that uses the action at that | |
| 8 | //! commit downloads it from g1t: faster than GitHub, the same bytes | |
| 9 | //! whatever happens to the tag, and there while GitHub is not. | |
| 10 | //! | |
| 11 | //! - A ref is looked up again once `REF_FRESH_MS` has passed, so a moved | |
| 12 | //! tag (`v4` moving to a new release) is followed within minutes. When | |
| 13 | //! GitHub does not answer, the commit the ref last named is used, and | |
| 14 | //! the job says so. | |
| 15 | //! - A commit's archive is the one GitHub serves for it, which never | |
| 16 | //! changes. One larger than `ARCHIVE_MAX_BYTES` is not kept: the job | |
| 17 | //! fetches it from GitHub itself. | |
| 18 | //! - An archive no job has used for `ARCHIVE_UNUSED_DAYS` is deleted by | |
| 19 | //! the hourly sweep. Archives are g1t's own, shared by every workspace, | |
| 20 | //! and charged to none. | |
| 21 | ||
| 22 | use futures_util::StreamExt; | |
| 23 | use g1t_contracts::time::rfc3339; | |
| 24 | use g1t_kit::now_ms; | |
| 25 | use serde::Deserialize; | |
| 26 | use serde_json::{Value, json}; | |
| 27 | use worker::Result; | |
| 28 | ||
| 29 | use crate::Actions; | |
| 30 | use crate::reach::UsesRef; | |
| 31 | ||
| 32 | /// The largest archive kept: the Worker holds it whole while saving it. | |
| 33 | pub(crate) const ARCHIVE_MAX_BYTES: usize = 32 * 1024 * 1024; | |
| 34 | /// How long a ref's commit is trusted before GitHub is asked again. | |
| 35 | pub(crate) const REF_FRESH_MS: u64 = 5 * 60 * 1000; | |
| 36 | /// An archive unused this long is deleted. | |
| 37 | pub(crate) const ARCHIVE_UNUSED_DAYS: u64 = 30; | |
| 38 | /// An advertisement larger than this is not read: a ref is never worth it. | |
| 39 | const ADVERTISEMENT_MAX_BYTES: usize = 8 * 1024 * 1024; | |
| 40 | const DAY_MS: u64 = 24 * 60 * 60 * 1000; | |
| 41 | ||
| 42 | /// Whether `text` is a whole commit id. | |
| 43 | pub(crate) fn is_sha(text: &str) -> bool { | |
| 44 | text.len() == 40 && text.chars().all(|c| c.is_ascii_hexdigit()) | |
| 45 | } | |
| 46 | ||
| 47 | /// The refs a smart HTTP advertisement (`info/refs?service=git-upload-pack`) | |
| 48 | /// lists, as (commit, name). Peeled tags keep their `^{}`. | |
| 49 | pub(crate) fn parse_advertisement(bytes: &[u8]) -> Vec<(String, String)> { | |
| 50 | let mut refs = Vec::new(); | |
| 51 | let mut at = 0; | |
| 52 | while at + 4 <= bytes.len() { | |
| 53 | let Some(length) = std::str::from_utf8(&bytes[at..at + 4]).ok().and_then(|hex| usize::from_str_radix(hex, 16).ok()) else { | |
| 54 | break; | |
| 55 | }; | |
| 56 | // A flush or delimiter packet. | |
| 57 | if length < 4 { | |
| 58 | at += 4; | |
| 59 | continue; | |
| 60 | } | |
| 61 | if at + length > bytes.len() { | |
| 62 | break; | |
| 63 | } | |
| 64 | let line = String::from_utf8_lossy(&bytes[at + 4..at + length]); | |
| 65 | at += length; | |
| 66 | let line = line.trim_end_matches('\n'); | |
| 67 | if line.starts_with('#') { | |
| 68 | continue; | |
| 69 | } | |
| 70 | // The first ref carries the capabilities after a NUL. | |
| 71 | let line = line.split('\0').next().unwrap_or_default(); | |
| 72 | if let Some((sha, name)) = line.split_once(' ') | |
| 73 | && is_sha(sha) | |
| 74 | { | |
| 75 | refs.push((sha.to_ascii_lowercase(), name.to_owned())); | |
| 76 | } | |
| 77 | } | |
| 78 | refs | |
| 79 | } | |
| 80 | ||
| 81 | /// The commit `wanted` names among `refs`, as git reads a short name: a | |
| 82 | /// commit id as it is, else a tag, else a branch (`refs/…` names one | |
| 83 | /// exactly). A tag's commit is its peeled one. | |
| 84 | pub(crate) fn pick(refs: &[(String, String)], wanted: &str) -> Option<String> { | |
| 85 | if is_sha(wanted) { | |
| 86 | return Some(wanted.to_ascii_lowercase()); | |
| 87 | } | |
| 88 | let names: Vec<String> = if wanted.starts_with("refs/") { | |
| 89 | vec![wanted.to_owned()] | |
| 90 | } else { | |
| 91 | vec![format!("refs/tags/{wanted}"), format!("refs/heads/{wanted}")] | |
| 92 | }; | |
| 93 | names.iter().find_map(|name| { | |
| 94 | let peeled = format!("{name}^{{}}"); | |
| 95 | refs.iter() | |
| 96 | .find(|(_, listed)| *listed == peeled) | |
| 97 | .or_else(|| refs.iter().find(|(_, listed)| listed == name)) | |
| 98 | .map(|(sha, _)| sha.clone()) | |
| 99 | }) | |
| 100 | } | |
| 101 | ||
| 102 | /// Where a commit's archive is kept in R2. | |
| 103 | pub(crate) fn archive_object(repository: &str, sha: &str) -> String { | |
| 104 | format!("action-archives/{}/{sha}.tar.gz", repository.to_ascii_lowercase()) | |
| 105 | } | |
| 106 | ||
| 107 | /// A GET to GitHub, whose body is read up to `max` bytes. None when it | |
| 108 | /// does not answer 200, or sends more. | |
| 109 | async fn fetch_capped(url: &str, max: usize) -> Option<Vec<u8>> { | |
| 110 | let headers = worker::Headers::new(); | |
| 111 | // GitHub answers a smart HTTP advertisement to git, which it knows by | |
| 112 | // its agent. | |
| 113 | headers.set("user-agent", "git/2.47.0 (g1t)").ok()?; | |
| 114 | let mut init = worker::RequestInit::new(); | |
| 115 | init.with_method(worker::Method::Get).with_headers(headers); | |
| 116 | let request = worker::Request::new_with_init(url, &init).ok()?; | |
| 117 | let mut response = worker::Fetch::Request(request).send().await.ok()?; | |
| 118 | if response.status_code() != 200 { | |
| 119 | return None; | |
| 120 | } | |
| 121 | let mut stream = response.stream().ok()?; | |
| 122 | let mut body = Vec::new(); | |
| 123 | while let Some(chunk) = stream.next().await { | |
| 124 | body.extend_from_slice(&chunk.ok()?); | |
| 125 | if body.len() > max { | |
| 126 | return None; | |
| 127 | } | |
| 128 | } | |
| 129 | Some(body) | |
| 130 | } | |
| 131 | ||
| A workflow's `uses: actions/setup-node@v4` means GitHub's `actions` again, whoever is on g1t. g1t looked for a repository on g1t before GitHub, and anyone could make a public workspace called `actions`, `docker` or `aws-actions` and have its `setup-node` run in every workflow that names GitHub's, with that job's secrets. Now another workspace's public repository answers only for a name GitHub has no public repository of; the caller's own workspace and g1t's own `g1t/…` still answer from g1t, and when GitHub can't be asked, GitHub's is used. The names of the GitHub accounts whose actions workflows use most can no longer be registered, renamed to, or taken by signing up with GitHub (which suggests the name with `-gh`); an account that has one keeps it, and mentions and invitations still find it. The Actions, authentication and workspaces guides say so. | 132 | /// The status GitHub answers a GET with, or None when it doesn't answer. |
| 133 | async fn status_of(url: &str) -> Option<u16> { | |
| 134 | let headers = worker::Headers::new(); | |
| 135 | headers.set("user-agent", "git/2.47.0 (g1t)").ok()?; | |
| 136 | let mut init = worker::RequestInit::new(); | |
| 137 | init.with_method(worker::Method::Get).with_headers(headers); | |
| 138 | let request = worker::Request::new_with_init(url, &init).ok()?; | |
| 139 | let response = worker::Fetch::Request(request).send().await.ok()?; | |
| 140 | Some(response.status_code()) | |
| 141 | } | |
| 142 | ||
| 143 | /// What GitHub's answer to a repository's advertisement says about it: | |
| 144 | /// Some(true) when it publishes the repository, Some(false) when it says | |
| 145 | /// there is none it will show (GitHub answers 401 for a repository that | |
| 146 | /// doesn't exist as for a private one), None for anything else, such as a | |
| 147 | /// rate limit or an error. | |
| 148 | pub(crate) fn published_by(status: Option<u16>) -> Option<bool> { | |
| 149 | match status? { | |
| 150 | 200 => Some(true), | |
| 151 | 401 | 404 => Some(false), | |
| 152 | _ => None, | |
| 153 | } | |
| 154 | } | |
| 155 | ||
| Merge remote-tracking branch 'origin/claude/peaceful-franklin-7qtkwx' | 156 | #[derive(Deserialize)] |
| 157 | struct RefRow { | |
| 158 | sha: String, | |
| 159 | resolved_at: String, | |
| 160 | } | |
| 161 | ||
| 162 | impl Actions { | |
| 163 | /// Where a job gets `used`, an action g1t does not host: | |
| 164 | /// `{"source": "github", "sha", "archive", "note"}`. `sha` is the commit | |
| 165 | /// its ref names (absent when GitHub does not know it, or never | |
| 166 | /// answered), `archive` whether g1t keeps that commit's archive for the | |
| 167 | /// job to download, and `note` anything the job should say about it. | |
| 168 | pub(crate) async fn github_action(&self, used: &UsesRef) -> Result<Value> { | |
| 169 | let repository = used.full_name().to_ascii_lowercase(); | |
| 170 | let now = now_ms(); | |
| 171 | let (sha, note) = self.resolve_github(&repository, &used.git_ref, now).await?; | |
| 172 | let Some(sha) = sha else { | |
| 173 | return Ok(json!({ "source": "github", "note": note })); | |
| 174 | }; | |
| 175 | let archive = self.keep_archive(&repository, &sha, now).await?; | |
| 176 | Ok(json!({ "source": "github", "sha": sha, "archive": archive, "note": note })) | |
| 177 | } | |
| 178 | ||
| A workflow's `uses: actions/setup-node@v4` means GitHub's `actions` again, whoever is on g1t. g1t looked for a repository on g1t before GitHub, and anyone could make a public workspace called `actions`, `docker` or `aws-actions` and have its `setup-node` run in every workflow that names GitHub's, with that job's secrets. Now another workspace's public repository answers only for a name GitHub has no public repository of; the caller's own workspace and g1t's own `g1t/…` still answer from g1t, and when GitHub can't be asked, GitHub's is used. The names of the GitHub accounts whose actions workflows use most can no longer be registered, renamed to, or taken by signing up with GitHub (which suggests the name with `-gh`); an account that has one keeps it, and mentions and invitations still find it. The Actions, authentication and workspaces guides say so. | 179 | /// Whether GitHub publishes `repository` (`owner/repo`): Some(true) when |
| 180 | /// it does, or once named a commit of it for a job; Some(false) when it | |
| 181 | /// says it has no such repository; None when it could not say. | |
| 182 | pub(crate) async fn github_publishes(&self, repository: &str) -> Result<Option<bool>> { | |
| 183 | let repository = repository.to_ascii_lowercase(); | |
| 184 | let seen = self | |
| 185 | .db | |
| 186 | .prepare("SELECT sha FROM action_refs WHERE repository = ? LIMIT 1") | |
| 187 | .bind(&[repository.as_str().into()])? | |
| 188 | .first::<Value>(None) | |
| 189 | .await?; | |
| 190 | if seen.is_some() { | |
| 191 | return Ok(Some(true)); | |
| 192 | } | |
| 193 | Ok(published_by(status_of(&format!("https://github.com/{repository}.git/info/refs?service=git-upload-pack")).await)) | |
| 194 | } | |
| 195 | ||
| Merge remote-tracking branch 'origin/claude/peaceful-franklin-7qtkwx' | 196 | /// The commit `git_ref` names in GitHub's `repository`, and a note when |
| 197 | /// it is the one it last named because GitHub did not answer. | |
| 198 | async fn resolve_github(&self, repository: &str, git_ref: &str, now: u64) -> Result<(Option<String>, Option<String>)> { | |
| 199 | if is_sha(git_ref) { | |
| 200 | return Ok((Some(git_ref.to_ascii_lowercase()), None)); | |
| 201 | } | |
| 202 | let known = self | |
| 203 | .db | |
| 204 | .prepare("SELECT sha, resolved_at FROM action_refs WHERE repository = ? AND ref = ?") | |
| 205 | .bind(&[repository.into(), git_ref.into()])? | |
| 206 | .first::<RefRow>(None) | |
| 207 | .await?; | |
| 208 | if let Some(row) = &known | |
| 209 | && row.resolved_at >= rfc3339(now.saturating_sub(REF_FRESH_MS)) | |
| 210 | { | |
| 211 | return Ok((Some(row.sha.clone()), None)); | |
| 212 | } | |
| 213 | let url = format!("https://github.com/{repository}.git/info/refs?service=git-upload-pack"); | |
| 214 | match fetch_capped(&url, ADVERTISEMENT_MAX_BYTES).await { | |
| 215 | Some(advertisement) => { | |
| 216 | let Some(sha) = pick(&parse_advertisement(&advertisement), git_ref) else { | |
| 217 | return Ok((None, None)); | |
| 218 | }; | |
| 219 | self.db | |
| 220 | .prepare( | |
| 221 | "INSERT INTO action_refs (repository, ref, sha, resolved_at) VALUES (?1, ?2, ?3, ?4) | |
| 222 | ON CONFLICT (repository, ref) DO UPDATE SET sha = ?3, resolved_at = ?4", | |
| 223 | ) | |
| 224 | .bind(&[repository.into(), git_ref.into(), sha.as_str().into(), rfc3339(now).into()])? | |
| 225 | .run() | |
| 226 | .await?; | |
| 227 | Ok((Some(sha), None)) | |
| 228 | } | |
| 229 | None => Ok(match known { | |
| 230 | Some(row) => { | |
| 231 | let note = format!( | |
| 232 | "GitHub did not say which commit {repository}@{git_ref} is; using {}, which it named on {}.", | |
| 233 | &row.sha, | |
| 234 | row.resolved_at.get(..10).unwrap_or(&row.resolved_at) | |
| 235 | ); | |
| 236 | (Some(row.sha), Some(note)) | |
| 237 | } | |
| 238 | None => (None, None), | |
| 239 | }), | |
| 240 | } | |
| 241 | } | |
| 242 | ||
| 243 | /// Whether g1t keeps the archive of `repository` at `sha`, fetching it | |
| 244 | /// from GitHub the first time. | |
| 245 | async fn keep_archive(&self, repository: &str, sha: &str, now: u64) -> Result<bool> { | |
| 246 | let Some(bucket) = &self.cache else { return Ok(false) }; | |
| 247 | #[derive(Deserialize)] | |
| 248 | struct Kept { | |
| 249 | used_at: String, | |
| 250 | } | |
| 251 | let kept = self | |
| 252 | .db | |
| 253 | .prepare("SELECT used_at FROM action_archives WHERE repository = ? AND sha = ?") | |
| 254 | .bind(&[repository.into(), sha.into()])? | |
| 255 | .first::<Kept>(None) | |
| 256 | .await?; | |
| 257 | if let Some(kept) = kept { | |
| 258 | // Marked used at most once a day: the sweep needs no more. | |
| 259 | if kept.used_at < rfc3339(now.saturating_sub(DAY_MS)) { | |
| 260 | self.db | |
| 261 | .prepare("UPDATE action_archives SET used_at = ? WHERE repository = ? AND sha = ?") | |
| 262 | .bind(&[rfc3339(now).into(), repository.into(), sha.into()])? | |
| 263 | .run() | |
| 264 | .await?; | |
| 265 | } | |
| 266 | return Ok(true); | |
| 267 | } | |
| 268 | let url = format!("https://codeload.github.com/{repository}/tar.gz/{sha}"); | |
| 269 | let Some(archive) = fetch_capped(&url, ARCHIVE_MAX_BYTES).await else { | |
| 270 | return Ok(false); | |
| 271 | }; | |
| 272 | let object = archive_object(repository, sha); | |
| 273 | let size = archive.len(); | |
| 274 | if let Err(error) = bucket.put(&object, archive).execute().await { | |
| 275 | worker::console_error!("actions: the archive of {repository}@{sha} was not kept: {error}"); | |
| 276 | return Ok(false); | |
| 277 | } | |
| 278 | let at = rfc3339(now); | |
| 279 | self.db | |
| 280 | .prepare( | |
| 281 | "INSERT INTO action_archives (repository, sha, object, size, stored_at, used_at) VALUES (?1, ?2, ?3, ?4, ?5, ?5) | |
| 282 | ON CONFLICT (repository, sha) DO UPDATE SET used_at = ?5", | |
| 283 | ) | |
| 284 | .bind(&[repository.into(), sha.into(), object.into(), (size as f64).into(), at.into()])? | |
| 285 | .run() | |
| 286 | .await?; | |
| 287 | Ok(true) | |
| 288 | } | |
| 289 | ||
| 290 | /// `job_action_archive`: a running job downloads an action's archive | |
| 291 | /// g1t keeps, by `report.repository` and `report.sha`. Answers the R2 | |
| 292 | /// object to send, `{"object", "size"}`. Any job may: they are public | |
| 293 | /// repositories' code. | |
| 294 | pub async fn job_action_archive(&self, a: g1t_contracts::actions::JobCallArgs) -> Result<g1t_contracts::Outcome<Value>> { | |
| 295 | crate::check!(self.job_for_token(&a).await?); | |
| 296 | let repository = a.report["repository"].as_str().unwrap_or_default().to_ascii_lowercase(); | |
| 297 | let sha = a.report["sha"].as_str().unwrap_or_default().to_ascii_lowercase(); | |
| 298 | if !is_sha(&sha) || crate::reach::parse_uses(&format!("{repository}@{sha}")).is_none() { | |
| 299 | return Ok(crate::fail(g1t_contracts::FailureCode::Invalid, "Name the action's repository as owner/repo, and its commit.")); | |
| 300 | } | |
| 301 | #[derive(Deserialize)] | |
| 302 | struct Kept { | |
| 303 | object: String, | |
| 304 | size: f64, | |
| 305 | } | |
| 306 | let kept = self | |
| 307 | .db | |
| 308 | .prepare("SELECT object, size FROM action_archives WHERE repository = ? AND sha = ?") | |
| 309 | .bind(&[repository.as_str().into(), sha.as_str().into()])? | |
| 310 | .first::<Kept>(None) | |
| 311 | .await?; | |
| 312 | Ok(match kept { | |
| 313 | Some(kept) => g1t_contracts::Outcome::Ok(json!({ "object": kept.object, "size": kept.size })), | |
| 314 | None => crate::fail(g1t_contracts::FailureCode::NotFound, format!("g1t does not keep {repository}@{sha}.")), | |
| 315 | }) | |
| 316 | } | |
| 317 | ||
| 318 | /// Hourly: archives no job has used for `ARCHIVE_UNUSED_DAYS`, and refs | |
| 319 | /// not looked up as long. | |
| 320 | pub async fn sweep_action_archives(&self, now: u64) -> Result<()> { | |
| 321 | let before = rfc3339(now.saturating_sub(ARCHIVE_UNUSED_DAYS * DAY_MS)); | |
| 322 | #[derive(Deserialize)] | |
| 323 | struct Gone { | |
| 324 | repository: String, | |
| 325 | sha: String, | |
| 326 | object: String, | |
| 327 | } | |
| 328 | let gone = self | |
| 329 | .db | |
| 330 | .prepare("SELECT repository, sha, object FROM action_archives WHERE used_at < ? LIMIT 100") | |
| 331 | .bind(&[before.as_str().into()])? | |
| 332 | .all() | |
| 333 | .await? | |
| 334 | .results::<Gone>()?; | |
| 335 | if let Some(bucket) = &self.cache | |
| 336 | && !gone.is_empty() | |
| 337 | { | |
| 338 | bucket.delete_multiple(gone.iter().map(|g| g.object.as_str()).collect()).await?; | |
| 339 | for archive in &gone { | |
| 340 | self.db | |
| 341 | .prepare("DELETE FROM action_archives WHERE repository = ? AND sha = ?") | |
| 342 | .bind(&[archive.repository.as_str().into(), archive.sha.as_str().into()])? | |
| 343 | .run() | |
| 344 | .await?; | |
| 345 | } | |
| 346 | } | |
| 347 | self.db.prepare("DELETE FROM action_refs WHERE resolved_at < ?").bind(&[before.into()])?.run().await?; | |
| 348 | Ok(()) | |
| 349 | } | |
| 350 | } | |
| 351 | ||
| 352 | #[cfg(test)] | |
| 353 | mod tests { | |
| 354 | use super::*; | |
| 355 | ||
| 356 | fn pkt(payload: &str) -> Vec<u8> { | |
| 357 | let mut out = format!("{:04x}", payload.len() + 4).into_bytes(); | |
| 358 | out.extend_from_slice(payload.as_bytes()); | |
| 359 | out | |
| 360 | } | |
| 361 | ||
| 362 | const MAIN: &str = "1111111111111111111111111111111111111111"; | |
| 363 | const V4_TAG: &str = "2222222222222222222222222222222222222222"; | |
| 364 | const V4_COMMIT: &str = "3333333333333333333333333333333333333333"; | |
| 365 | const V1_BRANCH: &str = "4444444444444444444444444444444444444444"; | |
| 366 | const V1_TAG: &str = "5555555555555555555555555555555555555555"; | |
| 367 | ||
| 368 | fn advertisement() -> Vec<u8> { | |
| 369 | let mut bytes = pkt("# service=git-upload-pack\n"); | |
| 370 | bytes.extend_from_slice(b"0000"); | |
| 371 | bytes.extend(pkt(&format!("{MAIN} HEAD\0multi_ack thin-pack symref=HEAD:refs/heads/main\n"))); | |
| 372 | bytes.extend(pkt(&format!("{MAIN} refs/heads/main\n"))); | |
| 373 | bytes.extend(pkt(&format!("{V1_BRANCH} refs/heads/v1\n"))); | |
| 374 | bytes.extend(pkt(&format!("{V1_TAG} refs/tags/v1\n"))); | |
| 375 | bytes.extend(pkt(&format!("{V4_TAG} refs/tags/v4\n"))); | |
| 376 | bytes.extend(pkt(&format!("{V4_COMMIT} refs/tags/v4^{{}}\n"))); | |
| 377 | bytes.extend_from_slice(b"0000"); | |
| 378 | bytes | |
| 379 | } | |
| 380 | ||
| 381 | #[test] | |
| A workflow's `uses: actions/setup-node@v4` means GitHub's `actions` again, whoever is on g1t. g1t looked for a repository on g1t before GitHub, and anyone could make a public workspace called `actions`, `docker` or `aws-actions` and have its `setup-node` run in every workflow that names GitHub's, with that job's secrets. Now another workspace's public repository answers only for a name GitHub has no public repository of; the caller's own workspace and g1t's own `g1t/…` still answer from g1t, and when GitHub can't be asked, GitHub's is used. The names of the GitHub accounts whose actions workflows use most can no longer be registered, renamed to, or taken by signing up with GitHub (which suggests the name with `-gh`); an account that has one keeps it, and mentions and invitations still find it. The Actions, authentication and workspaces guides say so. | 382 | fn only_an_answer_from_github_says_it_has_no_such_repository() { |
| 383 | assert_eq!(published_by(Some(200)), Some(true)); | |
| 384 | assert_eq!(published_by(Some(401)), Some(false)); | |
| 385 | assert_eq!(published_by(Some(404)), Some(false)); | |
| 386 | // A rate limit, an error or no answer says nothing, so the name stays GitHub's. | |
| 387 | assert_eq!(published_by(Some(429)), None); | |
| 388 | assert_eq!(published_by(Some(503)), None); | |
| 389 | assert_eq!(published_by(None), None); | |
| 390 | } | |
| 391 | ||
| 392 | #[test] | |
| Merge remote-tracking branch 'origin/claude/peaceful-franklin-7qtkwx' | 393 | fn an_advertisement_lists_its_refs_capabilities_aside() { |
| 394 | let refs = parse_advertisement(&advertisement()); | |
| 395 | assert_eq!(refs.len(), 6); | |
| 396 | assert_eq!(refs[0], (MAIN.to_owned(), "HEAD".to_owned())); | |
| 397 | assert_eq!(refs[5], (V4_COMMIT.to_owned(), "refs/tags/v4^{}".to_owned())); | |
| 398 | // Cut short: what came whole is kept. | |
| 399 | let whole = advertisement(); | |
| 400 | assert_eq!(parse_advertisement(&whole[..whole.len() - 30]).len(), 5); | |
| 401 | assert!(parse_advertisement(b"not git").is_empty()); | |
| 402 | } | |
| 403 | ||
| 404 | #[test] | |
| 405 | fn a_ref_is_read_as_git_reads_a_short_name() { | |
| 406 | let refs = parse_advertisement(&advertisement()); | |
| 407 | // A tag's commit, not the tag object. | |
| 408 | assert_eq!(pick(&refs, "v4").as_deref(), Some(V4_COMMIT)); | |
| 409 | // A tag before a branch of the same name. | |
| 410 | assert_eq!(pick(&refs, "v1").as_deref(), Some(V1_TAG)); | |
| 411 | assert_eq!(pick(&refs, "refs/heads/v1").as_deref(), Some(V1_BRANCH)); | |
| 412 | assert_eq!(pick(&refs, "main").as_deref(), Some(MAIN)); | |
| 413 | // A commit is itself, known or not. | |
| 414 | let other = "ABCDEF0123456789ABCDEF0123456789ABCDEF01"; | |
| 415 | assert_eq!(pick(&refs, other).as_deref(), Some("abcdef0123456789abcdef0123456789abcdef01")); | |
| 416 | assert_eq!(pick(&refs, "v9"), None); | |
| 417 | assert_eq!(pick(&refs, "abc123"), None); | |
| 418 | } | |
| 419 | ||
| 420 | #[test] | |
| 421 | fn archives_are_kept_by_repository_and_commit() { | |
| 422 | assert_eq!(archive_object("Actions/Checkout", V4_COMMIT), format!("action-archives/actions/checkout/{V4_COMMIT}.tar.gz")); | |
| 423 | assert!(is_sha(MAIN)); | |
| 424 | assert!(!is_sha("v4")); | |
| 425 | } | |
| 426 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.