Skip to content
1,662 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.

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

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