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_ops.rs

177 lines7,786 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 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.
11//!
12//! A free workspace is never charged for git operations. Past
13//! `GIT_OPERATIONS_FREE_CAP` in a month (50,000, billing's
14//! `GIT_OPERATIONS_INCLUDED`), it is slowed down instead: at most
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
19use g1t_contracts::repos::WorkspaceGitOperations;
20use serde::Deserialize;
21use worker::wasm_bindgen::JsValue;
22use worker::{D1Database, Env, Fetcher, Response, Result};
23
24/// The hour an operation is counted in: `YYYY-MM-DDTHH` of an RFC 3339 time.
25pub 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.
31pub 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.
36pub struct Limits {
37 pub free_cap: u64,
38 pub hourly: u64,
39}
40
41impl 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)]
49struct 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.
56pub 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.
78pub 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.
101pub 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.
114pub 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.
124/// Only ever asked for a free workspace: one on the plan pays for storage
125/// past the free amount and is never refused.
126pub 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.
132pub 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!(
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
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.
144pub fn too_many(namespace: &str, free_cap: u64, hourly: u64) -> Result<Response> {
145 let message = format!(
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"
147 );
148 let response = Response::error(message, 429)?;
149 response.headers().set("retry-after", "3600")?;
150 Ok(response)
151}
152
153#[cfg(test)]
154mod 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}