Skip to content
1,375 linesCodeBlameRaw

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
Rulesets on push: refused with the ruleset and rule named, commits read317/// The branches and tags a push asks to change, as rules see them: the
318/// full ref, where it pointed (`None`: it is created) and where it will
319/// (`None`: it is deleted).
320pub(crate) fn ref_updates(body: &[u8]) -> Vec<(String, Option<String>, Option<String>)> {
321 commands(body)
322 .0
323 .into_iter()
324 .filter(|command| command.name.starts_with(HEADS) || command.name.starts_with(TAGS))
325 .map(|Command { old, new, name }| (name, (old != ZERO_ID).then_some(old), (new != ZERO_ID).then_some(new)))
326 .collect()
327}
328
329/// A report-status answer, as git expects it.
330pub(crate) fn report_response(report: Vec<u8>) -> Result<Response> {
331 let headers = Headers::new();
332 headers.set("content-type", "application/x-git-receive-pack-result")?;
333 headers.set("cache-control", "no-cache")?;
334 Ok(Response::from_bytes(report)?.with_headers(headers))
335}
336
Agents as a team: lifecycle, merge queue, billing and a new shell337/// What git is told when a push would change a protected branch: every ref
338/// in it is declined, with the reason against the protected one, so that
339/// git prints it beside the branch. `None` if the push leaves the branch
Rulesets on push: refused with the ruleset and rule named, commits read340/// alone, or creates it in a repository that does not have it yet. Only
341/// where rulesets cannot be read (an installation without the work
342/// service); rulesets decide everywhere else (rules.rs).
343pub(crate) fn refusal(body: &[u8], protected: &str) -> Option<Vec<u8>> {
Agents as a team: lifecycle, merge queue, billing and a new shell344 let (commands, capabilities) = commands(body);
345 let reference = format!("{HEADS}{protected}");
346 if !commands
347 .iter()
348 .any(|command| command.name == reference && command.old != ZERO_ID)
349 {
350 return None;
351 }
352 let mut report = pkt_line(b"unpack ok\n");
353 for command in &commands {
354 let reason = if command.name == reference {
355 format!("{protected} is protected: push a branch and open a pull request")
356 } else {
357 format!("not pushed, because the same push would change {protected}")
358 };
359 report.extend(pkt_line(
360 format!("ng {} {reason}\n", command.name).as_bytes(),
361 ));
362 }
363 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 API364 Some(framed(report, &capabilities, &[]))
365}
366
367/// A report-status as git expects it: inside channel 1 when the client
368/// asked for side-band, after `messages` on channel 2, which git prints as
369/// `remote:` lines. Without side-band the messages cannot be shown.
370fn framed(report: Vec<u8>, capabilities: &str, messages: &[String]) -> Vec<u8> {
Agents as a team: lifecycle, merge queue, billing and a new shell371 let sideband = capabilities
372 .split(' ')
373 .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 API374 if !sideband {
375 return report;
376 }
377 let mut body = Vec::new();
378 for message in messages {
379 let mut packet = vec![2u8];
380 packet.extend_from_slice(message.as_bytes());
381 packet.push(b'\n');
382 body.extend(pkt_line(&packet));
383 }
384 // side-band (not -64k) packets carry at most 1000 bytes.
385 for chunk in report.chunks(990) {
386 let mut packet = vec![1u8];
387 packet.extend_from_slice(chunk);
388 body.extend(pkt_line(&packet));
389 }
390 body.extend_from_slice(b"0000");
391 body
392}
393
394/// Declines every ref in a push with `reason`, explaining why in
395/// `messages`: what push protection answers when a push adds a secret.
396pub fn declined(body: &[u8], reason: &str, messages: &[String]) -> Result<Response> {
397 let (commands, capabilities) = commands(body);
398 let mut report = pkt_line(b"unpack ok\n");
399 for command in &commands {
400 report.extend(pkt_line(format!("ng {} {reason}\n", command.name).as_bytes()));
401 }
402 report.extend_from_slice(b"0000");
403 let headers = Headers::new();
404 headers.set("content-type", "application/x-git-receive-pack-result")?;
405 headers.set("cache-control", "no-cache")?;
406 Ok(Response::from_bytes(framed(report, &capabilities, messages))?.with_headers(headers))
Agents as a team: lifecycle, merge queue, billing and a new shell407}
408
GitHub Actions on g1t, part one: reading workflows409/// A branch or tag a push asks to move.
410#[derive(Debug, PartialEq, Eq)]
411pub struct Pushed {
412 /// The full ref: `refs/heads/main`, `refs/tags/v1`.
413 pub git_ref: String,
414 /// Where it pointed before; `None` for a new ref.
415 pub before: Option<String>,
416 pub after: String,
417}
418
419impl Pushed {
420 pub fn branch(&self) -> Option<&str> {
421 self.git_ref.strip_prefix(HEADS)
422 }
423}
424
425/// The branches and tags a push asks to move, read from the commands at the
426/// start of a receive-pack request. Deletions and other refs are left out.
427fn pushed_branches(body: &[u8]) -> Vec<Pushed> {
Agents as a team: lifecycle, merge queue, billing and a new shell428 commands(body)
429 .0
430 .into_iter()
431 .filter(|command| command.new != ZERO_ID)
GitHub Actions on g1t, part one: reading workflows432 .filter(|command| command.name.starts_with(HEADS) || command.name.starts_with(TAGS))
433 .map(|Command { old, new, name }| Pushed {
434 git_ref: name,
435 before: (old != ZERO_ID).then_some(old),
436 after: new,
Agents as a team: lifecycle, merge queue, billing and a new shell437 })
438 .collect()
439}
440
Events service in Rust, with RFC 3339 times and accurate push events441/// The git store's answer, and what the request asked it to change.
442pub struct Forwarded {
443 pub response: Response,
GitHub Actions on g1t, part one: reading workflows444 /// For a push: the branches and tags it asks to move, and the commits
445 /// to move them to. Whether each moved is for the caller to confirm.
446 pub pushed: Vec<Pushed>,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put447 /// For a push: the size of the pack it sent, for the storage meter.
448 pub pack_bytes: u64,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily449 /// The bytes sent to the store.
450 pub sent: u64,
451 /// Whether the answer is the store's own (not g1t's, for a store that
452 /// was busy).
453 pub from_store: bool,
454 /// For a push: whether it was too large to scan for secrets first and
455 /// was streamed to the store unscanned (`LargePushes::Unscanned`). Its
456 /// `git.push` events say so, and security scans it after it lands.
457 pub unscanned: bool,
Events service in Rust, with RFC 3339 times and accurate push events458}
459
Agents as a team: lifecycle, merge queue, billing and a new shell460/// What became of a git request.
461pub enum Push {
462 Forwarded(Forwarded),
Rulesets on push: refused with the ruleset and rule named, commits read463 /// A push the rules of its branches or tags refuse (rules.rs), answered
464 /// here without reaching the store.
Agents as a team: lifecycle, merge queue, billing and a new shell465 Refused(Response),
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API466 /// A push that adds a secret nobody allowed, answered the same way.
467 Blocked(Response),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily468 /// A push the store could not hold: an object or the repository too
469 /// large, or too large to check. With the reason, for the audit log.
470 Declined(Response, String),
471}
472
473/// What a push may bring, checked as it arrives (pack_limits.rs).
474#[derive(Clone, Copy, Debug)]
475pub struct PushLimits {
476 /// The largest object the store holds.
477 pub max_object: u64,
478 /// What the repository holds now, as g1t counts it.
479 pub held: u64,
480 /// The most a repository may hold.
481 pub repo_limit: u64,
482 /// The largest push that is read whole and scanned for secrets.
483 pub scan_cap: usize,
484 /// What happens to a larger one.
485 pub large: LargePushes,
486}
487
488impl Default for PushLimits {
489 fn default() -> Self {
490 PushLimits {
491 max_object: crate::pack_limits::MAX_OBJECT_BYTES,
492 held: 0,
493 repo_limit: crate::pack_limits::DEFAULT_REPO_LIMIT_BYTES,
494 scan_cap: crate::secret_scan::MAX_SCANNED_PUSH,
495 large: LargePushes::Refuse,
496 }
497 }
498}
499
500/// What happens to a push larger than [`PushLimits::scan_cap`]: set by
501/// `LARGE_PUSHES`.
502#[derive(Clone, Copy, Debug, PartialEq, Eq)]
503pub enum LargePushes {
504 /// Declined (the default): push protection cannot read it, so it does
505 /// not let it in.
506 Refuse,
507 /// Streamed to the store without a scan for secrets; the size limits are
508 /// still checked as it passes.
509 Unscanned,
510}
511
512impl LargePushes {
513 pub fn from_var(value: Option<&str>) -> Self {
514 match value.map(str::trim) {
515 Some("unscanned") => LargePushes::Unscanned,
516 _ => LargePushes::Refuse,
517 }
518 }
519}
520
521/// A push the store could not hold.
522#[derive(Clone, Debug, PartialEq, Eq)]
523pub enum SizeViolation {
524 Object { size: u64 },
525 Repository { held: u64, incoming: u64, limit: u64 },
526 Unscannable { size: u64, cap: usize },
527}
528
529/// Why a push is declined for its size, as git shows it: the `ng` reason,
530/// and the lines printed as `remote:`.
531pub fn size_refusal(violation: &SizeViolation) -> (String, Vec<String>) {
532 use crate::pack_limits::{MAX_OBJECT_BYTES, PLATFORM_BODY_LIMIT_BYTES, megabytes};
533 match violation {
534 SizeViolation::Object { size } => (
535 format!("a file of {} is over the {} limit", megabytes(*size), megabytes(MAX_OBJECT_BYTES)),
536 vec![
537 format!("g1t stores files of up to {} each; this push has one of {}.", megabytes(MAX_OBJECT_BYTES), megabytes(*size)),
538 "Take it out of the commits (git rm --cached, then amend or rebase), and keep large".to_owned(),
539 "files elsewhere: https://docs.g1t.sh/guides/git/#size-limits. Nothing was pushed.".to_owned(),
540 ],
541 ),
542 SizeViolation::Repository { held, incoming, limit } => (
543 "the repository would be over its size limit".to_owned(),
544 vec![
545 format!(
546 "This repository holds about {} and the push adds {}, past the {} a repository may hold.",
547 megabytes(*held),
548 megabytes(*incoming),
549 megabytes(*limit)
550 ),
551 "Delete what you no longer need, or split it: https://docs.g1t.sh/guides/git/#size-limits.".to_owned(),
552 "Nothing was pushed.".to_owned(),
553 ],
554 ),
555 SizeViolation::Unscannable { size, cap } => (
556 "the push is too large to check for secrets".to_owned(),
557 vec![
558 format!(
559 "g1t checks every push for secrets and reads up to {} at once; this one is {}.",
560 megabytes(*cap as u64),
561 megabytes(*size)
562 ),
563 "Push in parts, oldest commits first, then push as usual:".to_owned(),
564 " git rev-list --reverse HEAD | awk 'NR % 500 == 0' | xargs -I{} git push origin {}:refs/heads/main".to_owned(),
565 format!("A push over {} is refused by the network before it reaches g1t (HTTP 413).", megabytes(PLATFORM_BODY_LIMIT_BYTES)),
566 "See https://docs.g1t.sh/guides/git/#size-limits. Nothing was pushed.".to_owned(),
567 ],
568 ),
569 }
570}
571
572/// Feeds the next chunk of a push to the size check; the first violation.
573fn check_size(sizer: &mut Option<PackSizer>, chunk: &[u8], limits: &PushLimits) -> Option<SizeViolation> {
574 let walker = sizer.as_mut()?;
575 match walker.feed(chunk) {
576 Ok(()) => {}
577 Err(Violation::ObjectTooLarge { size }) => return Some(SizeViolation::Object { size }),
578 Err(Violation::Malformed(why)) => {
579 // Not for g1t to judge: the store will say.
580 worker::console_error!("push not checked for size: {why}");
581 *sizer = None;
582 return None;
583 }
584 }
585 let incoming = walker.pack_bytes();
586 crate::pack_limits::over_repo_limit(limits.held, incoming, limits.repo_limit).then_some(SizeViolation::Repository {
587 held: limits.held,
588 incoming,
589 limit: limits.repo_limit,
590 })
591}
592
593/// A request body that streams from `stream`, for `fetch`.
594pub(crate) fn stream_body<S>(stream: S) -> Result<JsValue>
595where
596 S: futures_util::TryStream + 'static,
597 S::Ok: Into<Vec<u8>>,
598 S::Error: Into<worker::Error>,
599{
600 let response: worker::web_sys::Response = Response::from_stream(stream)?.into();
601 Ok(response.body().map_or(JsValue::NULL, Into::into))
602}
603
604/// What git is told when the store is busy: 429 or 503, with when to try
605/// again (resilience.rs).
606pub fn busy_response(busy: Busy) -> Result<Response> {
607 let response = Response::error(busy.message(), busy.status())?;
608 response.headers().set("retry-after", &busy.retry_after.to_string())?;
609 Ok(response)
610}
611
612/// The rest of a request's body, read and thrown away so that git hears
613/// the answer; how many bytes it was.
614async fn drain(stream: &mut worker::ByteStream) -> Result<u64> {
615 let mut size = 0;
616 while let Some(chunk) = stream.next().await {
617 size += chunk?.len() as u64;
618 }
619 Ok(size)
Agents as a team: lifecycle, merge queue, billing and a new shell620}
621
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look622/// One packet of a pkt-line stream: data, or a flush (`0000`), delimiter
623/// (`0001`) or response-end (`0002`) packet, kept as its four bytes.
624#[derive(Debug, PartialEq, Eq)]
625enum Packet {
626 Data(Vec<u8>),
627 Special([u8; 4]),
628}
629
630/// The packets in `bytes`, or `None` if it is not a whole pkt-line stream.
631fn packets(bytes: &[u8]) -> Option<Vec<Packet>> {
632 let mut out = Vec::new();
633 let mut position = 0;
634 while position < bytes.len() {
635 let header = bytes.get(position..position + 4)?;
636 let length = usize::from_str_radix(std::str::from_utf8(header).ok()?, 16).ok()?;
637 if length < 4 {
638 out.push(Packet::Special(header.try_into().ok()?));
639 position += 4;
640 continue;
641 }
642 out.push(Packet::Data(bytes.get(position + 4..position + length)?.to_vec()));
643 position += length;
644 }
645 Some(out)
646}
647
648fn encode(packets: &[Packet]) -> Vec<u8> {
649 let mut out = Vec::new();
650 for packet in packets {
651 match packet {
652 Packet::Data(data) => out.extend(pkt_line(data)),
653 Packet::Special(bytes) => out.extend_from_slice(bytes),
654 }
655 }
656 out
657}
658
659/// A ref advertisement (`info/refs` for upload-pack) or a protocol v2
660/// `ls-refs` answer with `HEAD` pointing at `branch`, the repository's
661/// default branch as g1t keeps it, so a clone checks it out. The git store
662/// holds the HEAD it was created with; g1t can change the default branch
663/// since. `None` when there is nothing to change: no `HEAD` line, `HEAD`
664/// already names `branch`, or `branch` is not advertised.
665pub fn with_head(body: &[u8], branch: &str) -> Option<Vec<u8>> {
666 let mut packets = packets(body)?;
667 let target = format!("{HEADS}{branch}");
668 let oid = packets.iter().find_map(|packet| {
669 let Packet::Data(data) = packet else { return None };
670 let line = data.split(|byte| *byte == 0).next()?;
671 let line = std::str::from_utf8(line).ok()?.trim_end();
672 let (oid, name) = line.split_once(' ')?;
673 // v2 lines may carry attributes after the name.
674 let name = name.split(' ').next()?;
675 (name == target).then(|| oid.to_owned())
676 })?;
677 let mut changed = false;
678 for packet in &mut packets {
679 let Packet::Data(data) = packet else { continue };
680 let text = String::from_utf8_lossy(data).into_owned();
681 let Some((_, rest)) = text.split_once(' ') else { continue };
682 if !(rest.starts_with("HEAD\0") || rest.starts_with("HEAD\n") || rest.starts_with("HEAD ") || rest == "HEAD") {
683 continue;
684 }
685 let mut line = format!("{oid} {rest}");
686 // v0: `symref=HEAD:refs/heads/<old>` among the capabilities.
687 // v2: `symref-target:refs/heads/<old>` after the name.
688 for marker in ["symref=HEAD:", "symref-target:"] {
689 if let Some(at) = line.find(marker) {
690 let start = at + marker.len();
691 let end = line[start..]
692 .find([' ', '\n', '\0'])
693 .map_or(line.len(), |offset| start + offset);
694 line.replace_range(start..end, &target);
695 }
696 }
697 changed = line != text;
698 if changed {
699 *data = line.into_bytes();
700 }
701 break;
702 }
703 changed.then(|| encode(&packets))
704}
705
706/// Whether a request to the git store is one whose answer names `HEAD`:
707/// the ref advertisement for a fetch, or a protocol v2 `ls-refs`.
708fn names_head(git: &GitRequest, body: Option<&[u8]>) -> bool {
709 if git.service != GitService::UploadPack {
710 return false;
711 }
712 match body {
713 None => git.endpoint == "info/refs",
714 Some(body) => {
715 git.endpoint == "git-upload-pack"
716 && body.windows(b"command=ls-refs".len()).any(|window| window == b"command=ls-refs")
717 }
718 }
719}
720
A fetch whose haves the store does not know gets its pack: g1t ends the store's early answer where git expects721/// Whether a request is a protocol v2 `fetch` still negotiating: it sends
722/// `have` lines and no `done`, so the answer may be acknowledgments only.
723fn negotiating(body: &[u8]) -> bool {
724 let Some(packets) = packets(body) else { return false };
725 let lines: Vec<&[u8]> = packets
726 .iter()
727 .filter_map(|packet| match packet {
728 Packet::Data(data) => Some(data.strip_suffix(b"\n").unwrap_or(data)),
729 Packet::Special(_) => None,
730 })
731 .collect();
732 lines.contains(&b"command=fetch".as_slice())
733 && lines.iter().any(|line| line.starts_with(b"have "))
734 && !lines.contains(&b"done".as_slice())
735}
736
737/// What to do with the start of a store's answer to a negotiating fetch.
738#[derive(Debug, PartialEq, Eq)]
739enum Acknowledged {
740 /// Not enough of it yet to tell.
741 NeedMore,
742 /// Send it on as it is.
743 Whole,
744 /// Acknowledgments without `ready`, followed by more sections: the
745 /// store's answer to keep is these first bytes, ended by a flush.
746 CutAt(usize),
747}
748
749/// How much of the answer to keep. The git store answers a fetch whose
750/// `have`s it does not know with `acknowledgments`, `NAK`, then a pack
751/// anyway; git refuses that ("expected no other sections to be sent after
752/// no 'ready'"), since a server that is not ready must end the response
753/// there and let the client negotiate again. Lines may be `sideband-all`
754/// framed (band 1, `\x01`).
755fn acknowledged(head: &[u8]) -> Acknowledged {
756 let mut position = 0;
757 let mut first = true;
758 loop {
759 let Some(header) = head.get(position..position + 4) else { return Acknowledged::NeedMore };
760 let Some(length) = std::str::from_utf8(header).ok().and_then(|hex| usize::from_str_radix(hex, 16).ok()) else {
761 return Acknowledged::Whole;
762 };
763 if length < 4 {
764 // The acknowledgments section's end: a delimiter means more
765 // sections follow, which only `ready` allows.
766 return match (first, header) {
767 (false, b"0001") => Acknowledged::CutAt(position),
768 _ => Acknowledged::Whole,
769 };
770 }
771 let Some(payload) = head.get(position + 4..position + length) else { return Acknowledged::NeedMore };
772 let line = payload.strip_prefix(b"\x01").unwrap_or(payload);
773 let line = line.strip_suffix(b"\n").unwrap_or(line);
774 if first && line != b"acknowledgments" {
775 return Acknowledged::Whole;
776 }
777 if line == b"ready" {
778 return Acknowledged::Whole;
779 }
780 first = false;
781 position += length;
782 }
783}
784
785/// The answer to a negotiating fetch, with the sections the store sent
786/// after acknowledgments without `ready` left off (see [`acknowledged`]).
787/// Reads only the start of the answer; the rest streams through.
788async fn without_early_pack(mut response: Response) -> Result<Response> {
789 const LOOK: usize = 64 * 1024;
790 let headers = response.headers().clone();
791 headers.delete("content-length")?;
792 let mut stream = response.stream()?;
793 let mut head = Vec::new();
794 loop {
795 match acknowledged(&head) {
796 Acknowledged::CutAt(at) => {
797 head.truncate(at);
The early answer ends with a flush only; git's HTTP transport adds the response-end packet itself798 // A flush ends the acknowledgments and the answer. No
799 // response-end packet: git's HTTP transport adds its own
800 // and refuses one from the server.
801 head.extend_from_slice(b"0000");
A fetch whose haves the store does not know gets its pack: g1t ends the store's early answer where git expects802 return Ok(Response::from_bytes(head)?.with_headers(headers));
803 }
804 Acknowledged::Whole => break,
805 Acknowledged::NeedMore if head.len() >= LOOK => break,
806 Acknowledged::NeedMore => match stream.next().await {
807 Some(chunk) => head.extend_from_slice(&chunk?),
808 None => break,
809 },
810 }
811 }
812 let rest = futures_util::stream::once(async move { Ok::<Vec<u8>, worker::Error>(head) }).chain(stream);
813 Ok(Response::from_stream(rest)?.with_headers(headers))
814}
815
Agents as a team: lifecycle, merge queue, billing and a new shell816/// Sends the request on to the git store and returns its response as is,
Rulesets on push: refused with the ruleset and rule named, commits read817/// unless it is a push the rules refuse (`rules`, rules.rs), one the
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily818/// store could not hold (`limits`, pack_limits.rs), or one that `scan`
819/// (push protection) answers itself. A fetch's ref listing has its `HEAD`
820/// pointed at `default_branch` (see [`with_head`]). A POST's body is
821/// `read` when the caller has read it already.
822///
823/// A push is read as it arrives: up to `limits.scan_cap` is kept, to be
824/// scanned and sent on whole; past it, the push is declined, or streamed
825/// to the store unscanned (`LargePushes`), never held. Reads the store
826/// fails for a moment (429, 5xx) are tried again with backoff; a push never
827/// is. A store still busy after that is answered 429 or 503 with
828/// `Retry-After`.
829#[allow(clippy::too_many_arguments)]
Rust repos service with shipping; pull requests kept in the model830pub async fn forward(
831 mut request: Request,
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms832 read: Option<Vec<u8>>,
Rust repos service with shipping; pull requests kept in the model833 git: &GitRequest,
834 access: &GitAccess,
Rulesets on push: refused with the ruleset and rule named, commits read835 rules: impl AsyncFnOnce(&[u8], bool) -> Result<Option<Response>>,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look836 default_branch: Option<&str>,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily837 limits: PushLimits,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API838 scan: impl AsyncFnOnce(&[u8]) -> Result<Option<Response>>,
Agents as a team: lifecycle, merge queue, billing and a new shell839) -> Result<Push> {
Rust repos service with shipping; pull requests kept in the model840 let headers = Headers::new();
841 headers.set("authorization", &format!("Bearer {}", access.token))?;
842 for name in FORWARDED_HEADERS {
843 if let Some(value) = request.headers().get(name)? {
844 headers.set(name, &value)?;
845 }
846 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily847 let query = request.url()?.query().map(|query| format!("?{query}")).unwrap_or_default();
848 let url = format!("{}/{}{query}", access.remote, git.endpoint);
849 let method = request.method();
Merge branch 'worktree-agent-a2013627e5ea4ab13'850 // Its own health and breaker: the fallback store's apart from Artifacts'.
851 let namespace = crate::store::health_namespace(&access.remote);
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily852
853 if method == Method::Post && git.endpoint == "git-receive-pack" {
Rulesets on push: refused with the ruleset and rule named, commits read854 return push(request, &url, headers, rules, limits, scan, &namespace).await;
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily855 }
856
857 // A read: the ref advertisement, `ls-refs`, or a fetch of objects.
858 let body = match (&method, read) {
859 (Method::Post, Some(body)) => Some(body),
860 (Method::Post, None) => Some(request.bytes().await?),
861 _ => None,
862 };
863 let lists_head = match &body {
864 None => method == Method::Get && names_head(git, None),
865 Some(body) => names_head(git, Some(body)),
866 };
867 let sent = body.as_ref().map_or(0, |body| body.len() as u64);
868 let mut attempt = 0;
869 let mut response = loop {
870 let mut init = RequestInit::new();
871 init.with_method(method.clone()).with_headers(headers.clone());
872 if let Some(body) = &body {
873 init.with_body(Some(Uint8Array::from(body.as_slice()).into()));
874 }
875 let started = g1t_kit::now_ms();
876 let answered = Fetch::Request(Request::new_with_init(&url, &init)?).send().await;
877 let ms = g1t_kit::now_ms().saturating_sub(started);
878 let failure = match &answered {
879 Ok(response) => resilience::classify_status(response.status_code()),
880 Err(_) => Some(Failure::Transient),
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms881 };
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily882 let outcome = match failure {
883 None => meters::Outcome::Ok,
884 Some(Failure::RateLimited) => meters::Outcome::RateLimited,
885 Some(_) => meters::Outcome::Failed,
886 };
887 meters::record_health(&namespace, outcome, ms);
888 match (answered, failure) {
889 (Ok(response), None) => break response,
890 (answered, Some(failure)) if resilience::retry(failure, attempt) => {
891 drop(answered);
892 let wait = resilience::backoff_ms(failure, attempt, worker::js_sys::Math::random());
893 worker::Delay::from(std::time::Duration::from_millis(wait)).await;
894 attempt += 1;
Agents as a team: lifecycle, merge queue, billing and a new shell895 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily896 (_, Some(failure)) => {
Merge branch 'worktree-agent-a2013627e5ea4ab13'897 let busy = Busy { rate_limited: failure == Failure::RateLimited, retry_after: 5, read_only: false };
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily898 return Ok(Push::Forwarded(Forwarded {
899 response: busy_response(busy)?,
900 pushed: Vec::new(),
901 pack_bytes: 0,
902 sent,
903 from_store: false,
904 unscanned: false,
905 }));
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API906 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily907 (Err(error), None) => return Err(error),
Events service in Rust, with RFC 3339 times and accurate push events908 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily909 };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look910 if let (true, Some(branch)) = (lists_head, default_branch)
911 && response.status_code() == 200
912 {
913 let headers = response.headers().clone();
914 headers.delete("content-length")?;
915 let body = response.bytes().await?;
916 let body = with_head(&body, branch).unwrap_or(body);
917 response = Response::from_bytes(body)?.with_headers(headers);
918 }
A fetch whose haves the store does not know gets its pack: g1t ends the store's early answer where git expects919 if git.endpoint == "git-upload-pack"
920 && response.status_code() == 200
921 && body.as_deref().is_some_and(negotiating)
922 {
923 response = without_early_pack(response).await?;
924 }
Agents as a team: lifecycle, merge queue, billing and a new shell925 Ok(Push::Forwarded(Forwarded {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look926 response,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily927 pushed: Vec::new(),
928 pack_bytes: 0,
929 sent,
930 from_store: true,
931 unscanned: false,
932 }))
933}
934
935/// A receive-pack request; see [`forward`].
936#[allow(clippy::too_many_arguments)]
937async fn push(
938 mut request: Request,
939 url: &str,
940 headers: Headers,
Rulesets on push: refused with the ruleset and rule named, commits read941 rules: impl AsyncFnOnce(&[u8], bool) -> Result<Option<Response>>,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily942 limits: PushLimits,
943 scan: impl AsyncFnOnce(&[u8]) -> Result<Option<Response>>,
944 namespace: &str,
945) -> Result<Push> {
946 let mut stream = request.stream()?;
947 let mut head: Vec<u8> = Vec::new();
948 let mut sizer = Some(PackSizer::new(limits.max_object));
949 let mut violation = None;
950 let mut ended = false;
951 while head.len() <= limits.scan_cap {
952 match stream.next().await {
953 Some(chunk) => {
954 let chunk = chunk?;
955 if violation.is_none() {
956 violation = check_size(&mut sizer, &chunk, &limits);
957 }
958 head.extend_from_slice(&chunk);
959 }
960 None => {
961 ended = true;
962 break;
963 }
964 }
965 }
Rulesets on push: refused with the ruleset and rule named, commits read966 // The rules of the branches and tags it changes, first: what they
967 // refuse is refused whatever else is wrong with it.
968 if let Some(response) = rules(&head, ended).await? {
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily969 if !ended {
970 drain(&mut stream).await?;
971 }
Rulesets on push: refused with the ruleset and rule named, commits read972 return Ok(Push::Refused(response));
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily973 }
974 if !ended && limits.large == LargePushes::Refuse && violation.is_none() {
975 let size = head.len() as u64 + drain(&mut stream).await?;
976 violation = Some(SizeViolation::Unscannable { size, cap: limits.scan_cap });
977 ended = true;
978 }
979 if let Some(violation) = violation {
980 if !ended {
981 drain(&mut stream).await?;
982 }
983 let (reason, messages) = size_refusal(&violation);
984 return Ok(Push::Declined(declined(&head, &reason, &messages)?, reason));
985 }
986 let pushed = pushed_branches(&head);
987 let mut init = RequestInit::new();
988 init.with_method(Method::Post).with_headers(headers);
989 let started = g1t_kit::now_ms();
990 let (answered, pack_bytes, sent, unscanned) = if ended {
991 if let Some(response) = scan(&head).await? {
992 return Ok(Push::Blocked(response));
993 }
994 let pack = pack_bytes(&head);
995 let sent = head.len() as u64;
996 init.with_body(Some(Uint8Array::from(head.as_slice()).into()));
997 drop(head);
998 (Fetch::Request(Request::new_with_init(url, &init)?).send().await, pack, sent, false)
999 } else {
1000 // Larger than can be scanned, and let through unscanned: streamed,
1001 // with the size limits checked as it passes. A violation ends the
1002 // stream before the pack does, so the store refuses it whole.
1003 worker::console_warn!("a push of more than {} bytes goes to the store unscanned", limits.scan_cap);
1004 let commands = head.iter().take(64 * 1024).copied().collect::<Vec<u8>>();
1005 let found: Rc<RefCell<Option<SizeViolation>>> = Rc::default();
1006 let walked = Rc::new(RefCell::new((sizer, 0u64)));
1007 let rest = {
1008 let found = found.clone();
1009 let walked = walked.clone();
1010 stream.map(move |chunk| {
1011 let chunk = chunk?;
1012 let mut walked = walked.borrow_mut();
1013 walked.1 += chunk.len() as u64;
1014 if let Some(violation) = check_size(&mut walked.0, &chunk, &limits) {
1015 *found.borrow_mut() = Some(violation);
1016 return Err(worker::Error::RustError("push over the size limit".into()));
1017 }
1018 Ok(chunk)
1019 })
1020 };
1021 let first = head.len() as u64;
1022 let body = futures_util::stream::once(async move { Ok::<Vec<u8>, worker::Error>(head) }).chain(rest);
1023 init.with_body(Some(stream_body(body)?));
1024 let answered = Fetch::Request(Request::new_with_init(url, &init)?).send().await;
1025 if let Some(violation) = found.borrow_mut().take() {
1026 let (reason, messages) = size_refusal(&violation);
1027 return Ok(Push::Declined(declined(&commands, &reason, &messages)?, reason));
1028 }
1029 let walked = walked.borrow();
1030 let pack = walked.0.as_ref().map_or_else(|| pack_bytes(&commands), PackSizer::pack_bytes);
1031 (answered, pack, first + walked.1, true)
1032 };
1033 let ms = g1t_kit::now_ms().saturating_sub(started);
1034 let failure = match &answered {
1035 Ok(response) => resilience::classify_status(response.status_code()),
1036 Err(_) => Some(Failure::Transient),
1037 };
1038 meters::record_health(
1039 namespace,
1040 match failure {
1041 None => meters::Outcome::Ok,
1042 Some(Failure::RateLimited) => meters::Outcome::RateLimited,
1043 Some(_) => meters::Outcome::Failed,
1044 },
1045 ms,
1046 );
1047 // A push is never tried again: the store may have taken it.
1048 let response = match (answered, failure) {
1049 (Ok(response), None) => response,
1050 (Ok(response), Some(Failure::RateLimited)) => {
1051 drop(response);
Merge branch 'worktree-agent-a2013627e5ea4ab13'1052 busy_response(Busy { rate_limited: true, retry_after: 5, read_only: false })?
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1053 }
1054 (Ok(response), Some(_)) => response,
1055 (Err(error), _) => {
1056 worker::console_error!("a push did not reach the store: {error}");
Merge branch 'worktree-agent-a2013627e5ea4ab13'1057 busy_response(Busy { rate_limited: false, retry_after: 5, read_only: false })?
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1058 }
1059 };
1060 Ok(Push::Forwarded(Forwarded {
1061 response,
Events service in Rust, with RFC 3339 times and accurate push events1062 pushed,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1063 pack_bytes,
1064 sent,
1065 from_store: true,
1066 unscanned,
Agents as a team: lifecycle, merge queue, billing and a new shell1067 }))
Events service in Rust, with RFC 3339 times and accurate push events1068}
1069
1070#[cfg(test)]
1071mod tests {
A fetch whose haves the store does not know gets its pack: g1t ends the store's early answer where git expects1072 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 200ms1073
1074 #[test]
1075 fn server_timing_names_each_step_and_the_total() {
1076 assert_eq!(
1077 server_timing(&[("repo", 12), ("token", 0), ("store", 140)], &[], 153),
1078 "repo;dur=12, token;dur=0, store;dur=140, total;dur=153"
1079 );
1080 assert_eq!(server_timing(&[], &[], 3), "total;dur=3");
1081 assert_eq!(
1082 server_timing(&[("repo", 1), ("cache", 2)], &[("refs", "hit-colo")], 4),
1083 "repo;dur=1, cache;dur=2, refs;desc=hit-colo, total;dur=4"
1084 );
1085 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1086
1087 #[test]
1088 fn a_renamed_repository_redirects_to_its_new_name() {
1089 // A rename keeps the old path in the same table as a transfer, so
1090 // the old remote is sent to the new name the same way.
1091 let to = RepoPath {
1092 namespace: "acme".into(),
1093 name: "booster".into(),
1094 };
1095 let url = Url::parse("https://g1t.sh/acme/rocket.git/info/refs?service=git-upload-pack").unwrap();
1096 assert_eq!(
1097 transferred(&url, &to).as_deref(),
1098 Some("https://g1t.sh/acme/booster.git/info/refs?service=git-upload-pack")
1099 );
1100 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1101
1102 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1103 fn head_follows_the_default_branch_in_a_v0_advertisement() {
1104 let main = "1111111111111111111111111111111111111111";
1105 let trunk = "2222222222222222222222222222222222222222";
1106 let body = [
1107 pkt("# service=git-upload-pack\n"),
1108 b"0000".to_vec(),
1109 pkt(&format!("{main} HEAD\0multi_ack symref=HEAD:refs/heads/main agent=git/2\n")),
1110 pkt(&format!("{main} refs/heads/main\n")),
1111 pkt(&format!("{trunk} refs/heads/trunk\n")),
1112 b"0000".to_vec(),
1113 ]
1114 .concat();
1115 let changed = String::from_utf8(with_head(&body, "trunk").unwrap()).unwrap();
1116 assert!(changed.contains(&format!("{trunk} HEAD\0multi_ack symref=HEAD:refs/heads/trunk agent=git/2\n")));
1117 assert!(changed.contains(&format!("{main} refs/heads/main\n")));
1118 assert!(changed.starts_with("001e# service=git-upload-pack\n0000"));
1119 // Already right, or a branch it does not have: left alone.
1120 assert!(with_head(&body, "main").is_none());
1121 assert!(with_head(&body, "gone").is_none());
1122 }
1123
1124 #[test]
1125 fn head_follows_the_default_branch_in_a_v2_listing() {
1126 let main = "1111111111111111111111111111111111111111";
1127 let trunk = "2222222222222222222222222222222222222222";
1128 let body = [
1129 pkt(&format!("{main} HEAD symref-target:refs/heads/main\n")),
1130 pkt(&format!("{main} refs/heads/main\n")),
1131 pkt(&format!("{trunk} refs/heads/trunk\n")),
1132 b"0000".to_vec(),
1133 ]
1134 .concat();
1135 let changed = String::from_utf8(with_head(&body, "trunk").unwrap()).unwrap();
1136 assert!(changed.starts_with(&String::from_utf8(pkt(&format!("{trunk} HEAD symref-target:refs/heads/trunk\n"))).unwrap()));
1137 assert!(changed.ends_with("0000"));
1138 // Without symrefs asked for, only the commit changes.
1139 let plain = [pkt(&format!("{main} HEAD\n")), pkt(&format!("{trunk} refs/heads/trunk\n")), b"0000".to_vec()].concat();
1140 let changed = String::from_utf8(with_head(&plain, "trunk").unwrap()).unwrap();
1141 assert!(changed.starts_with(&format!("0032{trunk} HEAD\n")));
1142 }
1143
1144 #[test]
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1145 fn a_push_is_measured_by_the_pack_after_its_commands() {
1146 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
1147 let new = "4807077b296e6edbf410d55e72749d3e1170c291";
1148 let pack = b"PACK\0\0\0\x02\0\0\0\0rest-of-pack";
1149 let body = [
1150 pkt(&format!("{old} {new} refs/heads/main\0 report-status\n")),
1151 b"0000".to_vec(),
1152 pack.to_vec(),
1153 ]
1154 .concat();
1155 assert_eq!(pack_bytes(&body), pack.len() as u64);
1156 // Only deletions: no pack.
1157 let body = [pkt(&format!("{old} {ZERO_ID} refs/heads/gone\n")), b"0000".to_vec()].concat();
1158 assert_eq!(pack_bytes(&body), 0);
1159 assert_eq!(pack_bytes(b"garbage"), 0);
1160 }
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1161
1162 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1163 fn a_transferred_repository_keeps_the_rest_of_the_address() {
1164 let to = RepoPath {
1165 namespace: "flagon-io".into(),
1166 name: "g1t".into(),
1167 };
1168 let url = Url::parse("https://g1t.sh/syntaqx/g1t.git/info/refs?service=git-receive-pack").unwrap();
1169 assert_eq!(
1170 transferred(&url, &to).as_deref(),
1171 Some("https://g1t.sh/flagon-io/g1t.git/info/refs?service=git-receive-pack")
1172 );
1173 let url = Url::parse("https://g1t.sh/syntaqx/g1t/git-upload-pack").unwrap();
1174 assert_eq!(
1175 transferred(&url, &to).as_deref(),
1176 Some("https://g1t.sh/flagon-io/g1t/git-upload-pack")
1177 );
1178 }
1179
1180 #[test]
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1181 fn a_renamed_workspace_keeps_the_rest_of_the_address() {
1182 let url = worker::Url::parse(
1183 "https://g1t.sh/acme/rocket.git/info/refs?service=git-upload-pack",
1184 )
1185 .unwrap();
1186 assert_eq!(
1187 with_namespace(&url, "acme-inc").as_deref(),
1188 Some("https://g1t.sh/acme-inc/rocket.git/info/refs?service=git-upload-pack")
1189 );
1190 let bare = worker::Url::parse("https://g1t.sh/acme").unwrap();
1191 assert_eq!(with_namespace(&bare, "acme-inc"), None);
1192 }
Events service in Rust, with RFC 3339 times and accurate push events1193
1194 fn pkt(payload: &str) -> Vec<u8> {
1195 format!("{:04x}{payload}", payload.len() + 4).into_bytes()
1196 }
1197
A fetch whose haves the store does not know gets its pack: g1t ends the store's early answer where git expects1198 fn joined(parts: &[&[u8]]) -> Vec<u8> {
1199 parts.concat()
1200 }
1201
1202 #[test]
1203 fn a_fetch_with_haves_and_no_done_is_negotiating() {
1204 let request = |lines: &[&str]| {
1205 let mut body = joined(&[&pkt("command=fetch\n"), &pkt("object-format=sha1\n"), b"0001"]);
1206 for line in lines {
1207 body.extend(pkt(&format!("{line}\n")));
1208 }
1209 body.extend(b"0000");
1210 body
1211 };
1212 let want = "want 8407eba58b925619274d012258c2b474a5dbf012";
1213 let have = "have 55cd670a89a80df4fa9d9f0244c44fbd2ed1db8b";
1214 assert!(negotiating(&request(&["deepen 1", want, have])));
1215 assert!(!negotiating(&request(&[want, have, "done"])));
1216 assert!(!negotiating(&request(&[want, "done"])));
1217 let ls_refs = joined(&[&pkt("command=ls-refs\n"), b"0001", &pkt("have nothing\n"), b"0000"]);
1218 assert!(!negotiating(&ls_refs));
1219 }
1220
1221 #[test]
1222 fn acknowledgments_without_ready_end_the_answer() {
1223 // What the store sent a shallow fetch whose only `have` it did not
1224 // know, sideband-all framed: a NAK, then a pack anyway.
1225 let answer = joined(&[
1226 &pkt("\x01acknowledgments\n"),
1227 &pkt("\x01NAK\n"),
1228 b"0001",
1229 &pkt("\x01shallow-info\n"),
1230 &pkt("\x01shallow 8407eba58b925619274d012258c2b474a5dbf012\n"),
1231 b"0001",
1232 &pkt("\x01packfile\n"),
1233 ]);
1234 let cut = joined(&[&pkt("\x01acknowledgments\n"), &pkt("\x01NAK\n")]).len();
1235 assert_eq!(acknowledged(&answer), Acknowledged::CutAt(cut));
1236 // Not yet at the section's end.
1237 assert_eq!(acknowledged(&answer[..cut - 2]), Acknowledged::NeedMore);
1238 assert_eq!(acknowledged(&answer[..cut]), Acknowledged::NeedMore);
1239 // Without sideband framing too.
1240 let plain = joined(&[&pkt("acknowledgments\n"), &pkt("ACK abc\n"), b"0001", &pkt("packfile\n")]);
1241 assert!(matches!(acknowledged(&plain), Acknowledged::CutAt(_)));
1242 }
1243
1244 #[test]
1245 fn a_ready_store_or_a_plain_pack_streams_through() {
1246 let ready = joined(&[
1247 &pkt("\x01acknowledgments\n"),
1248 &pkt("\x01ACK bab14ff1b6d9c4918100098009747d776759a967\n"),
1249 &pkt("\x01ready\n"),
1250 b"0001",
1251 &pkt("\x01packfile\n"),
1252 ]);
1253 assert_eq!(acknowledged(&ready), Acknowledged::Whole);
1254 // Acknowledgments only, ended by a flush: already right.
1255 let only = joined(&[&pkt("acknowledgments\n"), &pkt("NAK\n"), b"0000"]);
1256 assert_eq!(acknowledged(&only), Acknowledged::Whole);
1257 let pack = joined(&[&pkt("\x01packfile\n"), b"0000"]);
1258 assert_eq!(acknowledged(&pack), Acknowledged::Whole);
1259 assert_eq!(acknowledged(b"00"), Acknowledged::NeedMore);
1260 }
1261
Events service in Rust, with RFC 3339 times and accurate push events1262 #[test]
1263 fn pushed_branches_are_read_from_the_commands() {
1264 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
1265 let new = "4807077b296e6edbf410d55e72749d3e1170c291";
1266 let body = [
1267 pkt(&format!(
1268 "{old} {new} refs/heads/main\0 report-status side-band-64k\n"
1269 )),
1270 pkt(&format!("{ZERO_ID} {new} refs/heads/feature/x\n")),
1271 pkt(&format!("{old} {ZERO_ID} refs/heads/gone\n")),
1272 pkt(&format!("{ZERO_ID} {new} refs/tags/v1\n")),
1273 b"0000".to_vec(),
1274 b"PACK\0\0\0\x02\0\0\0\0".to_vec(),
1275 ]
1276 .concat();
1277 assert_eq!(
1278 pushed_branches(&body),
1279 [
GitHub Actions on g1t, part one: reading workflows1280 Pushed {
1281 git_ref: "refs/heads/main".to_owned(),
1282 before: Some(old.to_owned()),
1283 after: new.to_owned()
1284 },
1285 Pushed {
1286 git_ref: "refs/heads/feature/x".to_owned(),
1287 before: None,
1288 after: new.to_owned()
1289 },
1290 Pushed {
1291 git_ref: "refs/tags/v1".to_owned(),
1292 before: None,
1293 after: new.to_owned()
1294 },
Events service in Rust, with RFC 3339 times and accurate push events1295 ]
1296 );
1297 }
1298
1299 #[test]
Agents as a team: lifecycle, merge queue, billing and a new shell1300 fn a_push_to_a_protected_branch_is_declined_with_the_reason() {
1301 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
1302 let new = "4807077b296e6edbf410d55e72749d3e1170c291";
1303 let body = [
1304 pkt(&format!("{old} {new} refs/heads/main\0 report-status\n")),
1305 pkt(&format!("{ZERO_ID} {new} refs/heads/feature\n")),
1306 b"0000".to_vec(),
1307 ]
1308 .concat();
1309 let report = String::from_utf8(refusal(&body, "main").unwrap()).unwrap();
1310 assert!(report.starts_with("000eunpack ok\n"));
1311 assert!(report.contains("ng refs/heads/main main is protected"));
1312 assert!(report.contains("ng refs/heads/feature not pushed"));
1313 assert!(report.ends_with("0000"));
1314 }
1315
1316 #[test]
1317 fn the_report_is_framed_for_a_client_that_asked_for_side_band() {
1318 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
1319 let body = [
1320 pkt(&format!(
1321 "{old} {ZERO_ID} refs/heads/main\0 report-status side-band-64k\n"
1322 )),
1323 b"0000".to_vec(),
1324 ]
1325 .concat();
1326 let report = refusal(&body, "main").unwrap();
1327 // A length, then channel 1, then the report itself.
1328 assert_eq!(report[4], 1);
1329 assert_eq!(&report[5..18], b"000eunpack ok");
1330 assert!(report.ends_with(b"00000000"));
1331 }
1332
1333 #[test]
1334 fn other_branches_and_a_first_push_are_let_through() {
1335 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
1336 let new = "4807077b296e6edbf410d55e72749d3e1170c291";
1337 let feature = [
1338 pkt(&format!("{old} {new} refs/heads/feature\0 report-status\n")),
1339 b"0000".to_vec(),
1340 ]
1341 .concat();
1342 assert!(refusal(&feature, "main").is_none());
1343 // An empty repository has to be able to receive its first commits.
1344 let first = [
1345 pkt(&format!(
1346 "{ZERO_ID} {new} refs/heads/main\0 report-status\n"
1347 )),
1348 b"0000".to_vec(),
1349 ]
1350 .concat();
1351 assert!(refusal(&first, "main").is_none());
1352 }
1353
1354 #[test]
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API1355 fn a_blocked_push_explains_itself_on_the_progress_channel() {
1356 let report = b"000eunpack ok\n0000".to_vec();
1357 let messages = vec!["g1t found a secret in this push, so nothing was pushed.".to_owned()];
1358 let body = framed(report.clone(), "report-status side-band-64k", &messages);
1359 // Channel 2 first, which git prints as `remote:` lines.
1360 assert_eq!(body[4], 2);
1361 assert!(String::from_utf8_lossy(&body).contains("so nothing was pushed.\n"));
1362 let at = body.windows(5).position(|w| w == b"000eu").unwrap();
1363 assert_eq!(body[at - 1], 1);
1364 assert!(body.ends_with(b"0000"));
1365 // A client without side-band gets the bare report.
1366 assert_eq!(framed(report.clone(), "report-status", &messages), report);
1367 }
1368
1369 #[test]
Events service in Rust, with RFC 3339 times and accurate push events1370 fn a_fetch_request_names_no_branches() {
1371 assert!(
1372 pushed_branches(b"0032want c71546fcd893ef8b0f57388b65e620d759705dda\n0000").is_empty()
1373 );
1374 }
Rust repos service with shipping; pull requests kept in the model1375}

This file's history is long; its oldest lines are credited to the oldest commit read.