Skip to content

g1t/services/repos/src/git_http.rs

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

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