Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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