flagon-io/g1t

public

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

g1t/services/repos/src/git_http.rs

768 lines29,190 bytesCodeBlame

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

Rust repos service with shipping; pull requests kept in the model1//! Git over HTTPS: the smart HTTP remote at `/<namespace>/<repo>.git`,
2//! proxied to the git store with a short-lived token.
3
4use g1t_contracts::identity::GitCredentialsArgs;
5use g1t_contracts::repos::{GitAccess, GitService, RepoPath};
6use g1t_contracts::{FailureCode, Outcome, Viewer};
7use worker::js_sys::Uint8Array;
8use worker::{Fetch, Fetcher, Headers, Method, Request, RequestInit, Response, Result, Url};
9
10const ENDPOINTS: [&str; 3] = ["info/refs", "git-upload-pack", "git-receive-pack"];
11const FORWARDED_HEADERS: [&str; 5] = [
12 "accept",
13 "content-encoding",
14 "content-type",
15 "git-protocol",
16 "user-agent",
17];
18
19/// A git request, parsed from its URL.
20pub struct GitRequest {
21 pub path: RepoPath,
22 pub endpoint: &'static str,
23 pub service: GitService,
24}
25
26/// Parses `/<namespace>/<name>[.git]/<endpoint>`, or returns `None` if the
27/// request is not git's.
28pub fn parse(url: &Url) -> Option<GitRequest> {
29 let path = url.path().strip_prefix('/')?;
30 let endpoint = ENDPOINTS
31 .into_iter()
32 .find(|endpoint| path.ends_with(&format!("/{endpoint}")))?;
33 let repo = &path[..path.len() - endpoint.len() - 1];
34 let (namespace, name) = repo.split_once('/')?;
35 let name = name.strip_suffix(".git").unwrap_or(name);
36 if namespace.is_empty() || name.is_empty() || name.contains('/') {
37 return None;
38 }
39 let service = if endpoint == "info/refs" {
40 url.query_pairs()
41 .find(|(key, _)| key == "service")
42 .map(|(_, value)| value.into_owned())?
43 } else {
44 endpoint.to_owned()
45 };
46 let service = match service.as_str() {
47 "git-upload-pack" => GitService::UploadPack,
48 "git-receive-pack" => GitService::ReceivePack,
49 _ => return None,
50 };
51 Some(GitRequest {
52 path: RepoPath {
53 namespace: namespace.to_owned(),
54 name: name.to_owned(),
55 },
56 endpoint,
57 service,
58 })
59}
60
61/// The user named by an HTTP Basic `Authorization` header, as git sends it.
62pub async fn viewer(request: &Request, identity: &Fetcher) -> Result<Viewer> {
63 let Some(header) = request.headers().get("authorization")? else {
64 return Ok(None);
65 };
66 let Some((scheme, encoded)) = header.split_once(' ') else {
67 return Ok(None);
68 };
69 if !scheme.eq_ignore_ascii_case("basic") {
70 return Ok(None);
71 }
72 let Some(decoded) = decode_base64(encoded.trim()) else {
73 return Ok(None);
74 };
75 let Some((username, secret)) = decoded.split_once(':') else {
76 return Ok(None);
77 };
78 g1t_kit::call(
79 identity,
80 "user_for_git_credentials",
81 &GitCredentialsArgs {
82 username: username.to_owned(),
83 secret: secret.to_owned(),
84 },
85 )
86 .await
87}
88
89/// Standard base64 to a UTF-8 string, or `None` if either step fails.
90fn decode_base64(input: &str) -> Option<String> {
91 let mut bytes = Vec::with_capacity(input.len() * 3 / 4);
92 let mut buffer = 0u32;
93 let mut bits = 0;
94 for byte in input.bytes().filter(|byte| *byte != b'=') {
95 let value = match byte {
96 b'A'..=b'Z' => byte - b'A',
97 b'a'..=b'z' => byte - b'a' + 26,
98 b'0'..=b'9' => byte - b'0' + 52,
99 b'+' => 62,
100 b'/' => 63,
101 _ => return None,
102 };
103 buffer = (buffer << 6) | u32::from(value);
104 bits += 6;
105 if bits >= 8 {
106 bits -= 8;
107 bytes.push((buffer >> bits) as u8);
108 }
109 }
110 String::from_utf8(bytes).ok()
111}
112
113/// The response for a refused git request. Anonymous callers are asked to
114/// authenticate, which is what makes git prompt for credentials.
115pub fn refuse<T>(outcome: Outcome<T>) -> Result<Response> {
116 let Outcome::Fail(failure) = outcome else {
117 return Response::error("Not found", 404);
118 };
119 let mut response = Response::error(failure.message, failure.code.http_status())?;
120 if failure.code == FailureCode::Unauthenticated {
121 response
122 .headers_mut()
123 .set("www-authenticate", "Basic realm=\"g1t\"")?;
124 }
125 Ok(response)
126}
127
Agents and memory, checks and conflicts, profiles, slug renames, custom domains128/// `url` with its first path segment, the workspace, replaced by `slug`.
129pub fn with_namespace(url: &Url, slug: &str) -> Option<String> {
130 let rest = url.path().strip_prefix('/')?.split_once('/')?.1;
131 let mut moved = url.clone();
132 moved.set_path(&format!("/{slug}/{rest}"));
133 Some(moved.to_string())
134}
135
136/// Where a git request for a renamed workspace's old address should go
137/// now, if its first segment is an old slug that still redirects.
138pub async fn renamed(url: &Url, identity: &Fetcher) -> Result<Option<String>> {
139 let Some(old) = url.path().strip_prefix('/').and_then(|path| path.split('/').next()) else {
140 return Ok(None);
141 };
142 let current: Option<String> = g1t_kit::call(
143 identity,
144 "resolve_slug",
145 &g1t_contracts::identity::SlugArgs {
146 slug: old.to_owned(),
147 },
148 )
149 .await?;
150 Ok(current.and_then(|slug| with_namespace(url, &slug)))
151}
152
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look153/// `url` with its repository, the first two path segments, replaced by
154/// `to`: where a request for a transferred repository's old path goes.
155/// Keeps whether the old address ended in `.git`.
156pub fn transferred(url: &Url, to: &RepoPath) -> Option<String> {
157 let path = url.path().strip_prefix('/')?;
158 let mut segments = path.splitn(3, '/');
159 let (_, name, rest) = (segments.next()?, segments.next()?, segments.next()?);
160 let suffix = if name.ends_with(".git") { ".git" } else { "" };
161 let mut moved = url.clone();
162 moved.set_path(&format!("/{}/{}{suffix}/{rest}", to.namespace, to.name));
163 Some(moved.to_string())
164}
165
Agents and memory, checks and conflicts, profiles, slug renames, custom domains166/// A permanent redirect: 301 for git's first request for refs, which it
167/// follows and then uses the new address for the rest; 308 for the
168/// others, so a POST stays a POST.
169pub fn moved(location: &str, get: bool) -> Result<Response> {
170 let mut response = Response::empty()?.with_status(if get { 301 } else { 308 });
171 response.headers_mut().set("location", location)?;
172 Ok(response)
173}
174
Events service in Rust, with RFC 3339 times and accurate push events175const ZERO_ID: &str = "0000000000000000000000000000000000000000";
176const HEADS: &str = "refs/heads/";
GitHub Actions on g1t, part one: reading workflows177const TAGS: &str = "refs/tags/";
Events service in Rust, with RFC 3339 times and accurate push events178
Agents as a team: lifecycle, merge queue, billing and a new shell179/// One ref a push asks to change.
180struct Command {
181 old: String,
182 new: String,
183 name: String,
184}
185
186/// The commands at the start of a receive-pack request, and the
187/// capabilities the client sent with the first of them.
188fn commands(body: &[u8]) -> (Vec<Command>, String) {
189 let mut commands = Vec::new();
190 let mut capabilities = String::new();
Events service in Rust, with RFC 3339 times and accurate push events191 let mut position = 0;
192 // Commands are pkt-lines; a flush packet ends them and the pack follows.
193 while let Some(length) = body
194 .get(position..position + 4)
195 .and_then(|hex| std::str::from_utf8(hex).ok())
196 .and_then(|hex| usize::from_str_radix(hex, 16).ok())
197 {
198 if length < 4 || position + length > body.len() {
199 break;
200 }
201 let line = &body[position + 4..position + length];
202 position += length;
203 // `<old> <new> <ref>`, and on the first command a NUL then capabilities.
Agents as a team: lifecycle, merge queue, billing and a new shell204 let mut halves = line.splitn(2, |byte| *byte == 0);
205 let command = halves.next().unwrap_or_default();
206 if let Some(rest) = halves.next() {
207 capabilities = String::from_utf8_lossy(rest).trim().to_owned();
208 }
209 let Ok(command) = std::str::from_utf8(command) else {
Events service in Rust, with RFC 3339 times and accurate push events210 continue;
211 };
Agents as a team: lifecycle, merge queue, billing and a new shell212 let mut parts = command.trim_end().splitn(3, ' ');
213 if let (Some(old), Some(new), Some(name)) = (parts.next(), parts.next(), parts.next()) {
214 commands.push(Command {
215 old: old.to_owned(),
216 new: new.to_owned(),
217 name: name.to_owned(),
218 });
Events service in Rust, with RFC 3339 times and accurate push events219 }
220 }
Agents as a team: lifecycle, merge queue, billing and a new shell221 (commands, capabilities)
Events service in Rust, with RFC 3339 times and accurate push events222}
223
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put224/// The bytes of the pack a receive-pack request carries: everything after
225/// the flush packet that ends its commands. Zero for a push that only
226/// deletes refs.
227pub(crate) fn pack_bytes(body: &[u8]) -> u64 {
228 let mut position = 0;
229 while let Some(length) = body
230 .get(position..position + 4)
231 .and_then(|hex| std::str::from_utf8(hex).ok())
232 .and_then(|hex| usize::from_str_radix(hex, 16).ok())
233 {
234 if length == 0 {
235 return (body.len() - position - 4) as u64;
236 }
237 if length < 4 || position + length > body.len() {
238 break;
239 }
240 position += length;
241 }
242 0
243}
244
Agents as a team: lifecycle, merge queue, billing and a new shell245fn pkt_line(payload: &[u8]) -> Vec<u8> {
246 let mut line = format!("{:04x}", payload.len() + 4).into_bytes();
247 line.extend_from_slice(payload);
248 line
249}
250
251/// What git is told when a push would change a protected branch: every ref
252/// in it is declined, with the reason against the protected one, so that
253/// git prints it beside the branch. `None` if the push leaves the branch
254/// alone, or creates it in a repository that does not have it yet.
255fn refusal(body: &[u8], protected: &str) -> Option<Vec<u8>> {
256 let (commands, capabilities) = commands(body);
257 let reference = format!("{HEADS}{protected}");
258 if !commands
259 .iter()
260 .any(|command| command.name == reference && command.old != ZERO_ID)
261 {
262 return None;
263 }
264 let mut report = pkt_line(b"unpack ok\n");
265 for command in &commands {
266 let reason = if command.name == reference {
267 format!("{protected} is protected: push a branch and open a pull request")
268 } else {
269 format!("not pushed, because the same push would change {protected}")
270 };
271 report.extend(pkt_line(
272 format!("ng {} {reason}\n", command.name).as_bytes(),
273 ));
274 }
275 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 API276 Some(framed(report, &capabilities, &[]))
277}
278
279/// A report-status as git expects it: inside channel 1 when the client
280/// asked for side-band, after `messages` on channel 2, which git prints as
281/// `remote:` lines. Without side-band the messages cannot be shown.
282fn framed(report: Vec<u8>, capabilities: &str, messages: &[String]) -> Vec<u8> {
Agents as a team: lifecycle, merge queue, billing and a new shell283 let sideband = capabilities
284 .split(' ')
285 .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 API286 if !sideband {
287 return report;
288 }
289 let mut body = Vec::new();
290 for message in messages {
291 let mut packet = vec![2u8];
292 packet.extend_from_slice(message.as_bytes());
293 packet.push(b'\n');
294 body.extend(pkt_line(&packet));
295 }
296 // side-band (not -64k) packets carry at most 1000 bytes.
297 for chunk in report.chunks(990) {
298 let mut packet = vec![1u8];
299 packet.extend_from_slice(chunk);
300 body.extend(pkt_line(&packet));
301 }
302 body.extend_from_slice(b"0000");
303 body
304}
305
306/// Declines every ref in a push with `reason`, explaining why in
307/// `messages`: what push protection answers when a push adds a secret.
308pub fn declined(body: &[u8], reason: &str, messages: &[String]) -> Result<Response> {
309 let (commands, capabilities) = commands(body);
310 let mut report = pkt_line(b"unpack ok\n");
311 for command in &commands {
312 report.extend(pkt_line(format!("ng {} {reason}\n", command.name).as_bytes()));
313 }
314 report.extend_from_slice(b"0000");
315 let headers = Headers::new();
316 headers.set("content-type", "application/x-git-receive-pack-result")?;
317 headers.set("cache-control", "no-cache")?;
318 Ok(Response::from_bytes(framed(report, &capabilities, messages))?.with_headers(headers))
Agents as a team: lifecycle, merge queue, billing and a new shell319}
320
GitHub Actions on g1t, part one: reading workflows321/// A branch or tag a push asks to move.
322#[derive(Debug, PartialEq, Eq)]
323pub struct Pushed {
324 /// The full ref: `refs/heads/main`, `refs/tags/v1`.
325 pub git_ref: String,
326 /// Where it pointed before; `None` for a new ref.
327 pub before: Option<String>,
328 pub after: String,
329}
330
331impl Pushed {
332 pub fn branch(&self) -> Option<&str> {
333 self.git_ref.strip_prefix(HEADS)
334 }
335}
336
337/// The branches and tags a push asks to move, read from the commands at the
338/// start of a receive-pack request. Deletions and other refs are left out.
339fn pushed_branches(body: &[u8]) -> Vec<Pushed> {
Agents as a team: lifecycle, merge queue, billing and a new shell340 commands(body)
341 .0
342 .into_iter()
343 .filter(|command| command.new != ZERO_ID)
GitHub Actions on g1t, part one: reading workflows344 .filter(|command| command.name.starts_with(HEADS) || command.name.starts_with(TAGS))
345 .map(|Command { old, new, name }| Pushed {
346 git_ref: name,
347 before: (old != ZERO_ID).then_some(old),
348 after: new,
Agents as a team: lifecycle, merge queue, billing and a new shell349 })
350 .collect()
351}
352
Events service in Rust, with RFC 3339 times and accurate push events353/// The git store's answer, and what the request asked it to change.
354pub struct Forwarded {
355 pub response: Response,
GitHub Actions on g1t, part one: reading workflows356 /// For a push: the branches and tags it asks to move, and the commits
357 /// to move them to. Whether each moved is for the caller to confirm.
358 pub pushed: Vec<Pushed>,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put359 /// For a push: the size of the pack it sent, for the storage meter.
360 pub pack_bytes: u64,
Events service in Rust, with RFC 3339 times and accurate push events361}
362
Agents as a team: lifecycle, merge queue, billing and a new shell363/// What became of a git request.
364pub enum Push {
365 Forwarded(Forwarded),
366 /// A push to a protected branch, answered here without reaching the store.
367 Refused(Response),
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API368 /// A push that adds a secret nobody allowed, answered the same way.
369 Blocked(Response),
Agents as a team: lifecycle, merge queue, billing and a new shell370}
371
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look372/// One packet of a pkt-line stream: data, or a flush (`0000`), delimiter
373/// (`0001`) or response-end (`0002`) packet, kept as its four bytes.
374#[derive(Debug, PartialEq, Eq)]
375enum Packet {
376 Data(Vec<u8>),
377 Special([u8; 4]),
378}
379
380/// The packets in `bytes`, or `None` if it is not a whole pkt-line stream.
381fn packets(bytes: &[u8]) -> Option<Vec<Packet>> {
382 let mut out = Vec::new();
383 let mut position = 0;
384 while position < bytes.len() {
385 let header = bytes.get(position..position + 4)?;
386 let length = usize::from_str_radix(std::str::from_utf8(header).ok()?, 16).ok()?;
387 if length < 4 {
388 out.push(Packet::Special(header.try_into().ok()?));
389 position += 4;
390 continue;
391 }
392 out.push(Packet::Data(bytes.get(position + 4..position + length)?.to_vec()));
393 position += length;
394 }
395 Some(out)
396}
397
398fn encode(packets: &[Packet]) -> Vec<u8> {
399 let mut out = Vec::new();
400 for packet in packets {
401 match packet {
402 Packet::Data(data) => out.extend(pkt_line(data)),
403 Packet::Special(bytes) => out.extend_from_slice(bytes),
404 }
405 }
406 out
407}
408
409/// A ref advertisement (`info/refs` for upload-pack) or a protocol v2
410/// `ls-refs` answer with `HEAD` pointing at `branch`, the repository's
411/// default branch as g1t keeps it, so a clone checks it out. The git store
412/// holds the HEAD it was created with; g1t can change the default branch
413/// since. `None` when there is nothing to change: no `HEAD` line, `HEAD`
414/// already names `branch`, or `branch` is not advertised.
415pub fn with_head(body: &[u8], branch: &str) -> Option<Vec<u8>> {
416 let mut packets = packets(body)?;
417 let target = format!("{HEADS}{branch}");
418 let oid = packets.iter().find_map(|packet| {
419 let Packet::Data(data) = packet else { return None };
420 let line = data.split(|byte| *byte == 0).next()?;
421 let line = std::str::from_utf8(line).ok()?.trim_end();
422 let (oid, name) = line.split_once(' ')?;
423 // v2 lines may carry attributes after the name.
424 let name = name.split(' ').next()?;
425 (name == target).then(|| oid.to_owned())
426 })?;
427 let mut changed = false;
428 for packet in &mut packets {
429 let Packet::Data(data) = packet else { continue };
430 let text = String::from_utf8_lossy(data).into_owned();
431 let Some((_, rest)) = text.split_once(' ') else { continue };
432 if !(rest.starts_with("HEAD\0") || rest.starts_with("HEAD\n") || rest.starts_with("HEAD ") || rest == "HEAD") {
433 continue;
434 }
435 let mut line = format!("{oid} {rest}");
436 // v0: `symref=HEAD:refs/heads/<old>` among the capabilities.
437 // v2: `symref-target:refs/heads/<old>` after the name.
438 for marker in ["symref=HEAD:", "symref-target:"] {
439 if let Some(at) = line.find(marker) {
440 let start = at + marker.len();
441 let end = line[start..]
442 .find([' ', '\n', '\0'])
443 .map_or(line.len(), |offset| start + offset);
444 line.replace_range(start..end, &target);
445 }
446 }
447 changed = line != text;
448 if changed {
449 *data = line.into_bytes();
450 }
451 break;
452 }
453 changed.then(|| encode(&packets))
454}
455
456/// Whether a request to the git store is one whose answer names `HEAD`:
457/// the ref advertisement for a fetch, or a protocol v2 `ls-refs`.
458fn names_head(git: &GitRequest, body: Option<&[u8]>) -> bool {
459 if git.service != GitService::UploadPack {
460 return false;
461 }
462 match body {
463 None => git.endpoint == "info/refs",
464 Some(body) => {
465 git.endpoint == "git-upload-pack"
466 && body.windows(b"command=ls-refs".len()).any(|window| window == b"command=ls-refs")
467 }
468 }
469}
470
Agents as a team: lifecycle, merge queue, billing and a new shell471/// Sends the request on to the git store and returns its response as is,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API472/// unless it is a push that would change the `protected` branch, or one
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look473/// that `scan` (push protection) answers itself. A fetch's ref listing has
474/// its `HEAD` pointed at `default_branch` (see [`with_head`]).
Rust repos service with shipping; pull requests kept in the model475pub async fn forward(
476 mut request: Request,
477 git: &GitRequest,
478 access: &GitAccess,
Agents as a team: lifecycle, merge queue, billing and a new shell479 protected: Option<&str>,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look480 default_branch: Option<&str>,
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API481 scan: impl AsyncFnOnce(&[u8]) -> Result<Option<Response>>,
Agents as a team: lifecycle, merge queue, billing and a new shell482) -> Result<Push> {
Rust repos service with shipping; pull requests kept in the model483 let headers = Headers::new();
484 headers.set("authorization", &format!("Bearer {}", access.token))?;
485 for name in FORWARDED_HEADERS {
486 if let Some(value) = request.headers().get(name)? {
487 headers.set(name, &value)?;
488 }
489 }
490 let query = request
491 .url()?
492 .query()
493 .map(|query| format!("?{query}"))
494 .unwrap_or_default();
495 let mut init = RequestInit::new();
496 init.with_method(request.method()).with_headers(headers);
Events service in Rust, with RFC 3339 times and accurate push events497 let mut pushed = Vec::new();
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put498 let mut pack = 0;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look499 let mut lists_head = request.method() == Method::Get && names_head(git, None);
Rust repos service with shipping; pull requests kept in the model500 if request.method() == Method::Post {
501 // Pushes are capped at 100 MB by the platform, so buffering is safe.
502 let body = request.bytes().await?;
Events service in Rust, with RFC 3339 times and accurate push events503 if git.endpoint == "git-receive-pack" {
Agents as a team: lifecycle, merge queue, billing and a new shell504 if let Some(report) = protected.and_then(|branch| refusal(&body, branch)) {
505 let headers = Headers::new();
506 headers.set("content-type", "application/x-git-receive-pack-result")?;
507 headers.set("cache-control", "no-cache")?;
508 return Ok(Push::Refused(
509 Response::from_bytes(report)?.with_headers(headers),
510 ));
511 }
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API512 if let Some(response) = scan(&body).await? {
513 return Ok(Push::Blocked(response));
514 }
Events service in Rust, with RFC 3339 times and accurate push events515 pushed = pushed_branches(&body);
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put516 pack = pack_bytes(&body);
Events service in Rust, with RFC 3339 times and accurate push events517 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look518 lists_head = names_head(git, Some(&body));
Rust repos service with shipping; pull requests kept in the model519 init.with_body(Some(Uint8Array::from(body.as_slice()).into()));
520 }
521 let upstream =
522 Request::new_with_init(&format!("{}/{}{query}", access.remote, git.endpoint), &init)?;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look523 let mut response = Fetch::Request(upstream).send().await?;
524 if let (true, Some(branch)) = (lists_head, default_branch)
525 && response.status_code() == 200
526 {
527 let headers = response.headers().clone();
528 headers.delete("content-length")?;
529 let body = response.bytes().await?;
530 let body = with_head(&body, branch).unwrap_or(body);
531 response = Response::from_bytes(body)?.with_headers(headers);
532 }
Agents as a team: lifecycle, merge queue, billing and a new shell533 Ok(Push::Forwarded(Forwarded {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look534 response,
Events service in Rust, with RFC 3339 times and accurate push events535 pushed,
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put536 pack_bytes: pack,
Agents as a team: lifecycle, merge queue, billing and a new shell537 }))
Events service in Rust, with RFC 3339 times and accurate push events538}
539
540#[cfg(test)]
541mod tests {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look542 use super::{Pushed, RepoPath, Url, ZERO_ID, framed, pack_bytes, pushed_branches, refusal, transferred, with_head, with_namespace};
543
544 #[test]
545 fn a_renamed_repository_redirects_to_its_new_name() {
546 // A rename keeps the old path in the same table as a transfer, so
547 // the old remote is sent to the new name the same way.
548 let to = RepoPath {
549 namespace: "acme".into(),
550 name: "booster".into(),
551 };
552 let url = Url::parse("https://g1t.sh/acme/rocket.git/info/refs?service=git-upload-pack").unwrap();
553 assert_eq!(
554 transferred(&url, &to).as_deref(),
555 Some("https://g1t.sh/acme/booster.git/info/refs?service=git-upload-pack")
556 );
557 }
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put558
559 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look560 fn head_follows_the_default_branch_in_a_v0_advertisement() {
561 let main = "1111111111111111111111111111111111111111";
562 let trunk = "2222222222222222222222222222222222222222";
563 let body = [
564 pkt("# service=git-upload-pack\n"),
565 b"0000".to_vec(),
566 pkt(&format!("{main} HEAD\0multi_ack symref=HEAD:refs/heads/main agent=git/2\n")),
567 pkt(&format!("{main} refs/heads/main\n")),
568 pkt(&format!("{trunk} refs/heads/trunk\n")),
569 b"0000".to_vec(),
570 ]
571 .concat();
572 let changed = String::from_utf8(with_head(&body, "trunk").unwrap()).unwrap();
573 assert!(changed.contains(&format!("{trunk} HEAD\0multi_ack symref=HEAD:refs/heads/trunk agent=git/2\n")));
574 assert!(changed.contains(&format!("{main} refs/heads/main\n")));
575 assert!(changed.starts_with("001e# service=git-upload-pack\n0000"));
576 // Already right, or a branch it does not have: left alone.
577 assert!(with_head(&body, "main").is_none());
578 assert!(with_head(&body, "gone").is_none());
579 }
580
581 #[test]
582 fn head_follows_the_default_branch_in_a_v2_listing() {
583 let main = "1111111111111111111111111111111111111111";
584 let trunk = "2222222222222222222222222222222222222222";
585 let body = [
586 pkt(&format!("{main} HEAD symref-target:refs/heads/main\n")),
587 pkt(&format!("{main} refs/heads/main\n")),
588 pkt(&format!("{trunk} refs/heads/trunk\n")),
589 b"0000".to_vec(),
590 ]
591 .concat();
592 let changed = String::from_utf8(with_head(&body, "trunk").unwrap()).unwrap();
593 assert!(changed.starts_with(&String::from_utf8(pkt(&format!("{trunk} HEAD symref-target:refs/heads/trunk\n"))).unwrap()));
594 assert!(changed.ends_with("0000"));
595 // Without symrefs asked for, only the commit changes.
596 let plain = [pkt(&format!("{main} HEAD\n")), pkt(&format!("{trunk} refs/heads/trunk\n")), b"0000".to_vec()].concat();
597 let changed = String::from_utf8(with_head(&plain, "trunk").unwrap()).unwrap();
598 assert!(changed.starts_with(&format!("0032{trunk} HEAD\n")));
599 }
600
601 #[test]
Team plan, an open-source pool, monthly trials and honest metering; the sidebar for everyone; a workspace that stays put602 fn a_push_is_measured_by_the_pack_after_its_commands() {
603 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
604 let new = "4807077b296e6edbf410d55e72749d3e1170c291";
605 let pack = b"PACK\0\0\0\x02\0\0\0\0rest-of-pack";
606 let body = [
607 pkt(&format!("{old} {new} refs/heads/main\0 report-status\n")),
608 b"0000".to_vec(),
609 pack.to_vec(),
610 ]
611 .concat();
612 assert_eq!(pack_bytes(&body), pack.len() as u64);
613 // Only deletions: no pack.
614 let body = [pkt(&format!("{old} {ZERO_ID} refs/heads/gone\n")), b"0000".to_vec()].concat();
615 assert_eq!(pack_bytes(&body), 0);
616 assert_eq!(pack_bytes(b"garbage"), 0);
617 }
Agents and memory, checks and conflicts, profiles, slug renames, custom domains618
619 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look620 fn a_transferred_repository_keeps_the_rest_of_the_address() {
621 let to = RepoPath {
622 namespace: "flagon-io".into(),
623 name: "g1t".into(),
624 };
625 let url = Url::parse("https://g1t.sh/syntaqx/g1t.git/info/refs?service=git-receive-pack").unwrap();
626 assert_eq!(
627 transferred(&url, &to).as_deref(),
628 Some("https://g1t.sh/flagon-io/g1t.git/info/refs?service=git-receive-pack")
629 );
630 let url = Url::parse("https://g1t.sh/syntaqx/g1t/git-upload-pack").unwrap();
631 assert_eq!(
632 transferred(&url, &to).as_deref(),
633 Some("https://g1t.sh/flagon-io/g1t/git-upload-pack")
634 );
635 }
636
637 #[test]
Agents and memory, checks and conflicts, profiles, slug renames, custom domains638 fn a_renamed_workspace_keeps_the_rest_of_the_address() {
639 let url = worker::Url::parse(
640 "https://g1t.sh/acme/rocket.git/info/refs?service=git-upload-pack",
641 )
642 .unwrap();
643 assert_eq!(
644 with_namespace(&url, "acme-inc").as_deref(),
645 Some("https://g1t.sh/acme-inc/rocket.git/info/refs?service=git-upload-pack")
646 );
647 let bare = worker::Url::parse("https://g1t.sh/acme").unwrap();
648 assert_eq!(with_namespace(&bare, "acme-inc"), None);
649 }
Events service in Rust, with RFC 3339 times and accurate push events650
651 fn pkt(payload: &str) -> Vec<u8> {
652 format!("{:04x}{payload}", payload.len() + 4).into_bytes()
653 }
654
655 #[test]
656 fn pushed_branches_are_read_from_the_commands() {
657 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
658 let new = "4807077b296e6edbf410d55e72749d3e1170c291";
659 let body = [
660 pkt(&format!(
661 "{old} {new} refs/heads/main\0 report-status side-band-64k\n"
662 )),
663 pkt(&format!("{ZERO_ID} {new} refs/heads/feature/x\n")),
664 pkt(&format!("{old} {ZERO_ID} refs/heads/gone\n")),
665 pkt(&format!("{ZERO_ID} {new} refs/tags/v1\n")),
666 b"0000".to_vec(),
667 b"PACK\0\0\0\x02\0\0\0\0".to_vec(),
668 ]
669 .concat();
670 assert_eq!(
671 pushed_branches(&body),
672 [
GitHub Actions on g1t, part one: reading workflows673 Pushed {
674 git_ref: "refs/heads/main".to_owned(),
675 before: Some(old.to_owned()),
676 after: new.to_owned()
677 },
678 Pushed {
679 git_ref: "refs/heads/feature/x".to_owned(),
680 before: None,
681 after: new.to_owned()
682 },
683 Pushed {
684 git_ref: "refs/tags/v1".to_owned(),
685 before: None,
686 after: new.to_owned()
687 },
Events service in Rust, with RFC 3339 times and accurate push events688 ]
689 );
690 }
691
692 #[test]
Agents as a team: lifecycle, merge queue, billing and a new shell693 fn a_push_to_a_protected_branch_is_declined_with_the_reason() {
694 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
695 let new = "4807077b296e6edbf410d55e72749d3e1170c291";
696 let body = [
697 pkt(&format!("{old} {new} refs/heads/main\0 report-status\n")),
698 pkt(&format!("{ZERO_ID} {new} refs/heads/feature\n")),
699 b"0000".to_vec(),
700 ]
701 .concat();
702 let report = String::from_utf8(refusal(&body, "main").unwrap()).unwrap();
703 assert!(report.starts_with("000eunpack ok\n"));
704 assert!(report.contains("ng refs/heads/main main is protected"));
705 assert!(report.contains("ng refs/heads/feature not pushed"));
706 assert!(report.ends_with("0000"));
707 }
708
709 #[test]
710 fn the_report_is_framed_for_a_client_that_asked_for_side_band() {
711 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
712 let body = [
713 pkt(&format!(
714 "{old} {ZERO_ID} refs/heads/main\0 report-status side-band-64k\n"
715 )),
716 b"0000".to_vec(),
717 ]
718 .concat();
719 let report = refusal(&body, "main").unwrap();
720 // A length, then channel 1, then the report itself.
721 assert_eq!(report[4], 1);
722 assert_eq!(&report[5..18], b"000eunpack ok");
723 assert!(report.ends_with(b"00000000"));
724 }
725
726 #[test]
727 fn other_branches_and_a_first_push_are_let_through() {
728 let old = "c71546fcd893ef8b0f57388b65e620d759705dda";
729 let new = "4807077b296e6edbf410d55e72749d3e1170c291";
730 let feature = [
731 pkt(&format!("{old} {new} refs/heads/feature\0 report-status\n")),
732 b"0000".to_vec(),
733 ]
734 .concat();
735 assert!(refusal(&feature, "main").is_none());
736 // An empty repository has to be able to receive its first commits.
737 let first = [
738 pkt(&format!(
739 "{ZERO_ID} {new} refs/heads/main\0 report-status\n"
740 )),
741 b"0000".to_vec(),
742 ]
743 .concat();
744 assert!(refusal(&first, "main").is_none());
745 }
746
747 #[test]
Agents get guardrails, run credentials, an audit log, a context hub, repository instructions and mentions; security upkeep; snake_case API748 fn a_blocked_push_explains_itself_on_the_progress_channel() {
749 let report = b"000eunpack ok\n0000".to_vec();
750 let messages = vec!["g1t found a secret in this push, so nothing was pushed.".to_owned()];
751 let body = framed(report.clone(), "report-status side-band-64k", &messages);
752 // Channel 2 first, which git prints as `remote:` lines.
753 assert_eq!(body[4], 2);
754 assert!(String::from_utf8_lossy(&body).contains("so nothing was pushed.\n"));
755 let at = body.windows(5).position(|w| w == b"000eu").unwrap();
756 assert_eq!(body[at - 1], 1);
757 assert!(body.ends_with(b"0000"));
758 // A client without side-band gets the bare report.
759 assert_eq!(framed(report.clone(), "report-status", &messages), report);
760 }
761
762 #[test]
Events service in Rust, with RFC 3339 times and accurate push events763 fn a_fetch_request_names_no_branches() {
764 assert!(
765 pushed_branches(b"0032want c71546fcd893ef8b0f57388b65e620d759705dda\n0000").is_empty()
766 );
767 }
Rust repos service with shipping; pull requests kept in the model768}