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