Skip to content
1,633 linesCodeBlameRaw

Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.

The statement is a month at a time, a line per kind of charge1//! 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;
The statement is a month at a time, a line per kind of charge8use 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;
The statement is a month at a time, a line per kind of charge13use 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};
A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer17use crate::push_checks::{Checks, Verdict};
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily18use crate::resilience::{self, Busy, Failure};
19
The statement is a month at a time, a line per kind of charge20const ENDPOINTS: [&str; 3] = ["info/refs", "git-upload-pack", "git-receive-pack"];
21const FORWARDED_HEADERS: [&str; 5] = [
22 "accept",
23 "content-encoding",
24 "content-type",
25 "git-protocol",
26 "user-agent",
27];
28
29/// A git request, parsed from its URL.
30pub struct GitRequest {
31 pub path: RepoPath,
32 pub endpoint: &'static str,
33 pub service: GitService,
34}
35
Merge branch 'worktree-agent-a8385d293d42c913a'36impl GitRequest {
37 /// The same request for the repository of the same name under
38 /// `namespace`: where a workspace alias leads.
39 pub fn under(&self, namespace: &str) -> GitRequest {
40 GitRequest {
41 path: RepoPath {
42 namespace: namespace.to_owned(),
43 name: self.path.name.clone(),
44 },
45 endpoint: self.endpoint,
46 service: self.service,
47 }
48 }
49}
50
The statement is a month at a time, a line per kind of charge51/// Parses `/<namespace>/<name>[.git]/<endpoint>`, or returns `None` if the
52/// request is not git's.
53pub fn parse(url: &Url) -> Option<GitRequest> {
54 let path = url.path().strip_prefix('/')?;
55 let endpoint = ENDPOINTS
56 .into_iter()
57 .find(|endpoint| path.ends_with(&format!("/{endpoint}")))?;
58 let repo = &path[..path.len() - endpoint.len() - 1];
59 let (namespace, name) = repo.split_once('/')?;
60 let name = name.strip_suffix(".git").unwrap_or(name);
61 if namespace.is_empty() || name.is_empty() || name.contains('/') {
62 return None;
63 }
64 let service = if endpoint == "info/refs" {
65 url.query_pairs()
66 .find(|(key, _)| key == "service")
67 .map(|(_, value)| value.into_owned())?
68 } else {
69 endpoint.to_owned()
70 };
71 let service = match service.as_str() {
72 "git-upload-pack" => GitService::UploadPack,
73 "git-receive-pack" => GitService::ReceivePack,
74 _ => return None,
75 };
76 Some(GitRequest {
77 path: RepoPath {
78 namespace: namespace.to_owned(),
79 name: name.to_owned(),
80 },
81 endpoint,
82 service,
83 })
84}
85
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms86/// How long each step of a git request took, sent back to git as a
87/// `Server-Timing` header so that a slow clone shows where its time went.
88/// Step names and whole milliseconds only. The Workers clock moves only
89/// while a request waits on something, so each step is the time spent
90/// waiting on the database, another service or the git store. A note
A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer91/// says how a step went without a duration: `refs;desc=hit-colo`. A part
92/// is one of several things a step did at once, with its own duration: a
93/// push's `checks` step is its `read`, `rules` and `scan` parts side by side.
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms94pub struct Timing {
95 started: u64,
96 last: u64,
97 steps: Vec<(&'static str, u64)>,
A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer98 parts: Vec<(&'static str, u64)>,
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms99 notes: Vec<(&'static str, &'static str)>,
100}
101
102impl Timing {
103 pub fn start() -> Self {
104 let now = g1t_kit::now_ms();
105 Self {
106 started: now,
107 last: now,
108 steps: Vec::new(),
A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer109 parts: Vec::new(),
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms110 notes: Vec::new(),
111 }
112 }
113
A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer114 /// Says how long `part` of the step under way took. Parts overlap, so
115 /// they are listed after the steps and are not part of the total.
116 pub fn part(&mut self, part: &'static str, ms: u64) {
117 self.parts.push((part, ms));
118 }
119
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms120 /// Says how `name` went: where an answer or a credential came from.
121 pub fn note(&mut self, name: &'static str, description: &'static str) {
122 self.notes.push((name, description));
123 }
124
125 /// Ends a step, named `step`, that began when the last one ended.
126 pub fn mark(&mut self, step: &'static str) {
127 let now = g1t_kit::now_ms();
128 self.steps.push((step, now.saturating_sub(self.last)));
129 self.last = now;
130 }
131
132 /// `response`, with how long each step took.
133 pub fn apply(&self, response: Response) -> Result<Response> {
134 let total = g1t_kit::now_ms().saturating_sub(self.started);
135 let headers = response.headers().clone();
A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer136 headers.set("server-timing", &server_timing(&self.steps, &self.parts, &self.notes, total))?;
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms137 Ok(response.with_headers(headers))
138 }
139}
140
A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer141/// A `Server-Timing` value: each step with its duration, then each part,
142/// the notes, and the total.
143fn server_timing(steps: &[(&str, u64)], parts: &[(&str, u64)], notes: &[(&str, &str)], total: u64) -> String {
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms144 steps
145 .iter()
A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer146 .chain(parts)
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms147 .map(|(step, ms)| format!("{step};dur={ms}"))
148 .chain(notes.iter().map(|(name, description)| format!("{name};desc={description}")))
149 .chain(std::iter::once(format!("total;dur={total}")))
150 .collect::<Vec<_>>()
151 .join(", ")
152}
153
The statement is a month at a time, a line per kind of charge154/// The user named by an HTTP Basic `Authorization` header, as git sends it.
155pub async fn viewer(request: &Request, identity: &Fetcher) -> Result<Viewer> {
156 let Some(header) = request.headers().get("authorization")? else {
157 return Ok(None);
158 };
159 let Some((scheme, encoded)) = header.split_once(' ') else {
160 return Ok(None);
161 };
162 if !scheme.eq_ignore_ascii_case("basic") {
163 return Ok(None);
164 }
165 let Some(decoded) = decode_base64(encoded.trim()) else {
166 return Ok(None);
167 };
168 let Some((username, secret)) = decoded.split_once(':') else {
169 return Ok(None);
170 };
171 g1t_kit::call(
172 identity,
173 "user_for_git_credentials",
174 &GitCredentialsArgs {
175 username: username.to_owned(),
176 secret: secret.to_owned(),
177 },
178 )
179 .await
180}
181
182/// Standard base64 to a UTF-8 string, or `None` if either step fails.
183fn decode_base64(input: &str) -> Option<String> {
184 let mut bytes = Vec::with_capacity(input.len() * 3 / 4);
185 let mut buffer = 0u32;
186 let mut bits = 0;
187 for byte in input.bytes().filter(|byte| *byte != b'=') {
188 let value = match byte {
189 b'A'..=b'Z' => byte - b'A',
190 b'a'..=b'z' => byte - b'a' + 26,
191 b'0'..=b'9' => byte - b'0' + 52,
192 b'+' => 62,
193 b'/' => 63,
194 _ => return None,
195 };
196 buffer = (buffer << 6) | u32::from(value);
197 bits += 6;
198 if bits >= 8 {
199 bits -= 8;
200 bytes.push((buffer >> bits) as u8);
201 }
202 }
203 String::from_utf8(bytes).ok()
204}
205
206/// The response for a refused git request. Anonymous callers are asked to
207/// authenticate, which is what makes git prompt for credentials.
208pub fn refuse<T>(outcome: Outcome<T>) -> Result<Response> {
209 let Outcome::Fail(failure) = outcome else {
210 return Response::error("Not found", 404);
211 };
212 let mut response = Response::error(failure.message, failure.code.http_status())?;
213 if failure.code == FailureCode::Unauthenticated {
214 response
215 .headers_mut()
216 .set("www-authenticate", "Basic realm=\"g1t\"")?;
217 }
218 Ok(response)
219}
220
221/// `url` with its first path segment, the workspace, replaced by `slug`.
222pub fn with_namespace(url: &Url, slug: &str) -> Option<String> {
223 let rest = url.path().strip_prefix('/')?.split_once('/')?.1;
224 let mut moved = url.clone();
225 moved.set_path(&format!("/{slug}/{rest}"));
226 Some(moved.to_string())
227}
228
229/// Where a git request for a renamed workspace's old address should go
230/// now, if its first segment is an old slug that still redirects.
231pub async fn renamed(url: &Url, identity: &Fetcher) -> Result<Option<String>> {
232 let Some(old) = url.path().strip_prefix('/').and_then(|path| path.split('/').next()) else {
233 return Ok(None);
234 };
235 let current: Option<String> = g1t_kit::call(
236 identity,
237 "resolve_slug",
238 &g1t_contracts::identity::SlugArgs {
239 slug: old.to_owned(),
240 },
241 )
242 .await?;
243 Ok(current.and_then(|slug| with_namespace(url, &slug)))
244}
245
Merge branch 'worktree-agent-a8385d293d42c913a'246/// The request under the workspace its first segment is an alias of
247/// (identity's aliases.rs: `g1t` for `flagon-io`), if it is one. Answered
248/// in place rather than redirected: a push does not follow a redirect.
249pub async fn aliased(git: &GitRequest, identity: &Fetcher) -> Result<Option<GitRequest>> {
250 let slug: Option<String> = g1t_kit::call(
251 identity,
252 "resolve_alias",
253 &g1t_contracts::identity::SlugArgs {
254 slug: git.path.namespace.clone(),
255 },
256 )
257 .await?;
258 Ok(slug.map(|slug| git.under(&slug)))
259}
260
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look261/// `url` with its repository, the first two path segments, replaced by
262/// `to`: where a request for a transferred repository's old path goes.
263/// Keeps whether the old address ended in `.git`.
264pub fn transferred(url: &Url, to: &RepoPath) -> Option<String> {
265 let path = url.path().strip_prefix('/')?;
266 let mut segments = path.splitn(3, '/');
267 let (_, name, rest) = (segments.next()?, segments.next()?, segments.next()?);
268 let suffix = if name.ends_with(".git") { ".git" } else { "" };
269 let mut moved = url.clone();
270 moved.set_path(&format!("/{}/{}{suffix}/{rest}", to.namespace, to.name));
271 Some(moved.to_string())
272}
273
The statement is a month at a time, a line per kind of charge274/// A permanent redirect: 301 for git's first request for refs, which it
275/// follows and then uses the new address for the rest; 308 for the
276/// others, so a POST stays a POST.
277pub fn moved(location: &str, get: bool) -> Result<Response> {
278 let mut response = Response::empty()?.with_status(if get { 301 } else { 308 });
279 response.headers_mut().set("location", location)?;
280 Ok(response)
281}
282
283const ZERO_ID: &str = "0000000000000000000000000000000000000000";
284const HEADS: &str = "refs/heads/";
285const TAGS: &str = "refs/tags/";
286
287/// One ref a push asks to change.
288struct Command {
289 old: String,
290 new: String,
291 name: String,
292}
293
294/// The commands at the start of a receive-pack request, and the
295/// capabilities the client sent with the first of them.
296fn commands(body: &[u8]) -> (Vec<Command>, String) {
297 let mut commands = Vec::new();
298 let mut capabilities = String::new();
299 let mut position = 0;
300 // Commands are pkt-lines; a flush packet ends them and the pack follows.
301 while let Some(length) = body
302 .get(position..position + 4)
303 .and_then(|hex| std::str::from_utf8(hex).ok())
304 .and_then(|hex| usize::from_str_radix(hex, 16).ok())
305 {
306 if length < 4 || position + length > body.len() {
307 break;
308 }
309 let line = &body[position + 4..position + length];
310 position += length;
311 // `<old> <new> <ref>`, and on the first command a NUL then capabilities.
312 let mut halves = line.splitn(2, |byte| *byte == 0);
313 let command = halves.next().unwrap_or_default();
314 if let Some(rest) = halves.next() {
315 capabilities = String::from_utf8_lossy(rest).trim().to_owned();
316 }
317 let Ok(command) = std::str::from_utf8(command) else {
318 continue;
319 };
320 let mut parts = command.trim_end().splitn(3, ' ');
321 if let (Some(old), Some(new), Some(name)) = (parts.next(), parts.next(), parts.next()) {
322 commands.push(Command {
323 old: old.to_owned(),
324 new: new.to_owned(),
325 name: name.to_owned(),
326 });
327 }
328 }
329 (commands, capabilities)
330}
331
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put332/// The bytes of the pack a receive-pack request carries: everything after
333/// the flush packet that ends its commands. Zero for a push that only
334/// deletes refs.
335pub(crate) fn pack_bytes(body: &[u8]) -> u64 {
336 let mut position = 0;
337 while let Some(length) = body
338 .get(position..position + 4)
339 .and_then(|hex| std::str::from_utf8(hex).ok())
340 .and_then(|hex| usize::from_str_radix(hex, 16).ok())
341 {
342 if length == 0 {
343 return (body.len() - position - 4) as u64;
344 }
345 if length < 4 || position + length > body.len() {
346 break;
347 }
348 position += length;
349 }
350 0
351}
352
The statement is a month at a time, a line per kind of charge353fn pkt_line(payload: &[u8]) -> Vec<u8> {
354 let mut line = format!("{:04x}", payload.len() + 4).into_bytes();
355 line.extend_from_slice(payload);
356 line
357}
358
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge359/// The branches and tags a push asks to change, as rules see them: the
360/// full ref, where it pointed (`None`: it is created) and where it will
361/// (`None`: it is deleted).
362pub(crate) fn ref_updates(body: &[u8]) -> Vec<(String, Option<String>, Option<String>)> {
363 commands(body)
364 .0
365 .into_iter()
366 .filter(|command| command.name.starts_with(HEADS) || command.name.starts_with(TAGS))
367 .map(|Command { old, new, name }| (name, (old != ZERO_ID).then_some(old), (new != ZERO_ID).then_some(new)))
368 .collect()
369}
370
371/// A report-status answer, as git expects it.
372pub(crate) fn report_response(report: Vec<u8>) -> Result<Response> {
373 let headers = Headers::new();
374 headers.set("content-type", "application/x-git-receive-pack-result")?;
375 headers.set("cache-control", "no-cache")?;
376 Ok(Response::from_bytes(report)?.with_headers(headers))
377}
378
The statement is a month at a time, a line per kind of charge379/// What git is told when a push would change a protected branch: every ref
380/// in it is declined, with the reason against the protected one, so that
381/// git prints it beside the branch. `None` if the push leaves the branch
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge382/// alone, or creates it in a repository that does not have it yet. Only
383/// where rulesets cannot be read (an installation without the work
384/// service); rulesets decide everywhere else (rules.rs).
385pub(crate) fn refusal(body: &[u8], protected: &str) -> Option<Vec<u8>> {
The statement is a month at a time, a line per kind of charge386 let (commands, capabilities) = commands(body);
387 let reference = format!("{HEADS}{protected}");
388 if !commands
389 .iter()
390 .any(|command| command.name == reference && command.old != ZERO_ID)
391 {
392 return None;
393 }
394 let mut report = pkt_line(b"unpack ok\n");
395 for command in &commands {
396 let reason = if command.name == reference {
397 format!("{protected} is protected: push a branch and open a pull request")
398 } else {
399 format!("not pushed, because the same push would change {protected}")
400 };
401 report.extend(pkt_line(
402 format!("ng {} {reason}\n", command.name).as_bytes(),
403 ));
404 }
405 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 API406 Some(framed(report, &capabilities, &[]))
407}
408
409/// A report-status as git expects it: inside channel 1 when the client
410/// asked for side-band, after `messages` on channel 2, which git prints as
411/// `remote:` lines. Without side-band the messages cannot be shown.
412fn framed(report: Vec<u8>, capabilities: &str, messages: &[String]) -> Vec<u8> {
The statement is a month at a time, a line per kind of charge413 let sideband = capabilities
414 .split(' ')
415 .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 API416 if !sideband {
417 return report;
418 }
419 let mut body = Vec::new();
420 for message in messages {
421 let mut packet = vec![2u8];
422 packet.extend_from_slice(message.as_bytes());
423 packet.push(b'\n');
424 body.extend(pkt_line(&packet));
425 }
426 // side-band (not -64k) packets carry at most 1000 bytes.
427 for chunk in report.chunks(990) {
428 let mut packet = vec![1u8];
429 packet.extend_from_slice(chunk);
430 body.extend(pkt_line(&packet));
431 }
432 body.extend_from_slice(b"0000");
433 body
434}
435
436/// Declines every ref in a push with `reason`, explaining why in
437/// `messages`: what push protection answers when a push adds a secret.
438pub fn declined(body: &[u8], reason: &str, messages: &[String]) -> Result<Response> {
439 let (commands, capabilities) = commands(body);
440 let mut report = pkt_line(b"unpack ok\n");
441 for command in &commands {
442 report.extend(pkt_line(format!("ng {} {reason}\n", command.name).as_bytes()));
443 }
444 report.extend_from_slice(b"0000");
445 let headers = Headers::new();
446 headers.set("content-type", "application/x-git-receive-pack-result")?;
447 headers.set("cache-control", "no-cache")?;
448 Ok(Response::from_bytes(framed(report, &capabilities, messages))?.with_headers(headers))
The statement is a month at a time, a line per kind of charge449}
450
451/// A branch or tag a push asks to move.
452#[derive(Debug, PartialEq, Eq)]
453pub struct Pushed {
454 /// The full ref: `refs/heads/main`, `refs/tags/v1`.
455 pub git_ref: String,
456 /// Where it pointed before; `None` for a new ref.
457 pub before: Option<String>,
458 pub after: String,
459}
460
461impl Pushed {
462 pub fn branch(&self) -> Option<&str> {
463 self.git_ref.strip_prefix(HEADS)
464 }
465}
466
467/// The branches and tags a push asks to move, read from the commands at the
468/// start of a receive-pack request. Deletions and other refs are left out.
469fn pushed_branches(body: &[u8]) -> Vec<Pushed> {
470 commands(body)
471 .0
472 .into_iter()
473 .filter(|command| command.new != ZERO_ID)
474 .filter(|command| command.name.starts_with(HEADS) || command.name.starts_with(TAGS))
475 .map(|Command { old, new, name }| Pushed {
476 git_ref: name,
477 before: (old != ZERO_ID).then_some(old),
478 after: new,
479 })
480 .collect()
481}
482
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.483/// The branches and tags a push asks to delete: each full ref, with the
484/// commit it pointed to.
485fn deleted_refs(body: &[u8]) -> Vec<(String, String)> {
486 commands(body)
487 .0
488 .into_iter()
489 .filter(|command| command.new == ZERO_ID && command.old != ZERO_ID)
490 .filter(|command| command.name.starts_with(HEADS) || command.name.starts_with(TAGS))
491 .map(|Command { old, name, .. }| (name, old))
492 .collect()
493}
494
The statement is a month at a time, a line per kind of charge495/// The git store's answer, and what the request asked it to change.
496pub struct Forwarded {
497 pub response: Response,
498 /// For a push: the branches and tags it asks to move, and the commits
499 /// to move them to. Whether each moved is for the caller to confirm.
500 pub pushed: Vec<Pushed>,
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.501 /// For a push: the branches and tags it asks to delete, each with the
502 /// commit it pointed to. Whether each went is for the caller to confirm.
503 pub deleted: Vec<(String, String)>,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put504 /// For a push: the size of the pack it sent, for the storage meter.
505 pub pack_bytes: u64,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily506 /// The bytes sent to the store.
507 pub sent: u64,
508 /// Whether the answer is the store's own (not g1t's, for a store that
509 /// was busy).
510 pub from_store: bool,
511 /// For a push: whether it was too large to scan for secrets first and
512 /// was streamed to the store unscanned (`LargePushes::Unscanned`). Its
513 /// `git.push` events say so, and security scans it after it lands.
514 pub unscanned: bool,
The statement is a month at a time, a line per kind of charge515}
516
517/// What became of a git request.
518pub enum Push {
519 Forwarded(Forwarded),
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge520 /// A push the rules of its branches or tags refuse (rules.rs), answered
521 /// here without reaching the store.
The statement is a month at a time, a line per kind of charge522 Refused(Response),
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API523 /// A push that adds a secret nobody allowed, answered the same way.
524 Blocked(Response),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily525 /// A push the store could not hold: an object or the repository too
526 /// large, or too large to check. With the reason, for the audit log.
527 Declined(Response, String),
528}
529
530/// What a push may bring, checked as it arrives (pack_limits.rs).
531#[derive(Clone, Copy, Debug)]
532pub struct PushLimits {
533 /// The largest object the store holds.
534 pub max_object: u64,
535 /// What the repository holds now, as g1t counts it.
536 pub held: u64,
537 /// The most a repository may hold.
538 pub repo_limit: u64,
539 /// The largest push that is read whole and scanned for secrets.
540 pub scan_cap: usize,
541 /// What happens to a larger one.
542 pub large: LargePushes,
543}
544
545impl Default for PushLimits {
546 fn default() -> Self {
547 PushLimits {
548 max_object: crate::pack_limits::MAX_OBJECT_BYTES,
549 held: 0,
550 repo_limit: crate::pack_limits::DEFAULT_REPO_LIMIT_BYTES,
551 scan_cap: crate::secret_scan::MAX_SCANNED_PUSH,
552 large: LargePushes::Refuse,
553 }
554 }
555}
556
557/// What happens to a push larger than [`PushLimits::scan_cap`]: set by
558/// `LARGE_PUSHES`.
559#[derive(Clone, Copy, Debug, PartialEq, Eq)]
560pub enum LargePushes {
561 /// Declined (the default): push protection cannot read it, so it does
562 /// not let it in.
563 Refuse,
564 /// Streamed to the store without a scan for secrets; the size limits are
565 /// still checked as it passes.
566 Unscanned,
567}
568
569impl LargePushes {
570 pub fn from_var(value: Option<&str>) -> Self {
571 match value.map(str::trim) {
572 Some("unscanned") => LargePushes::Unscanned,
573 _ => LargePushes::Refuse,
574 }
575 }
576}
577
578/// A push the store could not hold.
579#[derive(Clone, Debug, PartialEq, Eq)]
580pub enum SizeViolation {
581 Object { size: u64 },
582 Repository { held: u64, incoming: u64, limit: u64 },
583 Unscannable { size: u64, cap: usize },
584}
585
586/// Why a push is declined for its size, as git shows it: the `ng` reason,
587/// and the lines printed as `remote:`.
588pub fn size_refusal(violation: &SizeViolation) -> (String, Vec<String>) {
589 use crate::pack_limits::{MAX_OBJECT_BYTES, PLATFORM_BODY_LIMIT_BYTES, megabytes};
590 match violation {
591 SizeViolation::Object { size } => (
592 format!("a file of {} is over the {} limit", megabytes(*size), megabytes(MAX_OBJECT_BYTES)),
593 vec![
594 format!("g1t stores files of up to {} each; this push has one of {}.", megabytes(MAX_OBJECT_BYTES), megabytes(*size)),
595 "Take it out of the commits (git rm --cached, then amend or rebase), and keep large".to_owned(),
596 "files elsewhere: https://docs.g1t.sh/guides/git/#size-limits. Nothing was pushed.".to_owned(),
597 ],
598 ),
599 SizeViolation::Repository { held, incoming, limit } => (
600 "the repository would be over its size limit".to_owned(),
601 vec![
602 format!(
603 "This repository holds about {} and the push adds {}, past the {} a repository may hold.",
604 megabytes(*held),
605 megabytes(*incoming),
606 megabytes(*limit)
607 ),
608 "Delete what you no longer need, or split it: https://docs.g1t.sh/guides/git/#size-limits.".to_owned(),
609 "Nothing was pushed.".to_owned(),
610 ],
611 ),
612 SizeViolation::Unscannable { size, cap } => (
613 "the push is too large to check for secrets".to_owned(),
614 vec![
615 format!(
616 "g1t checks every push for secrets and reads up to {} at once; this one is {}.",
617 megabytes(*cap as u64),
618 megabytes(*size)
619 ),
620 "Push in parts, oldest commits first, then push as usual:".to_owned(),
621 " git rev-list --reverse HEAD | awk 'NR % 500 == 0' | xargs -I{} git push origin {}:refs/heads/main".to_owned(),
622 format!("A push over {} is refused by the network before it reaches g1t (HTTP 413).", megabytes(PLATFORM_BODY_LIMIT_BYTES)),
623 "See https://docs.g1t.sh/guides/git/#size-limits. Nothing was pushed.".to_owned(),
624 ],
625 ),
626 }
627}
628
629/// Feeds the next chunk of a push to the size check; the first violation.
630fn check_size(sizer: &mut Option<PackSizer>, chunk: &[u8], limits: &PushLimits) -> Option<SizeViolation> {
631 let walker = sizer.as_mut()?;
632 match walker.feed(chunk) {
633 Ok(()) => {}
634 Err(Violation::ObjectTooLarge { size }) => return Some(SizeViolation::Object { size }),
635 Err(Violation::Malformed(why)) => {
636 // Not for g1t to judge: the store will say.
637 worker::console_error!("push not checked for size: {why}");
638 *sizer = None;
639 return None;
640 }
641 }
642 let incoming = walker.pack_bytes();
643 crate::pack_limits::over_repo_limit(limits.held, incoming, limits.repo_limit).then_some(SizeViolation::Repository {
644 held: limits.held,
645 incoming,
646 limit: limits.repo_limit,
647 })
648}
649
650/// A request body that streams from `stream`, for `fetch`.
651pub(crate) fn stream_body<S>(stream: S) -> Result<JsValue>
652where
653 S: futures_util::TryStream + 'static,
654 S::Ok: Into<Vec<u8>>,
655 S::Error: Into<worker::Error>,
656{
657 let response: worker::web_sys::Response = Response::from_stream(stream)?.into();
658 Ok(response.body().map_or(JsValue::NULL, Into::into))
659}
660
661/// What git is told when the store is busy: 429 or 503, with when to try
662/// again (resilience.rs).
663pub fn busy_response(busy: Busy) -> Result<Response> {
664 let response = Response::error(busy.message(), busy.status())?;
665 response.headers().set("retry-after", &busy.retry_after.to_string())?;
666 Ok(response)
667}
668
669/// The rest of a request's body, read and thrown away so that git hears
670/// the answer; how many bytes it was.
671async fn drain(stream: &mut worker::ByteStream) -> Result<u64> {
672 let mut size = 0;
673 while let Some(chunk) = stream.next().await {
674 size += chunk?.len() as u64;
675 }
676 Ok(size)
The statement is a month at a time, a line per kind of charge677}
678
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look679/// One packet of a pkt-line stream: data, or a flush (`0000`), delimiter
680/// (`0001`) or response-end (`0002`) packet, kept as its four bytes.
681#[derive(Debug, PartialEq, Eq)]
682enum Packet {
683 Data(Vec<u8>),
684 Special([u8; 4]),
685}
686
687/// The packets in `bytes`, or `None` if it is not a whole pkt-line stream.
688fn packets(bytes: &[u8]) -> Option<Vec<Packet>> {
689 let mut out = Vec::new();
690 let mut position = 0;
691 while position < bytes.len() {
692 let header = bytes.get(position..position + 4)?;
693 let length = usize::from_str_radix(std::str::from_utf8(header).ok()?, 16).ok()?;
694 if length < 4 {
695 out.push(Packet::Special(header.try_into().ok()?));
696 position += 4;
697 continue;
698 }
699 out.push(Packet::Data(bytes.get(position + 4..position + length)?.to_vec()));
700 position += length;
701 }
702 Some(out)
703}
704
705fn encode(packets: &[Packet]) -> Vec<u8> {
706 let mut out = Vec::new();
707 for packet in packets {
708 match packet {
709 Packet::Data(data) => out.extend(pkt_line(data)),
710 Packet::Special(bytes) => out.extend_from_slice(bytes),
711 }
712 }
713 out
714}
715
716/// A ref advertisement (`info/refs` for upload-pack) or a protocol v2
717/// `ls-refs` answer with `HEAD` pointing at `branch`, the repository's
718/// default branch as g1t keeps it, so a clone checks it out. The git store
719/// holds the HEAD it was created with; g1t can change the default branch
720/// since. `None` when there is nothing to change: no `HEAD` line, `HEAD`
721/// already names `branch`, or `branch` is not advertised.
722pub fn with_head(body: &[u8], branch: &str) -> Option<Vec<u8>> {
723 let mut packets = packets(body)?;
724 let target = format!("{HEADS}{branch}");
725 let oid = packets.iter().find_map(|packet| {
726 let Packet::Data(data) = packet else { return None };
727 let line = data.split(|byte| *byte == 0).next()?;
728 let line = std::str::from_utf8(line).ok()?.trim_end();
729 let (oid, name) = line.split_once(' ')?;
730 // v2 lines may carry attributes after the name.
731 let name = name.split(' ').next()?;
732 (name == target).then(|| oid.to_owned())
733 })?;
734 let mut changed = false;
735 for packet in &mut packets {
736 let Packet::Data(data) = packet else { continue };
737 let text = String::from_utf8_lossy(data).into_owned();
738 let Some((_, rest)) = text.split_once(' ') else { continue };
739 if !(rest.starts_with("HEAD\0") || rest.starts_with("HEAD\n") || rest.starts_with("HEAD ") || rest == "HEAD") {
740 continue;
741 }
742 let mut line = format!("{oid} {rest}");
743 // v0: `symref=HEAD:refs/heads/<old>` among the capabilities.
744 // v2: `symref-target:refs/heads/<old>` after the name.
745 for marker in ["symref=HEAD:", "symref-target:"] {
746 if let Some(at) = line.find(marker) {
747 let start = at + marker.len();
748 let end = line[start..]
749 .find([' ', '\n', '\0'])
750 .map_or(line.len(), |offset| start + offset);
751 line.replace_range(start..end, &target);
752 }
753 }
754 changed = line != text;
755 if changed {
756 *data = line.into_bytes();
757 }
758 break;
759 }
760 changed.then(|| encode(&packets))
761}
762
Merge main into Artifacts Phase 2763/// The capability that asks git to send a push's pack whole: every delta's
764/// base inside it, none left for the receiving end to find (a "thin" pack).
765const NO_THIN: &[u8] = b"no-thin";
766
767/// The receive-pack ref advertisement (`info/refs?service=git-receive-pack`,
768/// protocol v0 or v1) with `no-thin` among its capabilities, so that git
769/// sends a pack whose deltas have their bases in it (git's `send-pack`
770/// turns thin packs off when the server says `no-thin`). Push protection
771/// and the rules then read every object from the pack, and none from the
772/// store (secret_scan.rs `supply_bases`). The capabilities follow the NUL
773/// on the first ref line, or on the `capabilities^{}` line of an empty
774/// repository. `None` when there is nothing to change or it cannot be
775/// changed safely: `no-thin` is there already, or the answer is not a whole
776/// pkt-line advertisement in that shape. Never for upload-pack.
777pub fn with_no_thin(body: &[u8]) -> Option<Vec<u8>> {
778 let mut packets = packets(body)?;
779 // Past the `# service=` line and its flush, and v1's `version 1`: the
780 // first ref line, which carries the capabilities.
781 let line = packets.iter_mut().find_map(|packet| match packet {
782 Packet::Data(data) if !data.starts_with(b"# service=") && !data.starts_with(b"version ") => Some(data),
783 _ => None,
784 })?;
785 let nul = line.iter().position(|byte| *byte == 0)?;
786 // `<oid> <ref>` before the NUL: 40 (SHA-1) or 64 (SHA-256) hex digits.
787 let (oid, name) = std::str::from_utf8(&line[..nul]).ok()?.split_once(' ')?;
788 if !matches!(oid.len(), 40 | 64) || !oid.bytes().all(|byte| byte.is_ascii_hexdigit()) || name.is_empty() {
789 return None;
790 }
791 let end = if line.ends_with(b"\n") { line.len() - 1 } else { line.len() };
792 let capabilities = &line[nul + 1..end];
793 if capabilities.split(|byte| *byte == b' ').any(|capability| capability == NO_THIN) {
794 return None;
795 }
796 let mut added = Vec::with_capacity(NO_THIN.len() + 1);
797 if !capabilities.is_empty() && !capabilities.ends_with(b" ") {
798 added.push(b' ');
799 }
800 added.extend_from_slice(NO_THIN);
801 // A pkt-line holds at most 65516 bytes of data.
802 if line.len() + added.len() > 65516 {
803 return None;
804 }
805 line.splice(end..end, added);
806 Some(encode(&packets))
807}
808
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look809/// Whether a request to the git store is one whose answer names `HEAD`:
810/// the ref advertisement for a fetch, or a protocol v2 `ls-refs`.
811fn names_head(git: &GitRequest, body: Option<&[u8]>) -> bool {
812 if git.service != GitService::UploadPack {
813 return false;
814 }
815 match body {
816 None => git.endpoint == "info/refs",
817 Some(body) => {
818 git.endpoint == "git-upload-pack"
819 && body.windows(b"command=ls-refs".len()).any(|window| window == b"command=ls-refs")
820 }
821 }
822}
823
A fetch whose haves the store does not know gets its pack: g1t ends the store's early answer where git expects824/// Whether a request is a protocol v2 `fetch` still negotiating: it sends
825/// `have` lines and no `done`, so the answer may be acknowledgments only.
826fn negotiating(body: &[u8]) -> bool {
827 let Some(packets) = packets(body) else { return false };
828 let lines: Vec<&[u8]> = packets
829 .iter()
830 .filter_map(|packet| match packet {
831 Packet::Data(data) => Some(data.strip_suffix(b"\n").unwrap_or(data)),
832 Packet::Special(_) => None,
833 })
834 .collect();
835 lines.contains(&b"command=fetch".as_slice())
836 && lines.iter().any(|line| line.starts_with(b"have "))
837 && !lines.contains(&b"done".as_slice())
838}
839
840/// What to do with the start of a store's answer to a negotiating fetch.
841#[derive(Debug, PartialEq, Eq)]
842enum Acknowledged {
843 /// Not enough of it yet to tell.
844 NeedMore,
845 /// Send it on as it is.
846 Whole,
847 /// Acknowledgments without `ready`, followed by more sections: the
848 /// store's answer to keep is these first bytes, ended by a flush.
849 CutAt(usize),
850}
851
852/// How much of the answer to keep. The git store answers a fetch whose
853/// `have`s it does not know with `acknowledgments`, `NAK`, then a pack
854/// anyway; git refuses that ("expected no other sections to be sent after
855/// no 'ready'"), since a server that is not ready must end the response
856/// there and let the client negotiate again. Lines may be `sideband-all`
857/// framed (band 1, `\x01`).
858fn acknowledged(head: &[u8]) -> Acknowledged {
859 let mut position = 0;
860 let mut first = true;
861 loop {
862 let Some(header) = head.get(position..position + 4) else { return Acknowledged::NeedMore };
863 let Some(length) = std::str::from_utf8(header).ok().and_then(|hex| usize::from_str_radix(hex, 16).ok()) else {
864 return Acknowledged::Whole;
865 };
866 if length < 4 {
867 // The acknowledgments section's end: a delimiter means more
868 // sections follow, which only `ready` allows.
869 return match (first, header) {
870 (false, b"0001") => Acknowledged::CutAt(position),
871 _ => Acknowledged::Whole,
872 };
873 }
874 let Some(payload) = head.get(position + 4..position + length) else { return Acknowledged::NeedMore };
875 let line = payload.strip_prefix(b"\x01").unwrap_or(payload);
876 let line = line.strip_suffix(b"\n").unwrap_or(line);
877 if first && line != b"acknowledgments" {
878 return Acknowledged::Whole;
879 }
880 if line == b"ready" {
881 return Acknowledged::Whole;
882 }
883 first = false;
884 position += length;
885 }
886}
887
888/// The answer to a negotiating fetch, with the sections the store sent
889/// after acknowledgments without `ready` left off (see [`acknowledged`]).
890/// Reads only the start of the answer; the rest streams through.
891async fn without_early_pack(mut response: Response) -> Result<Response> {
892 const LOOK: usize = 64 * 1024;
893 let headers = response.headers().clone();
894 headers.delete("content-length")?;
895 let mut stream = response.stream()?;
896 let mut head = Vec::new();
897 loop {
898 match acknowledged(&head) {
899 Acknowledged::CutAt(at) => {
900 head.truncate(at);
The early answer ends with a flush only; git's HTTP transport adds the response-end packet itself901 // A flush ends the acknowledgments and the answer. No
902 // response-end packet: git's HTTP transport adds its own
903 // and refuses one from the server.
904 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 expects905 return Ok(Response::from_bytes(head)?.with_headers(headers));
906 }
907 Acknowledged::Whole => break,
908 Acknowledged::NeedMore if head.len() >= LOOK => break,
909 Acknowledged::NeedMore => match stream.next().await {
910 Some(chunk) => head.extend_from_slice(&chunk?),
911 None => break,
912 },
913 }
914 }
915 let rest = futures_util::stream::once(async move { Ok::<Vec<u8>, worker::Error>(head) }).chain(stream);
916 Ok(Response::from_stream(rest)?.with_headers(headers))
917}
918
The statement is a month at a time, a line per kind of charge919/// Sends the request on to the git store and returns its response as is,
A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer920/// unless it is a push `checks` refuse (the workflow gate, the rules, push
921/// protection: push_checks.rs), or one the store could not hold (`limits`,
922/// pack_limits.rs). `checks` is given as much of the push as was read,
923/// whether that is all of it, and whether to scan it for secrets. A
924/// fetch's ref listing has its `HEAD` pointed at `default_branch` (see
925/// [`with_head`]). A POST's body is `read` when the caller has read it
926/// already.
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily927///
928/// A push is read as it arrives: up to `limits.scan_cap` is kept, to be
929/// scanned and sent on whole; past it, the push is declined, or streamed
930/// to the store unscanned (`LargePushes`), never held. Reads the store
931/// fails for a moment (429, 5xx) are tried again with backoff; a push never
932/// is. A store still busy after that is answered 429 or 503 with
933/// `Retry-After`.
934#[allow(clippy::too_many_arguments)]
The statement is a month at a time, a line per kind of charge935pub async fn forward(
936 mut request: Request,
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms937 read: Option<Vec<u8>>,
The statement is a month at a time, a line per kind of charge938 git: &GitRequest,
939 access: &GitAccess,
A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer940 checks: impl AsyncFnOnce(&[u8], bool, bool) -> Result<Checks>,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look941 default_branch: Option<&str>,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily942 limits: PushLimits,
A push says where its time went: receiving it, the rules, the secret scan and the git store's answer each have their own Server-Timing step943 timing: &mut Timing,
The statement is a month at a time, a line per kind of charge944) -> Result<Push> {
945 let headers = Headers::new();
946 headers.set("authorization", &format!("Bearer {}", access.token))?;
947 for name in FORWARDED_HEADERS {
948 if let Some(value) = request.headers().get(name)? {
949 headers.set(name, &value)?;
950 }
951 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily952 let query = request.url()?.query().map(|query| format!("?{query}")).unwrap_or_default();
953 let url = format!("{}/{}{query}", access.remote, git.endpoint);
954 let method = request.method();
Merge branch 'worktree-agent-a2013627e5ea4ab13'955 // Its own health and breaker: the fallback store's apart from Artifacts'.
956 let namespace = crate::store::health_namespace(&access.remote);
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily957
958 if method == Method::Post && git.endpoint == "git-receive-pack" {
A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer959 return push(request, &url, headers, checks, limits, &namespace, timing).await;
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily960 }
961
962 // A read: the ref advertisement, `ls-refs`, or a fetch of objects.
963 let body = match (&method, read) {
964 (Method::Post, Some(body)) => Some(body),
965 (Method::Post, None) => Some(request.bytes().await?),
966 _ => None,
967 };
968 let lists_head = match &body {
969 None => method == Method::Get && names_head(git, None),
970 Some(body) => names_head(git, Some(body)),
971 };
972 let sent = body.as_ref().map_or(0, |body| body.len() as u64);
973 let mut attempt = 0;
974 let mut response = loop {
975 let mut init = RequestInit::new();
976 init.with_method(method.clone()).with_headers(headers.clone());
977 if let Some(body) = &body {
978 init.with_body(Some(Uint8Array::from(body.as_slice()).into()));
979 }
980 let started = g1t_kit::now_ms();
981 let answered = Fetch::Request(Request::new_with_init(&url, &init)?).send().await;
982 let ms = g1t_kit::now_ms().saturating_sub(started);
983 let failure = match &answered {
984 Ok(response) => resilience::classify_status(response.status_code()),
985 Err(_) => Some(Failure::Transient),
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms986 };
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily987 let outcome = match failure {
988 None => meters::Outcome::Ok,
989 Some(Failure::RateLimited) => meters::Outcome::RateLimited,
990 Some(_) => meters::Outcome::Failed,
991 };
992 meters::record_health(&namespace, outcome, ms);
993 match (answered, failure) {
994 (Ok(response), None) => break response,
995 (answered, Some(failure)) if resilience::retry(failure, attempt) => {
996 drop(answered);
997 let wait = resilience::backoff_ms(failure, attempt, worker::js_sys::Math::random());
998 worker::Delay::from(std::time::Duration::from_millis(wait)).await;
999 attempt += 1;
The statement is a month at a time, a line per kind of charge1000 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1001 (_, Some(failure)) => {
Merge branch 'worktree-agent-a2013627e5ea4ab13'1002 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 daily1003 return Ok(Push::Forwarded(Forwarded {
1004 response: busy_response(busy)?,
1005 pushed: Vec::new(),
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.1006 deleted: Vec::new(),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1007 pack_bytes: 0,
1008 sent,
1009 from_store: false,
1010 unscanned: false,
1011 }));
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API1012 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1013 (Err(error), None) => return Err(error),
The statement is a month at a time, a line per kind of charge1014 }
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1015 };
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1016 if let (true, Some(branch)) = (lists_head, default_branch)
1017 && response.status_code() == 200
1018 {
1019 let headers = response.headers().clone();
1020 headers.delete("content-length")?;
1021 let body = response.bytes().await?;
1022 let body = with_head(&body, branch).unwrap_or(body);
1023 response = Response::from_bytes(body)?.with_headers(headers);
1024 }
Merge main into Artifacts Phase 21025 // A push's ref advertisement asks for a pack without outside bases.
1026 if method == Method::Get
1027 && git.service == GitService::ReceivePack
1028 && git.endpoint == "info/refs"
1029 && response.status_code() == 200
1030 {
1031 let headers = response.headers().clone();
1032 headers.delete("content-length")?;
1033 let body = response.bytes().await?;
1034 let body = with_no_thin(&body).unwrap_or(body);
1035 response = Response::from_bytes(body)?.with_headers(headers);
1036 }
A fetch whose haves the store does not know gets its pack: g1t ends the store's early answer where git expects1037 if git.endpoint == "git-upload-pack"
1038 && response.status_code() == 200
1039 && body.as_deref().is_some_and(negotiating)
1040 {
1041 response = without_early_pack(response).await?;
1042 }
The statement is a month at a time, a line per kind of charge1043 Ok(Push::Forwarded(Forwarded {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1044 response,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1045 pushed: Vec::new(),
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.1046 deleted: Vec::new(),
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1047 pack_bytes: 0,
1048 sent,
1049 from_store: true,
1050 unscanned: false,
1051 }))
1052}
1053
A push says where its time went: receiving it, the rules, the secret scan and the git store's answer each have their own Server-Timing step1054/// A receive-pack request; see [`forward`]. Its steps: `recv` (the push
A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer1055/// read), `checks`, `upload` (the store's answer).
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1056#[allow(clippy::too_many_arguments)]
1057async fn push(
1058 mut request: Request,
1059 url: &str,
1060 headers: Headers,
A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer1061 checks: impl AsyncFnOnce(&[u8], bool, bool) -> Result<Checks>,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1062 limits: PushLimits,
1063 namespace: &str,
A push says where its time went: receiving it, the rules, the secret scan and the git store's answer each have their own Server-Timing step1064 timing: &mut Timing,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1065) -> Result<Push> {
1066 let mut stream = request.stream()?;
1067 let mut head: Vec<u8> = Vec::new();
1068 let mut sizer = Some(PackSizer::new(limits.max_object));
1069 let mut violation = None;
1070 let mut ended = false;
1071 while head.len() <= limits.scan_cap {
1072 match stream.next().await {
1073 Some(chunk) => {
1074 let chunk = chunk?;
1075 if violation.is_none() {
1076 violation = check_size(&mut sizer, &chunk, &limits);
1077 }
1078 head.extend_from_slice(&chunk);
1079 }
1080 None => {
1081 ended = true;
1082 break;
1083 }
1084 }
1085 }
A push says where its time went: receiving it, the rules, the secret scan and the git store's answer each have their own Server-Timing step1086 timing.mark("recv");
A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer1087 // The workflow gate and the rules of the branches and tags it changes,
1088 // with push protection alongside for a push read whole that is within
1089 // the size limits (push_checks.rs). What the rules refuse is refused
1090 // whatever else is wrong with it.
1091 let checked = checks(&head, ended, ended && violation.is_none()).await?;
1092 for (part, ms) in checked.spans {
1093 timing.part(part, ms);
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1094 }
Merge main into Artifacts Phase 21095 // Whether the pack came thin: g1t asks for whole ones (`no-thin`, see
1096 // [`with_no_thin`]), so a thin one is a client that ignored it, and its
1097 // bases were read from the store (`read`).
1098 if let Some(bases) = checked.bases {
1099 timing.note("thin", if bases.thin() { "yes" } else { "no" });
1100 if bases.thin() {
1101 let agent = request.headers().get("user-agent").ok().flatten().unwrap_or_default();
1102 worker::console_warn!(
1103 "a thin push from {agent}: {} bases outside the pack, {} asked of the store, {} left unresolved",
1104 bases.missing,
1105 bases.asked,
1106 bases.left
1107 );
1108 }
1109 }
A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer1110 timing.mark("checks");
1111 let blocked = match checked.verdict {
1112 Verdict::Refused(response) => {
1113 if !ended {
1114 drain(&mut stream).await?;
1115 }
1116 return Ok(Push::Refused(response));
1117 }
1118 Verdict::Blocked(response) => Some(response),
1119 Verdict::Clear => None,
1120 };
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1121 if !ended && limits.large == LargePushes::Refuse && violation.is_none() {
1122 let size = head.len() as u64 + drain(&mut stream).await?;
1123 violation = Some(SizeViolation::Unscannable { size, cap: limits.scan_cap });
1124 ended = true;
1125 }
1126 if let Some(violation) = violation {
1127 if !ended {
1128 drain(&mut stream).await?;
1129 }
1130 let (reason, messages) = size_refusal(&violation);
1131 return Ok(Push::Declined(declined(&head, &reason, &messages)?, reason));
1132 }
1133 let pushed = pushed_branches(&head);
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.1134 let deleted = deleted_refs(&head);
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1135 let mut init = RequestInit::new();
1136 init.with_method(Method::Post).with_headers(headers);
1137 let started = g1t_kit::now_ms();
1138 let (answered, pack_bytes, sent, unscanned) = if ended {
A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer1139 if let Some(response) = blocked {
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1140 return Ok(Push::Blocked(response));
1141 }
1142 let pack = pack_bytes(&head);
1143 let sent = head.len() as u64;
1144 init.with_body(Some(Uint8Array::from(head.as_slice()).into()));
1145 drop(head);
1146 (Fetch::Request(Request::new_with_init(url, &init)?).send().await, pack, sent, false)
1147 } else {
1148 // Larger than can be scanned, and let through unscanned: streamed,
1149 // with the size limits checked as it passes. A violation ends the
1150 // stream before the pack does, so the store refuses it whole.
1151 worker::console_warn!("a push of more than {} bytes goes to the store unscanned", limits.scan_cap);
1152 let commands = head.iter().take(64 * 1024).copied().collect::<Vec<u8>>();
1153 let found: Rc<RefCell<Option<SizeViolation>>> = Rc::default();
1154 let walked = Rc::new(RefCell::new((sizer, 0u64)));
1155 let rest = {
1156 let found = found.clone();
1157 let walked = walked.clone();
1158 stream.map(move |chunk| {
1159 let chunk = chunk?;
1160 let mut walked = walked.borrow_mut();
1161 walked.1 += chunk.len() as u64;
1162 if let Some(violation) = check_size(&mut walked.0, &chunk, &limits) {
1163 *found.borrow_mut() = Some(violation);
1164 return Err(worker::Error::RustError("push over the size limit".into()));
1165 }
1166 Ok(chunk)
1167 })
1168 };
1169 let first = head.len() as u64;
1170 let body = futures_util::stream::once(async move { Ok::<Vec<u8>, worker::Error>(head) }).chain(rest);
1171 init.with_body(Some(stream_body(body)?));
1172 let answered = Fetch::Request(Request::new_with_init(url, &init)?).send().await;
1173 if let Some(violation) = found.borrow_mut().take() {
1174 let (reason, messages) = size_refusal(&violation);
1175 return Ok(Push::Declined(declined(&commands, &reason, &messages)?, reason));
1176 }
1177 let walked = walked.borrow();
1178 let pack = walked.0.as_ref().map_or_else(|| pack_bytes(&commands), PackSizer::pack_bytes);
1179 (answered, pack, first + walked.1, true)
1180 };
1181 let ms = g1t_kit::now_ms().saturating_sub(started);
A push says where its time went: receiving it, the rules, the secret scan and the git store's answer each have their own Server-Timing step1182 timing.mark("upload");
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1183 let failure = match &answered {
1184 Ok(response) => resilience::classify_status(response.status_code()),
1185 Err(_) => Some(Failure::Transient),
1186 };
1187 meters::record_health(
1188 namespace,
1189 match failure {
1190 None => meters::Outcome::Ok,
1191 Some(Failure::RateLimited) => meters::Outcome::RateLimited,
1192 Some(_) => meters::Outcome::Failed,
1193 },
1194 ms,
1195 );
1196 // A push is never tried again: the store may have taken it.
1197 let response = match (answered, failure) {
1198 (Ok(response), None) => response,
1199 (Ok(response), Some(Failure::RateLimited)) => {
1200 drop(response);
Merge branch 'worktree-agent-a2013627e5ea4ab13'1201 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 daily1202 }
1203 (Ok(response), Some(_)) => response,
1204 (Err(error), _) => {
1205 worker::console_error!("a push did not reach the store: {error}");
Merge branch 'worktree-agent-a2013627e5ea4ab13'1206 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 daily1207 }
1208 };
1209 Ok(Push::Forwarded(Forwarded {
1210 response,
The statement is a month at a time, a line per kind of charge1211 pushed,
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.1212 deleted,
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily1213 pack_bytes,
1214 sent,
1215 from_store: true,
1216 unscanned,
The statement is a month at a time, a line per kind of charge1217 }))
1218}
1219
1220#[cfg(test)]
1221mod tests {
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.1222 use super::{Acknowledged, GitService, Pushed, RepoPath, Url, ZERO_ID, acknowledged, deleted_refs, framed, negotiating, pack_bytes, parse, pushed_branches, refusal, server_timing, transferred, with_head, with_namespace, with_no_thin};
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms1223
1224 #[test]
1225 fn server_timing_names_each_step_and_the_total() {
1226 assert_eq!(
A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer1227 server_timing(&[("repo", 12), ("token", 0), ("store", 140)], &[], &[], 153),
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms1228 "repo;dur=12, token;dur=0, store;dur=140, total;dur=153"
1229 );
A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer1230 assert_eq!(server_timing(&[], &[], &[], 3), "total;dur=3");
1231 // A push's checks run side by side: their parts come after the steps.
1232 assert_eq!(
1233 server_timing(&[("recv", 30), ("checks", 120), ("upload", 300)], &[("read", 40), ("rules", 110), ("scan", 90)], &[], 450),
1234 "recv;dur=30, checks;dur=120, upload;dur=300, read;dur=40, rules;dur=110, scan;dur=90, total;dur=450"
1235 );
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms1236 assert_eq!(
A push is checked once and side by side: its pack is read and its bases fetched once for the rules, the workflow gate and the secret scan, which run together, other services are asked while the pack is read, and cache writes and rule records finish after git has its answer1237 server_timing(&[("repo", 1), ("cache", 2)], &[], &[("refs", "hit-colo")], 4),
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms1238 "repo;dur=1, cache;dur=2, refs;desc=hit-colo, total;dur=4"
1239 );
1240 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1241
1242 #[test]
1243 fn a_renamed_repository_redirects_to_its_new_name() {
1244 // A rename keeps the old path in the same table as a transfer, so
1245 // the old remote is sent to the new name the same way.
1246 let to = RepoPath {
1247 namespace: "acme".into(),
1248 name: "booster".into(),
1249 };
1250 let url = Url::parse("https://g1t.sh/acme/rocket.git/info/refs?service=git-upload-pack").unwrap();
1251 assert_eq!(
1252 transferred(&url, &to).as_deref(),
1253 Some("https://g1t.sh/acme/booster.git/info/refs?service=git-upload-pack")
1254 );
1255 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1256
1257 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1258 fn head_follows_the_default_branch_in_a_v0_advertisement() {
1259 let main = "1111111111111111111111111111111111111111";
1260 let trunk = "2222222222222222222222222222222222222222";
1261 let body = [
1262 pkt("# service=git-upload-pack\n"),
1263 b"0000".to_vec(),
1264 pkt(&format!("{main} HEAD\0multi_ack symref=HEAD:refs/heads/main agent=git/2\n")),
1265 pkt(&format!("{main} refs/heads/main\n")),
1266 pkt(&format!("{trunk} refs/heads/trunk\n")),
1267 b"0000".to_vec(),
1268 ]
1269 .concat();
1270 let changed = String::from_utf8(with_head(&body, "trunk").unwrap()).unwrap();
1271 assert!(changed.contains(&format!("{trunk} HEAD\0multi_ack symref=HEAD:refs/heads/trunk agent=git/2\n")));
1272 assert!(changed.contains(&format!("{main} refs/heads/main\n")));
1273 assert!(changed.starts_with("001e# service=git-upload-pack\n0000"));
1274 // Already right, or a branch it does not have: left alone.
1275 assert!(with_head(&body, "main").is_none());
1276 assert!(with_head(&body, "gone").is_none());
1277 }
1278
1279 #[test]
Merge main into Artifacts Phase 21280 fn a_push_advertisement_asks_for_a_pack_without_outside_bases() {
1281 let main = "1111111111111111111111111111111111111111";
1282 let topic = "2222222222222222222222222222222222222222";
1283 let body = [
1284 pkt("# service=git-receive-pack\n"),
1285 b"0000".to_vec(),
1286 pkt(&format!("{main} refs/heads/main\0report-status delete-refs side-band-64k quiet ofs-delta agent=git/2.45\n")),
1287 pkt(&format!("{topic} refs/heads/topic\n")),
1288 b"0000".to_vec(),
1289 ]
1290 .concat();
1291 let changed = with_no_thin(&body).unwrap();
1292 let expected = [
1293 pkt("# service=git-receive-pack\n"),
1294 b"0000".to_vec(),
1295 pkt(&format!("{main} refs/heads/main\0report-status delete-refs side-band-64k quiet ofs-delta agent=git/2.45 no-thin\n")),
1296 pkt(&format!("{topic} refs/heads/topic\n")),
1297 b"0000".to_vec(),
1298 ]
1299 .concat();
1300 assert_eq!(String::from_utf8(changed.clone()).unwrap(), String::from_utf8(expected).unwrap());
1301 // The length of the line that grew is its new one: the whole parses.
1302 assert!(super::packets(&changed).is_some());
1303 // Said once: an answer that has it already is left alone.
1304 assert!(with_no_thin(&changed).is_none());
1305
1306 // Protocol v1 begins with `version 1`.
1307 let v1 = [
1308 pkt("# service=git-receive-pack\n"),
1309 b"0000".to_vec(),
1310 pkt("version 1\n"),
1311 pkt(&format!("{main} refs/heads/main\0report-status ofs-delta\n")),
1312 b"0000".to_vec(),
1313 ]
1314 .concat();
1315 let changed = String::from_utf8(with_no_thin(&v1).unwrap()).unwrap();
1316 assert!(changed.contains(&String::from_utf8(pkt(&format!("{main} refs/heads/main\0report-status ofs-delta no-thin\n"))).unwrap()));
1317 assert!(changed.contains("000eversion 1\n"));
1318 }
1319
1320 #[test]
1321 fn an_empty_repository_advertisement_asks_for_a_whole_pack_too() {
1322 let zero = "0000000000000000000000000000000000000000";
1323 let body = [
1324 pkt("# service=git-receive-pack\n"),
1325 b"0000".to_vec(),
1326 pkt(&format!("{zero} capabilities^{{}}\0report-status delete-refs ofs-delta\n")),
1327 b"0000".to_vec(),
1328 ]
1329 .concat();
1330 let changed = String::from_utf8(with_no_thin(&body).unwrap()).unwrap();
1331 assert!(changed.contains(&String::from_utf8(pkt(&format!("{zero} capabilities^{{}}\0report-status delete-refs ofs-delta no-thin\n"))).unwrap()));
1332 // No capabilities at all, and no newline: still one list.
1333 let bare = [pkt("# service=git-receive-pack\n"), b"0000".to_vec(), pkt(&format!("{zero} capabilities^{{}}\0")), b"0000".to_vec()].concat();
1334 let changed = String::from_utf8(with_no_thin(&bare).unwrap()).unwrap();
1335 assert!(changed.contains(&String::from_utf8(pkt(&format!("{zero} capabilities^{{}}\0no-thin"))).unwrap()));
1336 }
1337
1338 #[test]
1339 fn an_advertisement_that_cannot_be_read_goes_through_untouched() {
1340 let main = "1111111111111111111111111111111111111111";
1341 // Not pkt-lines; a length past the end; an error page.
1342 assert!(with_no_thin(b"not a git answer").is_none());
1343 assert!(with_no_thin(b"00ff1111").is_none());
1344 assert!(with_no_thin(b"<html>503 Service Unavailable</html>").is_none());
1345 assert!(with_no_thin(b"").is_none());
1346 // A first ref line without capabilities, or without an object id.
1347 let without = [pkt("# service=git-receive-pack\n"), b"0000".to_vec(), pkt(&format!("{main} refs/heads/main\n")), b"0000".to_vec()].concat();
1348 assert!(with_no_thin(&without).is_none());
1349 let unnamed = [pkt("# service=git-receive-pack\n"), b"0000".to_vec(), pkt("nothing here\0report-status\n"), b"0000".to_vec()].concat();
1350 assert!(with_no_thin(&unnamed).is_none());
1351 // `no-thin` inside another capability's value is not `no-thin`.
1352 let lookalike = [pkt("# service=git-receive-pack\n"), b"0000".to_vec(), pkt(&format!("{main} refs/heads/main\0agent=no-thin-ish\n")), b"0000".to_vec()].concat();
1353 assert!(String::from_utf8(with_no_thin(&lookalike).unwrap()).unwrap().contains("agent=no-thin-ish no-thin\n"));
1354 }
1355
1356 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1357 fn head_follows_the_default_branch_in_a_v2_listing() {
1358 let main = "1111111111111111111111111111111111111111";
1359 let trunk = "2222222222222222222222222222222222222222";
1360 let body = [
1361 pkt(&format!("{main} HEAD symref-target:refs/heads/main\n")),
1362 pkt(&format!("{main} refs/heads/main\n")),
1363 pkt(&format!("{trunk} refs/heads/trunk\n")),
1364 b"0000".to_vec(),
1365 ]
1366 .concat();
1367 let changed = String::from_utf8(with_head(&body, "trunk").unwrap()).unwrap();
1368 assert!(changed.starts_with(&String::from_utf8(pkt(&format!("{trunk} HEAD symref-target:refs/heads/trunk\n"))).unwrap()));
1369 assert!(changed.ends_with("0000"));
1370 // Without symrefs asked for, only the commit changes.
1371 let plain = [pkt(&format!("{main} HEAD\n")), pkt(&format!("{trunk} refs/heads/trunk\n")), b"0000".to_vec()].concat();
1372 let changed = String::from_utf8(with_head(&plain, "trunk").unwrap()).unwrap();
1373 assert!(changed.starts_with(&format!("0032{trunk} HEAD\n")));
1374 }
1375
1376 #[test]
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put1377 fn a_push_is_measured_by_the_pack_after_its_commands() {
1378 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
1379 let new = "4807077b296e6edbf410d55e72749d3e1170c291";
1380 let pack = b"PACK\0\0\0\x02\0\0\0\0rest-of-pack";
1381 let body = [
1382 pkt(&format!("{old} {new} refs/heads/main\0 report-status\n")),
1383 b"0000".to_vec(),
1384 pack.to_vec(),
1385 ]
1386 .concat();
1387 assert_eq!(pack_bytes(&body), pack.len() as u64);
1388 // Only deletions: no pack.
1389 let body = [pkt(&format!("{old} {ZERO_ID} refs/heads/gone\n")), b"0000".to_vec()].concat();
1390 assert_eq!(pack_bytes(&body), 0);
1391 assert_eq!(pack_bytes(b"garbage"), 0);
1392 }
The statement is a month at a time, a line per kind of charge1393
1394 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1395 fn a_transferred_repository_keeps_the_rest_of_the_address() {
1396 let to = RepoPath {
1397 namespace: "flagon-io".into(),
1398 name: "g1t".into(),
1399 };
1400 let url = Url::parse("https://g1t.sh/syntaqx/g1t.git/info/refs?service=git-receive-pack").unwrap();
1401 assert_eq!(
1402 transferred(&url, &to).as_deref(),
1403 Some("https://g1t.sh/flagon-io/g1t.git/info/refs?service=git-receive-pack")
1404 );
1405 let url = Url::parse("https://g1t.sh/syntaqx/g1t/git-upload-pack").unwrap();
1406 assert_eq!(
1407 transferred(&url, &to).as_deref(),
1408 Some("https://g1t.sh/flagon-io/g1t/git-upload-pack")
1409 );
1410 }
1411
1412 #[test]
Merge branch 'worktree-agent-a8385d293d42c913a'1413 fn an_alias_is_answered_as_its_workspaces_repository() {
1414 for (address, service) in [
1415 ("https://g1t.sh/g1t/g1t.git/info/refs?service=git-upload-pack", GitService::UploadPack),
1416 ("https://g1t.sh/g1t/g1t.git/git-receive-pack", GitService::ReceivePack),
1417 ("https://g1t.sh/g1t/g1t/git-upload-pack", GitService::UploadPack),
1418 ] {
1419 let git = parse(&Url::parse(address).unwrap()).unwrap();
1420 assert_eq!(git.path.namespace, "g1t", "{address}");
1421 let canonical = git.under("flagon-io");
1422 assert_eq!(
1423 canonical.path,
1424 RepoPath {
1425 namespace: "flagon-io".into(),
1426 name: "g1t".into(),
1427 },
1428 "{address}"
1429 );
1430 assert_eq!(canonical.service, service);
1431 assert_eq!(canonical.endpoint, git.endpoint);
1432 }
1433 }
1434
1435 #[test]
The statement is a month at a time, a line per kind of charge1436 fn a_renamed_workspace_keeps_the_rest_of_the_address() {
1437 let url = worker::Url::parse(
1438 "https://g1t.sh/acme/rocket.git/info/refs?service=git-upload-pack",
1439 )
1440 .unwrap();
1441 assert_eq!(
1442 with_namespace(&url, "acme-inc").as_deref(),
1443 Some("https://g1t.sh/acme-inc/rocket.git/info/refs?service=git-upload-pack")
1444 );
1445 let bare = worker::Url::parse("https://g1t.sh/acme").unwrap();
1446 assert_eq!(with_namespace(&bare, "acme-inc"), None);
1447 }
1448
1449 fn pkt(payload: &str) -> Vec<u8> {
1450 format!("{:04x}{payload}", payload.len() + 4).into_bytes()
1451 }
1452
A fetch whose haves the store does not know gets its pack: g1t ends the store's early answer where git expects1453 fn joined(parts: &[&[u8]]) -> Vec<u8> {
1454 parts.concat()
1455 }
1456
1457 #[test]
1458 fn a_fetch_with_haves_and_no_done_is_negotiating() {
1459 let request = |lines: &[&str]| {
1460 let mut body = joined(&[&pkt("command=fetch\n"), &pkt("object-format=sha1\n"), b"0001"]);
1461 for line in lines {
1462 body.extend(pkt(&format!("{line}\n")));
1463 }
1464 body.extend(b"0000");
1465 body
1466 };
1467 let want = "want 8407eba58b925619274d012258c2b474a5dbf012";
1468 let have = "have 55cd670a89a80df4fa9d9f0244c44fbd2ed1db8b";
1469 assert!(negotiating(&request(&["deepen 1", want, have])));
1470 assert!(!negotiating(&request(&[want, have, "done"])));
1471 assert!(!negotiating(&request(&[want, "done"])));
1472 let ls_refs = joined(&[&pkt("command=ls-refs\n"), b"0001", &pkt("have nothing\n"), b"0000"]);
1473 assert!(!negotiating(&ls_refs));
1474 }
1475
1476 #[test]
1477 fn acknowledgments_without_ready_end_the_answer() {
1478 // What the store sent a shallow fetch whose only `have` it did not
1479 // know, sideband-all framed: a NAK, then a pack anyway.
1480 let answer = joined(&[
1481 &pkt("\x01acknowledgments\n"),
1482 &pkt("\x01NAK\n"),
1483 b"0001",
1484 &pkt("\x01shallow-info\n"),
1485 &pkt("\x01shallow 8407eba58b925619274d012258c2b474a5dbf012\n"),
1486 b"0001",
1487 &pkt("\x01packfile\n"),
1488 ]);
1489 let cut = joined(&[&pkt("\x01acknowledgments\n"), &pkt("\x01NAK\n")]).len();
1490 assert_eq!(acknowledged(&answer), Acknowledged::CutAt(cut));
1491 // Not yet at the section's end.
1492 assert_eq!(acknowledged(&answer[..cut - 2]), Acknowledged::NeedMore);
1493 assert_eq!(acknowledged(&answer[..cut]), Acknowledged::NeedMore);
1494 // Without sideband framing too.
1495 let plain = joined(&[&pkt("acknowledgments\n"), &pkt("ACK abc\n"), b"0001", &pkt("packfile\n")]);
1496 assert!(matches!(acknowledged(&plain), Acknowledged::CutAt(_)));
1497 }
1498
1499 #[test]
1500 fn a_ready_store_or_a_plain_pack_streams_through() {
1501 let ready = joined(&[
1502 &pkt("\x01acknowledgments\n"),
1503 &pkt("\x01ACK bab14ff1b6d9c4918100098009747d776759a967\n"),
1504 &pkt("\x01ready\n"),
1505 b"0001",
1506 &pkt("\x01packfile\n"),
1507 ]);
1508 assert_eq!(acknowledged(&ready), Acknowledged::Whole);
1509 // Acknowledgments only, ended by a flush: already right.
1510 let only = joined(&[&pkt("acknowledgments\n"), &pkt("NAK\n"), b"0000"]);
1511 assert_eq!(acknowledged(&only), Acknowledged::Whole);
1512 let pack = joined(&[&pkt("\x01packfile\n"), b"0000"]);
1513 assert_eq!(acknowledged(&pack), Acknowledged::Whole);
1514 assert_eq!(acknowledged(b"00"), Acknowledged::NeedMore);
1515 }
1516
The statement is a month at a time, a line per kind of charge1517 #[test]
1518 fn pushed_branches_are_read_from_the_commands() {
1519 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
1520 let new = "4807077b296e6edbf410d55e72749d3e1170c291";
1521 let body = [
1522 pkt(&format!(
1523 "{old} {new} refs/heads/main\0 report-status side-band-64k\n"
1524 )),
1525 pkt(&format!("{ZERO_ID} {new} refs/heads/feature/x\n")),
1526 pkt(&format!("{old} {ZERO_ID} refs/heads/gone\n")),
1527 pkt(&format!("{ZERO_ID} {new} refs/tags/v1\n")),
1528 b"0000".to_vec(),
1529 b"PACK\0\0\0\x02\0\0\0\0".to_vec(),
1530 ]
1531 .concat();
1532 assert_eq!(
1533 pushed_branches(&body),
1534 [
1535 Pushed {
1536 git_ref: "refs/heads/main".to_owned(),
1537 before: Some(old.to_owned()),
1538 after: new.to_owned()
1539 },
1540 Pushed {
1541 git_ref: "refs/heads/feature/x".to_owned(),
1542 before: None,
1543 after: new.to_owned()
1544 },
1545 Pushed {
1546 git_ref: "refs/tags/v1".to_owned(),
1547 before: None,
1548 after: new.to_owned()
1549 },
1550 ]
1551 );
Merge branch 'claude/cool-newton-7nm8r9' (#36): real GitHub Actions on g1t, managed deploy workflows, g1t/deploy and the Sysbox runner 0.2.0. The merge keeps both sides' webhook events and API operations, says agent images need tar, guards the job engine's stop to Unix so the runner builds on Windows again, and keeps the moves tests' previews from going idle as the calendar passes their fixture's date.1552 assert_eq!(deleted_refs(&body), [("refs/heads/gone".to_owned(), old.to_owned())]);
1553 let tag = [pkt(&format!("{old} {ZERO_ID} refs/tags/v0\0 report-status\n")), pkt(&format!("{old} {ZERO_ID} refs/notes/x\n")), b"0000".to_vec()].concat();
1554 assert_eq!(deleted_refs(&tag), [("refs/tags/v0".to_owned(), old.to_owned())]);
The statement is a month at a time, a line per kind of charge1555 }
1556
1557 #[test]
1558 fn a_push_to_a_protected_branch_is_declined_with_the_reason() {
1559 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
1560 let new = "4807077b296e6edbf410d55e72749d3e1170c291";
1561 let body = [
1562 pkt(&format!("{old} {new} refs/heads/main\0 report-status\n")),
1563 pkt(&format!("{ZERO_ID} {new} refs/heads/feature\n")),
1564 b"0000".to_vec(),
1565 ]
1566 .concat();
1567 let report = String::from_utf8(refusal(&body, "main").unwrap()).unwrap();
1568 assert!(report.starts_with("000eunpack ok\n"));
1569 assert!(report.contains("ng refs/heads/main main is protected"));
1570 assert!(report.contains("ng refs/heads/feature not pushed"));
1571 assert!(report.ends_with("0000"));
1572 }
1573
1574 #[test]
1575 fn the_report_is_framed_for_a_client_that_asked_for_side_band() {
1576 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
1577 let body = [
1578 pkt(&format!(
1579 "{old} {ZERO_ID} refs/heads/main\0 report-status side-band-64k\n"
1580 )),
1581 b"0000".to_vec(),
1582 ]
1583 .concat();
1584 let report = refusal(&body, "main").unwrap();
1585 // A length, then channel 1, then the report itself.
1586 assert_eq!(report[4], 1);
1587 assert_eq!(&report[5..18], b"000eunpack ok");
1588 assert!(report.ends_with(b"00000000"));
1589 }
1590
1591 #[test]
1592 fn other_branches_and_a_first_push_are_let_through() {
1593 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
1594 let new = "4807077b296e6edbf410d55e72749d3e1170c291";
1595 let feature = [
1596 pkt(&format!("{old} {new} refs/heads/feature\0 report-status\n")),
1597 b"0000".to_vec(),
1598 ]
1599 .concat();
1600 assert!(refusal(&feature, "main").is_none());
1601 // An empty repository has to be able to receive its first commits.
1602 let first = [
1603 pkt(&format!(
1604 "{ZERO_ID} {new} refs/heads/main\0 report-status\n"
1605 )),
1606 b"0000".to_vec(),
1607 ]
1608 .concat();
1609 assert!(refusal(&first, "main").is_none());
1610 }
1611
1612 #[test]
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API1613 fn a_blocked_push_explains_itself_on_the_progress_channel() {
1614 let report = b"000eunpack ok\n0000".to_vec();
1615 let messages = vec!["g1t found a secret in this push, so nothing was pushed.".to_owned()];
1616 let body = framed(report.clone(), "report-status side-band-64k", &messages);
1617 // Channel 2 first, which git prints as `remote:` lines.
1618 assert_eq!(body[4], 2);
1619 assert!(String::from_utf8_lossy(&body).contains("so nothing was pushed.\n"));
1620 let at = body.windows(5).position(|w| w == b"000eu").unwrap();
1621 assert_eq!(body[at - 1], 1);
1622 assert!(body.ends_with(b"0000"));
1623 // A client without side-band gets the bare report.
1624 assert_eq!(framed(report.clone(), "report-status", &messages), report);
1625 }
1626
1627 #[test]
The statement is a month at a time, a line per kind of charge1628 fn a_fetch_request_names_no_branches() {
1629 assert!(
1630 pushed_branches(b"0032want c71546fcd893ef8b0f57388b65e620d759705dda\n0000").is_empty()
1631 );
1632 }
1633}

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