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.
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 1 | //! Git operations through g1t's git endpoints, counted per workspace. |
| 2 | //! | |
| 3 | //! Cloudflare Artifacts charges g1t for every operation from 2026-10-14 | |
| 4 | //! ($0.15 per 1,000): each clone, fetch and push. Every upload-pack (clone | |
| 5 | //! or fetch) and receive-pack (push) request through here is one, counted | |
| 6 | //! by the hour. Billing reads the month's count each day (`git_operations`) | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 7 | //! and charges workspaces on the plan for what is past the amount that is |
| 8 | //! free for everyone (50,000 a month), at cost plus 20%. A workspace on the | |
| 9 | //! plan is never slowed or refused for git operations or for storage: it | |
| 10 | //! pays for them as usage, up to its spend limit. | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 11 | //! |
| 12 | //! A free workspace is never charged for git operations. Past | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 13 | //! `GIT_OPERATIONS_FREE_CAP` in a month (50,000, billing's |
| 14 | //! `GIT_OPERATIONS_INCLUDED`), it is slowed down instead: at most | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 15 | //! `GIT_OPERATIONS_FREE_HOURLY` (60) an hour, answered 429 with when to try |
| 16 | //! again. Pushes from agents' sandboxes go to the store directly and are | |
| 17 | //! not counted. | |
| 18 | ||
| 19 | use g1t_contracts::repos::WorkspaceGitOperations; | |
| 20 | use serde::Deserialize; | |
| 21 | use worker::wasm_bindgen::JsValue; | |
| 22 | use worker::{D1Database, Env, Fetcher, Response, Result}; | |
| 23 | ||
| 24 | /// The hour an operation is counted in: `YYYY-MM-DDTHH` of an RFC 3339 time. | |
| 25 | pub fn hour_key(timestamp: &str) -> String { | |
| 26 | timestamp[..13].to_owned() | |
| 27 | } | |
| 28 | ||
| 29 | /// Whether a free workspace's operation should wait: past the month's cap, | |
| 30 | /// and past the hour's share. | |
| 31 | pub fn slow_down(month_ops: u64, hour_ops: u64, free_cap: u64, hourly: u64) -> bool { | |
| 32 | month_ops > free_cap && hour_ops > hourly | |
| 33 | } | |
| 34 | ||
| 35 | /// The limits, from the repos service's variables. | |
| 36 | pub struct Limits { | |
| 37 | pub free_cap: u64, | |
| 38 | pub hourly: u64, | |
| 39 | } | |
| 40 | ||
| 41 | impl Limits { | |
| 42 | pub fn from_env(env: &Env) -> Self { | |
| 43 | let number = |name: &str, default: u64| env.var(name).ok().and_then(|v| v.to_string().parse().ok()).unwrap_or(default); | |
| 44 | Limits { free_cap: number("GIT_OPERATIONS_FREE_CAP", 50_000), hourly: number("GIT_OPERATIONS_FREE_HOURLY", 60) } | |
| 45 | } | |
| 46 | } | |
| 47 | ||
| 48 | #[derive(Deserialize)] | |
| 49 | struct Counts { | |
| 50 | month: Option<f64>, | |
| 51 | hour: Option<f64>, | |
| 52 | } | |
| 53 | ||
| 54 | /// Counts one operation for `namespace` now; returns the month's and the | |
| 55 | /// hour's counts with it. | |
| 56 | pub async fn count(db: &D1Database, namespace: &str, now: &str) -> Result<(u64, u64)> { | |
| 57 | let hour = hour_key(now); | |
| 58 | let month = &now[..7]; | |
| 59 | let results = db | |
| 60 | .batch(vec![ | |
| 61 | db.prepare( | |
| 62 | "INSERT INTO git_operations (namespace, hour, operations) VALUES (?1, ?2, 1) | |
| 63 | ON CONFLICT (namespace, hour) DO UPDATE SET operations = operations + 1", | |
| 64 | ) | |
| 65 | .bind(&[namespace.into(), hour.as_str().into()])?, | |
| 66 | db.prepare( | |
| 67 | "SELECT SUM(operations) AS month, SUM(CASE WHEN hour = ?2 THEN operations END) AS hour | |
| 68 | FROM git_operations WHERE namespace = ?1 AND substr(hour, 1, 7) = ?3", | |
| 69 | ) | |
| 70 | .bind(&[namespace.into(), hour.as_str().into(), month.into()])?, | |
| 71 | ]) | |
| 72 | .await?; | |
| 73 | let counts = results.get(1).map(|r| r.results::<Counts>()).transpose()?.and_then(|rows| rows.into_iter().next()); | |
| 74 | Ok(counts.map_or((1, 1), |c| (c.month.unwrap_or(1.0) as u64, c.hour.unwrap_or(1.0) as u64))) | |
| 75 | } | |
| 76 | ||
| 77 | /// Each workspace's operations in `month`, from `since` (an hour) on. | |
| 78 | pub async fn totals(db: &D1Database, month: &str, since: Option<&str>, namespace: Option<&str>) -> Result<Vec<WorkspaceGitOperations>> { | |
| 79 | #[derive(Deserialize)] | |
| 80 | struct Row { | |
| 81 | namespace: String, | |
| 82 | operations: Option<f64>, | |
| 83 | } | |
| 84 | Ok(db | |
| 85 | .prepare( | |
| 86 | "SELECT namespace, SUM(operations) AS operations FROM git_operations | |
| 87 | WHERE substr(hour, 1, 7) = ?1 AND hour >= COALESCE(?2, '') AND (?3 IS NULL OR namespace = ?3) | |
| 88 | GROUP BY namespace", | |
| 89 | ) | |
| 90 | .bind(&[month.into(), since.map_or(JsValue::NULL, JsValue::from), namespace.map_or(JsValue::NULL, JsValue::from)])? | |
| 91 | .all() | |
| 92 | .await? | |
| 93 | .results::<Row>()? | |
| 94 | .into_iter() | |
| 95 | .map(|row| WorkspaceGitOperations { namespace: row.namespace, operations: row.operations.unwrap_or(0.0) as u64 }) | |
| 96 | .collect()) | |
| 97 | } | |
| 98 | ||
| 99 | /// Whether billing says the workspace is free. Unknown (billing not bound | |
| 100 | /// or not answering) counts as not free: nothing is slowed down on a guess. | |
| 101 | pub async fn is_free(billing: Option<&Fetcher>, namespace: &str) -> bool { | |
| 102 | let Some(billing) = billing else { return false }; | |
| 103 | let args = g1t_contracts::billing::EntitlementsArgs { workspace: namespace.to_owned() }; | |
| 104 | match g1t_kit::call::<_, serde_json::Value>(billing, "entitlements", &args).await { | |
| 105 | Ok(found) => found["plan"].as_str() == Some("free"), | |
| 106 | Err(error) => { | |
| 107 | worker::console_error!("could not ask billing about {namespace}: {error}"); | |
| 108 | false | |
| 109 | } | |
| 110 | } | |
| 111 | } | |
| 112 | ||
| 113 | /// The private storage a free workspace may push to: 1 GB unless set. | |
| 114 | pub fn free_private_bytes(env: &worker::Env) -> i64 { | |
| 115 | env.var("FREE_PRIVATE_STORAGE_BYTES") | |
| 116 | .ok() | |
| 117 | .and_then(|value| value.to_string().parse().ok()) | |
| 118 | .unwrap_or(1_000_000_000) | |
| 119 | } | |
| 120 | ||
| 121 | /// Whether a push to a private repository should be refused: a free | |
| 122 | /// workspace whose private repositories already hold its free amount. Free | |
| 123 | /// workspaces are never charged for storage; past it, pushes stop instead. | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 124 | /// Only ever asked for a free workspace: one on the plan pays for storage |
| 125 | /// past the free amount and is never refused. | |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 126 | pub fn storage_full(private_bytes: i64, free_bytes: i64) -> bool { |
| 127 | private_bytes >= free_bytes | |
| 128 | } | |
| 129 | ||
| 130 | /// The answer to a push a free workspace has no room for. Plain text on | |
| 131 | /// the push's first request, which git shows as the reason. | |
| 132 | pub fn storage_full_response(namespace: &str, private_bytes: i64, free_bytes: i64) -> Result<Response> { | |
| 133 | let gb = |bytes: i64| bytes as f64 / 1_000_000_000.0; | |
| 134 | let message = format!( | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 135 | "{namespace}'s private repositories hold {:.2} GB, and a free workspace has {:.0} GB. Free workspaces are never charged for storage, so pushes to private repositories stop here. Make the repository public, delete what you no longer need, or start the g1t plan, where storage past it is usage at cost plus 20% and pushes never stop: https://g1t.sh/{namespace}/-/billing |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 136 | ", |
| 137 | gb(private_bytes), | |
| 138 | gb(free_bytes) | |
| 139 | ); | |
| 140 | Response::error(message, 403) | |
| 141 | } | |
| 142 | ||
| 143 | /// The answer to a free workspace past its share: try again next hour. | |
| 144 | pub fn too_many(namespace: &str, free_cap: u64, hourly: u64) -> Result<Response> { | |
| 145 | let message = format!( | |
| status.g1t.sh with incident management, invites that land you in the workspace, settings as pages, usage without quotas | 146 | "{namespace} has made more than {free_cap} git operations this month, so g1t allows {hourly} an hour until the month turns. Free workspaces are never charged for git operations. On the g1t plan they are never slowed: past {free_cap} a month they are usage at cost plus 20%: https://g1t.sh/{namespace}/-/billing\n" |
| Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look | 147 | ); |
| 148 | let response = Response::error(message, 429)?; | |
| 149 | response.headers().set("retry-after", "3600")?; | |
| 150 | Ok(response) | |
| 151 | } | |
| 152 | ||
| 153 | #[cfg(test)] | |
| 154 | mod tests { | |
| 155 | use super::*; | |
| 156 | ||
| 157 | #[test] | |
| 158 | fn operations_are_counted_by_the_hour() { | |
| 159 | assert_eq!(hour_key("2026-10-14T09:59:59.000Z"), "2026-10-14T09"); | |
| 160 | } | |
| 161 | ||
| 162 | #[test] | |
| 163 | fn a_free_workspace_pushes_until_its_private_storage_is_full() { | |
| 164 | assert!(!storage_full(999_999_999, 1_000_000_000)); | |
| 165 | assert!(storage_full(1_000_000_000, 1_000_000_000)); | |
| 166 | assert!(storage_full(3_000_000_000, 1_000_000_000)); | |
| 167 | } | |
| 168 | ||
| 169 | #[test] | |
| 170 | fn a_free_workspace_is_slowed_only_past_its_monthly_cap() { | |
| 171 | // Under the cap: never slowed, however busy the hour. | |
| 172 | assert!(!slow_down(49_999, 5_000, 50_000, 60)); | |
| 173 | // Past it: 60 an hour, then wait. | |
| 174 | assert!(!slow_down(50_001, 60, 50_000, 60)); | |
| 175 | assert!(slow_down(50_001, 61, 50_000, 60)); | |
| 176 | } | |
| 177 | } |