pr_01m47d24b0e6n91zwymwxg0vpx/services/repos/src/git_ops.rs

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