g1t/services/repos/src/land.rs

150 lines5,529 bytesCodeBlame
1//! Landing an attempt: moving a repository's branch forward to a commit
2//! that so far exists only in a fork.
3//!
4//! The Artifacts binding cannot write, so this speaks git's smart HTTP
5//! protocol directly. It asks the fork for a pack holding exactly the
6//! objects the target is missing, then pushes that pack to the target
7//! unchanged. No object is parsed or rebuilt along the way.
8
9use g1t_contracts::repos::GitAccess;
10use worker::js_sys::Uint8Array;
11use worker::{Error, Fetch, Headers, Method, Request, RequestInit, Result};
12
13const ZERO_ID: &str = "0000000000000000000000000000000000000000";
14const FLUSH: &[u8] = b"0000";
15/// Side-band channels: pack data, and fatal errors.
16const PACK_BAND: u8 = 1;
17const ERROR_BAND: u8 = 3;
18
19fn pkt_line(payload: &str) -> Vec<u8> {
20 format!("{:04x}{payload}", payload.len() + 4).into_bytes()
21}
22
23async fn post(access: &GitAccess, service: &str, body: Vec<u8>) -> Result<Vec<u8>> {
24 let headers = Headers::new();
25 headers.set("authorization", &format!("Bearer {}", access.token))?;
26 headers.set("content-type", &format!("application/x-{service}-request"))?;
27 headers.set("accept", &format!("application/x-{service}-result"))?;
28 let mut init = RequestInit::new();
29 init.with_method(Method::Post)
30 .with_headers(headers)
31 .with_body(Some(Uint8Array::from(body.as_slice()).into()));
32 let request = Request::new_with_init(&format!("{}/{service}", access.remote), &init)?;
33 let mut response = Fetch::Request(request).send().await?;
34 let bytes = response.bytes().await?;
35 if response.status_code() != 200 {
36 return Err(Error::RustError(format!(
37 "{service} returned {}: {}",
38 response.status_code(),
39 String::from_utf8_lossy(&bytes)
40 )));
41 }
42 Ok(bytes)
43}
44
45/// The payloads of the pkt-lines in `bytes`, and the offset where they stop.
46fn read_pkt_lines(bytes: &[u8]) -> (Vec<&[u8]>, usize) {
47 let mut lines = Vec::new();
48 let mut position = 0;
49 while position + 4 <= bytes.len() {
50 let Some(length) = std::str::from_utf8(&bytes[position..position + 4])
51 .ok()
52 .and_then(|hex| usize::from_str_radix(hex, 16).ok())
53 else {
54 break;
55 };
56 if length < 4 {
57 // Flush, delimiter and response-end packets carry no payload.
58 position += 4;
59 continue;
60 }
61 let end = (position + length).min(bytes.len());
62 lines.push(&bytes[position + 4..end]);
63 position = end;
64 }
65 (lines, position)
66}
67
68/// A pack holding everything reachable from `want` that is not reachable
69/// from `have`.
70async fn fetch_pack(source: &GitAccess, want: &str, have: Option<&str>) -> Result<Vec<u8>> {
71 // Side-band framing puts the pack in its own channel, so its exact bytes
72 // can be recovered. Without it the response ends in a stray flush packet
73 // that a receiver rejects as junk after the pack.
74 let mut body = pkt_line(&format!("want {want} side-band-64k\n"));
75 body.extend_from_slice(FLUSH);
76 if let Some(have) = have {
77 body.extend(pkt_line(&format!("have {have}\n")));
78 }
79 body.extend(pkt_line("done\n"));
80
81 let response = post(source, "git-upload-pack", body).await?;
82 let (lines, _) = read_pkt_lines(&response);
83 let mut pack = Vec::new();
84 for line in lines {
85 match line.first() {
86 Some(&PACK_BAND) => pack.extend_from_slice(&line[1..]),
87 Some(&ERROR_BAND) => {
88 return Err(Error::RustError(format!(
89 "the fork refused the fetch: {}",
90 String::from_utf8_lossy(&line[1..])
91 )));
92 }
93 // ACK and NAK lines, and progress messages.
94 _ => {}
95 }
96 }
97 if !pack.starts_with(b"PACK") {
98 return Err(Error::RustError(format!(
99 "the fork did not send a pack: {}",
100 String::from_utf8_lossy(&response)
101 )));
102 }
103 Ok(pack)
104}
105
106/// Updates `branch` on the target from `old` to `new`, sending `pack`.
107/// `Err(reason)` in the inner result means git refused the update, for
108/// example because the branch is no longer at `old`.
109async fn push_pack(
110 target: &GitAccess,
111 branch: &str,
112 old: Option<&str>,
113 new: &str,
114 pack: Vec<u8>,
115) -> Result<std::result::Result<(), String>> {
116 let reference = format!("refs/heads/{branch}");
117 let mut body = pkt_line(&format!(
118 "{} {new} {reference}\0 report-status\n",
119 old.unwrap_or(ZERO_ID)
120 ));
121 body.extend_from_slice(FLUSH);
122 body.extend(pack);
123
124 let response = post(target, "git-receive-pack", body).await?;
125 let (lines, _) = read_pkt_lines(&response);
126 let lines: Vec<String> = lines
127 .into_iter()
128 .map(|line| String::from_utf8_lossy(line).trim_end().to_owned())
129 .collect();
130 let unpacked = lines.iter().any(|line| line == "unpack ok");
131 let updated = lines.iter().any(|line| *line == format!("ok {reference}"));
132 Ok(if unpacked && updated {
133 Ok(())
134 } else {
135 Err(lines.join("; "))
136 })
137}
138
139/// Moves `branch` on `target` from `old` to `new`, a commit that exists in
140/// `source`. The caller must have checked that `new` descends from `old`.
141pub async fn fast_forward(
142 source: &GitAccess,
143 target: &GitAccess,
144 branch: &str,
145 old: Option<&str>,
146 new: &str,
147) -> Result<std::result::Result<(), String>> {
148 let pack = fetch_pack(source, new, old).await?;
149 push_pack(target, branch, old, new, pack).await
150}