g1t/services/repos/src/mirror.rs

604 lines25,844 bytesCodeBlame

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.

Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1//! Copying every branch and tag from one git server to another: importing a
2//! repository with a credential, keeping a mirror in step with the host it
3//! mirrors, and pushing a repository's refs out to a host it is mirrored to.
4//!
5//! Like landing (see `land.rs`), this speaks git's smart HTTP protocol and
6//! relays the pack it receives unchanged. Both sides are asked for their
7//! refs; the source is asked for one pack holding what the target lacks;
8//! the target is sent one push that moves every ref that differs.
9
10use std::collections::{BTreeMap, HashSet};
11
12use futures_util::StreamExt;
13use worker::js_sys::Uint8Array;
14use worker::{Fetch, Headers, Method, Request, RequestInit, Result};
15
16use g1t_contracts::repos::{MirrorArgs, MirrorDirection, Mirrored};
17use g1t_contracts::{FailureCode, Outcome};
18
19use crate::land::{read_pkt_lines, unpack_sideband};
20use crate::registry::store_key;
21use crate::store::{GitRepo, GitStore, Scope};
22use crate::{Repos, import, not_found};
23
24const ZERO_ID: &str = "0000000000000000000000000000000000000000";
25/// The most a pack may hold. A Worker holds it in memory while relaying it.
26pub const MAX_PACK_BYTES: usize = 40 * 1024 * 1024;
27/// How many of the target's commits are named to the source as already
28/// had, so a sync only carries what is new.
29const MAX_HAVES: usize = 256;
30const USER_AGENT: &str = "git/2.45.0 (g1t mirror)";
31/// A pack with no objects: for a push whose refs all name objects the
32/// target already holds. The last 20 bytes are the SHA-1 of the first 12.
33const EMPTY_PACK: [u8; 32] = [
34 b'P', b'A', b'C', b'K', 0, 0, 0, 2, 0, 0, 0, 0, 0x02, 0x9d, 0x08, 0x82, 0x3b, 0xd8, 0xa8, 0xea,
35 0xb5, 0x10, 0xad, 0x6a, 0xc7, 0x5c, 0x82, 0x3c, 0xfd, 0x3e, 0xd3, 0x1e,
36];
37
38/// One side of a copy: a repository's smart HTTP address and the
39/// `authorization` header that opens it. The header is never logged.
40pub struct Endpoint {
41 pub url: String,
42 pub authorization: String,
43}
44
45impl Endpoint {
46 /// A g1t repository, with a credential from the git store.
47 pub fn bearer(url: &str, token: &str) -> Self {
48 Endpoint {
49 url: url.trim_end_matches('/').to_owned(),
50 authorization: format!("Bearer {token}"),
51 }
52 }
53
A public import copies every branch and tag, so an imported library keeps its releases54 /// A public repository anywhere, read with no credential: importing
55 /// one copies every branch and tag, as with a credential.
56 pub fn anonymous(url: &str) -> Self {
57 Endpoint {
58 url: url.trim_end_matches('/').to_owned(),
59 authorization: String::new(),
60 }
61 }
62
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look63 /// A GitHub repository, with an installation access token. GitHub takes
64 /// one as the password of the user `x-access-token`. The token is used
65 /// as given: its length and shape are GitHub's to change.
66 pub fn github(url: &str, token: &str) -> Self {
67 Endpoint {
68 url: url.trim_end_matches('/').to_owned(),
69 authorization: format!("Basic {}", base64(&format!("x-access-token:{token}"))),
70 }
71 }
72}
73
74/// What a copy changed.
75#[derive(Debug, Default, PartialEq, Eq)]
76pub struct Copied {
77 /// Refs created or moved, each with where it was and where it is now.
78 pub updated: Vec<(String, Option<String>, String)>,
79 pub deleted: Vec<String>,
80 /// The branch the source's HEAD names, when it said.
81 pub head: Option<String>,
82}
83
84/// Which refs a copy moves.
85#[derive(Clone, Copy, Debug, PartialEq, Eq)]
86pub enum Prune {
87 /// Refs the source no longer has are deleted from the target: the
88 /// target is a mirror of the source.
89 Yes,
90 /// Refs only the target has are left alone: pushing out to a host that
91 /// may have branches of its own.
92 No,
93}
94
95fn base64(text: &str) -> String {
96 const ALPHABET: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
97 let bytes = text.as_bytes();
98 let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4);
99 for chunk in bytes.chunks(3) {
100 let n = (chunk[0] as u32) << 16
101 | (*chunk.get(1).unwrap_or(&0) as u32) << 8
102 | *chunk.get(2).unwrap_or(&0) as u32;
103 for (i, shift) in [18, 12, 6, 0].into_iter().enumerate() {
104 if i <= chunk.len() {
105 out.push(ALPHABET[(n >> shift) as usize & 63] as char);
106 } else {
107 out.push('=');
108 }
109 }
110 }
111 out
112}
113
114fn pkt_line(payload: &str) -> Vec<u8> {
115 format!("{:04x}{payload}", payload.len() + 4).into_bytes()
116}
117
118/// A ref advertisement: branch and tag refs by name, and the branch HEAD
119/// points to when the server said.
120#[derive(Debug, Default, PartialEq, Eq)]
121pub struct Advertised {
122 pub refs: BTreeMap<String, String>,
123 pub head: Option<String>,
124}
125
126impl Advertised {
127 /// The branch to make a copy's default: the one HEAD names, else
128 /// `main`, else the first branch. `None` for a repository with none.
129 pub fn default_branch(&self) -> Option<String> {
130 let branches: Vec<&str> = self.refs.keys().filter_map(|name| name.strip_prefix("refs/heads/")).collect();
131 self.head
132 .clone()
133 .filter(|head| branches.contains(&head.as_str()))
134 .or_else(|| branches.iter().find(|name| **name == "main").map(|name| name.to_string()))
135 .or_else(|| branches.first().map(|name| name.to_string()))
136 }
137}
138
139/// Asks a source for its refs before anything is made from it, so an
140/// address or credential that does not work leaves nothing behind.
141pub async fn probe(source: &Endpoint) -> Result<std::result::Result<Advertised, String>> {
142 advertise(source, "git-upload-pack").await
143}
144
145/// Reads `info/refs`. Peeled tags (`^{}`) and the placeholder an empty
146/// repository sends are skipped; only branches and tags are kept.
147pub fn parse_advertisement(bytes: &[u8]) -> Advertised {
148 let (lines, _) = read_pkt_lines(bytes);
149 let mut advertised = Advertised::default();
150 for line in lines {
151 let mut parts = line.splitn(2, |byte| *byte == 0);
152 let Some(reference) = parts.next().and_then(|bytes| std::str::from_utf8(bytes).ok()) else {
153 continue;
154 };
155 if let Some(capabilities) = parts.next().and_then(|bytes| std::str::from_utf8(bytes).ok()) {
156 advertised.head = capabilities
157 .split(' ')
158 .find_map(|capability| capability.trim().strip_prefix("symref=HEAD:refs/heads/"))
159 .map(str::to_owned)
160 .or(advertised.head);
161 }
162 let Some((hash, name)) = reference.trim_end().split_once(' ') else {
163 continue;
164 };
165 let kept = (name.starts_with("refs/heads/") || name.starts_with("refs/tags/")) && !name.ends_with("^{}");
166 if kept && hash.len() == 40 && hash != ZERO_ID {
167 advertised.refs.insert(name.to_owned(), hash.to_owned());
168 }
169 }
170 advertised
171}
172
173/// One ref command for a push: `(name, old, new)`, `new` the zero id to
174/// delete.
175pub type Command = (String, Option<String>, String);
176
177/// What has to change on `target` so its refs match `source`'s.
178pub fn plan(source: &Advertised, target: &Advertised, prune: Prune) -> Vec<Command> {
179 let mut commands = Vec::new();
180 for (name, hash) in &source.refs {
181 let old = target.refs.get(name);
182 if old != Some(hash) {
183 commands.push((name.clone(), old.cloned(), hash.clone()));
184 }
185 }
186 if prune == Prune::Yes {
187 for (name, hash) in &target.refs {
188 if !source.refs.contains_key(name) {
189 commands.push((name.clone(), Some(hash.clone()), ZERO_ID.to_owned()));
190 }
191 }
192 }
193 commands
194}
195
196/// The body of an upload-pack request for `wants`, naming `haves`.
197pub fn upload_request(wants: &[String], haves: &[String]) -> Vec<u8> {
198 let mut body = Vec::new();
199 for (index, want) in wants.iter().enumerate() {
200 // Capabilities ride on the first want only.
201 let line = if index == 0 { format!("want {want} side-band-64k\n") } else { format!("want {want}\n") };
202 body.extend(pkt_line(&line));
203 }
204 body.extend_from_slice(b"0000");
205 for have in haves.iter().take(MAX_HAVES) {
206 body.extend(pkt_line(&format!("have {have}\n")));
207 }
208 body.extend(pkt_line("done\n"));
209 body
210}
211
212/// The body of a receive-pack request: the commands, then the pack when
213/// anything but deletions is sent.
214pub fn receive_request(commands: &[Command], pack: Option<Vec<u8>>) -> Vec<u8> {
215 let deletes = commands.iter().any(|(_, _, new)| new == ZERO_ID);
216 let capabilities = if deletes { "report-status delete-refs" } else { "report-status" };
217 let mut body = Vec::new();
218 for (index, (name, old, new)) in commands.iter().enumerate() {
219 let old = old.as_deref().unwrap_or(ZERO_ID);
220 let line = if index == 0 { format!("{old} {new} {name}\0 {capabilities}\n") } else { format!("{old} {new} {name}\n") };
221 body.extend(pkt_line(&line));
222 }
223 body.extend_from_slice(b"0000");
224 if commands.iter().any(|(_, _, new)| new != ZERO_ID) {
225 body.extend(pack.unwrap_or_else(|| EMPTY_PACK.to_vec()));
226 }
227 body
228}
229
230/// Which commands a receive-pack report says were refused, with why.
231pub fn refused(report: &[u8], commands: &[Command]) -> Vec<String> {
232 let (lines, _) = read_pkt_lines(report);
233 let lines: Vec<String> = lines
234 .into_iter()
235 .map(|line| {
236 // A report may come inside side-band channel 1.
237 let line = if line.first() == Some(&1) { &line[1..] } else { line };
238 String::from_utf8_lossy(line).trim_end().to_owned()
239 })
240 .collect();
241 let mut problems: Vec<String> = lines
242 .iter()
243 .filter(|line| line.starts_with("unpack ") && *line != "unpack ok")
244 .cloned()
245 .collect();
246 for (name, _, _) in commands {
247 if !lines.iter().any(|line| *line == format!("ok {name}")) {
248 let reason = lines
249 .iter()
250 .find_map(|line| line.strip_prefix(&format!("ng {name} ")))
251 .unwrap_or("not reported");
252 problems.push(format!("{name}: {reason}"));
253 }
254 }
255 problems
256}
257
258fn request(method: Method, url: &str, endpoint: &Endpoint, body: Option<(&str, Vec<u8>)>) -> Result<Request> {
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily259 // Requests to g1t's own store are metered (meters.rs); GitHub's are not.
260 if endpoint.authorization.starts_with("Bearer ") {
261 let sent = body.as_ref().map_or(0, |(_, body)| body.len() as u64);
262 let meter = match body.as_ref().map(|(service, _)| *service) {
263 Some("git-receive-pack") => "internal.git.receive_pack",
264 Some(_) => "internal.git.fetch",
265 None => "internal.git.info_refs",
266 };
267 crate::meters::record_remote(meter, &endpoint.url, sent, 0);
268 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look269 let headers = Headers::new();
270 headers.set("user-agent", USER_AGENT)?;
A public import copies every branch and tag, so an imported library keeps its releases271 if !endpoint.authorization.is_empty() {
272 headers.set("authorization", &endpoint.authorization)?;
273 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look274 let mut init = RequestInit::new();
275 if let Some((service, body)) = body {
276 headers.set("content-type", &format!("application/x-{service}-request"))?;
277 headers.set("accept", &format!("application/x-{service}-result"))?;
278 init.with_body(Some(Uint8Array::from(body.as_slice()).into()));
279 }
280 init.with_method(method).with_headers(headers);
281 Request::new_with_init(url, &init)
282}
283
284/// Asks a server for its refs, as the given service would see them.
285async fn advertise(endpoint: &Endpoint, service: &str) -> Result<std::result::Result<Advertised, String>> {
286 let url = format!("{}/info/refs?service={service}", endpoint.url);
287 let mut response = match Fetch::Request(request(Method::Get, &url, endpoint, None)?).send().await {
288 Ok(response) => response,
289 Err(_) => return Ok(Err("The repository could not be reached.".to_owned())),
290 };
291 match response.status_code() {
292 200 => Ok(Ok(parse_advertisement(&response.bytes().await?))),
293 401 | 403 => Ok(Err("The repository refused g1t's credential.".to_owned())),
294 404 => Ok(Err("The repository was not found, or g1t may not read it.".to_owned())),
295 status => Ok(Err(format!("The repository answered {status}."))),
296 }
297}
298
299/// Fetches one pack holding `wants`, reading in pieces so that one too
300/// large is noticed before it is all held.
301async fn fetch_pack(source: &Endpoint, wants: &[String], haves: &[String]) -> Result<std::result::Result<Vec<u8>, String>> {
302 let body = upload_request(wants, haves);
303 let url = format!("{}/git-upload-pack", source.url);
304 let mut response = Fetch::Request(request(Method::Post, &url, source, Some(("git-upload-pack", body)))?)
305 .send()
306 .await?;
307 if response.status_code() != 200 {
308 return Ok(Err(format!("The source refused to send the repository ({}).", response.status_code())));
309 }
310 let mut received = Vec::new();
311 let mut stream = response.stream()?;
312 while let Some(chunk) = stream.next().await {
313 received.extend_from_slice(&chunk?);
314 if received.len() > MAX_PACK_BYTES {
315 return Ok(Err(format!(
316 "The repository is larger than {} MB, the most g1t copies at once. Push it with git instead.",
317 MAX_PACK_BYTES / 1024 / 1024
318 )));
319 }
320 }
321 Ok(unpack_sideband(&received).map_err(|_| "The source did not send a usable pack.".to_owned()))
322}
323
324/// Makes `target`'s branches and tags match `source`'s. With
325/// `Prune::No`, refs only the target has are kept. `Err` in the inner
326/// result is a reason to show a person.
327pub async fn copy(source: &Endpoint, target: &Endpoint, prune: Prune) -> Result<std::result::Result<Copied, String>> {
328 let theirs = match advertise(source, "git-upload-pack").await? {
329 Ok(refs) => refs,
330 Err(reason) => return Ok(Err(reason)),
331 };
332 let ours = match advertise(target, "git-receive-pack").await? {
333 Ok(refs) => refs,
334 Err(reason) => return Ok(Err(format!("Writing the copy failed: {reason}"))),
335 };
336 let commands = plan(&theirs, &ours, prune);
337 let mut copied = Copied {
338 head: theirs.head.clone(),
339 ..Copied::default()
340 };
341 if commands.is_empty() {
342 return Ok(Ok(copied));
343 }
344 let held: HashSet<&String> = ours.refs.values().collect();
345 let mut wants: Vec<String> = commands
346 .iter()
347 .map(|(_, _, new)| new)
348 .filter(|new| *new != ZERO_ID && !held.contains(new))
349 .cloned()
350 .collect();
351 wants.sort();
352 wants.dedup();
353 let pack = if wants.is_empty() {
354 None
355 } else {
356 let haves: Vec<String> = ours.refs.values().cloned().collect::<HashSet<_>>().into_iter().collect();
357 match fetch_pack(source, &wants, &haves).await? {
358 Ok(pack) => Some(pack),
359 Err(reason) => return Ok(Err(reason)),
360 }
361 };
362 let body = receive_request(&commands, pack);
363 let url = format!("{}/git-receive-pack", target.url);
364 let mut response = Fetch::Request(request(Method::Post, &url, target, Some(("git-receive-pack", body)))?)
365 .send()
366 .await?;
367 let report = response.bytes().await?;
368 if response.status_code() != 200 {
369 return Ok(Err(format!("The push was refused ({}).", response.status_code())));
370 }
371 let problems = refused(&report, &commands);
372 if !problems.is_empty() {
373 return Ok(Err(format!("Some refs were not updated: {}", problems.join("; "))));
374 }
375 for (name, old, new) in commands {
376 if new == ZERO_ID {
377 copied.deleted.push(name);
378 } else {
379 copied.updated.push((name, old, new));
380 }
381 }
382 Ok(Ok(copied))
383}
384
A public import copies every branch and tag, so an imported library keeps its releases385/// The pushes an import announces, one for each ref it made: the default
386/// branch first (what follows a repository, such as its Composer package,
387/// starts from it), then the other branches, then the tags.
388pub fn import_pushes(updated: &[(String, Option<String>, String)], default_branch: &str) -> Vec<(String, String)> {
389 let default = format!("refs/heads/{default_branch}");
390 let rank = |name: &str| {
391 if name == default {
392 0
393 } else if name.starts_with("refs/heads/") {
394 1
395 } else {
396 2
397 }
398 };
399 let mut pushes: Vec<(String, String)> = updated
400 .iter()
401 .filter(|(name, _, new)| (name.starts_with("refs/heads/") || name.starts_with("refs/tags/")) && new != ZERO_ID)
402 .map(|(name, _, new)| (name.clone(), new.clone()))
403 .collect();
404 pushes.sort_by(|a, b| rank(&a.0).cmp(&rank(&b.0)).then_with(|| a.0.cmp(&b.0)));
405 pushes
406}
407
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look408impl<S: GitStore> Repos<S> {
409 /// `mirror`: a mirror catching up with the host it mirrors, or a
410 /// repository pushing its refs out to one. Each branch moved on g1t is
411 /// announced as a push, so deployments and checks follow it.
412 pub(crate) async fn mirror(&self, a: MirrorArgs) -> Result<Outcome<Mirrored>> {
413 let Some(repo) = self.registry.by_id(&a.repo_id).await? else {
414 return Ok(not_found());
415 };
416 let Some(url) = import::clean_url(&a.url) else {
417 return Ok(Outcome::fail(FailureCode::Invalid, "That is not an https repository address."));
418 };
Repositories shard across git store namespaces, move between them, and can keep to the EU; a namespace can be served read-only from the self-hosted git store, rebuilt from the nightly backups (#20, #25)419 // Catching up writes: it waits for a move between namespaces (moves.rs).
420 let repo = if a.direction == MirrorDirection::Pull {
421 match self.unpaused(repo).await? {
422 Ok(repo) => repo,
423 Err((code, message)) => return Ok(Outcome::fail(code, message)),
424 }
425 } else {
426 repo
427 };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look428 let scope = match a.direction {
429 MirrorDirection::Pull => Scope::Write,
430 MirrorDirection::Push => Scope::Read,
431 };
432 let access = self.store.open(&store_key(&repo)).await?.access(scope).await?;
433 let ours = Endpoint::bearer(&access.remote, &access.token);
434 let theirs = Endpoint::github(&url, &a.token);
435 let copied = match a.direction {
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms436 MirrorDirection::Pull => {
437 let copied = copy(&theirs, &ours, Prune::Yes).await?;
438 self.refs_moved(&repo.id).await;
439 copied
440 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look441 MirrorDirection::Push => copy(&ours, &theirs, Prune::No).await?,
442 };
443 let copied = match copied {
444 Ok(copied) => copied,
445 Err(reason) => return Ok(Outcome::fail(FailureCode::Conflict, reason)),
446 };
447 if a.direction == MirrorDirection::Pull {
448 for (name, old, new) in &copied.updated {
449 if name.starts_with("refs/heads/") {
450 self.publish_push(&repo, name, old.as_deref(), new, None).await?;
451 }
452 }
453 }
454 Ok(Outcome::Ok(Mirrored {
455 updated: copied.updated.into_iter().map(|(name, _, _)| name).collect(),
456 deleted: copied.deleted,
457 }))
458 }
459}
460
461#[cfg(test)]
462mod tests {
463 use super::*;
464
465 const A: &str = "c71546fcd893ef8b0f57388b65e620d759705dda";
466 const B: &str = "4807077b296e6edbf410d55e72749d3e1170c291";
467 const C: &str = "1111111111111111111111111111111111111111";
468
469 fn advertised(refs: &[(&str, &str)]) -> Advertised {
470 Advertised {
471 refs: refs.iter().map(|(name, hash)| (name.to_string(), hash.to_string())).collect(),
472 head: None,
473 }
474 }
475
476 #[test]
477 fn the_basic_header_carries_the_token_whole() {
478 assert_eq!(base64("x-access-token:abc"), "eC1hY2Nlc3MtdG9rZW46YWJj");
479 assert_eq!(base64("a"), "YQ==");
480 assert_eq!(base64("ab"), "YWI=");
481 // GitHub's stateless installation tokens run to hundreds of
482 // characters; nothing here assumes a length.
483 let token = format!("ghs_{}", "x".repeat(516));
484 assert_eq!(token.len(), 520);
485 let endpoint = Endpoint::github("https://github.com/o/r.git/", &token);
486 assert_eq!(endpoint.url, "https://github.com/o/r.git");
487 let encoded = endpoint.authorization.strip_prefix("Basic ").unwrap();
488 assert_eq!(encoded, base64(&format!("x-access-token:{token}")));
489 assert_eq!(encoded.len(), (15 + 520usize).div_ceil(3) * 4);
490 }
491
492 #[test]
493 fn the_advertisement_keeps_branches_and_tags() {
494 let bytes = [
495 pkt_line("# service=git-upload-pack\n"),
496 b"0000".to_vec(),
497 pkt_line(&format!("{A} HEAD\0multi_ack side-band-64k symref=HEAD:refs/heads/trunk agent=git/x\n")),
498 pkt_line(&format!("{A} refs/heads/trunk\n")),
499 pkt_line(&format!("{B} refs/tags/v1\n")),
500 pkt_line(&format!("{A} refs/tags/v1^{{}}\n")),
501 pkt_line(&format!("{C} refs/pull/1/head\n")),
502 b"0000".to_vec(),
503 ]
504 .concat();
505 let parsed = parse_advertisement(&bytes);
506 assert_eq!(parsed.head.as_deref(), Some("trunk"));
507 assert_eq!(parsed, Advertised {
508 refs: advertised(&[("refs/heads/trunk", A), ("refs/tags/v1", B)]).refs,
509 head: Some("trunk".to_owned()),
510 });
511 assert_eq!(parsed.default_branch().as_deref(), Some("trunk"));
512 assert_eq!(advertised(&[("refs/heads/a", A), ("refs/heads/main", A)]).default_branch().as_deref(), Some("main"));
513 assert_eq!(advertised(&[("refs/tags/v1", A)]).default_branch(), None);
514 let empty = [pkt_line(&format!("{ZERO_ID} capabilities^{{}}\0report-status\n")), b"0000".to_vec()].concat();
515 assert!(parse_advertisement(&empty).refs.is_empty());
516 }
517
518 #[test]
A public import copies every branch and tag, so an imported library keeps its releases519 fn a_public_import_copies_every_branch_and_tag_annotated_ones_whole() {
520 // An empty repository being filled from a public source: every
521 // branch and tag is made, an annotated tag as its tag object (so it
522 // stays annotated), and the source's pull request refs and peeled
523 // lines are left out.
524 let bytes = [
525 pkt_line("# service=git-upload-pack\n"),
526 b"0000".to_vec(),
527 pkt_line(&format!("{A} HEAD\0multi_ack symref=HEAD:refs/heads/master\n")),
528 pkt_line(&format!("{A} refs/heads/master\n")),
529 pkt_line(&format!("{B} refs/heads/next\n")),
530 pkt_line(&format!("{C} refs/pull/7/head\n")),
531 pkt_line(&format!("{C} refs/tags/1.0.0\n")),
532 pkt_line(&format!("{A} refs/tags/1.0.0^{{}}\n")),
533 pkt_line(&format!("{B} refs/tags/2.0.0\n")),
534 b"0000".to_vec(),
535 ]
536 .concat();
537 let source = parse_advertisement(&bytes);
538 assert_eq!(source.default_branch().as_deref(), Some("master"), "HEAD stays the default branch");
539 let commands = plan(&source, &Advertised::default(), Prune::Yes);
540 let names: Vec<(&str, &str)> = commands.iter().map(|(name, _, new)| (name.as_str(), new.as_str())).collect();
541 assert_eq!(
542 names,
543 [("refs/heads/master", A), ("refs/heads/next", B), ("refs/tags/1.0.0", C), ("refs/tags/2.0.0", B)]
544 );
545 let pushes = import_pushes(&commands, "master");
546 let order: Vec<&str> = pushes.iter().map(|(name, _)| name.as_str()).collect();
547 assert_eq!(order, ["refs/heads/master", "refs/heads/next", "refs/tags/1.0.0", "refs/tags/2.0.0"]);
548 let pushes = import_pushes(&commands, "next");
549 assert_eq!(pushes[0].0, "refs/heads/next", "the default branch is announced first");
550 assert_eq!(Endpoint::anonymous("https://github.com/php-fig/log.git/").url, "https://github.com/php-fig/log.git");
551 assert!(Endpoint::anonymous("https://x").authorization.is_empty(), "no credential is sent");
552 }
553
554 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look555 fn a_mirror_prunes_and_a_push_out_does_not() {
556 let source = advertised(&[("refs/heads/main", A), ("refs/tags/v1", B)]);
557 let target = advertised(&[("refs/heads/main", B), ("refs/heads/old", C)]);
558 assert_eq!(plan(&source, &target, Prune::Yes), vec![
559 ("refs/heads/main".to_owned(), Some(B.to_owned()), A.to_owned()),
560 ("refs/tags/v1".to_owned(), None, B.to_owned()),
561 ("refs/heads/old".to_owned(), Some(C.to_owned()), ZERO_ID.to_owned()),
562 ]);
563 assert_eq!(plan(&source, &target, Prune::No).len(), 2);
564 assert!(plan(&source, &source, Prune::Yes).is_empty());
565 }
566
567 #[test]
568 fn a_push_of_known_objects_sends_an_empty_pack() {
569 let commands = vec![("refs/tags/v1".to_owned(), None, A.to_owned())];
570 let body = receive_request(&commands, None);
571 assert!(body.ends_with(&EMPTY_PACK));
572 let deletes = vec![("refs/heads/x".to_owned(), Some(A.to_owned()), ZERO_ID.to_owned())];
573 let body = receive_request(&deletes, None);
574 assert!(body.ends_with(b"0000"));
575 assert!(String::from_utf8_lossy(&body).contains("delete-refs"));
576 }
577
578 #[test]
579 fn wants_carry_capabilities_once() {
580 let body = String::from_utf8(upload_request(&[A.to_owned(), B.to_owned()], &[C.to_owned()])).unwrap();
581 assert_eq!(body.matches("side-band-64k").count(), 1);
582 assert!(body.contains(&format!("want {B}\n")));
583 assert!(body.contains(&format!("have {C}\n")));
584 assert!(body.ends_with("0009done\n"));
585 }
586
587 #[test]
588 fn refusals_are_reported_by_ref() {
589 let commands = vec![
590 ("refs/heads/main".to_owned(), None, A.to_owned()),
591 ("refs/heads/x".to_owned(), None, B.to_owned()),
592 ];
593 let report = [
594 pkt_line("unpack ok\n"),
595 pkt_line("ok refs/heads/main\n"),
596 pkt_line("ng refs/heads/x protected branch\n"),
597 b"0000".to_vec(),
598 ]
599 .concat();
600 assert_eq!(refused(&report, &commands), vec!["refs/heads/x: protected branch".to_owned()]);
601 let fine = [pkt_line("unpack ok\n"), pkt_line("ok refs/heads/main\n"), pkt_line("ok refs/heads/x\n")].concat();
602 assert!(refused(&fine, &commands).is_empty());
603 }
604}