g1t/services/repos/src/git_http.rs

1,354 lines53,138 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.

Rust repos service with shipping; pull requests kept in the model1//! Git over HTTPS: the smart HTTP remote at `/<namespace>/<repo>.git`,
2//! proxied to the git store with a short-lived token.
3
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily4use std::cell::RefCell;
5use std::rc::Rc;
6
7use futures_util::StreamExt;
Rust repos service with shipping; pull requests kept in the model8use g1t_contracts::identity::GitCredentialsArgs;
9use g1t_contracts::repos::{GitAccess, GitService, RepoPath};
10use g1t_contracts::{FailureCode, Outcome, Viewer};
11use worker::js_sys::Uint8Array;
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily12use worker::wasm_bindgen::JsValue;
Rust repos service with shipping; pull requests kept in the model13use worker::{Fetch, Fetcher, Headers, Method, Request, RequestInit, Response, Result, Url};
14
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily15use crate::meters;
16use crate::pack_limits::{PackSizer, Violation};
17use crate::resilience::{self, Busy, Failure};
18
Rust repos service with shipping; pull requests kept in the model19const ENDPOINTS: [&str; 3] = ["info/refs", "git-upload-pack", "git-receive-pack"];
20const FORWARDED_HEADERS: [&str; 5] = [
21 "accept",
22 "content-encoding",
23 "content-type",
24 "git-protocol",
25 "user-agent",
26];
27
28/// A git request, parsed from its URL.
29pub struct GitRequest {
30 pub path: RepoPath,
31 pub endpoint: &'static str,
32 pub service: GitService,
33}
34
35/// Parses `/<namespace>/<name>[.git]/<endpoint>`, or returns `None` if the
36/// request is not git's.
37pub fn parse(url: &Url) -> Option<GitRequest> {
38 let path = url.path().strip_prefix('/')?;
39 let endpoint = ENDPOINTS
40 .into_iter()
41 .find(|endpoint| path.ends_with(&format!("/{endpoint}")))?;
42 let repo = &path[..path.len() - endpoint.len() - 1];
43 let (namespace, name) = repo.split_once('/')?;
44 let name = name.strip_suffix(".git").unwrap_or(name);
45 if namespace.is_empty() || name.is_empty() || name.contains('/') {
46 return None;
47 }
48 let service = if endpoint == "info/refs" {
49 url.query_pairs()
50 .find(|(key, _)| key == "service")
51 .map(|(_, value)| value.into_owned())?
52 } else {
53 endpoint.to_owned()
54 };
55 let service = match service.as_str() {
56 "git-upload-pack" => GitService::UploadPack,
57 "git-receive-pack" => GitService::ReceivePack,
58 _ => return None,
59 };
60 Some(GitRequest {
61 path: RepoPath {
62 namespace: namespace.to_owned(),
63 name: name.to_owned(),
64 },
65 endpoint,
66 service,
67 })
68}
69
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms70/// How long each step of a git request took, sent back to git as a
71/// `Server-Timing` header so that a slow clone shows where its time went.
72/// Step names and whole milliseconds only. The Workers clock moves only
73/// while a request waits on something, so each step is the time spent
74/// waiting on the database, another service or the git store. A note
75/// says how a step went without a duration: `refs;desc=hit-colo`.
76pub struct Timing {
77 started: u64,
78 last: u64,
79 steps: Vec<(&'static str, u64)>,
80 notes: Vec<(&'static str, &'static str)>,
81}
82
83impl Timing {
84 pub fn start() -> Self {
85 let now = g1t_kit::now_ms();
86 Self {
87 started: now,
88 last: now,
89 steps: Vec::new(),
90 notes: Vec::new(),
91 }
92 }
93
94 /// Says how `name` went: where an answer or a credential came from.
95 pub fn note(&mut self, name: &'static str, description: &'static str) {
96 self.notes.push((name, description));
97 }
98
99 /// Ends a step, named `step`, that began when the last one ended.
100 pub fn mark(&mut self, step: &'static str) {
101 let now = g1t_kit::now_ms();
102 self.steps.push((step, now.saturating_sub(self.last)));
103 self.last = now;
104 }
105
106 /// `response`, with how long each step took.
107 pub fn apply(&self, response: Response) -> Result<Response> {
108 let total = g1t_kit::now_ms().saturating_sub(self.started);
109 let headers = response.headers().clone();
110 headers.set("server-timing", &server_timing(&self.steps, &self.notes, total))?;
111 Ok(response.with_headers(headers))
112 }
113}
114
115/// A `Server-Timing` value: each step with its duration, the notes, then
116/// the total.
117fn server_timing(steps: &[(&str, u64)], notes: &[(&str, &str)], total: u64) -> String {
118 steps
119 .iter()
120 .map(|(step, ms)| format!("{step};dur={ms}"))
121 .chain(notes.iter().map(|(name, description)| format!("{name};desc={description}")))
122 .chain(std::iter::once(format!("total;dur={total}")))
123 .collect::<Vec<_>>()
124 .join(", ")
125}
126
Rust repos service with shipping; pull requests kept in the model127/// The user named by an HTTP Basic `Authorization` header, as git sends it.
128pub async fn viewer(request: &Request, identity: &Fetcher) -> Result<Viewer> {
129 let Some(header) = request.headers().get("authorization")? else {
130 return Ok(None);
131 };
132 let Some((scheme, encoded)) = header.split_once(' ') else {
133 return Ok(None);
134 };
135 if !scheme.eq_ignore_ascii_case("basic") {
136 return Ok(None);
137 }
138 let Some(decoded) = decode_base64(encoded.trim()) else {
139 return Ok(None);
140 };
141 let Some((username, secret)) = decoded.split_once(':') else {
142 return Ok(None);
143 };
144 g1t_kit::call(
145 identity,
146 "user_for_git_credentials",
147 &GitCredentialsArgs {
148 username: username.to_owned(),
149 secret: secret.to_owned(),
150 },
151 )
152 .await
153}
154
155/// Standard base64 to a UTF-8 string, or `None` if either step fails.
156fn decode_base64(input: &str) -> Option<String> {
157 let mut bytes = Vec::with_capacity(input.len() * 3 / 4);
158 let mut buffer = 0u32;
159 let mut bits = 0;
160 for byte in input.bytes().filter(|byte| *byte != b'=') {
161 let value = match byte {
162 b'A'..=b'Z' => byte - b'A',
163 b'a'..=b'z' => byte - b'a' + 26,
164 b'0'..=b'9' => byte - b'0' + 52,
165 b'+' => 62,
166 b'/' => 63,
167 _ => return None,
168 };
169 buffer = (buffer << 6) | u32::from(value);
170 bits += 6;
171 if bits >= 8 {
172 bits -= 8;
173 bytes.push((buffer >> bits) as u8);
174 }
175 }
176 String::from_utf8(bytes).ok()
177}
178
179/// The response for a refused git request. Anonymous callers are asked to
180/// authenticate, which is what makes git prompt for credentials.
181pub fn refuse<T>(outcome: Outcome<T>) -> Result<Response> {
182 let Outcome::Fail(failure) = outcome else {
183 return Response::error("Not found", 404);
184 };
185 let mut response = Response::error(failure.message, failure.code.http_status())?;
186 if failure.code == FailureCode::Unauthenticated {
187 response
188 .headers_mut()
189 .set("www-authenticate", "Basic realm=\"g1t\"")?;
190 }
191 Ok(response)
192}
193
Agents and memory, checks and conflicts, profiles, slug renames, custom domains194/// `url` with its first path segment, the workspace, replaced by `slug`.
195pub fn with_namespace(url: &Url, slug: &str) -> Option<String> {
196 let rest = url.path().strip_prefix('/')?.split_once('/')?.1;
197 let mut moved = url.clone();
198 moved.set_path(&format!("/{slug}/{rest}"));
199 Some(moved.to_string())
200}
201
202/// Where a git request for a renamed workspace's old address should go
203/// now, if its first segment is an old slug that still redirects.
204pub async fn renamed(url: &Url, identity: &Fetcher) -> Result<Option<String>> {
205 let Some(old) = url.path().strip_prefix('/').and_then(|path| path.split('/').next()) else {
206 return Ok(None);
207 };
208 let current: Option<String> = g1t_kit::call(
209 identity,
210 "resolve_slug",
211 &g1t_contracts::identity::SlugArgs {
212 slug: old.to_owned(),
213 },
214 )
215 .await?;
216 Ok(current.and_then(|slug| with_namespace(url, &slug)))
217}
218
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look219/// `url` with its repository, the first two path segments, replaced by
220/// `to`: where a request for a transferred repository's old path goes.
221/// Keeps whether the old address ended in `.git`.
222pub fn transferred(url: &Url, to: &RepoPath) -> Option<String> {
223 let path = url.path().strip_prefix('/')?;
224 let mut segments = path.splitn(3, '/');
225 let (_, name, rest) = (segments.next()?, segments.next()?, segments.next()?);
226 let suffix = if name.ends_with(".git") { ".git" } else { "" };
227 let mut moved = url.clone();
228 moved.set_path(&format!("/{}/{}{suffix}/{rest}", to.namespace, to.name));
229 Some(moved.to_string())
230}
231
Agents and memory, checks and conflicts, profiles, slug renames, custom domains232/// A permanent redirect: 301 for git's first request for refs, which it
233/// follows and then uses the new address for the rest; 308 for the
234/// others, so a POST stays a POST.
235pub fn moved(location: &str, get: bool) -> Result<Response> {
236 let mut response = Response::empty()?.with_status(if get { 301 } else { 308 });
237 response.headers_mut().set("location", location)?;
238 Ok(response)
239}
240
Events service in Rust, with RFC 3339 times and accurate push events241const ZERO_ID: &str = "0000000000000000000000000000000000000000";
242const HEADS: &str = "refs/heads/";
GitHub Actions on g1t, part one: reading workflows243const TAGS: &str = "refs/tags/";
Events service in Rust, with RFC 3339 times and accurate push events244
Agents as a team: lifecycle, merge queue, billing and a new shell245/// One ref a push asks to change.
246struct Command {
247 old: String,
248 new: String,
249 name: String,
250}
251
252/// The commands at the start of a receive-pack request, and the
253/// capabilities the client sent with the first of them.
254fn commands(body: &[u8]) -> (Vec<Command>, String) {
255 let mut commands = Vec::new();
256 let mut capabilities = String::new();
Events service in Rust, with RFC 3339 times and accurate push events257 let mut position = 0;
258 // Commands are pkt-lines; a flush packet ends them and the pack follows.
259 while let Some(length) = body
260 .get(position..position + 4)
261 .and_then(|hex| std::str::from_utf8(hex).ok())
262 .and_then(|hex| usize::from_str_radix(hex, 16).ok())
263 {
264 if length < 4 || position + length > body.len() {
265 break;
266 }
267 let line = &body[position + 4..position + length];
268 position += length;
269 // `<old> <new> <ref>`, and on the first command a NUL then capabilities.
Agents as a team: lifecycle, merge queue, billing and a new shell270 let mut halves = line.splitn(2, |byte| *byte == 0);
271 let command = halves.next().unwrap_or_default();
272 if let Some(rest) = halves.next() {
273 capabilities = String::from_utf8_lossy(rest).trim().to_owned();
274 }
275 let Ok(command) = std::str::from_utf8(command) else {
Events service in Rust, with RFC 3339 times and accurate push events276 continue;
277 };
Agents as a team: lifecycle, merge queue, billing and a new shell278 let mut parts = command.trim_end().splitn(3, ' ');
279 if let (Some(old), Some(new), Some(name)) = (parts.next(), parts.next(), parts.next()) {
280 commands.push(Command {
281 old: old.to_owned(),
282 new: new.to_owned(),
283 name: name.to_owned(),
284 });
Events service in Rust, with RFC 3339 times and accurate push events285 }
286 }
Agents as a team: lifecycle, merge queue, billing and a new shell287 (commands, capabilities)
Events service in Rust, with RFC 3339 times and accurate push events288}
289
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put290/// The bytes of the pack a receive-pack request carries: everything after
291/// the flush packet that ends its commands. Zero for a push that only
292/// deletes refs.
293pub(crate) fn pack_bytes(body: &[u8]) -> u64 {
294 let mut position = 0;
295 while let Some(length) = body
296 .get(position..position + 4)
297 .and_then(|hex| std::str::from_utf8(hex).ok())
298 .and_then(|hex| usize::from_str_radix(hex, 16).ok())
299 {
300 if length == 0 {
301 return (body.len() - position - 4) as u64;
302 }
303 if length < 4 || position + length > body.len() {
304 break;
305 }
306 position += length;
307 }
308 0
309}
310
Agents as a team: lifecycle, merge queue, billing and a new shell311fn pkt_line(payload: &[u8]) -> Vec<u8> {
312 let mut line = format!("{:04x}", payload.len() + 4).into_bytes();
313 line.extend_from_slice(payload);
314 line
315}
316
317/// What git is told when a push would change a protected branch: every ref
318/// in it is declined, with the reason against the protected one, so that
319/// git prints it beside the branch. `None` if the push leaves the branch
320/// alone, or creates it in a repository that does not have it yet.
321fn refusal(body: &[u8], protected: &str) -> Option<Vec<u8>> {
322 let (commands, capabilities) = commands(body);
323 let reference = format!("{HEADS}{protected}");
324 if !commands
325 .iter()
326 .any(|command| command.name == reference && command.old != ZERO_ID)
327 {
328 return None;
329 }
330 let mut report = pkt_line(b"unpack ok\n");
331 for command in &commands {
332 let reason = if command.name == reference {
333 format!("{protected} is protected: push a branch and open a pull request")
334 } else {
335 format!("not pushed, because the same push would change {protected}")
336 };
337 report.extend(pkt_line(
338 format!("ng {} {reason}\n", command.name).as_bytes(),
339 ));
340 }
341 report.extend_from_slice(b"0000");
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API342 Some(framed(report, &capabilities, &[]))
343}
344
345/// A report-status as git expects it: inside channel 1 when the client
346/// asked for side-band, after `messages` on channel 2, which git prints as
347/// `remote:` lines. Without side-band the messages cannot be shown.
348fn framed(report: Vec<u8>, capabilities: &str, messages: &[String]) -> Vec<u8> {
Agents as a team: lifecycle, merge queue, billing and a new shell349 let sideband = capabilities
350 .split(' ')
351 .any(|capability| capability.starts_with("side-band"));
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API352 if !sideband {
353 return report;
354 }
355 let mut body = Vec::new();
356 for message in messages {
357 let mut packet = vec![2u8];
358 packet.extend_from_slice(message.as_bytes());
359 packet.push(b'\n');
360 body.extend(pkt_line(&packet));
361 }
362 // side-band (not -64k) packets carry at most 1000 bytes.
363 for chunk in report.chunks(990) {
364 let mut packet = vec![1u8];
365 packet.extend_from_slice(chunk);
366 body.extend(pkt_line(&packet));
367 }
368 body.extend_from_slice(b"0000");
369 body
370}
371
372/// Declines every ref in a push with `reason`, explaining why in
373/// `messages`: what push protection answers when a push adds a secret.
374pub fn declined(body: &[u8], reason: &str, messages: &[String]) -> Result<Response> {
375 let (commands, capabilities) = commands(body);
376 let mut report = pkt_line(b"unpack ok\n");
377 for command in &commands {
378 report.extend(pkt_line(format!("ng {} {reason}\n", command.name).as_bytes()));
379 }
380 report.extend_from_slice(b"0000");
381 let headers = Headers::new();
382 headers.set("content-type", "application/x-git-receive-pack-result")?;
383 headers.set("cache-control", "no-cache")?;
384 Ok(Response::from_bytes(framed(report, &capabilities, messages))?.with_headers(headers))
Agents as a team: lifecycle, merge queue, billing and a new shell385}
386
GitHub Actions on g1t, part one: reading workflows387/// A branch or tag a push asks to move.
388#[derive(Debug, PartialEq, Eq)]
389pub struct Pushed {
390 /// The full ref: `refs/heads/main`, `refs/tags/v1`.
391 pub git_ref: String,
392 /// Where it pointed before; `None` for a new ref.
393 pub before: Option<String>,
394 pub after: String,
395}
396
397impl Pushed {
398 pub fn branch(&self) -> Option<&str> {
399 self.git_ref.strip_prefix(HEADS)
400 }
401}
402
403/// The branches and tags a push asks to move, read from the commands at the
404/// start of a receive-pack request. Deletions and other refs are left out.
405fn pushed_branches(body: &[u8]) -> Vec<Pushed> {
Agents as a team: lifecycle, merge queue, billing and a new shell406 commands(body)
407 .0
408 .into_iter()
409 .filter(|command| command.new != ZERO_ID)
GitHub Actions on g1t, part one: reading workflows410 .filter(|command| command.name.starts_with(HEADS) || command.name.starts_with(TAGS))
411 .map(|Command { old, new, name }| Pushed {
412 git_ref: name,
413 before: (old != ZERO_ID).then_some(old),
414 after: new,
Agents as a team: lifecycle, merge queue, billing and a new shell415 })
416 .collect()
417}
418
Events service in Rust, with RFC 3339 times and accurate push events419/// The git store's answer, and what the request asked it to change.
420pub struct Forwarded {
421 pub response: Response,
GitHub Actions on g1t, part one: reading workflows422 /// For a push: the branches and tags it asks to move, and the commits
423 /// to move them to. Whether each moved is for the caller to confirm.
424 pub pushed: Vec<Pushed>,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put425 /// For a push: the size of the pack it sent, for the storage meter.
426 pub pack_bytes: u64,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily427 /// The bytes sent to the store.
428 pub sent: u64,
429 /// Whether the answer is the store's own (not g1t's, for a store that
430 /// was busy).
431 pub from_store: bool,
432 /// For a push: whether it was too large to scan for secrets first and
433 /// was streamed to the store unscanned (`LargePushes::Unscanned`). Its
434 /// `git.push` events say so, and security scans it after it lands.
435 pub unscanned: bool,
Events service in Rust, with RFC 3339 times and accurate push events436}
437
Agents as a team: lifecycle, merge queue, billing and a new shell438/// What became of a git request.
439pub enum Push {
440 Forwarded(Forwarded),
441 /// A push to a protected branch, answered here without reaching the store.
442 Refused(Response),
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API443 /// A push that adds a secret nobody allowed, answered the same way.
444 Blocked(Response),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily445 /// A push the store could not hold: an object or the repository too
446 /// large, or too large to check. With the reason, for the audit log.
447 Declined(Response, String),
448}
449
450/// What a push may bring, checked as it arrives (pack_limits.rs).
451#[derive(Clone, Copy, Debug)]
452pub struct PushLimits {
453 /// The largest object the store holds.
454 pub max_object: u64,
455 /// What the repository holds now, as g1t counts it.
456 pub held: u64,
457 /// The most a repository may hold.
458 pub repo_limit: u64,
459 /// The largest push that is read whole and scanned for secrets.
460 pub scan_cap: usize,
461 /// What happens to a larger one.
462 pub large: LargePushes,
463}
464
465impl Default for PushLimits {
466 fn default() -> Self {
467 PushLimits {
468 max_object: crate::pack_limits::MAX_OBJECT_BYTES,
469 held: 0,
470 repo_limit: crate::pack_limits::DEFAULT_REPO_LIMIT_BYTES,
471 scan_cap: crate::secret_scan::MAX_SCANNED_PUSH,
472 large: LargePushes::Refuse,
473 }
474 }
475}
476
477/// What happens to a push larger than [`PushLimits::scan_cap`]: set by
478/// `LARGE_PUSHES`.
479#[derive(Clone, Copy, Debug, PartialEq, Eq)]
480pub enum LargePushes {
481 /// Declined (the default): push protection cannot read it, so it does
482 /// not let it in.
483 Refuse,
484 /// Streamed to the store without a scan for secrets; the size limits are
485 /// still checked as it passes.
486 Unscanned,
487}
488
489impl LargePushes {
490 pub fn from_var(value: Option<&str>) -> Self {
491 match value.map(str::trim) {
492 Some("unscanned") => LargePushes::Unscanned,
493 _ => LargePushes::Refuse,
494 }
495 }
496}
497
498/// A push the store could not hold.
499#[derive(Clone, Debug, PartialEq, Eq)]
500pub enum SizeViolation {
501 Object { size: u64 },
502 Repository { held: u64, incoming: u64, limit: u64 },
503 Unscannable { size: u64, cap: usize },
504}
505
506/// Why a push is declined for its size, as git shows it: the `ng` reason,
507/// and the lines printed as `remote:`.
508pub fn size_refusal(violation: &SizeViolation) -> (String, Vec<String>) {
509 use crate::pack_limits::{MAX_OBJECT_BYTES, PLATFORM_BODY_LIMIT_BYTES, megabytes};
510 match violation {
511 SizeViolation::Object { size } => (
512 format!("a file of {} is over the {} limit", megabytes(*size), megabytes(MAX_OBJECT_BYTES)),
513 vec![
514 format!("g1t stores files of up to {} each; this push has one of {}.", megabytes(MAX_OBJECT_BYTES), megabytes(*size)),
515 "Take it out of the commits (git rm --cached, then amend or rebase), and keep large".to_owned(),
516 "files elsewhere: https://docs.g1t.sh/guides/git/#size-limits. Nothing was pushed.".to_owned(),
517 ],
518 ),
519 SizeViolation::Repository { held, incoming, limit } => (
520 "the repository would be over its size limit".to_owned(),
521 vec![
522 format!(
523 "This repository holds about {} and the push adds {}, past the {} a repository may hold.",
524 megabytes(*held),
525 megabytes(*incoming),
526 megabytes(*limit)
527 ),
528 "Delete what you no longer need, or split it: https://docs.g1t.sh/guides/git/#size-limits.".to_owned(),
529 "Nothing was pushed.".to_owned(),
530 ],
531 ),
532 SizeViolation::Unscannable { size, cap } => (
533 "the push is too large to check for secrets".to_owned(),
534 vec![
535 format!(
536 "g1t checks every push for secrets and reads up to {} at once; this one is {}.",
537 megabytes(*cap as u64),
538 megabytes(*size)
539 ),
540 "Push in parts, oldest commits first, then push as usual:".to_owned(),
541 " git rev-list --reverse HEAD | awk 'NR % 500 == 0' | xargs -I{} git push origin {}:refs/heads/main".to_owned(),
542 format!("A push over {} is refused by the network before it reaches g1t (HTTP 413).", megabytes(PLATFORM_BODY_LIMIT_BYTES)),
543 "See https://docs.g1t.sh/guides/git/#size-limits. Nothing was pushed.".to_owned(),
544 ],
545 ),
546 }
547}
548
549/// Feeds the next chunk of a push to the size check; the first violation.
550fn check_size(sizer: &mut Option<PackSizer>, chunk: &[u8], limits: &PushLimits) -> Option<SizeViolation> {
551 let walker = sizer.as_mut()?;
552 match walker.feed(chunk) {
553 Ok(()) => {}
554 Err(Violation::ObjectTooLarge { size }) => return Some(SizeViolation::Object { size }),
555 Err(Violation::Malformed(why)) => {
556 // Not for g1t to judge: the store will say.
557 worker::console_error!("push not checked for size: {why}");
558 *sizer = None;
559 return None;
560 }
561 }
562 let incoming = walker.pack_bytes();
563 crate::pack_limits::over_repo_limit(limits.held, incoming, limits.repo_limit).then_some(SizeViolation::Repository {
564 held: limits.held,
565 incoming,
566 limit: limits.repo_limit,
567 })
568}
569
570/// A request body that streams from `stream`, for `fetch`.
571pub(crate) fn stream_body<S>(stream: S) -> Result<JsValue>
572where
573 S: futures_util::TryStream + 'static,
574 S::Ok: Into<Vec<u8>>,
575 S::Error: Into<worker::Error>,
576{
577 let response: worker::web_sys::Response = Response::from_stream(stream)?.into();
578 Ok(response.body().map_or(JsValue::NULL, Into::into))
579}
580
581/// What git is told when the store is busy: 429 or 503, with when to try
582/// again (resilience.rs).
583pub fn busy_response(busy: Busy) -> Result<Response> {
584 let response = Response::error(busy.message(), busy.status())?;
585 response.headers().set("retry-after", &busy.retry_after.to_string())?;
586 Ok(response)
587}
588
589/// The rest of a request's body, read and thrown away so that git hears
590/// the answer; how many bytes it was.
591async fn drain(stream: &mut worker::ByteStream) -> Result<u64> {
592 let mut size = 0;
593 while let Some(chunk) = stream.next().await {
594 size += chunk?.len() as u64;
595 }
596 Ok(size)
Agents as a team: lifecycle, merge queue, billing and a new shell597}
598
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look599/// One packet of a pkt-line stream: data, or a flush (`0000`), delimiter
600/// (`0001`) or response-end (`0002`) packet, kept as its four bytes.
601#[derive(Debug, PartialEq, Eq)]
602enum Packet {
603 Data(Vec<u8>),
604 Special([u8; 4]),
605}
606
607/// The packets in `bytes`, or `None` if it is not a whole pkt-line stream.
608fn packets(bytes: &[u8]) -> Option<Vec<Packet>> {
609 let mut out = Vec::new();
610 let mut position = 0;
611 while position < bytes.len() {
612 let header = bytes.get(position..position + 4)?;
613 let length = usize::from_str_radix(std::str::from_utf8(header).ok()?, 16).ok()?;
614 if length < 4 {
615 out.push(Packet::Special(header.try_into().ok()?));
616 position += 4;
617 continue;
618 }
619 out.push(Packet::Data(bytes.get(position + 4..position + length)?.to_vec()));
620 position += length;
621 }
622 Some(out)
623}
624
625fn encode(packets: &[Packet]) -> Vec<u8> {
626 let mut out = Vec::new();
627 for packet in packets {
628 match packet {
629 Packet::Data(data) => out.extend(pkt_line(data)),
630 Packet::Special(bytes) => out.extend_from_slice(bytes),
631 }
632 }
633 out
634}
635
636/// A ref advertisement (`info/refs` for upload-pack) or a protocol v2
637/// `ls-refs` answer with `HEAD` pointing at `branch`, the repository's
638/// default branch as g1t keeps it, so a clone checks it out. The git store
639/// holds the HEAD it was created with; g1t can change the default branch
640/// since. `None` when there is nothing to change: no `HEAD` line, `HEAD`
641/// already names `branch`, or `branch` is not advertised.
642pub fn with_head(body: &[u8], branch: &str) -> Option<Vec<u8>> {
643 let mut packets = packets(body)?;
644 let target = format!("{HEADS}{branch}");
645 let oid = packets.iter().find_map(|packet| {
646 let Packet::Data(data) = packet else { return None };
647 let line = data.split(|byte| *byte == 0).next()?;
648 let line = std::str::from_utf8(line).ok()?.trim_end();
649 let (oid, name) = line.split_once(' ')?;
650 // v2 lines may carry attributes after the name.
651 let name = name.split(' ').next()?;
652 (name == target).then(|| oid.to_owned())
653 })?;
654 let mut changed = false;
655 for packet in &mut packets {
656 let Packet::Data(data) = packet else { continue };
657 let text = String::from_utf8_lossy(data).into_owned();
658 let Some((_, rest)) = text.split_once(' ') else { continue };
659 if !(rest.starts_with("HEAD\0") || rest.starts_with("HEAD\n") || rest.starts_with("HEAD ") || rest == "HEAD") {
660 continue;
661 }
662 let mut line = format!("{oid} {rest}");
663 // v0: `symref=HEAD:refs/heads/<old>` among the capabilities.
664 // v2: `symref-target:refs/heads/<old>` after the name.
665 for marker in ["symref=HEAD:", "symref-target:"] {
666 if let Some(at) = line.find(marker) {
667 let start = at + marker.len();
668 let end = line[start..]
669 .find([' ', '\n', '\0'])
670 .map_or(line.len(), |offset| start + offset);
671 line.replace_range(start..end, &target);
672 }
673 }
674 changed = line != text;
675 if changed {
676 *data = line.into_bytes();
677 }
678 break;
679 }
680 changed.then(|| encode(&packets))
681}
682
683/// Whether a request to the git store is one whose answer names `HEAD`:
684/// the ref advertisement for a fetch, or a protocol v2 `ls-refs`.
685fn names_head(git: &GitRequest, body: Option<&[u8]>) -> bool {
686 if git.service != GitService::UploadPack {
687 return false;
688 }
689 match body {
690 None => git.endpoint == "info/refs",
691 Some(body) => {
692 git.endpoint == "git-upload-pack"
693 && body.windows(b"command=ls-refs".len()).any(|window| window == b"command=ls-refs")
694 }
695 }
696}
697
A fetch whose haves the store does not know gets its pack: g1t ends the store's early answer where git expects698/// Whether a request is a protocol v2 `fetch` still negotiating: it sends
699/// `have` lines and no `done`, so the answer may be acknowledgments only.
700fn negotiating(body: &[u8]) -> bool {
701 let Some(packets) = packets(body) else { return false };
702 let lines: Vec<&[u8]> = packets
703 .iter()
704 .filter_map(|packet| match packet {
705 Packet::Data(data) => Some(data.strip_suffix(b"\n").unwrap_or(data)),
706 Packet::Special(_) => None,
707 })
708 .collect();
709 lines.contains(&b"command=fetch".as_slice())
710 && lines.iter().any(|line| line.starts_with(b"have "))
711 && !lines.contains(&b"done".as_slice())
712}
713
714/// What to do with the start of a store's answer to a negotiating fetch.
715#[derive(Debug, PartialEq, Eq)]
716enum Acknowledged {
717 /// Not enough of it yet to tell.
718 NeedMore,
719 /// Send it on as it is.
720 Whole,
721 /// Acknowledgments without `ready`, followed by more sections: the
722 /// store's answer to keep is these first bytes, ended by a flush.
723 CutAt(usize),
724}
725
726/// How much of the answer to keep. The git store answers a fetch whose
727/// `have`s it does not know with `acknowledgments`, `NAK`, then a pack
728/// anyway; git refuses that ("expected no other sections to be sent after
729/// no 'ready'"), since a server that is not ready must end the response
730/// there and let the client negotiate again. Lines may be `sideband-all`
731/// framed (band 1, `\x01`).
732fn acknowledged(head: &[u8]) -> Acknowledged {
733 let mut position = 0;
734 let mut first = true;
735 loop {
736 let Some(header) = head.get(position..position + 4) else { return Acknowledged::NeedMore };
737 let Some(length) = std::str::from_utf8(header).ok().and_then(|hex| usize::from_str_radix(hex, 16).ok()) else {
738 return Acknowledged::Whole;
739 };
740 if length < 4 {
741 // The acknowledgments section's end: a delimiter means more
742 // sections follow, which only `ready` allows.
743 return match (first, header) {
744 (false, b"0001") => Acknowledged::CutAt(position),
745 _ => Acknowledged::Whole,
746 };
747 }
748 let Some(payload) = head.get(position + 4..position + length) else { return Acknowledged::NeedMore };
749 let line = payload.strip_prefix(b"\x01").unwrap_or(payload);
750 let line = line.strip_suffix(b"\n").unwrap_or(line);
751 if first && line != b"acknowledgments" {
752 return Acknowledged::Whole;
753 }
754 if line == b"ready" {
755 return Acknowledged::Whole;
756 }
757 first = false;
758 position += length;
759 }
760}
761
762/// The answer to a negotiating fetch, with the sections the store sent
763/// after acknowledgments without `ready` left off (see [`acknowledged`]).
764/// Reads only the start of the answer; the rest streams through.
765async fn without_early_pack(mut response: Response) -> Result<Response> {
766 const LOOK: usize = 64 * 1024;
767 let headers = response.headers().clone();
768 headers.delete("content-length")?;
769 let mut stream = response.stream()?;
770 let mut head = Vec::new();
771 loop {
772 match acknowledged(&head) {
773 Acknowledged::CutAt(at) => {
774 head.truncate(at);
775 // A flush ends the acknowledgments; a response-end packet
776 // ends the stateless answer, as upload-pack's does.
777 head.extend_from_slice(b"00000002");
778 return Ok(Response::from_bytes(head)?.with_headers(headers));
779 }
780 Acknowledged::Whole => break,
781 Acknowledged::NeedMore if head.len() >= LOOK => break,
782 Acknowledged::NeedMore => match stream.next().await {
783 Some(chunk) => head.extend_from_slice(&chunk?),
784 None => break,
785 },
786 }
787 }
788 let rest = futures_util::stream::once(async move { Ok::<Vec<u8>, worker::Error>(head) }).chain(stream);
789 Ok(Response::from_stream(rest)?.with_headers(headers))
790}
791
Agents as a team: lifecycle, merge queue, billing and a new shell792/// Sends the request on to the git store and returns its response as is,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily793/// unless it is a push that would change the `protected` branch, one the
794/// store could not hold (`limits`, pack_limits.rs), or one that `scan`
795/// (push protection) answers itself. A fetch's ref listing has its `HEAD`
796/// pointed at `default_branch` (see [`with_head`]). A POST's body is
797/// `read` when the caller has read it already.
798///
799/// A push is read as it arrives: up to `limits.scan_cap` is kept, to be
800/// scanned and sent on whole; past it, the push is declined, or streamed
801/// to the store unscanned (`LargePushes`), never held. Reads the store
802/// fails for a moment (429, 5xx) are tried again with backoff; a push never
803/// is. A store still busy after that is answered 429 or 503 with
804/// `Retry-After`.
805#[allow(clippy::too_many_arguments)]
Rust repos service with shipping; pull requests kept in the model806pub async fn forward(
807 mut request: Request,
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms808 read: Option<Vec<u8>>,
Rust repos service with shipping; pull requests kept in the model809 git: &GitRequest,
810 access: &GitAccess,
Agents as a team: lifecycle, merge queue, billing and a new shell811 protected: Option<&str>,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look812 default_branch: Option<&str>,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily813 limits: PushLimits,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API814 scan: impl AsyncFnOnce(&[u8]) -> Result<Option<Response>>,
Agents as a team: lifecycle, merge queue, billing and a new shell815) -> Result<Push> {
Rust repos service with shipping; pull requests kept in the model816 let headers = Headers::new();
817 headers.set("authorization", &format!("Bearer {}", access.token))?;
818 for name in FORWARDED_HEADERS {
819 if let Some(value) = request.headers().get(name)? {
820 headers.set(name, &value)?;
821 }
822 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily823 let query = request.url()?.query().map(|query| format!("?{query}")).unwrap_or_default();
824 let url = format!("{}/{}{query}", access.remote, git.endpoint);
825 let method = request.method();
826 let (namespace, _) = crate::store::locate(&crate::store::key_from_remote(&access.remote).unwrap_or_default());
827
828 if method == Method::Post && git.endpoint == "git-receive-pack" {
829 return push(request, &url, headers, protected, limits, scan, &namespace).await;
830 }
831
832 // A read: the ref advertisement, `ls-refs`, or a fetch of objects.
833 let body = match (&method, read) {
834 (Method::Post, Some(body)) => Some(body),
835 (Method::Post, None) => Some(request.bytes().await?),
836 _ => None,
837 };
838 let lists_head = match &body {
839 None => method == Method::Get && names_head(git, None),
840 Some(body) => names_head(git, Some(body)),
841 };
842 let sent = body.as_ref().map_or(0, |body| body.len() as u64);
843 let mut attempt = 0;
844 let mut response = loop {
845 let mut init = RequestInit::new();
846 init.with_method(method.clone()).with_headers(headers.clone());
847 if let Some(body) = &body {
848 init.with_body(Some(Uint8Array::from(body.as_slice()).into()));
849 }
850 let started = g1t_kit::now_ms();
851 let answered = Fetch::Request(Request::new_with_init(&url, &init)?).send().await;
852 let ms = g1t_kit::now_ms().saturating_sub(started);
853 let failure = match &answered {
854 Ok(response) => resilience::classify_status(response.status_code()),
855 Err(_) => Some(Failure::Transient),
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms856 };
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily857 let outcome = match failure {
858 None => meters::Outcome::Ok,
859 Some(Failure::RateLimited) => meters::Outcome::RateLimited,
860 Some(_) => meters::Outcome::Failed,
861 };
862 meters::record_health(&namespace, outcome, ms);
863 match (answered, failure) {
864 (Ok(response), None) => break response,
865 (answered, Some(failure)) if resilience::retry(failure, attempt) => {
866 drop(answered);
867 let wait = resilience::backoff_ms(failure, attempt, worker::js_sys::Math::random());
868 worker::Delay::from(std::time::Duration::from_millis(wait)).await;
869 attempt += 1;
Agents as a team: lifecycle, merge queue, billing and a new shell870 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily871 (_, Some(failure)) => {
872 let busy = Busy { rate_limited: failure == Failure::RateLimited, retry_after: 5 };
873 return Ok(Push::Forwarded(Forwarded {
874 response: busy_response(busy)?,
875 pushed: Vec::new(),
876 pack_bytes: 0,
877 sent,
878 from_store: false,
879 unscanned: false,
880 }));
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API881 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily882 (Err(error), None) => return Err(error),
Events service in Rust, with RFC 3339 times and accurate push events883 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily884 };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look885 if let (true, Some(branch)) = (lists_head, default_branch)
886 && response.status_code() == 200
887 {
888 let headers = response.headers().clone();
889 headers.delete("content-length")?;
890 let body = response.bytes().await?;
891 let body = with_head(&body, branch).unwrap_or(body);
892 response = Response::from_bytes(body)?.with_headers(headers);
893 }
A fetch whose haves the store does not know gets its pack: g1t ends the store's early answer where git expects894 if git.endpoint == "git-upload-pack"
895 && response.status_code() == 200
896 && body.as_deref().is_some_and(negotiating)
897 {
898 response = without_early_pack(response).await?;
899 }
Agents as a team: lifecycle, merge queue, billing and a new shell900 Ok(Push::Forwarded(Forwarded {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look901 response,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily902 pushed: Vec::new(),
903 pack_bytes: 0,
904 sent,
905 from_store: true,
906 unscanned: false,
907 }))
908}
909
910/// A receive-pack request; see [`forward`].
911#[allow(clippy::too_many_arguments)]
912async fn push(
913 mut request: Request,
914 url: &str,
915 headers: Headers,
916 protected: Option<&str>,
917 limits: PushLimits,
918 scan: impl AsyncFnOnce(&[u8]) -> Result<Option<Response>>,
919 namespace: &str,
920) -> Result<Push> {
921 let mut stream = request.stream()?;
922 let mut head: Vec<u8> = Vec::new();
923 let mut sizer = Some(PackSizer::new(limits.max_object));
924 let mut violation = None;
925 let mut ended = false;
926 while head.len() <= limits.scan_cap {
927 match stream.next().await {
928 Some(chunk) => {
929 let chunk = chunk?;
930 if violation.is_none() {
931 violation = check_size(&mut sizer, &chunk, &limits);
932 }
933 head.extend_from_slice(&chunk);
934 }
935 None => {
936 ended = true;
937 break;
938 }
939 }
940 }
941 let report_headers = || -> Result<Headers> {
942 let headers = Headers::new();
943 headers.set("content-type", "application/x-git-receive-pack-result")?;
944 headers.set("cache-control", "no-cache")?;
945 Ok(headers)
946 };
947 if let Some(report) = protected.and_then(|branch| refusal(&head, branch)) {
948 if !ended {
949 drain(&mut stream).await?;
950 }
951 return Ok(Push::Refused(Response::from_bytes(report)?.with_headers(report_headers()?)));
952 }
953 if !ended && limits.large == LargePushes::Refuse && violation.is_none() {
954 let size = head.len() as u64 + drain(&mut stream).await?;
955 violation = Some(SizeViolation::Unscannable { size, cap: limits.scan_cap });
956 ended = true;
957 }
958 if let Some(violation) = violation {
959 if !ended {
960 drain(&mut stream).await?;
961 }
962 let (reason, messages) = size_refusal(&violation);
963 return Ok(Push::Declined(declined(&head, &reason, &messages)?, reason));
964 }
965 let pushed = pushed_branches(&head);
966 let mut init = RequestInit::new();
967 init.with_method(Method::Post).with_headers(headers);
968 let started = g1t_kit::now_ms();
969 let (answered, pack_bytes, sent, unscanned) = if ended {
970 if let Some(response) = scan(&head).await? {
971 return Ok(Push::Blocked(response));
972 }
973 let pack = pack_bytes(&head);
974 let sent = head.len() as u64;
975 init.with_body(Some(Uint8Array::from(head.as_slice()).into()));
976 drop(head);
977 (Fetch::Request(Request::new_with_init(url, &init)?).send().await, pack, sent, false)
978 } else {
979 // Larger than can be scanned, and let through unscanned: streamed,
980 // with the size limits checked as it passes. A violation ends the
981 // stream before the pack does, so the store refuses it whole.
982 worker::console_warn!("a push of more than {} bytes goes to the store unscanned", limits.scan_cap);
983 let commands = head.iter().take(64 * 1024).copied().collect::<Vec<u8>>();
984 let found: Rc<RefCell<Option<SizeViolation>>> = Rc::default();
985 let walked = Rc::new(RefCell::new((sizer, 0u64)));
986 let rest = {
987 let found = found.clone();
988 let walked = walked.clone();
989 stream.map(move |chunk| {
990 let chunk = chunk?;
991 let mut walked = walked.borrow_mut();
992 walked.1 += chunk.len() as u64;
993 if let Some(violation) = check_size(&mut walked.0, &chunk, &limits) {
994 *found.borrow_mut() = Some(violation);
995 return Err(worker::Error::RustError("push over the size limit".into()));
996 }
997 Ok(chunk)
998 })
999 };
1000 let first = head.len() as u64;
1001 let body = futures_util::stream::once(async move { Ok::<Vec<u8>, worker::Error>(head) }).chain(rest);
1002 init.with_body(Some(stream_body(body)?));
1003 let answered = Fetch::Request(Request::new_with_init(url, &init)?).send().await;
1004 if let Some(violation) = found.borrow_mut().take() {
1005 let (reason, messages) = size_refusal(&violation);
1006 return Ok(Push::Declined(declined(&commands, &reason, &messages)?, reason));
1007 }
1008 let walked = walked.borrow();
1009 let pack = walked.0.as_ref().map_or_else(|| pack_bytes(&commands), PackSizer::pack_bytes);
1010 (answered, pack, first + walked.1, true)
1011 };
1012 let ms = g1t_kit::now_ms().saturating_sub(started);
1013 let failure = match &answered {
1014 Ok(response) => resilience::classify_status(response.status_code()),
1015 Err(_) => Some(Failure::Transient),
1016 };
1017 meters::record_health(
1018 namespace,
1019 match failure {
1020 None => meters::Outcome::Ok,
1021 Some(Failure::RateLimited) => meters::Outcome::RateLimited,
1022 Some(_) => meters::Outcome::Failed,
1023 },
1024 ms,
1025 );
1026 // A push is never tried again: the store may have taken it.
1027 let response = match (answered, failure) {
1028 (Ok(response), None) => response,
1029 (Ok(response), Some(Failure::RateLimited)) => {
1030 drop(response);
1031 busy_response(Busy { rate_limited: true, retry_after: 5 })?
1032 }
1033 (Ok(response), Some(_)) => response,
1034 (Err(error), _) => {
1035 worker::console_error!("a push did not reach the store: {error}");
1036 busy_response(Busy { rate_limited: false, retry_after: 5 })?
1037 }
1038 };
1039 Ok(Push::Forwarded(Forwarded {
1040 response,
Events service in Rust, with RFC 3339 times and accurate push events1041 pushed,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1042 pack_bytes,
1043 sent,
1044 from_store: true,
1045 unscanned,
Agents as a team: lifecycle, merge queue, billing and a new shell1046 }))
Events service in Rust, with RFC 3339 times and accurate push events1047}
1048
1049#[cfg(test)]
1050mod tests {
A fetch whose haves the store does not know gets its pack: g1t ends the store's early answer where git expects1051 use super::{Acknowledged, Pushed, RepoPath, Url, ZERO_ID, acknowledged, framed, negotiating, pack_bytes, pushed_branches, refusal, server_timing, transferred, with_head, with_namespace};
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms1052
1053 #[test]
1054 fn server_timing_names_each_step_and_the_total() {
1055 assert_eq!(
1056 server_timing(&[("repo", 12), ("token", 0), ("store", 140)], &[], 153),
1057 "repo;dur=12, token;dur=0, store;dur=140, total;dur=153"
1058 );
1059 assert_eq!(server_timing(&[], &[], 3), "total;dur=3");
1060 assert_eq!(
1061 server_timing(&[("repo", 1), ("cache", 2)], &[("refs", "hit-colo")], 4),
1062 "repo;dur=1, cache;dur=2, refs;desc=hit-colo, total;dur=4"
1063 );
1064 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1065
1066 #[test]
1067 fn a_renamed_repository_redirects_to_its_new_name() {
1068 // A rename keeps the old path in the same table as a transfer, so
1069 // the old remote is sent to the new name the same way.
1070 let to = RepoPath {
1071 namespace: "acme".into(),
1072 name: "booster".into(),
1073 };
1074 let url = Url::parse("https://g1t.sh/acme/rocket.git/info/refs?service=git-upload-pack").unwrap();
1075 assert_eq!(
1076 transferred(&url, &to).as_deref(),
1077 Some("https://g1t.sh/acme/booster.git/info/refs?service=git-upload-pack")
1078 );
1079 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1080
1081 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1082 fn head_follows_the_default_branch_in_a_v0_advertisement() {
1083 let main = "1111111111111111111111111111111111111111";
1084 let trunk = "2222222222222222222222222222222222222222";
1085 let body = [
1086 pkt("# service=git-upload-pack\n"),
1087 b"0000".to_vec(),
1088 pkt(&format!("{main} HEAD\0multi_ack symref=HEAD:refs/heads/main agent=git/2\n")),
1089 pkt(&format!("{main} refs/heads/main\n")),
1090 pkt(&format!("{trunk} refs/heads/trunk\n")),
1091 b"0000".to_vec(),
1092 ]
1093 .concat();
1094 let changed = String::from_utf8(with_head(&body, "trunk").unwrap()).unwrap();
1095 assert!(changed.contains(&format!("{trunk} HEAD\0multi_ack symref=HEAD:refs/heads/trunk agent=git/2\n")));
1096 assert!(changed.contains(&format!("{main} refs/heads/main\n")));
1097 assert!(changed.starts_with("001e# service=git-upload-pack\n0000"));
1098 // Already right, or a branch it does not have: left alone.
1099 assert!(with_head(&body, "main").is_none());
1100 assert!(with_head(&body, "gone").is_none());
1101 }
1102
1103 #[test]
1104 fn head_follows_the_default_branch_in_a_v2_listing() {
1105 let main = "1111111111111111111111111111111111111111";
1106 let trunk = "2222222222222222222222222222222222222222";
1107 let body = [
1108 pkt(&format!("{main} HEAD symref-target:refs/heads/main\n")),
1109 pkt(&format!("{main} refs/heads/main\n")),
1110 pkt(&format!("{trunk} refs/heads/trunk\n")),
1111 b"0000".to_vec(),
1112 ]
1113 .concat();
1114 let changed = String::from_utf8(with_head(&body, "trunk").unwrap()).unwrap();
1115 assert!(changed.starts_with(&String::from_utf8(pkt(&format!("{trunk} HEAD symref-target:refs/heads/trunk\n"))).unwrap()));
1116 assert!(changed.ends_with("0000"));
1117 // Without symrefs asked for, only the commit changes.
1118 let plain = [pkt(&format!("{main} HEAD\n")), pkt(&format!("{trunk} refs/heads/trunk\n")), b"0000".to_vec()].concat();
1119 let changed = String::from_utf8(with_head(&plain, "trunk").unwrap()).unwrap();
1120 assert!(changed.starts_with(&format!("0032{trunk} HEAD\n")));
1121 }
1122
1123 #[test]
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1124 fn a_push_is_measured_by_the_pack_after_its_commands() {
1125 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
1126 let new = "4807077b296e6edbf410d55e72749d3e1170c291";
1127 let pack = b"PACK\0\0\0\x02\0\0\0\0rest-of-pack";
1128 let body = [
1129 pkt(&format!("{old} {new} refs/heads/main\0 report-status\n")),
1130 b"0000".to_vec(),
1131 pack.to_vec(),
1132 ]
1133 .concat();
1134 assert_eq!(pack_bytes(&body), pack.len() as u64);
1135 // Only deletions: no pack.
1136 let body = [pkt(&format!("{old} {ZERO_ID} refs/heads/gone\n")), b"0000".to_vec()].concat();
1137 assert_eq!(pack_bytes(&body), 0);
1138 assert_eq!(pack_bytes(b"garbage"), 0);
1139 }
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1140
1141 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1142 fn a_transferred_repository_keeps_the_rest_of_the_address() {
1143 let to = RepoPath {
1144 namespace: "flagon-io".into(),
1145 name: "g1t".into(),
1146 };
1147 let url = Url::parse("https://g1t.sh/syntaqx/g1t.git/info/refs?service=git-receive-pack").unwrap();
1148 assert_eq!(
1149 transferred(&url, &to).as_deref(),
1150 Some("https://g1t.sh/flagon-io/g1t.git/info/refs?service=git-receive-pack")
1151 );
1152 let url = Url::parse("https://g1t.sh/syntaqx/g1t/git-upload-pack").unwrap();
1153 assert_eq!(
1154 transferred(&url, &to).as_deref(),
1155 Some("https://g1t.sh/flagon-io/g1t/git-upload-pack")
1156 );
1157 }
1158
1159 #[test]
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1160 fn a_renamed_workspace_keeps_the_rest_of_the_address() {
1161 let url = worker::Url::parse(
1162 "https://g1t.sh/acme/rocket.git/info/refs?service=git-upload-pack",
1163 )
1164 .unwrap();
1165 assert_eq!(
1166 with_namespace(&url, "acme-inc").as_deref(),
1167 Some("https://g1t.sh/acme-inc/rocket.git/info/refs?service=git-upload-pack")
1168 );
1169 let bare = worker::Url::parse("https://g1t.sh/acme").unwrap();
1170 assert_eq!(with_namespace(&bare, "acme-inc"), None);
1171 }
Events service in Rust, with RFC 3339 times and accurate push events1172
1173 fn pkt(payload: &str) -> Vec<u8> {
1174 format!("{:04x}{payload}", payload.len() + 4).into_bytes()
1175 }
1176
A fetch whose haves the store does not know gets its pack: g1t ends the store's early answer where git expects1177 fn joined(parts: &[&[u8]]) -> Vec<u8> {
1178 parts.concat()
1179 }
1180
1181 #[test]
1182 fn a_fetch_with_haves_and_no_done_is_negotiating() {
1183 let request = |lines: &[&str]| {
1184 let mut body = joined(&[&pkt("command=fetch\n"), &pkt("object-format=sha1\n"), b"0001"]);
1185 for line in lines {
1186 body.extend(pkt(&format!("{line}\n")));
1187 }
1188 body.extend(b"0000");
1189 body
1190 };
1191 let want = "want 8407eba58b925619274d012258c2b474a5dbf012";
1192 let have = "have 55cd670a89a80df4fa9d9f0244c44fbd2ed1db8b";
1193 assert!(negotiating(&request(&["deepen 1", want, have])));
1194 assert!(!negotiating(&request(&[want, have, "done"])));
1195 assert!(!negotiating(&request(&[want, "done"])));
1196 let ls_refs = joined(&[&pkt("command=ls-refs\n"), b"0001", &pkt("have nothing\n"), b"0000"]);
1197 assert!(!negotiating(&ls_refs));
1198 }
1199
1200 #[test]
1201 fn acknowledgments_without_ready_end_the_answer() {
1202 // What the store sent a shallow fetch whose only `have` it did not
1203 // know, sideband-all framed: a NAK, then a pack anyway.
1204 let answer = joined(&[
1205 &pkt("\x01acknowledgments\n"),
1206 &pkt("\x01NAK\n"),
1207 b"0001",
1208 &pkt("\x01shallow-info\n"),
1209 &pkt("\x01shallow 8407eba58b925619274d012258c2b474a5dbf012\n"),
1210 b"0001",
1211 &pkt("\x01packfile\n"),
1212 ]);
1213 let cut = joined(&[&pkt("\x01acknowledgments\n"), &pkt("\x01NAK\n")]).len();
1214 assert_eq!(acknowledged(&answer), Acknowledged::CutAt(cut));
1215 // Not yet at the section's end.
1216 assert_eq!(acknowledged(&answer[..cut - 2]), Acknowledged::NeedMore);
1217 assert_eq!(acknowledged(&answer[..cut]), Acknowledged::NeedMore);
1218 // Without sideband framing too.
1219 let plain = joined(&[&pkt("acknowledgments\n"), &pkt("ACK abc\n"), b"0001", &pkt("packfile\n")]);
1220 assert!(matches!(acknowledged(&plain), Acknowledged::CutAt(_)));
1221 }
1222
1223 #[test]
1224 fn a_ready_store_or_a_plain_pack_streams_through() {
1225 let ready = joined(&[
1226 &pkt("\x01acknowledgments\n"),
1227 &pkt("\x01ACK bab14ff1b6d9c4918100098009747d776759a967\n"),
1228 &pkt("\x01ready\n"),
1229 b"0001",
1230 &pkt("\x01packfile\n"),
1231 ]);
1232 assert_eq!(acknowledged(&ready), Acknowledged::Whole);
1233 // Acknowledgments only, ended by a flush: already right.
1234 let only = joined(&[&pkt("acknowledgments\n"), &pkt("NAK\n"), b"0000"]);
1235 assert_eq!(acknowledged(&only), Acknowledged::Whole);
1236 let pack = joined(&[&pkt("\x01packfile\n"), b"0000"]);
1237 assert_eq!(acknowledged(&pack), Acknowledged::Whole);
1238 assert_eq!(acknowledged(b"00"), Acknowledged::NeedMore);
1239 }
1240
Events service in Rust, with RFC 3339 times and accurate push events1241 #[test]
1242 fn pushed_branches_are_read_from_the_commands() {
1243 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
1244 let new = "4807077b296e6edbf410d55e72749d3e1170c291";
1245 let body = [
1246 pkt(&format!(
1247 "{old} {new} refs/heads/main\0 report-status side-band-64k\n"
1248 )),
1249 pkt(&format!("{ZERO_ID} {new} refs/heads/feature/x\n")),
1250 pkt(&format!("{old} {ZERO_ID} refs/heads/gone\n")),
1251 pkt(&format!("{ZERO_ID} {new} refs/tags/v1\n")),
1252 b"0000".to_vec(),
1253 b"PACK\0\0\0\x02\0\0\0\0".to_vec(),
1254 ]
1255 .concat();
1256 assert_eq!(
1257 pushed_branches(&body),
1258 [
GitHub Actions on g1t, part one: reading workflows1259 Pushed {
1260 git_ref: "refs/heads/main".to_owned(),
1261 before: Some(old.to_owned()),
1262 after: new.to_owned()
1263 },
1264 Pushed {
1265 git_ref: "refs/heads/feature/x".to_owned(),
1266 before: None,
1267 after: new.to_owned()
1268 },
1269 Pushed {
1270 git_ref: "refs/tags/v1".to_owned(),
1271 before: None,
1272 after: new.to_owned()
1273 },
Events service in Rust, with RFC 3339 times and accurate push events1274 ]
1275 );
1276 }
1277
1278 #[test]
Agents as a team: lifecycle, merge queue, billing and a new shell1279 fn a_push_to_a_protected_branch_is_declined_with_the_reason() {
1280 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
1281 let new = "4807077b296e6edbf410d55e72749d3e1170c291";
1282 let body = [
1283 pkt(&format!("{old} {new} refs/heads/main\0 report-status\n")),
1284 pkt(&format!("{ZERO_ID} {new} refs/heads/feature\n")),
1285 b"0000".to_vec(),
1286 ]
1287 .concat();
1288 let report = String::from_utf8(refusal(&body, "main").unwrap()).unwrap();
1289 assert!(report.starts_with("000eunpack ok\n"));
1290 assert!(report.contains("ng refs/heads/main main is protected"));
1291 assert!(report.contains("ng refs/heads/feature not pushed"));
1292 assert!(report.ends_with("0000"));
1293 }
1294
1295 #[test]
1296 fn the_report_is_framed_for_a_client_that_asked_for_side_band() {
1297 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
1298 let body = [
1299 pkt(&format!(
1300 "{old} {ZERO_ID} refs/heads/main\0 report-status side-band-64k\n"
1301 )),
1302 b"0000".to_vec(),
1303 ]
1304 .concat();
1305 let report = refusal(&body, "main").unwrap();
1306 // A length, then channel 1, then the report itself.
1307 assert_eq!(report[4], 1);
1308 assert_eq!(&report[5..18], b"000eunpack ok");
1309 assert!(report.ends_with(b"00000000"));
1310 }
1311
1312 #[test]
1313 fn other_branches_and_a_first_push_are_let_through() {
1314 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
1315 let new = "4807077b296e6edbf410d55e72749d3e1170c291";
1316 let feature = [
1317 pkt(&format!("{old} {new} refs/heads/feature\0 report-status\n")),
1318 b"0000".to_vec(),
1319 ]
1320 .concat();
1321 assert!(refusal(&feature, "main").is_none());
1322 // An empty repository has to be able to receive its first commits.
1323 let first = [
1324 pkt(&format!(
1325 "{ZERO_ID} {new} refs/heads/main\0 report-status\n"
1326 )),
1327 b"0000".to_vec(),
1328 ]
1329 .concat();
1330 assert!(refusal(&first, "main").is_none());
1331 }
1332
1333 #[test]
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API1334 fn a_blocked_push_explains_itself_on_the_progress_channel() {
1335 let report = b"000eunpack ok\n0000".to_vec();
1336 let messages = vec!["g1t found a secret in this push, so nothing was pushed.".to_owned()];
1337 let body = framed(report.clone(), "report-status side-band-64k", &messages);
1338 // Channel 2 first, which git prints as `remote:` lines.
1339 assert_eq!(body[4], 2);
1340 assert!(String::from_utf8_lossy(&body).contains("so nothing was pushed.\n"));
1341 let at = body.windows(5).position(|w| w == b"000eu").unwrap();
1342 assert_eq!(body[at - 1], 1);
1343 assert!(body.ends_with(b"0000"));
1344 // A client without side-band gets the bare report.
1345 assert_eq!(framed(report.clone(), "report-status", &messages), report);
1346 }
1347
1348 #[test]
Events service in Rust, with RFC 3339 times and accurate push events1349 fn a_fetch_request_names_no_branches() {
1350 assert!(
1351 pushed_branches(b"0032want c71546fcd893ef8b0f57388b65e620d759705dda\n0000").is_empty()
1352 );
1353 }
Rust repos service with shipping; pull requests kept in the model1354}