flagon-io/g1t

public

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

g1t/services/repos/src/git_http.rs

1,190 lines46,099 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
Agents as a team: lifecycle, merge queue, billing and a new shell698/// 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 daily699/// unless it is a push that would change the `protected` branch, one the
700/// store could not hold (`limits`, pack_limits.rs), or one that `scan`
701/// (push protection) answers itself. A fetch's ref listing has its `HEAD`
702/// pointed at `default_branch` (see [`with_head`]). A POST's body is
703/// `read` when the caller has read it already.
704///
705/// A push is read as it arrives: up to `limits.scan_cap` is kept, to be
706/// scanned and sent on whole; past it, the push is declined, or streamed
707/// to the store unscanned (`LargePushes`), never held. Reads the store
708/// fails for a moment (429, 5xx) are tried again with backoff; a push never
709/// is. A store still busy after that is answered 429 or 503 with
710/// `Retry-After`.
711#[allow(clippy::too_many_arguments)]
Rust repos service with shipping; pull requests kept in the model712pub async fn forward(
713 mut request: Request,
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms714 read: Option<Vec<u8>>,
Rust repos service with shipping; pull requests kept in the model715 git: &GitRequest,
716 access: &GitAccess,
Agents as a team: lifecycle, merge queue, billing and a new shell717 protected: Option<&str>,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look718 default_branch: Option<&str>,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily719 limits: PushLimits,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API720 scan: impl AsyncFnOnce(&[u8]) -> Result<Option<Response>>,
Agents as a team: lifecycle, merge queue, billing and a new shell721) -> Result<Push> {
Rust repos service with shipping; pull requests kept in the model722 let headers = Headers::new();
723 headers.set("authorization", &format!("Bearer {}", access.token))?;
724 for name in FORWARDED_HEADERS {
725 if let Some(value) = request.headers().get(name)? {
726 headers.set(name, &value)?;
727 }
728 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily729 let query = request.url()?.query().map(|query| format!("?{query}")).unwrap_or_default();
730 let url = format!("{}/{}{query}", access.remote, git.endpoint);
731 let method = request.method();
732 let (namespace, _) = crate::store::locate(&crate::store::key_from_remote(&access.remote).unwrap_or_default());
733
734 if method == Method::Post && git.endpoint == "git-receive-pack" {
735 return push(request, &url, headers, protected, limits, scan, &namespace).await;
736 }
737
738 // A read: the ref advertisement, `ls-refs`, or a fetch of objects.
739 let body = match (&method, read) {
740 (Method::Post, Some(body)) => Some(body),
741 (Method::Post, None) => Some(request.bytes().await?),
742 _ => None,
743 };
744 let lists_head = match &body {
745 None => method == Method::Get && names_head(git, None),
746 Some(body) => names_head(git, Some(body)),
747 };
748 let sent = body.as_ref().map_or(0, |body| body.len() as u64);
749 let mut attempt = 0;
750 let mut response = loop {
751 let mut init = RequestInit::new();
752 init.with_method(method.clone()).with_headers(headers.clone());
753 if let Some(body) = &body {
754 init.with_body(Some(Uint8Array::from(body.as_slice()).into()));
755 }
756 let started = g1t_kit::now_ms();
757 let answered = Fetch::Request(Request::new_with_init(&url, &init)?).send().await;
758 let ms = g1t_kit::now_ms().saturating_sub(started);
759 let failure = match &answered {
760 Ok(response) => resilience::classify_status(response.status_code()),
761 Err(_) => Some(Failure::Transient),
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms762 };
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily763 let outcome = match failure {
764 None => meters::Outcome::Ok,
765 Some(Failure::RateLimited) => meters::Outcome::RateLimited,
766 Some(_) => meters::Outcome::Failed,
767 };
768 meters::record_health(&namespace, outcome, ms);
769 match (answered, failure) {
770 (Ok(response), None) => break response,
771 (answered, Some(failure)) if resilience::retry(failure, attempt) => {
772 drop(answered);
773 let wait = resilience::backoff_ms(failure, attempt, worker::js_sys::Math::random());
774 worker::Delay::from(std::time::Duration::from_millis(wait)).await;
775 attempt += 1;
Agents as a team: lifecycle, merge queue, billing and a new shell776 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily777 (_, Some(failure)) => {
778 let busy = Busy { rate_limited: failure == Failure::RateLimited, retry_after: 5 };
779 return Ok(Push::Forwarded(Forwarded {
780 response: busy_response(busy)?,
781 pushed: Vec::new(),
782 pack_bytes: 0,
783 sent,
784 from_store: false,
785 unscanned: false,
786 }));
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API787 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily788 (Err(error), None) => return Err(error),
Events service in Rust, with RFC 3339 times and accurate push events789 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily790 };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look791 if let (true, Some(branch)) = (lists_head, default_branch)
792 && response.status_code() == 200
793 {
794 let headers = response.headers().clone();
795 headers.delete("content-length")?;
796 let body = response.bytes().await?;
797 let body = with_head(&body, branch).unwrap_or(body);
798 response = Response::from_bytes(body)?.with_headers(headers);
799 }
Agents as a team: lifecycle, merge queue, billing and a new shell800 Ok(Push::Forwarded(Forwarded {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look801 response,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily802 pushed: Vec::new(),
803 pack_bytes: 0,
804 sent,
805 from_store: true,
806 unscanned: false,
807 }))
808}
809
810/// A receive-pack request; see [`forward`].
811#[allow(clippy::too_many_arguments)]
812async fn push(
813 mut request: Request,
814 url: &str,
815 headers: Headers,
816 protected: Option<&str>,
817 limits: PushLimits,
818 scan: impl AsyncFnOnce(&[u8]) -> Result<Option<Response>>,
819 namespace: &str,
820) -> Result<Push> {
821 let mut stream = request.stream()?;
822 let mut head: Vec<u8> = Vec::new();
823 let mut sizer = Some(PackSizer::new(limits.max_object));
824 let mut violation = None;
825 let mut ended = false;
826 while head.len() <= limits.scan_cap {
827 match stream.next().await {
828 Some(chunk) => {
829 let chunk = chunk?;
830 if violation.is_none() {
831 violation = check_size(&mut sizer, &chunk, &limits);
832 }
833 head.extend_from_slice(&chunk);
834 }
835 None => {
836 ended = true;
837 break;
838 }
839 }
840 }
841 let report_headers = || -> Result<Headers> {
842 let headers = Headers::new();
843 headers.set("content-type", "application/x-git-receive-pack-result")?;
844 headers.set("cache-control", "no-cache")?;
845 Ok(headers)
846 };
847 if let Some(report) = protected.and_then(|branch| refusal(&head, branch)) {
848 if !ended {
849 drain(&mut stream).await?;
850 }
851 return Ok(Push::Refused(Response::from_bytes(report)?.with_headers(report_headers()?)));
852 }
853 if !ended && limits.large == LargePushes::Refuse && violation.is_none() {
854 let size = head.len() as u64 + drain(&mut stream).await?;
855 violation = Some(SizeViolation::Unscannable { size, cap: limits.scan_cap });
856 ended = true;
857 }
858 if let Some(violation) = violation {
859 if !ended {
860 drain(&mut stream).await?;
861 }
862 let (reason, messages) = size_refusal(&violation);
863 return Ok(Push::Declined(declined(&head, &reason, &messages)?, reason));
864 }
865 let pushed = pushed_branches(&head);
866 let mut init = RequestInit::new();
867 init.with_method(Method::Post).with_headers(headers);
868 let started = g1t_kit::now_ms();
869 let (answered, pack_bytes, sent, unscanned) = if ended {
870 if let Some(response) = scan(&head).await? {
871 return Ok(Push::Blocked(response));
872 }
873 let pack = pack_bytes(&head);
874 let sent = head.len() as u64;
875 init.with_body(Some(Uint8Array::from(head.as_slice()).into()));
876 drop(head);
877 (Fetch::Request(Request::new_with_init(url, &init)?).send().await, pack, sent, false)
878 } else {
879 // Larger than can be scanned, and let through unscanned: streamed,
880 // with the size limits checked as it passes. A violation ends the
881 // stream before the pack does, so the store refuses it whole.
882 worker::console_warn!("a push of more than {} bytes goes to the store unscanned", limits.scan_cap);
883 let commands = head.iter().take(64 * 1024).copied().collect::<Vec<u8>>();
884 let found: Rc<RefCell<Option<SizeViolation>>> = Rc::default();
885 let walked = Rc::new(RefCell::new((sizer, 0u64)));
886 let rest = {
887 let found = found.clone();
888 let walked = walked.clone();
889 stream.map(move |chunk| {
890 let chunk = chunk?;
891 let mut walked = walked.borrow_mut();
892 walked.1 += chunk.len() as u64;
893 if let Some(violation) = check_size(&mut walked.0, &chunk, &limits) {
894 *found.borrow_mut() = Some(violation);
895 return Err(worker::Error::RustError("push over the size limit".into()));
896 }
897 Ok(chunk)
898 })
899 };
900 let first = head.len() as u64;
901 let body = futures_util::stream::once(async move { Ok::<Vec<u8>, worker::Error>(head) }).chain(rest);
902 init.with_body(Some(stream_body(body)?));
903 let answered = Fetch::Request(Request::new_with_init(url, &init)?).send().await;
904 if let Some(violation) = found.borrow_mut().take() {
905 let (reason, messages) = size_refusal(&violation);
906 return Ok(Push::Declined(declined(&commands, &reason, &messages)?, reason));
907 }
908 let walked = walked.borrow();
909 let pack = walked.0.as_ref().map_or_else(|| pack_bytes(&commands), PackSizer::pack_bytes);
910 (answered, pack, first + walked.1, true)
911 };
912 let ms = g1t_kit::now_ms().saturating_sub(started);
913 let failure = match &answered {
914 Ok(response) => resilience::classify_status(response.status_code()),
915 Err(_) => Some(Failure::Transient),
916 };
917 meters::record_health(
918 namespace,
919 match failure {
920 None => meters::Outcome::Ok,
921 Some(Failure::RateLimited) => meters::Outcome::RateLimited,
922 Some(_) => meters::Outcome::Failed,
923 },
924 ms,
925 );
926 // A push is never tried again: the store may have taken it.
927 let response = match (answered, failure) {
928 (Ok(response), None) => response,
929 (Ok(response), Some(Failure::RateLimited)) => {
930 drop(response);
931 busy_response(Busy { rate_limited: true, retry_after: 5 })?
932 }
933 (Ok(response), Some(_)) => response,
934 (Err(error), _) => {
935 worker::console_error!("a push did not reach the store: {error}");
936 busy_response(Busy { rate_limited: false, retry_after: 5 })?
937 }
938 };
939 Ok(Push::Forwarded(Forwarded {
940 response,
Events service in Rust, with RFC 3339 times and accurate push events941 pushed,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily942 pack_bytes,
943 sent,
944 from_store: true,
945 unscanned,
Agents as a team: lifecycle, merge queue, billing and a new shell946 }))
Events service in Rust, with RFC 3339 times and accurate push events947}
948
949#[cfg(test)]
950mod tests {
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms951 use super::{Pushed, RepoPath, Url, ZERO_ID, framed, pack_bytes, pushed_branches, refusal, server_timing, transferred, with_head, with_namespace};
952
953 #[test]
954 fn server_timing_names_each_step_and_the_total() {
955 assert_eq!(
956 server_timing(&[("repo", 12), ("token", 0), ("store", 140)], &[], 153),
957 "repo;dur=12, token;dur=0, store;dur=140, total;dur=153"
958 );
959 assert_eq!(server_timing(&[], &[], 3), "total;dur=3");
960 assert_eq!(
961 server_timing(&[("repo", 1), ("cache", 2)], &[("refs", "hit-colo")], 4),
962 "repo;dur=1, cache;dur=2, refs;desc=hit-colo, total;dur=4"
963 );
964 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look965
966 #[test]
967 fn a_renamed_repository_redirects_to_its_new_name() {
968 // A rename keeps the old path in the same table as a transfer, so
969 // the old remote is sent to the new name the same way.
970 let to = RepoPath {
971 namespace: "acme".into(),
972 name: "booster".into(),
973 };
974 let url = Url::parse("https://g1t.sh/acme/rocket.git/info/refs?service=git-upload-pack").unwrap();
975 assert_eq!(
976 transferred(&url, &to).as_deref(),
977 Some("https://g1t.sh/acme/booster.git/info/refs?service=git-upload-pack")
978 );
979 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put980
981 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look982 fn head_follows_the_default_branch_in_a_v0_advertisement() {
983 let main = "1111111111111111111111111111111111111111";
984 let trunk = "2222222222222222222222222222222222222222";
985 let body = [
986 pkt("# service=git-upload-pack\n"),
987 b"0000".to_vec(),
988 pkt(&format!("{main} HEAD\0multi_ack symref=HEAD:refs/heads/main agent=git/2\n")),
989 pkt(&format!("{main} refs/heads/main\n")),
990 pkt(&format!("{trunk} refs/heads/trunk\n")),
991 b"0000".to_vec(),
992 ]
993 .concat();
994 let changed = String::from_utf8(with_head(&body, "trunk").unwrap()).unwrap();
995 assert!(changed.contains(&format!("{trunk} HEAD\0multi_ack symref=HEAD:refs/heads/trunk agent=git/2\n")));
996 assert!(changed.contains(&format!("{main} refs/heads/main\n")));
997 assert!(changed.starts_with("001e# service=git-upload-pack\n0000"));
998 // Already right, or a branch it does not have: left alone.
999 assert!(with_head(&body, "main").is_none());
1000 assert!(with_head(&body, "gone").is_none());
1001 }
1002
1003 #[test]
1004 fn head_follows_the_default_branch_in_a_v2_listing() {
1005 let main = "1111111111111111111111111111111111111111";
1006 let trunk = "2222222222222222222222222222222222222222";
1007 let body = [
1008 pkt(&format!("{main} HEAD symref-target:refs/heads/main\n")),
1009 pkt(&format!("{main} refs/heads/main\n")),
1010 pkt(&format!("{trunk} refs/heads/trunk\n")),
1011 b"0000".to_vec(),
1012 ]
1013 .concat();
1014 let changed = String::from_utf8(with_head(&body, "trunk").unwrap()).unwrap();
1015 assert!(changed.starts_with(&String::from_utf8(pkt(&format!("{trunk} HEAD symref-target:refs/heads/trunk\n"))).unwrap()));
1016 assert!(changed.ends_with("0000"));
1017 // Without symrefs asked for, only the commit changes.
1018 let plain = [pkt(&format!("{main} HEAD\n")), pkt(&format!("{trunk} refs/heads/trunk\n")), b"0000".to_vec()].concat();
1019 let changed = String::from_utf8(with_head(&plain, "trunk").unwrap()).unwrap();
1020 assert!(changed.starts_with(&format!("0032{trunk} HEAD\n")));
1021 }
1022
1023 #[test]
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1024 fn a_push_is_measured_by_the_pack_after_its_commands() {
1025 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
1026 let new = "4807077b296e6edbf410d55e72749d3e1170c291";
1027 let pack = b"PACK\0\0\0\x02\0\0\0\0rest-of-pack";
1028 let body = [
1029 pkt(&format!("{old} {new} refs/heads/main\0 report-status\n")),
1030 b"0000".to_vec(),
1031 pack.to_vec(),
1032 ]
1033 .concat();
1034 assert_eq!(pack_bytes(&body), pack.len() as u64);
1035 // Only deletions: no pack.
1036 let body = [pkt(&format!("{old} {ZERO_ID} refs/heads/gone\n")), b"0000".to_vec()].concat();
1037 assert_eq!(pack_bytes(&body), 0);
1038 assert_eq!(pack_bytes(b"garbage"), 0);
1039 }
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1040
1041 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1042 fn a_transferred_repository_keeps_the_rest_of_the_address() {
1043 let to = RepoPath {
1044 namespace: "flagon-io".into(),
1045 name: "g1t".into(),
1046 };
1047 let url = Url::parse("https://g1t.sh/syntaqx/g1t.git/info/refs?service=git-receive-pack").unwrap();
1048 assert_eq!(
1049 transferred(&url, &to).as_deref(),
1050 Some("https://g1t.sh/flagon-io/g1t.git/info/refs?service=git-receive-pack")
1051 );
1052 let url = Url::parse("https://g1t.sh/syntaqx/g1t/git-upload-pack").unwrap();
1053 assert_eq!(
1054 transferred(&url, &to).as_deref(),
1055 Some("https://g1t.sh/flagon-io/g1t/git-upload-pack")
1056 );
1057 }
1058
1059 #[test]
Agents and memory, checks and conflicts, profiles, slug renames, custom domains1060 fn a_renamed_workspace_keeps_the_rest_of_the_address() {
1061 let url = worker::Url::parse(
1062 "https://g1t.sh/acme/rocket.git/info/refs?service=git-upload-pack",
1063 )
1064 .unwrap();
1065 assert_eq!(
1066 with_namespace(&url, "acme-inc").as_deref(),
1067 Some("https://g1t.sh/acme-inc/rocket.git/info/refs?service=git-upload-pack")
1068 );
1069 let bare = worker::Url::parse("https://g1t.sh/acme").unwrap();
1070 assert_eq!(with_namespace(&bare, "acme-inc"), None);
1071 }
Events service in Rust, with RFC 3339 times and accurate push events1072
1073 fn pkt(payload: &str) -> Vec<u8> {
1074 format!("{:04x}{payload}", payload.len() + 4).into_bytes()
1075 }
1076
1077 #[test]
1078 fn pushed_branches_are_read_from_the_commands() {
1079 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
1080 let new = "4807077b296e6edbf410d55e72749d3e1170c291";
1081 let body = [
1082 pkt(&format!(
1083 "{old} {new} refs/heads/main\0 report-status side-band-64k\n"
1084 )),
1085 pkt(&format!("{ZERO_ID} {new} refs/heads/feature/x\n")),
1086 pkt(&format!("{old} {ZERO_ID} refs/heads/gone\n")),
1087 pkt(&format!("{ZERO_ID} {new} refs/tags/v1\n")),
1088 b"0000".to_vec(),
1089 b"PACK\0\0\0\x02\0\0\0\0".to_vec(),
1090 ]
1091 .concat();
1092 assert_eq!(
1093 pushed_branches(&body),
1094 [
GitHub Actions on g1t, part one: reading workflows1095 Pushed {
1096 git_ref: "refs/heads/main".to_owned(),
1097 before: Some(old.to_owned()),
1098 after: new.to_owned()
1099 },
1100 Pushed {
1101 git_ref: "refs/heads/feature/x".to_owned(),
1102 before: None,
1103 after: new.to_owned()
1104 },
1105 Pushed {
1106 git_ref: "refs/tags/v1".to_owned(),
1107 before: None,
1108 after: new.to_owned()
1109 },
Events service in Rust, with RFC 3339 times and accurate push events1110 ]
1111 );
1112 }
1113
1114 #[test]
Agents as a team: lifecycle, merge queue, billing and a new shell1115 fn a_push_to_a_protected_branch_is_declined_with_the_reason() {
1116 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
1117 let new = "4807077b296e6edbf410d55e72749d3e1170c291";
1118 let body = [
1119 pkt(&format!("{old} {new} refs/heads/main\0 report-status\n")),
1120 pkt(&format!("{ZERO_ID} {new} refs/heads/feature\n")),
1121 b"0000".to_vec(),
1122 ]
1123 .concat();
1124 let report = String::from_utf8(refusal(&body, "main").unwrap()).unwrap();
1125 assert!(report.starts_with("000eunpack ok\n"));
1126 assert!(report.contains("ng refs/heads/main main is protected"));
1127 assert!(report.contains("ng refs/heads/feature not pushed"));
1128 assert!(report.ends_with("0000"));
1129 }
1130
1131 #[test]
1132 fn the_report_is_framed_for_a_client_that_asked_for_side_band() {
1133 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
1134 let body = [
1135 pkt(&format!(
1136 "{old} {ZERO_ID} refs/heads/main\0 report-status side-band-64k\n"
1137 )),
1138 b"0000".to_vec(),
1139 ]
1140 .concat();
1141 let report = refusal(&body, "main").unwrap();
1142 // A length, then channel 1, then the report itself.
1143 assert_eq!(report[4], 1);
1144 assert_eq!(&report[5..18], b"000eunpack ok");
1145 assert!(report.ends_with(b"00000000"));
1146 }
1147
1148 #[test]
1149 fn other_branches_and_a_first_push_are_let_through() {
1150 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
1151 let new = "4807077b296e6edbf410d55e72749d3e1170c291";
1152 let feature = [
1153 pkt(&format!("{old} {new} refs/heads/feature\0 report-status\n")),
1154 b"0000".to_vec(),
1155 ]
1156 .concat();
1157 assert!(refusal(&feature, "main").is_none());
1158 // An empty repository has to be able to receive its first commits.
1159 let first = [
1160 pkt(&format!(
1161 "{ZERO_ID} {new} refs/heads/main\0 report-status\n"
1162 )),
1163 b"0000".to_vec(),
1164 ]
1165 .concat();
1166 assert!(refusal(&first, "main").is_none());
1167 }
1168
1169 #[test]
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API1170 fn a_blocked_push_explains_itself_on_the_progress_channel() {
1171 let report = b"000eunpack ok\n0000".to_vec();
1172 let messages = vec!["g1t found a secret in this push, so nothing was pushed.".to_owned()];
1173 let body = framed(report.clone(), "report-status side-band-64k", &messages);
1174 // Channel 2 first, which git prints as `remote:` lines.
1175 assert_eq!(body[4], 2);
1176 assert!(String::from_utf8_lossy(&body).contains("so nothing was pushed.\n"));
1177 let at = body.windows(5).position(|w| w == b"000eu").unwrap();
1178 assert_eq!(body[at - 1], 1);
1179 assert!(body.ends_with(b"0000"));
1180 // A client without side-band gets the bare report.
1181 assert_eq!(framed(report.clone(), "report-status", &messages), report);
1182 }
1183
1184 #[test]
Events service in Rust, with RFC 3339 times and accurate push events1185 fn a_fetch_request_names_no_branches() {
1186 assert!(
1187 pushed_branches(b"0032want c71546fcd893ef8b0f57388b65e620d759705dda\n0000").is_empty()
1188 );
1189 }
Rust repos service with shipping; pull requests kept in the model1190}