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.
| Docs: the inbox, what lands in it and why, its tabs and actions | 1 | //! The repos service: repository metadata, contents, forks, landing, and |
| 2 | //! git over HTTPS. | |
| 3 | //! | |
| 4 | //! Other services reach it over `POST /rpc/<method>`; see | |
| 5 | //! `g1t_contracts::repos` for the methods and their arguments. Any other | |
| 6 | //! request is treated as git's smart HTTP protocol. | |
| 7 | ||
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 8 | mod about; |
| 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. | 9 | mod archive_links; |
| Docs: the inbox, what lands in it and why, its tabs and actions | 10 | mod backups; |
| 11 | mod blame; | |
| 12 | mod catch_up; | |
| 13 | mod coalesce; | |
| 14 | mod commit_file; | |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 15 | mod contributors; |
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 16 | mod copy; |
| Docs: the inbox, what lands in it and why, its tabs and actions | 17 | mod diff; |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 18 | mod drift; |
| Docs: the inbox, what lands in it and why, its tabs and actions | 19 | mod fallback; |
| 20 | mod forks; | |
| 21 | mod git_http; | |
| 22 | mod git_ops; | |
| 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. | 23 | mod hidden_refs; |
| Docs: the inbox, what lands in it and why, its tabs and actions | 24 | mod import; |
| 25 | mod land; | |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 26 | mod languages; |
| Docs: the inbox, what lands in it and why, its tabs and actions | 27 | mod last_commits; |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 28 | mod license; |
| Docs: the inbox, what lands in it and why, its tabs and actions | 29 | mod lifecycle; |
| Merge branch 'worktree-agent-a8752162fea25f63f' into spend-guardrails | 30 | mod limits; |
| Docs: the inbox, what lands in it and why, its tabs and actions | 31 | mod listing; |
| 32 | mod meters; | |
| 33 | mod moves; | |
| 34 | mod namespaces; | |
| 35 | mod pack_cache; | |
| 36 | mod pack_limits; | |
| 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 answer | 37 | mod push_checks; |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 38 | mod push_commits; |
| Docs: the inbox, what lands in it and why, its tabs and actions | 39 | mod refs; |
| 40 | mod refs_cache; | |
| 41 | mod registry; | |
| 42 | mod resilience; | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 43 | mod rule_facts; |
| 44 | mod rules; | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 45 | mod run_access; |
| 46 | mod secret_scan; | |
| 47 | mod shards; | |
| 48 | mod shared; | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 49 | mod signatures; |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 50 | mod stats; |
| Docs: the inbox, what lands in it and why, its tabs and actions | 51 | mod store; |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 52 | mod test_merge; |
| Docs: the inbox, what lands in it and why, its tabs and actions | 53 | mod transfer; |
| Workflow files need workflow_files:write from a token; fine-grained permission table | 54 | mod workflow_gate; |
| Docs: the inbox, what lands in it and why, its tabs and actions | 55 | |
| 56 | use g1t_contracts::events::{ | |
| Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9 | 57 | Event, GitDeleted, GitPush, NewEvent, Publish, RepoCreated, RepoForked, RepoUpdated, WorkspaceDeleted, |
| Docs: the inbox, what lands in it and why, its tabs and actions | 58 | WorkspaceDeleting, WorkspaceRenamed, WorkspaceRestored, |
| 59 | }; | |
| 60 | use g1t_contracts::access::{self, Capability}; | |
| 61 | use g1t_contracts::repos::*; | |
| 62 | use g1t_contracts::time::rfc3339; | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 63 | use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, User, Viewer, is_valid_repo_name, new_id}; |
| Docs: the inbox, what lands in it and why, its tabs and actions | 64 | use g1t_kit::{args, now_ms, reply, rpc_method}; |
| 65 | use std::collections::{HashMap, HashSet, VecDeque}; | |
| 66 | use std::rc::Rc; | |
| 67 | ||
| 68 | use serde::Serialize; | |
| 69 | use worker::{ | |
| Merge branch 'worktree-agent-ad8a36dfcd4176015' into spend-guardrails | 70 | Context, Env, Fetcher, MessageBatch, MessageExt, Method, Request, Response, Result, ScheduleContext, ScheduledEvent, |
| Docs: the inbox, what lands in it and why, its tabs and actions | 71 | event, |
| 72 | }; | |
| 73 | ||
| 74 | use registry::{Registry, can_read, can_write, store_key}; | |
| 75 | use store::{ArtifactsStore, GitRepo, GitStore, Scope}; | |
| 76 | ||
| 77 | /// Namespace that holds every pull request's fork: `pulls/<pull id>`. | |
| 78 | pub(crate) const PULLS_NAMESPACE: &str = "pulls"; | |
| 79 | const MAX_TEXT_BYTES: usize = 512 * 1024; | |
| 80 | /// How far back a pull request may have forked and still be landed. | |
| 81 | const MAX_ANCESTRY: u32 = 1000; | |
| Merge branch 'worktree-agent-a432c4ed9deb834b1' | 82 | /// The most files one `compare` asked for by path reads. |
| 83 | const MAX_COMPARED_PATHS: usize = 50; | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 84 | /// The most tags a repository's Tags page reads and lists. |
| 85 | const MAX_TAGS_READ: usize = 100; | |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 86 | /// Branch heads measured in one `branch_drift` call. |
| 87 | const MAX_DRIFT_HEADS: usize = 100; | |
| Merge last commits over the whole history: progress kept per ref and path, a push reads only its own commits | 88 | /// How long a `last_commits` walk asked without a budget runs before it |
| 89 | /// stops and keeps its progress for the next call. The site asks from a | |
| 90 | /// waitUntil, which may run 30 s past its response. | |
| 91 | const LAST_COMMITS_WALK_MS: u64 = 20_000; | |
| A file's history and a repository's failed runs are one read each, so Ask (and any agent) answers "why does this file exist" and "why did the last Deploy fail" from what happened rather than by paging. list_commits with `path` now gives that file's or directory's own history: the commits on the first-parent chain that changed it (added, changed, removed), newest first, with `last_changed` the newest and `complete` false when a long history was not walked to its start in time. The repos service's new path_log walks the history as last_commits does, reading ahead in batches, a tree only where the path's directory changed, within its read budget and 8 s. list_workflow_runs takes GitHub's `status`, a status (queued, in_progress, waiting, completed) or a conclusion (failure, success, cancelled…), on REST (status, and head_sha on a workflow's runs too) and MCP, and the actions service filters by either. Ask's system prompt finds failed runs with status failure, says a run still going has no result rather than calling it passed, judges health from the newest completed run and what is still going, and reads a file's history for why it exists, quoting its commit messages. The quality bar no longer reads a run's number, in a link to the run or a table of runs, as an issue's. Its last run on production scored 97.1% of checks and a judge mean of 3.81 over 27 questions, against 52.1% and 3.08 before Ask had tools; these address its weakest answers. Checked: path_log's walks over a file, a directory, a missing path, a limit, pages and a read budget; cargo test for the workspace (the actions crates on their own), the wasm builds, the TypeScript typecheck, every workspace's tests, the deploy tool's and the plugin's tests and the docs build; the OpenAPI document, the CLI's operations and its reference are regenerated, and the MCP reference says both. | 92 | /// How long a path's history may walk when the caller gives no budget. |
| 93 | const PATH_LOG_WALK_MS: u64 = 10_000; | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 94 | |
| 95 | /// One path segment, percent-encoded for a cache key. | |
| 96 | fn urlencoding_segment(segment: &str) -> String { | |
| 97 | segment | |
| 98 | .bytes() | |
| 99 | .map(|b| if b.is_ascii_alphanumeric() || b"-._~".contains(&b) { (b as char).to_string() } else { format!("%{b:02X}") }) | |
| 100 | .collect() | |
| 101 | } | |
| 102 | const MAX_DESCRIPTION_CHARS: usize = 200; | |
| 103 | pub(crate) const SOURCE: &str = "repos"; | |
| 104 | pub(crate) const UNVERIFIED: &str = "Confirm your email address first. Check your inbox, or resend the link from the banner on g1t.sh."; | |
| 105 | ||
| 106 | pub(crate) fn not_found<T>() -> Outcome<T> { | |
| 107 | Outcome::fail(FailureCode::NotFound, "Repository not found.") | |
| 108 | } | |
| 109 | ||
| 110 | /// Decoded text, or `None` when the file is too large or looks binary. | |
| 111 | /// Whether a ref is a full commit hash rather than a branch name. | |
| 112 | fn is_commit_hash(git_ref: &str) -> bool { | |
| 113 | git_ref.len() == 40 && git_ref.bytes().all(|b| b.is_ascii_hexdigit()) | |
| 114 | } | |
| 115 | ||
| 116 | fn text_of(bytes: Vec<u8>) -> Option<String> { | |
| 117 | if bytes.len() > MAX_TEXT_BYTES || bytes.contains(&0) { | |
| 118 | return None; | |
| 119 | } | |
| 120 | Some(String::from_utf8_lossy(&bytes).into_owned()) | |
| 121 | } | |
| 122 | ||
| 123 | fn is_readme(name: &str) -> bool { | |
| 124 | matches!( | |
| 125 | name.to_lowercase().as_str(), | |
| 126 | "readme" | "readme.md" | "readme.markdown" | "readme.txt" | |
| 127 | ) | |
| 128 | } | |
| 129 | ||
| 130 | /// Whether `ancestor` is reachable from the newest commit in `history`. | |
| 131 | /// | |
| 132 | /// `history` is the first-parent chain, which is all the store lists; a fork | |
| 133 | /// that merged the target branch in has the target's head on a second | |
| 134 | /// parent, so the walk follows every parent. | |
| 135 | async fn descends_from<R: GitRepo>(repo: &R, history: &[Commit], ancestor: &str) -> Result<bool> { | |
| 136 | let known: HashMap<&str, &[String]> = history | |
| 137 | .iter() | |
| 138 | .map(|commit| (commit.hash.as_str(), commit.parents.as_slice())) | |
| 139 | .collect(); | |
| 140 | let mut seen = HashSet::new(); | |
| 141 | let mut queue: Vec<String> = history | |
| 142 | .first() | |
| 143 | .map(|c| c.hash.clone()) | |
| 144 | .into_iter() | |
| 145 | .collect(); | |
| 146 | while let Some(hash) = queue.pop() { | |
| 147 | if hash == ancestor { | |
| 148 | return Ok(true); | |
| 149 | } | |
| 150 | if !seen.insert(hash.clone()) || seen.len() > MAX_ANCESTRY as usize { | |
| 151 | continue; | |
| 152 | } | |
| 153 | match known.get(hash.as_str()) { | |
| 154 | Some(parents) => queue.extend(parents.iter().cloned()), | |
| 155 | None => queue.extend(repo.parents(&hash).await?.unwrap_or_default()), | |
| 156 | } | |
| 157 | } | |
| 158 | Ok(false) | |
| 159 | } | |
| 160 | ||
| 161 | /// The commit closest to the newest in `history` that is also in `shared`: | |
| 162 | /// where a fork and the repository it came from last agreed. | |
| 163 | async fn nearest_ancestor_in<R: GitRepo>( | |
| 164 | repo: &R, | |
| 165 | history: &[Commit], | |
| 166 | shared: &HashSet<String>, | |
| 167 | ) -> Result<Option<String>> { | |
| 168 | let known: HashMap<&str, &[String]> = history | |
| 169 | .iter() | |
| 170 | .map(|commit| (commit.hash.as_str(), commit.parents.as_slice())) | |
| 171 | .collect(); | |
| 172 | let mut seen = HashSet::new(); | |
| 173 | let mut queue: VecDeque<String> = history | |
| 174 | .first() | |
| 175 | .map(|c| c.hash.clone()) | |
| 176 | .into_iter() | |
| 177 | .collect(); | |
| 178 | while let Some(hash) = queue.pop_front() { | |
| 179 | if shared.contains(&hash) { | |
| 180 | return Ok(Some(hash)); | |
| 181 | } | |
| 182 | if !seen.insert(hash.clone()) || seen.len() > MAX_ANCESTRY as usize { | |
| 183 | continue; | |
| 184 | } | |
| 185 | match known.get(hash.as_str()) { | |
| 186 | Some(parents) => queue.extend(parents.iter().cloned()), | |
| 187 | None => queue.extend(repo.parents(&hash).await?.unwrap_or_default()), | |
| 188 | } | |
| 189 | } | |
| 190 | Ok(None) | |
| 191 | } | |
| 192 | ||
| 193 | thread_local! { | |
| 194 | /// Targets' sides of mergeability, by head (coalesce.rs). | |
| 195 | static TARGETS: std::cell::RefCell<coalesce::Memo<coalesce::TargetKey, Rc<coalesce::TargetSide>>> = | |
| 196 | std::cell::RefCell::new(coalesce::Memo::new(coalesce::TARGET_TTL_MS, 32)); | |
| 197 | /// What targets changed between two trees. | |
| 198 | static THEIRS: std::cell::RefCell<coalesce::Memo<coalesce::TheirsKey, (Vec<String>, bool)>> = | |
| 199 | std::cell::RefCell::new(coalesce::Memo::new(coalesce::THEIRS_TTL_MS, 256)); | |
| 200 | /// What repositories hold, as read for a push's first request, for the | |
| 201 | /// same push's second: a push's POST does not wait on the database. | |
| 202 | static HELD: std::cell::RefCell<coalesce::Memo<String, u64>> = | |
| 203 | std::cell::RefCell::new(coalesce::Memo::new(60_000, 512)); | |
| 204 | } | |
| 205 | ||
| 206 | pub(crate) struct Repos<S: GitStore> { | |
| 207 | registry: Registry, | |
| 208 | store: S, | |
| 209 | events: Fetcher, | |
| 210 | /// Asked during a push which secrets have been allowed. | |
| 211 | security: Option<Fetcher>, | |
| 212 | /// Asked whether a workspace is on a plan, for its private storage. | |
| 213 | billing: Option<Fetcher>, | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 214 | /// Says which rulesets hold for a change to a branch or tag, and keeps |
| 215 | /// how they judged it (rules.rs). `None` where it is not deployed: the | |
| 216 | /// old protection flag then holds on push. | |
| 217 | work: Option<Fetcher>, | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 218 | /// Told when a repository moves, for the tokens of agents at work on it. |
| 219 | identity: Option<Fetcher>, | |
| 220 | /// What a free workspace's private repositories may hold. | |
| 221 | free_private_bytes: i64, | |
| 222 | /// Days a pull request's working copy is kept after it settles (forks.rs). | |
| 223 | pub(crate) fork_days: u64, | |
| 224 | /// The most a repository may hold (pack_limits.rs), and what happens | |
| 225 | /// to a push too large to scan. | |
| 226 | repo_limit: u64, | |
| 227 | large_pushes: git_http::LargePushes, | |
| 228 | /// Which git store namespace new repositories go in (shards.rs), and | |
| The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook. | 229 | /// the most each should hold (`GITSTORE_NAMESPACE_LIMITS`). |
| Docs: the inbox, what lands in it and why, its tabs and actions | 230 | placement: shards::Placement, |
| 231 | limits: HashMap<String, u64>, | |
| 232 | /// What isolates share: answers that list refs (refs_cache.rs). | |
| 233 | shared: Option<Rc<shared::Shared>>, | |
| 234 | /// Packs for fresh clones (pack_cache.rs); `None` without the bucket. | |
| 235 | packs: Option<Rc<pack_cache::Packs>>, | |
| 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 answer | 236 | /// What this request started that need not hold up its answer |
| 237 | /// (store.rs), handed to its `waitUntil` once it has answered. | |
| 238 | deferred: Rc<store::Deferred>, | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 239 | } |
| 240 | ||
| 241 | impl<S: GitStore> Repos<S> { | |
| 242 | /// Records that the refs of the repository with this id changed, once | |
| 243 | /// they have, so that the answers kept that list them go stale (see | |
| 244 | /// refs_cache.rs). Everything that changes a repository's refs calls | |
| 245 | /// this after it (`every_ref_writer_records_the_change` checks). A | |
| 246 | /// failure is logged: the change itself happened, and what was kept | |
| 247 | /// expires within `refs_cache::TTL_SECONDS` regardless. | |
| 248 | pub(crate) async fn refs_moved(&self, repo_id: &str) { | |
| 249 | if let Err(error) = self.registry.refs_moved(repo_id).await { | |
| 250 | worker::console_error!("refs of {repo_id} changed but not recorded: {error}"); | |
| 251 | } | |
| 252 | } | |
| 253 | ||
| 254 | pub(crate) async fn publish<T: Serialize>(&self, event: NewEvent<T>) -> Result<()> { | |
| 255 | g1t_kit::call( | |
| 256 | &self.events, | |
| 257 | "publish", | |
| 258 | &Publish { | |
| 259 | events: vec![event], | |
| 260 | }, | |
| 261 | ) | |
| 262 | .await | |
| 263 | } | |
| 264 | ||
| 265 | /// Whether the viewer may read `repo`. A pull request's fork of a | |
| 266 | /// private repository can be read by everyone who can read that | |
| 267 | /// repository, so its members can review and check out the change, as | |
| 268 | /// well as by whoever opened the pull request. | |
| 269 | async fn may_read(&self, repo: &Repo, viewer: &Viewer) -> Result<bool> { | |
| 270 | if can_read(repo, viewer) { | |
| 271 | return Ok(true); | |
| 272 | } | |
| 273 | let Some(source_id) = &repo.fork_of else { | |
| 274 | return Ok(false); | |
| 275 | }; | |
| 276 | Ok(self | |
| 277 | .registry | |
| 278 | .by_id(source_id) | |
| 279 | .await? | |
| 280 | .is_some_and(|source| can_read(&source, viewer))) | |
| 281 | } | |
| 282 | ||
| 283 | /// `repo`, if there is one and the viewer may read it. | |
| 284 | async fn visible(&self, repo: Option<Repo>, viewer: &Viewer) -> Result<Option<Repo>> { | |
| 285 | Ok(match repo { | |
| 286 | Some(repo) if self.may_read(&repo, viewer).await? => Some(repo), | |
| 287 | _ => None, | |
| 288 | }) | |
| 289 | } | |
| 290 | ||
| 291 | /// Resolves a repo the viewer may read; private repos look missing. | |
| 292 | pub(crate) async fn readable(&self, path: &RepoPath, viewer: &Viewer) -> Result<Option<Repo>> { | |
| 293 | self.visible(self.registry.by_path(path).await?, viewer) | |
| 294 | .await | |
| 295 | } | |
| 296 | ||
| 297 | async fn get(&self, a: GetArgs) -> Result<Outcome<Repo>> { | |
| 298 | Ok(self | |
| 299 | .readable(&a.path, &a.viewer) | |
| 300 | .await? | |
| 301 | .map_or_else(not_found, Outcome::Ok)) | |
| 302 | } | |
| 303 | ||
| 304 | async fn get_by_id(&self, a: GetByIdArgs) -> Result<Outcome<Repo>> { | |
| 305 | Ok(self | |
| 306 | .visible(self.registry.by_id(&a.id).await?, &a.viewer) | |
| 307 | .await? | |
| 308 | .map_or_else(not_found, Outcome::Ok)) | |
| 309 | } | |
| 310 | ||
| 311 | async fn update(&self, a: UpdateArgs) -> Result<Outcome<Repo>> { | |
| 312 | let viewer = Some(a.actor.clone()); | |
| 313 | let Some(repo) = self.readable(&a.path, &viewer).await? else { | |
| 314 | return Ok(not_found()); | |
| 315 | }; | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 316 | // Its details take Maintain; its protection, Admin; who can see it, |
| 317 | // Admin and the member privileges (below). See g1t_contracts::access. | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 318 | let protection_changes = a.protected.is_some_and(|protected| protected != repo.protected); |
| 319 | let details_change = a.description.is_some() || a.website.is_some() || a.topics.is_some(); | |
| 320 | let mut needed = Vec::new(); | |
| 321 | if details_change || !protection_changes { | |
| 322 | needed.push(Capability::ManageSettings); | |
| 323 | } | |
| 324 | if protection_changes { | |
| 325 | needed.push(Capability::ManageProtection); | |
| 326 | } | |
| 327 | let full_name = format!("{}/{}", repo.namespace, repo.name); | |
| 328 | if repo.fork_of.is_some() { | |
| 329 | return Ok(Outcome::fail(FailureCode::Forbidden, access::needs(Capability::ManageSettings, &full_name))); | |
| 330 | } | |
| 331 | if let Some(missing) = needed.into_iter().find(|capability| !registry::can(&repo, &viewer, *capability)) { | |
| 332 | return Ok(Outcome::fail(FailureCode::Forbidden, access::needs(missing, &full_name))); | |
| 333 | } | |
| 334 | if !a.actor.verified { | |
| 335 | return Ok(Outcome::fail(FailureCode::Forbidden, UNVERIFIED)); | |
| 336 | } | |
| 337 | if let Some((code, message)) = lifecycle::archived_refusal(&repo) { | |
| 338 | return Ok(Outcome::fail(code, message)); | |
| 339 | } | |
| 340 | let description = match a.description { | |
| 341 | Some(text) => Some( | |
| 342 | text.trim() | |
| 343 | .chars() | |
| 344 | .take(MAX_DESCRIPTION_CHARS) | |
| 345 | .collect::<String>(), | |
| 346 | ) | |
| 347 | .filter(|text| !text.is_empty()), | |
| 348 | None => repo.description.clone(), | |
| 349 | }; | |
| 350 | let website = match a.website.as_deref() { | |
| 351 | Some(text) => match clean_website(text) { | |
| 352 | Ok(website) => website, | |
| 353 | Err(reason) => return Ok(Outcome::fail(FailureCode::Invalid, reason)), | |
| 354 | }, | |
| 355 | None => repo.website.clone(), | |
| 356 | }; | |
| 357 | // Who can see it is an owner's to change, and a free workspace's | |
| 358 | // storage may not take it private: see lifecycle.rs. | |
| 359 | let wants_private = a.is_private.filter(|private| *private != repo.is_private); | |
| 360 | if wants_private.is_some() | |
| 361 | && let Err((code, message)) = lifecycle::admin_only( | |
| 362 | lifecycle::Asker::on(&a.actor, &repo), | |
| 363 | &repo.namespace, | |
| 364 | "change the visibility of", | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 365 | Capability::ChangeVisibility, |
| Docs: the inbox, what lands in it and why, its tabs and actions | 366 | ) |
| 367 | { | |
| 368 | return Ok(Outcome::fail(code, message)); | |
| 369 | } | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 370 | if let Some(private) = wants_private |
| 371 | && let Some(why) = lifecycle::visibility_refusal(&a.actor, &repo, private) | |
| 372 | { | |
| 373 | return Ok(Outcome::fail(FailureCode::Forbidden, why)); | |
| 374 | } | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 375 | let is_private = repo.is_private; |
| 376 | let protected = a.protected.unwrap_or(repo.protected); | |
| 377 | let topics = match &a.topics { | |
| 378 | Some(topics) => match clean_topics(topics) { | |
| 379 | Ok(topics) => topics, | |
| 380 | Err(reason) => return Ok(Outcome::fail(FailureCode::Invalid, reason)), | |
| 381 | }, | |
| 382 | None => repo.topics.clone(), | |
| 383 | }; | |
| 384 | self.registry | |
| 385 | .update(&repo.id, description.as_deref(), protected, &topics, website.as_deref()) | |
| 386 | .await?; | |
| 387 | let updated = Repo { | |
| 388 | description, | |
| 389 | is_private, | |
| 390 | protected, | |
| 391 | topics, | |
| 392 | website, | |
| 393 | ..repo | |
| 394 | }; | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 395 | // Whether the default branch takes only pull requests is now its |
| 396 | // branch protection ruleset's to say (work's rulesets.rs). | |
| 397 | if let (Some(protected), Some(work)) = (a.protected, &self.work) { | |
| 398 | #[derive(Serialize)] | |
| 399 | struct RequirePullRequest<'a> { | |
| 400 | repo: &'a Repo, | |
| 401 | protected: bool, | |
| 402 | actor: &'a User, | |
| 403 | } | |
| 404 | let set: Result<Outcome<bool>> = | |
| 405 | g1t_kit::call(work, "set_requires_pull_request", &RequirePullRequest { repo: &updated, protected, actor: &a.actor }).await; | |
| 406 | match set { | |
| 407 | Ok(Outcome::Ok(_)) => {} | |
| 408 | Ok(Outcome::Fail(failure)) => return Ok(Outcome::Fail(failure)), | |
| 409 | Err(error) => return Err(error), | |
| 410 | } | |
| 411 | } | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 412 | if let Some(private) = wants_private { |
| 413 | return self.change_visibility(updated, private, &a.actor, a.surface).await; | |
| 414 | } | |
| 415 | let visibility_changed = false; | |
| 416 | // Search and anything else that shows the repository hears of it; | |
| 417 | // a change of visibility is announced on its own as well, so that | |
| 418 | // what was public stops being shown at once. | |
| 419 | self.publish(NewEvent { | |
| 420 | kind: "repo.updated", | |
| 421 | source: SOURCE, | |
| 422 | repo_id: Some(updated.id.clone()), | |
| 423 | actor: Some(a.actor.id.clone()), | |
| 424 | data: RepoUpdated { | |
| 425 | repo_id: updated.id.clone(), | |
| 426 | namespace: updated.namespace.clone(), | |
| 427 | name: updated.name.clone(), | |
| 428 | is_private, | |
| 429 | visibility_changed, | |
| 430 | }, | |
| 431 | }) | |
| 432 | .await?; | |
| 433 | Ok(Outcome::Ok(updated)) | |
| 434 | } | |
| 435 | ||
| 436 | /// The repository with this id, if it is not a fork, and its store. | |
| 437 | async fn stored(&self, repo_id: &str) -> Result<Option<S::Repo>> { | |
| 438 | match self.registry.by_id(repo_id).await? { | |
| 439 | Some(repo) if repo.fork_of.is_none() => Ok(Some(self.store.open(&store_key(&repo)).await?)), | |
| 440 | _ => Ok(None), | |
| 441 | } | |
| 442 | } | |
| 443 | ||
| 444 | async fn list_files(&self, a: ListFilesArgs) -> Result<FileList> { | |
| 445 | let Some(repo) = self.registry.by_id(&a.repo_id).await?.filter(|repo| repo.fork_of.is_none()) else { | |
| 446 | return Ok(FileList::default()); | |
| 447 | }; | |
| 448 | let git = self.store.open(&store_key(&repo)).await?; | |
| 449 | let head = a.git_ref.unwrap_or_else(|| repo.default_branch.clone()); | |
| 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. | 450 | listing::list(&git, None, &head, &a.skip_dirs, a.limit, a.links).await |
| 451 | } | |
| 452 | ||
| 453 | /// A link to download a branch, tag or commit as an archive, good for | |
| 454 | /// a few minutes with no session or token (archive_links.rs). | |
| 455 | async fn archive_link(&self, a: ArchiveLinkArgs, sealer: Option<&g1t_secrets::Sealer>) -> Result<Outcome<ArchiveLink>> { | |
| 456 | let Some(repo) = self.readable(&a.path, &a.viewer).await? else { | |
| 457 | return Ok(not_found()); | |
| 458 | }; | |
| 459 | let Some(sealer) = sealer else { | |
| 460 | return Ok(Outcome::fail(FailureCode::Invalid, "Archive downloads are not set up on this installation.")); | |
| 461 | }; | |
| 462 | let git = self.read_git(&repo).await?; | |
| 463 | let git_ref = a.git_ref.filter(|git_ref| !git_ref.is_empty()).unwrap_or_else(|| repo.default_branch.clone()); | |
| 464 | let Some(head) = git.log(&git_ref, 1).await?.into_iter().next() else { | |
| 465 | return Ok(Outcome::fail(FailureCode::NotFound, "No such branch, tag or commit.")); | |
| 466 | }; | |
| 467 | let (token, expires) = archive_links::seal(sealer, &repo.id, &head.hash, now_ms()); | |
| 468 | Ok(Outcome::Ok(ArchiveLink { repo, commit: head.hash, token, expires_at: rfc3339(expires) })) | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 469 | } |
| 470 | ||
| 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. | 471 | /// What an archive link's token lets its holder download. |
| 472 | async fn open_archive_token(&self, a: OpenArchiveTokenArgs, sealer: Option<&g1t_secrets::Sealer>) -> Result<Option<ArchiveGrant>> { | |
| 473 | let Some(sealer) = sealer else { | |
| 474 | return Ok(None); | |
| 475 | }; | |
| 476 | let Some(repo) = self.registry.by_path(&a.path).await? else { | |
| 477 | return Ok(None); | |
| 478 | }; | |
| 479 | let Some(commit) = archive_links::open(sealer, &repo.id, &a.token, now_ms()) else { | |
| 480 | return Ok(None); | |
| 481 | }; | |
| 482 | Ok(Some(ArchiveGrant { repo_id: repo.id, namespace: repo.namespace, name: repo.name, commit })) | |
| 483 | } | |
| 484 | ||
| Docs: the inbox, what lands in it and why, its tabs and actions | 485 | async fn changed_files(&self, a: ChangedFilesArgs) -> Result<FileList> { |
| 486 | let Some(git) = self.stored(&a.repo_id).await? else { | |
| 487 | return Ok(FileList::default()); | |
| 488 | }; | |
| 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. | 489 | listing::list(&git, a.base.as_deref(), &a.head, &a.skip_dirs, a.limit, false).await |
| Docs: the inbox, what lands in it and why, its tabs and actions | 490 | } |
| 491 | ||
| 492 | /// Branches and tags with their commits, for g1t's own services. | |
| 493 | async fn refs_of(&self, a: RefsArgs) -> Result<Option<RepoRefs>> { | |
| 494 | let Some(repo) = self.registry.by_id(&a.repo_id).await?.filter(|repo| repo.fork_of.is_none()) else { | |
| 495 | return Ok(None); | |
| 496 | }; | |
| 497 | let git = self.store.open(&store_key(&repo)).await?; | |
| 498 | let access = git.access(Scope::Read).await?; | |
| 499 | let refs = refs::heads_and_tags(refs::all(&access).await?) | |
| 500 | .into_iter() | |
| 501 | .map(|(name, commit)| GitRefEntry { name, commit }) | |
| 502 | .collect(); | |
| 503 | Ok(Some(RepoRefs { repo, refs })) | |
| 504 | } | |
| 505 | ||
| 506 | async fn raw_file(&self, a: RawFileArgs) -> Result<Option<RawFile>> { | |
| 507 | use base64::Engine; | |
| 508 | let Some(git) = self.stored(&a.repo_id).await? else { | |
| 509 | return Ok(None); | |
| 510 | }; | |
| 511 | Ok(git | |
| 512 | .read_file(&a.git_ref, &a.path) | |
| 513 | .await? | |
| 514 | .filter(|bytes| bytes.len() <= a.max_bytes as usize) | |
| 515 | .map(|bytes| RawFile { size: bytes.len() as u64, data: base64::engine::general_purpose::STANDARD.encode(bytes) })) | |
| 516 | } | |
| 517 | ||
| 518 | async fn raw_blobs(&self, a: RawBlobsArgs) -> Result<Vec<RawBlob>> { | |
| 519 | use base64::Engine; | |
| 520 | let Some(git) = self.stored(&a.repo_id).await? else { | |
| 521 | return Ok(Vec::new()); | |
| 522 | }; | |
| 523 | let hashes: Vec<&String> = a.hashes.iter().take(MAX_READ_BLOBS).collect(); | |
| 524 | let mut out = Vec::with_capacity(hashes.len()); | |
| 525 | // A few at a time, as listing::read does: each is a round trip. | |
| 526 | for group in hashes.chunks(8) { | |
| 527 | let read = futures_util::future::try_join_all(group.iter().map(|hash| git.read_blob(hash))).await?; | |
| 528 | for (hash, bytes) in group.iter().zip(read) { | |
| 529 | let size = bytes.as_ref().map_or(0, |bytes| bytes.len() as u64); | |
| 530 | let data = bytes | |
| 531 | .filter(|bytes| bytes.len() <= a.max_bytes as usize) | |
| 532 | .map(|bytes| base64::engine::general_purpose::STANDARD.encode(bytes)); | |
| 533 | out.push(RawBlob { hash: (*hash).clone(), size, data }); | |
| 534 | } | |
| 535 | } | |
| 536 | Ok(out) | |
| 537 | } | |
| 538 | ||
| 539 | async fn read_blobs(&self, a: ReadBlobsArgs) -> Result<Vec<BlobText>> { | |
| 540 | let Some(git) = self.stored(&a.repo_id).await? else { | |
| 541 | return Ok(Vec::new()); | |
| 542 | }; | |
| 543 | listing::read(&git, &a.hashes, a.max_bytes.min(MAX_TEXT_BYTES as u32)).await | |
| 544 | } | |
| 545 | ||
| 546 | async fn create(&self, a: CreateArgs) -> Result<Outcome<Repo>> { | |
| 547 | if !a.owner.verified { | |
| 548 | return Ok(Outcome::fail(FailureCode::Forbidden, UNVERIFIED)); | |
| 549 | } | |
| 550 | let name = a.name.trim().to_lowercase(); | |
| 551 | if !is_valid_repo_name(&name) { | |
| 552 | return Ok(Outcome::fail( | |
| 553 | FailureCode::Invalid, | |
| 554 | "Use letters, digits, dots, hyphens and underscores only.", | |
| 555 | )); | |
| 556 | } | |
| 557 | let namespace = a.namespace.trim().to_lowercase(); | |
| 558 | if namespace.is_empty() { | |
| 559 | return Ok(Outcome::fail( | |
| 560 | FailureCode::Invalid, | |
| 561 | "Say which workspace to create the repository in.", | |
| 562 | )); | |
| 563 | } | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 564 | let Some(role) = a.owner.role_in(&namespace) else { |
| Docs: the inbox, what lands in it and why, its tabs and actions | 565 | return Ok(Outcome::fail( |
| 566 | FailureCode::Forbidden, | |
| 567 | "You are not a member of that workspace.", | |
| 568 | )); | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 569 | }; |
| 570 | // Who may create which: the workspace's member privileges. A | |
| 571 | // workspace's own token acts as an owner would. | |
| 572 | let role = if a.owner.kind == PrincipalKind::Workspace { Role::Owner } else { role }; | |
| 573 | if let Some(why) = a.owner.privileges_in(&namespace).creation_refusal(role, a.is_private, &namespace) { | |
| 574 | return Ok(Outcome::fail(FailureCode::Forbidden, why)); | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 575 | } |
| 576 | let path = RepoPath { namespace, name }; | |
| 577 | match self.registry.by_path_any(&path).await? { | |
| 578 | Some((_, None)) => { | |
| 579 | return Ok(Outcome::fail( | |
| 580 | FailureCode::Conflict, | |
| 581 | "That workspace already has a repository with that name.", | |
| 582 | )); | |
| 583 | } | |
| 584 | Some((_, Some(_))) => { | |
| 585 | return Ok(Outcome::fail( | |
| 586 | FailureCode::Conflict, | |
| 587 | format!( | |
| 588 | "{}/{} was deleted recently and can still be restored, so its name is taken. Restore it, or delete it permanently from the workspace's Recently deleted list.", | |
| 589 | path.namespace, path.name | |
| 590 | ), | |
| 591 | )); | |
| 592 | } | |
| 593 | None => {} | |
| 594 | } | |
| 595 | // With a credential (a GitHub App installation's token), everything | |
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 596 | // is copied: every branch and tag. See copy.rs. |
| Docs: the inbox, what lands in it and why, its tabs and actions | 597 | let mut credentialed = None; |
| 598 | if let (Some(url), Some(token)) = (a.import_url.as_deref(), a.import_token.as_deref()) { | |
| 599 | let Some(url) = import::clean_url(url) else { | |
| 600 | return Ok(Outcome::fail(FailureCode::Invalid, "That is not an https repository address.")); | |
| 601 | }; | |
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 602 | let source = copy::Endpoint::github(&url, token); |
| 603 | match copy::probe(&source).await? { | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 604 | Ok(advertised) => credentialed = Some((source, advertised)), |
| 605 | Err(reason) => return Ok(Outcome::fail(FailureCode::Invalid, reason)), | |
| 606 | } | |
| 607 | } | |
| 608 | // An import is fetched before anything is created, so that an | |
| 609 | // address that does not work leaves nothing behind. | |
| 610 | let mut imported = None; | |
| 611 | if let Some(url) = a | |
| 612 | .import_url | |
| 613 | .as_deref() | |
| 614 | .map(str::trim) | |
| 615 | .filter(|url| !url.is_empty() && credentialed.is_none()) | |
| 616 | { | |
| 617 | let Some(url) = import::clean_url(url) else { | |
| 618 | return Ok(Outcome::fail( | |
| 619 | FailureCode::Invalid, | |
| 620 | "Give the https address of a public repository, such as https://github.com/owner/repo.", | |
| 621 | )); | |
| 622 | }; | |
| 623 | let remote = match import::discover(&url).await? { | |
| 624 | Ok(remote) => remote, | |
| 625 | Err(reason) => return Ok(Outcome::fail(FailureCode::Invalid, reason)), | |
| 626 | }; | |
| 627 | imported = Some((remote, url)); | |
| 628 | } | |
| 629 | let now = now_ms(); | |
| 630 | let repo = Repo { | |
| 631 | id: new_id("rep", now), | |
| 632 | namespace: path.namespace, | |
| 633 | name: path.name, | |
| 634 | description: a | |
| 635 | .description | |
| 636 | .map(|text| text.trim().to_owned()) | |
| 637 | .filter(|text| !text.is_empty()), | |
| 638 | is_private: a.is_private, | |
| 639 | owner_id: a.owner.id.clone(), | |
| 640 | default_branch: imported | |
| 641 | .as_ref() | |
| 642 | .map(|(remote, _)| remote.branch.clone()) | |
| 643 | .or_else(|| credentialed.as_ref().and_then(|(_, advertised)| advertised.default_branch())) | |
| 644 | .unwrap_or_else(|| "main".to_owned()), | |
| 645 | fork_of: None, | |
| 646 | protected: false, | |
| 647 | created_at: rfc3339(now), | |
| 648 | topics: Vec::new(), | |
| 649 | website: None, | |
| 650 | archived_at: None, | |
| 651 | }; | |
| 652 | let namespace = match self.place(&repo).await? { | |
| 653 | Ok(namespace) => namespace, | |
| 654 | Err(unplaced) => return Ok(Outcome::fail(FailureCode::Conflict, unplaced.message())), | |
| 655 | }; | |
| 656 | self.registry | |
| 657 | .claim_store_key(&repo, namespace.as_deref(), &self.store.default_namespace()) | |
| 658 | .await?; | |
| 659 | self.store | |
| 660 | .create( | |
| 661 | &store_key(&repo), | |
| 662 | repo.description.as_deref(), | |
| 663 | &repo.default_branch, | |
| 664 | ) | |
| 665 | .await?; | |
| 666 | self.registry.insert(&repo).await?; | |
| 667 | // A repository that was transferred away from this path stops | |
| 668 | // redirecting here. | |
| 669 | self.registry | |
| 670 | .drop_redirect(&RepoPath { | |
| 671 | namespace: repo.namespace.clone(), | |
| 672 | name: repo.name.clone(), | |
| 673 | }) | |
| 674 | .await?; | |
| 675 | // Every branch and tag the import made, announced as pushes. | |
| 676 | let mut pushed: Vec<(String, String)> = Vec::new(); | |
| 677 | // A public repository, read with no credential: every branch and | |
| 678 | // tag is copied too, the default branch the one its HEAD names. | |
| 679 | if let Some((_, url)) = imported { | |
| 680 | let access = self | |
| 681 | .store | |
| 682 | .open(&store_key(&repo)) | |
| 683 | .await? | |
| 684 | .access(Scope::Write) | |
| 685 | .await?; | |
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 686 | let target = copy::Endpoint::bearer(&access.remote, &access.token); |
| 687 | let copied = copy::copy(©::Endpoint::anonymous(&url), &target).await?; | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 688 | self.refs_moved(&repo.id).await; |
| 689 | match copied { | |
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 690 | Ok(copied) => pushed = copy::import_pushes(&copied.updated, &repo.default_branch), |
| Docs: the inbox, what lands in it and why, its tabs and actions | 691 | Err(reason) => { |
| 692 | self.registry.remove(&repo.id).await?; | |
| 693 | return Ok(Outcome::fail( | |
| 694 | FailureCode::Invalid, | |
| 695 | format!("The repository could not be stored: {reason}"), | |
| 696 | )); | |
| 697 | } | |
| 698 | } | |
| 699 | } | |
| 700 | if let Some((source, _)) = credentialed { | |
| 701 | let access = self | |
| 702 | .store | |
| 703 | .open(&store_key(&repo)) | |
| 704 | .await? | |
| 705 | .access(Scope::Write) | |
| 706 | .await?; | |
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 707 | let target = copy::Endpoint::bearer(&access.remote, &access.token); |
| 708 | let copied = copy::copy(&source, &target).await?; | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 709 | self.refs_moved(&repo.id).await; |
| 710 | match copied { | |
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 711 | Ok(copied) => pushed = copy::import_pushes(&copied.updated, &repo.default_branch), |
| Docs: the inbox, what lands in it and why, its tabs and actions | 712 | Err(reason) => { |
| 713 | self.registry.remove(&repo.id).await?; | |
| 714 | return Ok(Outcome::fail( | |
| 715 | FailureCode::Invalid, | |
| 716 | format!("The repository could not be copied: {reason}"), | |
| 717 | )); | |
| 718 | } | |
| 719 | } | |
| 720 | } | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 721 | // Whoever creates a repository is an Admin of it, as a role given |
| 722 | // to them on it, whatever the workspace's base permission. | |
| 723 | if a.owner.kind == PrincipalKind::User | |
| 724 | && let Some(identity) = &self.identity | |
| 725 | { | |
| 726 | let granted: Result<bool> = g1t_kit::call( | |
| 727 | identity, | |
| 728 | "grant_creator", | |
| 729 | &g1t_contracts::members::GrantCreatorArgs { | |
| 730 | repo_id: repo.id.clone(), | |
| 731 | namespace: repo.namespace.clone(), | |
| 732 | name: repo.name.clone(), | |
| 733 | user_id: a.owner.id.clone(), | |
| 734 | }, | |
| 735 | ) | |
| 736 | .await; | |
| 737 | if let Err(error) = granted { | |
| 738 | worker::console_error!("creator of {} not given Admin: {error}", repo.id); | |
| 739 | } | |
| 740 | } | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 741 | self.publish(NewEvent { |
| 742 | kind: "repo.created", | |
| 743 | source: SOURCE, | |
| 744 | repo_id: Some(repo.id.clone()), | |
| 745 | actor: Some(a.owner.id), | |
| 746 | data: RepoCreated { | |
| 747 | repo_id: repo.id.clone(), | |
| 748 | namespace: repo.namespace.clone(), | |
| 749 | name: repo.name.clone(), | |
| 750 | is_private: repo.is_private, | |
| 751 | }, | |
| 752 | }) | |
| 753 | .await?; | |
| 754 | for (git_ref, head) in &pushed { | |
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 755 | self.publish_push(&repo, git_ref, None, head, None, None).await?; |
| Docs: the inbox, what lands in it and why, its tabs and actions | 756 | } |
| 757 | Ok(Outcome::Ok(repo)) | |
| 758 | } | |
| 759 | ||
| 760 | /// Where a workspace keeps its data, asked of identity only when an EU | |
| 761 | /// namespace is configured: without one, every workspace's | |
| 762 | /// repositories go anywhere and identity is never asked. | |
| 763 | async fn residency_of(&self, workspace: &str) -> Result<shards::Residency> { | |
| 764 | if self.placement.eu.is_none() { | |
| 765 | return Ok(shards::Residency::Anywhere); | |
| 766 | } | |
| 767 | let Some(identity) = &self.identity else { | |
| 768 | return Ok(shards::Residency::Anywhere); | |
| 769 | }; | |
| 770 | let residency: Option<g1t_contracts::identity::DataResidency> = g1t_kit::call( | |
| 771 | identity, | |
| 772 | "workspace_residency", | |
| 773 | &g1t_contracts::identity::SlugArgs { slug: workspace.to_owned() }, | |
| 774 | ) | |
| 775 | .await?; | |
| 776 | Ok(match residency { | |
| 777 | Some(g1t_contracts::identity::DataResidency::Eu) => shards::Residency::Eu, | |
| 778 | _ => shards::Residency::Anywhere, | |
| 779 | }) | |
| 780 | } | |
| 781 | ||
| 782 | /// How each bound namespace stands (namespaces.rs). | |
| 783 | async fn standings(&self) -> Result<Vec<namespaces::Standing>> { | |
| 784 | let bound = self.store.namespaces(); | |
| 785 | let default = self.store.default_namespace(); | |
| 786 | let now = now_ms(); | |
| 787 | let config = namespaces::Configured { | |
| 788 | bound: &bound, | |
| 789 | default: &default, | |
| 790 | placement: &self.placement, | |
| 791 | limits: &self.limits, | |
| 792 | on_fallback: &|namespace| self.store.on_fallback(&shards::compose(Some(namespace), "x", &default)), | |
| 793 | writable: &|namespace| self.store.writable(namespace), | |
| 794 | breaker_open: &|namespace| resilience::open_now(namespace, now), | |
| 795 | }; | |
| 796 | let (held, recent) = futures_util::future::join(namespaces::held(&self.registry.db), namespaces::recent(&self.registry.db, now)).await; | |
| 797 | Ok(namespaces::standings(&config, &held?, &recent?)) | |
| 798 | } | |
| 799 | ||
| 800 | /// The namespace a new repository goes in (shards.rs): its workspace's | |
| 801 | /// residency, then how each namespace stands, read only when there is | |
| 802 | /// a choice to make. `Ok(None)` for the default. | |
| 803 | async fn place(&self, repo: &Repo) -> Result<std::result::Result<Option<String>, shards::Unplaced>> { | |
| 804 | let residency = self.residency_of(&repo.namespace).await?; | |
| 805 | let bound = self.store.namespaces(); | |
| 806 | let loads = if residency == shards::Residency::Anywhere && !self.placement.needs_loads(&bound) { | |
| 807 | // One namespace to choose from at most: nothing to read. | |
| 808 | bound | |
| 809 | .iter() | |
| 810 | .map(|namespace| shards::Load { | |
| 811 | namespace: namespace.clone(), | |
| 812 | bound: true, | |
| 813 | writable: self.store.writable(namespace), | |
| 814 | ..shards::Load::default() | |
| 815 | }) | |
| 816 | .collect() | |
| 817 | } else { | |
| 818 | let default = self.store.default_namespace(); | |
| 819 | let now = now_ms(); | |
| 820 | let config = namespaces::Configured { | |
| 821 | bound: &bound, | |
| 822 | default: &default, | |
| 823 | placement: &self.placement, | |
| 824 | limits: &self.limits, | |
| 825 | on_fallback: &|namespace| self.store.on_fallback(&shards::compose(Some(namespace), "x", &default)), | |
| 826 | writable: &|namespace| self.store.writable(namespace), | |
| 827 | breaker_open: &|namespace| resilience::open_now(namespace, now), | |
| 828 | }; | |
| 829 | namespaces::loads(&self.registry.db, &config, now).await? | |
| 830 | }; | |
| 831 | Ok(self.placement.choose(&repo.id, residency, &loads)) | |
| 832 | } | |
| 833 | ||
| 834 | /// `storage_options`: what a workspace may choose about where its | |
| 835 | /// repositories are kept. | |
| 836 | fn storage_options(&self) -> StorageOptions { | |
| 837 | let bound = self.store.namespaces(); | |
| 838 | StorageOptions { eu_available: self.placement.eu_available(&bound, |namespace| self.store.writable(namespace)) } | |
| 839 | } | |
| 840 | ||
| 841 | async fn tree(&self, a: TreeArgs) -> Result<Outcome<TreeView>> { | |
| 842 | let Some(repo) = self.readable(&a.path, &a.viewer).await? else { | |
| 843 | return Ok(not_found()); | |
| 844 | }; | |
| 845 | let git = self.read_git(&repo).await?; | |
| 846 | let git_ref = a | |
| 847 | .git_ref | |
| 848 | .clone() | |
| 849 | .unwrap_or_else(|| repo.default_branch.clone()); | |
| 850 | ||
| 851 | let Some(head) = git.log(&git_ref, 1).await?.into_iter().next() else { | |
| 852 | // An unknown ref is an error; a repo with no commits is just empty. | |
| 853 | if a.git_ref.is_some() { | |
| 854 | return Ok(Outcome::fail( | |
| 855 | FailureCode::NotFound, | |
| 856 | "No such branch, tag or commit.", | |
| 857 | )); | |
| 858 | } | |
| 859 | return Ok(Outcome::Ok(TreeView { | |
| 860 | repo, | |
| 861 | git_ref, | |
| 862 | path: a.tree_path, | |
| 863 | head: None, | |
| 864 | entries: Vec::new(), | |
| 865 | readme: None, | |
| 866 | })); | |
| 867 | }; | |
| 868 | ||
| 869 | let no_directory = || Outcome::fail(FailureCode::NotFound, "No such directory."); | |
| 870 | let mut entries = git.read_tree(&head.tree_hash).await?; | |
| 871 | for segment in a.tree_path.split('/').filter(|segment| !segment.is_empty()) { | |
| 872 | let next = entries.as_ref().and_then(|entries| { | |
| 873 | entries | |
| 874 | .iter() | |
| 875 | .find(|entry| entry.name == segment && entry.kind == EntryKind::Tree) | |
| 876 | }); | |
| 877 | let Some(next) = next else { | |
| 878 | return Ok(no_directory()); | |
| 879 | }; | |
| 880 | entries = git.read_tree(&next.hash).await?; | |
| 881 | } | |
| 882 | let Some(mut entries) = entries else { | |
| 883 | return Ok(no_directory()); | |
| 884 | }; | |
| 885 | // Directories first, then by name. | |
| 886 | entries.sort_by(|a, b| { | |
| 887 | (b.kind == EntryKind::Tree) | |
| 888 | .cmp(&(a.kind == EntryKind::Tree)) | |
| 889 | .then_with(|| a.name.cmp(&b.name)) | |
| 890 | }); | |
| 891 | ||
| 892 | let readme_entry = entries | |
| 893 | .iter() | |
| 894 | .find(|entry| entry.kind == EntryKind::Blob && is_readme(&entry.name)); | |
| 895 | let readme = match readme_entry { | |
| 896 | Some(entry) => git.read_blob(&entry.hash).await?.map(|bytes| Readme { | |
| 897 | name: entry.name.clone(), | |
| 898 | text: text_of(bytes), | |
| 899 | }), | |
| 900 | None => None, | |
| 901 | }; | |
| 902 | Ok(Outcome::Ok(TreeView { | |
| 903 | repo, | |
| 904 | git_ref, | |
| 905 | path: a.tree_path, | |
| 906 | head: Some(head), | |
| 907 | entries, | |
| 908 | readme, | |
| 909 | })) | |
| 910 | } | |
| 911 | ||
| 912 | async fn blob(&self, a: BlobArgs) -> Result<Outcome<BlobView>> { | |
| 913 | let Some(repo) = self.readable(&a.path, &a.viewer).await? else { | |
| 914 | return Ok(not_found()); | |
| 915 | }; | |
| 916 | let bytes = if a.file_path.is_empty() { | |
| 917 | None | |
| 918 | } else { | |
| 919 | let git = self.read_git(&repo).await?; | |
| 920 | git.read_file(&a.git_ref, &a.file_path).await? | |
| 921 | }; | |
| 922 | let Some(bytes) = bytes else { | |
| 923 | return Ok(Outcome::fail(FailureCode::NotFound, "No such file.")); | |
| 924 | }; | |
| 925 | Ok(Outcome::Ok(BlobView { | |
| 926 | repo, | |
| 927 | git_ref: a.git_ref, | |
| 928 | path: a.file_path, | |
| 929 | size: bytes.len() as u64, | |
| 930 | text: text_of(bytes), | |
| 931 | })) | |
| 932 | } | |
| 933 | ||
| 934 | async fn blame(&self, a: BlameArgs) -> Result<Outcome<Blame>> { | |
| 935 | let Some(repo) = self.readable(&a.path, &a.viewer).await? else { | |
| 936 | return Ok(not_found()); | |
| 937 | }; | |
| 938 | let git = self.read_git(&repo).await?; | |
| 939 | let git_ref = a.git_ref.unwrap_or_else(|| repo.default_branch.clone()); | |
| 940 | Ok(match blame::blame(&git, &git_ref, &a.file_path).await? { | |
| 941 | Some(blame) => Outcome::Ok(blame), | |
| 942 | None => not_found(), | |
| 943 | }) | |
| 944 | } | |
| 945 | ||
| 946 | async fn log(&self, a: LogArgs) -> Result<Outcome<Vec<Commit>>> { | |
| 947 | let Some(repo) = self.readable(&a.path, &a.viewer).await? else { | |
| 948 | return Ok(not_found()); | |
| 949 | }; | |
| 950 | let git = self.read_git(&repo).await?; | |
| 951 | let git_ref = a.git_ref.unwrap_or_else(|| repo.default_branch.clone()); | |
| 952 | Ok(Outcome::Ok(git.log(&git_ref, a.limit).await?)) | |
| 953 | } | |
| 954 | ||
| A file's history and a repository's failed runs are one read each, so Ask (and any agent) answers "why does this file exist" and "why did the last Deploy fail" from what happened rather than by paging. list_commits with `path` now gives that file's or directory's own history: the commits on the first-parent chain that changed it (added, changed, removed), newest first, with `last_changed` the newest and `complete` false when a long history was not walked to its start in time. The repos service's new path_log walks the history as last_commits does, reading ahead in batches, a tree only where the path's directory changed, within its read budget and 8 s. list_workflow_runs takes GitHub's `status`, a status (queued, in_progress, waiting, completed) or a conclusion (failure, success, cancelled…), on REST (status, and head_sha on a workflow's runs too) and MCP, and the actions service filters by either. Ask's system prompt finds failed runs with status failure, says a run still going has no result rather than calling it passed, judges health from the newest completed run and what is still going, and reads a file's history for why it exists, quoting its commit messages. The quality bar no longer reads a run's number, in a link to the run or a table of runs, as an issue's. Its last run on production scored 97.1% of checks and a judge mean of 3.81 over 27 questions, against 52.1% and 3.08 before Ask had tools; these address its weakest answers. Checked: path_log's walks over a file, a directory, a missing path, a limit, pages and a read budget; cargo test for the workspace (the actions crates on their own), the wasm builds, the TypeScript typecheck, every workspace's tests, the deploy tool's and the plugin's tests and the docs build; the OpenAPI document, the CLI's operations and its reference are regenerated, and the MCP reference says both. | 955 | /// The commits that changed a file or directory, newest first: the |
| 956 | /// first-parent chain walked within `PATH_LOG_WALK_MS` (or the caller's | |
| 957 | /// budget) and `last_commits::MAX_READS`. | |
| 958 | async fn path_log(&self, a: g1t_contracts::repos::PathLogArgs) -> Result<Outcome<g1t_contracts::repos::PathLog>> { | |
| 959 | let Some(repo) = self.readable(&a.path, &a.viewer).await? else { | |
| 960 | return Ok(not_found()); | |
| 961 | }; | |
| 962 | let git = self.read_git(&repo).await?; | |
| 963 | let git_ref = a.git_ref.unwrap_or_else(|| repo.default_branch.clone()); | |
| 964 | let Some(head) = git.log(&git_ref, 1).await?.into_iter().next() else { | |
| 965 | return Ok(Outcome::fail(FailureCode::NotFound, "No such branch, tag or commit.")); | |
| 966 | }; | |
| 967 | let started = worker::Date::now().as_millis(); | |
| 968 | let budget = a.budget_ms.unwrap_or(PATH_LOG_WALK_MS); | |
| 969 | let out_of_time = move || worker::Date::now().as_millis().saturating_sub(started) > budget; | |
| 970 | let limit = a.limit.clamp(1, 100) as usize; | |
| 971 | let (commits, complete) = last_commits::path_log(&git, &head.hash, &a.file_path, limit, &out_of_time, last_commits::MAX_READS).await?; | |
| 972 | Ok(Outcome::Ok(g1t_contracts::repos::PathLog { commits, complete })) | |
| 973 | } | |
| 974 | ||
| Merge last commits over the whole history: progress kept per ref and path, a push reads only its own commits | 975 | /// Which commit last changed each entry of a directory. A finished |
| 976 | /// answer is kept in this colo's cache by repository, head commit and | |
| 977 | /// path: a commit's history never changes, so it is good for as long as | |
| 978 | /// it is kept. Every walk also keeps its progress by ref and path | |
| 979 | /// (last_commits.rs), so the next call goes on from it: from where it | |
| 980 | /// stopped, or for a new head, only back to the old one. | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 981 | async fn last_commits(&self, a: g1t_contracts::repos::LastCommitsArgs) -> Result<Outcome<g1t_contracts::repos::LastCommits>> { |
| 982 | let Some(repo) = self.readable(&a.path, &a.viewer).await? else { | |
| 983 | return Ok(not_found()); | |
| 984 | }; | |
| 985 | let git = self.read_git(&repo).await?; | |
| 986 | let git_ref = a.git_ref.unwrap_or_else(|| repo.default_branch.clone()); | |
| 987 | let Some(head) = git.log(&git_ref, 1).await?.into_iter().next() else { | |
| 988 | return Ok(Outcome::fail(FailureCode::NotFound, "No such branch, tag or commit.")); | |
| 989 | }; | |
| Merge last commits over the whole history: progress kept per ref and path, a push reads only its own commits | 990 | // v2: v1 kept walks cut short at 300 commits as if finished. |
| Docs: the inbox, what lands in it and why, its tabs and actions | 991 | let key = format!( |
| Merge last commits over the whole history: progress kept per ref and path, a push reads only its own commits | 992 | "https://last-commits.g1t.internal/v2/{}/{}/{}", |
| Docs: the inbox, what lands in it and why, its tabs and actions | 993 | repo.id, |
| 994 | head.hash, | |
| 995 | a.tree_path.split('/').map(urlencoding_segment).collect::<Vec<_>>().join("/") | |
| 996 | ); | |
| 997 | let cache = worker::Cache::default(); | |
| 998 | if let Ok(Some(mut kept)) = cache.get(key.as_str(), false).await { | |
| 999 | if let Ok(found) = kept.json::<g1t_contracts::repos::LastCommits>().await { | |
| 1000 | return Ok(Outcome::Ok(found)); | |
| 1001 | } | |
| 1002 | } | |
| Merge last commits over the whole history: progress kept per ref and path, a push reads only its own commits | 1003 | let memo = last_commits::Memo::new(&repo.id, &git_ref, &a.tree_path); |
| 1004 | let shared = self.shared.as_deref(); | |
| 1005 | let progress = memo.get(shared).await; | |
| 1006 | // Asked with a budget, the walk stops past it; without one, past | |
| 1007 | // LAST_COMMITS_WALK_MS, well before the caller's waitUntil ends. | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1008 | let started = worker::Date::now().as_millis(); |
| Merge last commits over the whole history: progress kept per ref and path, a push reads only its own commits | 1009 | let budget = a.budget_ms.unwrap_or(LAST_COMMITS_WALK_MS); |
| 1010 | let out_of_time = move || worker::Date::now().as_millis().saturating_sub(started) > budget; | |
| 1011 | let walk = last_commits::last_commits(&git, &head.hash, &a.tree_path, progress, &out_of_time, last_commits::MAX_READS).await?; | |
| 1012 | if walk.reads > 0 { | |
| 1013 | memo.keep(shared, &walk.progress).await; | |
| 1014 | } | |
| 1015 | let settled = walk.progress.settled(); | |
| 1016 | let found = g1t_contracts::repos::LastCommits { complete: walk.progress.complete(), entries: walk.progress.found }; | |
| 1017 | if !settled { | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1018 | return Ok(Outcome::Ok(found)); |
| 1019 | } | |
| 1020 | if let Ok(mut response) = worker::Response::from_json(&found) { | |
| 1021 | let _ = response.headers_mut().set("cache-control", "max-age=604800"); | |
| 1022 | let _ = cache.put(key.as_str(), response).await; | |
| 1023 | } | |
| 1024 | Ok(Outcome::Ok(found)) | |
| 1025 | } | |
| 1026 | ||
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 1027 | /// How far each branch head has moved from the default branch's head, |
| Merge branch drift: count across merges the way git does; v2 cache key | 1028 | /// in one call (drift.rs). Each count is kept in this colo's cache by |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 1029 | /// repository and the pair of hashes, for good: neither history can |
| Merge branch drift: count across merges the way git does; v2 cache key | 1030 | /// change. A head that moved is the only one walked. A failed read is |
| 1031 | /// not kept, and "too far to count" only for a day. | |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 1032 | async fn branch_drift(&self, a: BranchDriftArgs) -> Result<Outcome<BranchDrifts>> { |
| 1033 | let Some(repo) = self.readable(&a.path, &a.viewer).await? else { | |
| 1034 | return Ok(not_found()); | |
| 1035 | }; | |
| 1036 | if !store::is_commit_hash(&a.base) { | |
| 1037 | return Ok(Outcome::fail(FailureCode::Invalid, "The default branch's head is a full commit hash.")); | |
| 1038 | } | |
| 1039 | let heads: Vec<String> = a.heads.into_iter().take(MAX_DRIFT_HEADS).collect(); | |
| 1040 | let git = self.read_git(&repo).await?; | |
| Merge branch drift: count across merges the way git does; v2 cache key | 1041 | // v2: answers kept before 2026-10-09 said "no count" for every |
| 1042 | // branch whose default branch took a merge since it left (drift.rs). | |
| 1043 | let key = |head: &str| format!("https://drift.g1t.internal/v2/{}/{}/{head}", repo.id, a.base); | |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 1044 | let cache = worker::Cache::default(); |
| 1045 | let (base, kept) = futures_util::future::join( | |
| 1046 | git.log(&a.base, 1), | |
| 1047 | futures_util::future::join_all(heads.iter().map(|head| { | |
| 1048 | let (cache, url) = (&cache, key(head)); | |
| 1049 | async move { | |
| 1050 | if !store::is_commit_hash(head) { | |
| 1051 | return None; | |
| 1052 | } | |
| 1053 | let mut found = cache.get(url.as_str(), false).await.ok()??; | |
| 1054 | found.json::<BranchDrift>().await.ok() | |
| 1055 | } | |
| 1056 | })), | |
| 1057 | ) | |
| 1058 | .await; | |
| 1059 | let missing: Vec<String> = heads | |
| 1060 | .iter() | |
| 1061 | .zip(&kept) | |
| 1062 | .filter(|(head, kept)| kept.is_none() && store::is_commit_hash(head)) | |
| 1063 | .map(|(head, _)| head.clone()) | |
| 1064 | .collect(); | |
| 1065 | let measured = drift::measure(&git, &a.base, &missing).await; | |
| 1066 | let mut fresh: HashMap<String, BranchDrift> = HashMap::new(); | |
| 1067 | for (head, found) in missing.into_iter().zip(measured) { | |
| 1068 | let answer = BranchDrift { head: head.clone(), commit: found.commit, drift: found.drift }; | |
| 1069 | if found.settled | |
| 1070 | && let Ok(mut response) = worker::Response::from_json(&answer) | |
| 1071 | { | |
| Merge branch drift: count across merges the way git does; v2 cache key | 1072 | // A count is kept for good; "too far to count" for a day, so |
| 1073 | // a change to how far a walk goes reaches it. | |
| 1074 | let max_age = if answer.drift.is_some() { "public, max-age=31536000, immutable" } else { "public, max-age=86400" }; | |
| 1075 | let _ = response.headers_mut().set("cache-control", max_age); | |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 1076 | let _ = cache.put(key(&head).as_str(), response).await; |
| 1077 | } | |
| 1078 | fresh.insert(head, answer); | |
| 1079 | } | |
| 1080 | let branches = heads | |
| 1081 | .iter() | |
| 1082 | .zip(kept) | |
| 1083 | .map(|(head, kept)| { | |
| 1084 | kept.or_else(|| fresh.get(head).cloned()) | |
| 1085 | .unwrap_or_else(|| BranchDrift { head: head.clone(), commit: None, drift: None }) | |
| 1086 | }) | |
| 1087 | .collect(); | |
| 1088 | Ok(Outcome::Ok(BranchDrifts { base: base.ok().and_then(|log| log.into_iter().next()), branches })) | |
| 1089 | } | |
| 1090 | ||
| Docs: the inbox, what lands in it and why, its tabs and actions | 1091 | /// The repository's tags, newest commit first, at most 100. |
| 1092 | async fn tags(&self, a: g1t_contracts::repos::TagsArgs) -> Result<Outcome<Vec<g1t_contracts::repos::Tag>>> { | |
| 1093 | let Some(repo) = self.readable(&a.path, &a.viewer).await? else { | |
| 1094 | return Ok(not_found()); | |
| 1095 | }; | |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 1096 | // Kept until the refs move, as the branch list is (store.rs): listing |
| 1097 | // the refs is a round trip to the store on every call otherwise. | |
| 1098 | let version = refs_cache::usable(registry::refs_state(&repo.id), now_ms()).filter(|_| !self.store.on_fallback(&store_key(&repo))); | |
| 1099 | let kept_at = version.map(|version| format!("https://tags.g1t.internal/{}/{version}", repo.id)); | |
| 1100 | if let Some(url) = &kept_at | |
| 1101 | && let Ok(Some(mut kept)) = worker::Cache::default().get(url.as_str(), false).await | |
| 1102 | && let Ok(tags) = kept.json::<Vec<g1t_contracts::repos::Tag>>().await | |
| 1103 | { | |
| 1104 | return Ok(Outcome::Ok(tags)); | |
| 1105 | } | |
| 1106 | let (tags, complete) = self.read_tags(&repo).await?; | |
| 1107 | if complete | |
| 1108 | && let Some(url) = &kept_at | |
| 1109 | && let Ok(mut response) = worker::Response::from_json(&tags) | |
| 1110 | { | |
| 1111 | let _ = response.headers_mut().set("cache-control", "public, max-age=300"); | |
| 1112 | let _ = worker::Cache::default().put(url.as_str(), response).await; | |
| 1113 | } | |
| 1114 | Ok(Outcome::Ok(tags)) | |
| 1115 | } | |
| 1116 | ||
| 1117 | /// The tags, and whether every one's commit was read (only then kept). | |
| 1118 | async fn read_tags(&self, repo: &Repo) -> Result<(Vec<g1t_contracts::repos::Tag>, bool)> { | |
| 1119 | let git = self.store.open(&store_key(repo)).await?; | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1120 | let access = git.access(Scope::Read).await?; |
| 1121 | let named: Vec<(String, String)> = refs::heads_and_tags(refs::all(&access).await?) | |
| 1122 | .into_iter() | |
| 1123 | .filter_map(|(name, hash)| name.strip_prefix("refs/tags/").map(|tag| (tag.to_owned(), hash))) | |
| 1124 | .collect(); | |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 1125 | let read = self.read_git(repo).await?; |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1126 | let commits = futures_util::future::join_all(named.iter().take(MAX_TAGS_READ).map(|(_, hash)| read.log(hash, 1))).await; |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 1127 | let complete = commits.iter().all(Result::is_ok); |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1128 | let mut tags: Vec<g1t_contracts::repos::Tag> = named |
| 1129 | .into_iter() | |
| 1130 | .zip(commits.into_iter().map(|found| found.ok().and_then(|list| list.into_iter().next())).chain(std::iter::repeat(None))) | |
| 1131 | .map(|((name, _), commit)| g1t_contracts::repos::Tag { name, commit }) | |
| 1132 | .collect(); | |
| 1133 | tags.sort_by(|a, b| { | |
| 1134 | let at = |tag: &g1t_contracts::repos::Tag| tag.commit.as_ref().map(|c| c.authored_at.clone()).unwrap_or_default(); | |
| 1135 | at(b).cmp(&at(a)).then_with(|| b.name.cmp(&a.name)) | |
| 1136 | }); | |
| 1137 | tags.truncate(MAX_TAGS_READ); | |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 1138 | Ok((tags, complete)) |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1139 | } |
| 1140 | ||
| 1141 | /// The repository's branches, default branch first. | |
| 1142 | async fn branches(&self, a: BranchesArgs) -> Result<Outcome<Vec<Branch>>> { | |
| 1143 | let Some(repo) = self.readable(&a.path, &a.viewer).await? else { | |
| 1144 | return Ok(not_found()); | |
| 1145 | }; | |
| 1146 | let mut branches = self.read_git(&repo).await?.branches().await?; | |
| 1147 | branches.sort_by_key(|branch| branch.name != repo.default_branch); | |
| 1148 | Ok(Outcome::Ok(branches)) | |
| 1149 | } | |
| 1150 | ||
| 1151 | /// Whether a pull request's source lacks commits that the branch it | |
| 1152 | /// would merge into has. | |
| 1153 | async fn behind(&self, a: BehindArgs) -> Result<bool> { | |
| 1154 | let Some(source) = self.registry.by_id(&a.source_id).await? else { | |
| 1155 | return Ok(false); | |
| 1156 | }; | |
| 1157 | let target = match &source.fork_of { | |
| 1158 | Some(id) => self.registry.by_id(id).await?, | |
| 1159 | None => Some(source.clone()), | |
| 1160 | }; | |
| 1161 | let Some(target) = target else { | |
| 1162 | return Ok(false); | |
| 1163 | }; | |
| 1164 | let branch = a.branch.unwrap_or_else(|| target.default_branch.clone()); | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1165 | let target_branch = a.target_branch.unwrap_or_else(|| target.default_branch.clone()); |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1166 | let target_head = self |
| 1167 | .read_git(&target) | |
| 1168 | .await? | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1169 | .log(&target_branch, 1) |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1170 | .await? |
| 1171 | .into_iter() | |
| 1172 | .next() | |
| 1173 | .map(|commit| commit.hash); | |
| 1174 | let Some(target_head) = target_head else { | |
| 1175 | return Ok(false); | |
| 1176 | }; | |
| 1177 | let source_git = self.read_git(&source).await?; | |
| 1178 | let history = source_git.log(&branch, MAX_ANCESTRY).await?; | |
| 1179 | if history.is_empty() { | |
| 1180 | return Ok(false); | |
| 1181 | } | |
| 1182 | Ok(!descends_from(&source_git, &history, &target_head).await?) | |
| 1183 | } | |
| 1184 | ||
| 1185 | /// The files a pull request's source and the default branch it would | |
| 1186 | /// merge into each changed since they last agreed. Where the two lists | |
| 1187 | /// share no file, the merge cannot conflict; where they do, it may. | |
| 1188 | async fn divergence(&self, a: BehindArgs) -> Result<Option<Divergence>> { | |
| 1189 | let Some(source) = self.registry.by_id(&a.source_id).await? else { | |
| 1190 | return Ok(None); | |
| 1191 | }; | |
| 1192 | let target = match &source.fork_of { | |
| 1193 | Some(id) => self.registry.by_id(id).await?, | |
| 1194 | None => Some(source.clone()), | |
| 1195 | }; | |
| 1196 | let Some(target) = target else { | |
| 1197 | return Ok(None); | |
| 1198 | }; | |
| 1199 | let branch = a.branch.unwrap_or_else(|| target.default_branch.clone()); | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1200 | let target_branch = a.target_branch.unwrap_or_else(|| target.default_branch.clone()); |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1201 | let source_git = self.read_git(&source).await?; |
| 1202 | let target_git = self.read_git(&target).await?; | |
| 1203 | // The target's side is the same for every pull request into it, and | |
| 1204 | // worked out once per head (coalesce.rs). | |
| 1205 | let (history, side) = futures_util::future::try_join( | |
| 1206 | source_git.log(&branch, MAX_ANCESTRY), | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1207 | self.target_side(&target, &target_git, &target_branch), |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1208 | ) |
| 1209 | .await?; | |
| 1210 | let target_history = &side.history; | |
| 1211 | let (Some(head), Some(base)) = (history.first(), target_history.first()) else { | |
| 1212 | return Ok(None); | |
| 1213 | }; | |
| 1214 | let behind = !descends_from(&source_git, &history, &base.hash).await?; | |
| 1215 | let merge_base = nearest_ancestor_in(&source_git, &history, &side.shared).await?; | |
| 1216 | let mut divergence = Divergence { | |
| 1217 | head: head.hash.clone(), | |
| 1218 | base: base.hash.clone(), | |
| 1219 | merge_base: merge_base.clone(), | |
| 1220 | behind, | |
| 1221 | ..Divergence::default() | |
| 1222 | }; | |
| 1223 | let merge_base_tree = match &merge_base { | |
| 1224 | Some(hash) => target_history | |
| 1225 | .iter() | |
| 1226 | .find(|commit| commit.hash == *hash) | |
| 1227 | .map(|commit| commit.tree_hash.clone()), | |
| 1228 | None => None, | |
| 1229 | }; | |
| 1230 | let Some(merge_base_tree) = merge_base_tree else { | |
| 1231 | // No common history to compare from: say nothing is known. | |
| 1232 | divergence.truncated = true; | |
| 1233 | return Ok(Some(divergence)); | |
| 1234 | }; | |
| 1235 | let (ours, truncated_ours) = | |
| 1236 | diff::changed_paths(&source_git, Some(&merge_base_tree), &head.tree_hash).await?; | |
| 1237 | divergence.ours = ours; | |
| 1238 | divergence.truncated = truncated_ours; | |
| 1239 | if behind { | |
| 1240 | let now = now_ms(); | |
| 1241 | let key = (target.id.clone(), merge_base_tree.clone(), base.tree_hash.clone()); | |
| 1242 | let (theirs, truncated_theirs) = match THEIRS.with(|memo| memo.borrow().get(&key, now)) { | |
| 1243 | Some(kept) => kept, | |
| 1244 | None => { | |
| 1245 | let found = diff::changed_paths(&target_git, Some(&merge_base_tree), &base.tree_hash).await?; | |
| 1246 | THEIRS.with(|memo| memo.borrow_mut().put(key, found.clone(), now)); | |
| 1247 | found | |
| 1248 | } | |
| 1249 | }; | |
| 1250 | divergence.theirs = theirs; | |
| 1251 | divergence.truncated |= truncated_theirs; | |
| 1252 | } | |
| 1253 | Ok(Some(divergence)) | |
| 1254 | } | |
| 1255 | ||
| 1256 | /// A target branch's history from its head, worked out once per head | |
| 1257 | /// for every pull request asking about it (coalesce.rs). The head is | |
| 1258 | /// read under the refs version; the history by its hash, which the | |
| 1259 | /// object cache keeps for good. | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1260 | async fn target_side<R: GitRepo>(&self, target: &Repo, git: &R, branch: &str) -> Result<Rc<coalesce::TargetSide>> { |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1261 | let now = now_ms(); |
| 1262 | let key = refs_cache::usable(registry::refs_state(&target.id), now) | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1263 | .map(|version| (target.id.clone(), branch.to_owned(), version)); |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1264 | if let Some(key) = &key |
| 1265 | && let Some(side) = TARGETS.with(|memo| memo.borrow().get(key, now)) | |
| 1266 | { | |
| 1267 | return Ok(side); | |
| 1268 | } | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1269 | let history = match git.log(branch, 1).await?.first() { |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1270 | Some(head) => git.log(&head.hash, MAX_ANCESTRY).await?, |
| 1271 | None => Vec::new(), | |
| 1272 | }; | |
| 1273 | let side = coalesce::TargetSide::new(history); | |
| 1274 | if let Some(key) = key { | |
| 1275 | TARGETS.with(|memo| memo.borrow_mut().put(key, side.clone(), now)); | |
| 1276 | } | |
| 1277 | Ok(side) | |
| 1278 | } | |
| 1279 | ||
| 1280 | async fn head(&self, a: HeadArgs) -> Result<Option<String>> { | |
| 1281 | let Some(repo) = self.registry.by_id(&a.repo_id).await? else { | |
| 1282 | return Ok(None); | |
| 1283 | }; | |
| 1284 | let branch = if a.branch.is_empty() { &repo.default_branch } else { &a.branch }; | |
| 1285 | let git = self.read_git(&repo).await?; | |
| 1286 | Ok(git | |
| 1287 | .log(branch, 1) | |
| 1288 | .await? | |
| 1289 | .into_iter() | |
| 1290 | .next() | |
| 1291 | .map(|commit| commit.hash)) | |
| 1292 | } | |
| 1293 | ||
| 1294 | async fn delete_branch(&self, a: DeleteBranchArgs) -> Result<Outcome<bool>> { | |
| Merge update PRs close themselves: g1t closes its security and version updates once they are no longer needed, and deletes their branches | 1295 | if !deletable_branch(&a.branch, a.head.as_deref()) { |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1296 | return Ok(Outcome::fail( |
| 1297 | FailureCode::Forbidden, | |
| 1298 | "Only branches g1t made for itself can be deleted this way.", | |
| 1299 | )); | |
| 1300 | } | |
| 1301 | let Some(repo) = self.registry.by_id(&a.repo_id).await? else { | |
| 1302 | return Ok(not_found()); | |
| 1303 | }; | |
| Merge update PRs close themselves: g1t closes its security and version updates once they are no longer needed, and deletes their branches | 1304 | if a.branch == repo.default_branch { |
| 1305 | return Ok(Outcome::fail(FailureCode::Forbidden, "The default branch is never deleted.")); | |
| 1306 | } | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1307 | let repo = match self.unpaused(repo).await? { |
| 1308 | Ok(repo) => repo, | |
| 1309 | Err((code, message)) => return Ok(Outcome::fail(code, message)), | |
| 1310 | }; | |
| 1311 | self.live(&repo).await?; | |
| 1312 | let git = self.store.open(&store_key(&repo)).await?; | |
| 1313 | let Some(old) = git | |
| 1314 | .branches() | |
| 1315 | .await? | |
| 1316 | .into_iter() | |
| 1317 | .find(|branch| branch.name == a.branch) | |
| 1318 | .map(|branch| branch.hash) | |
| 1319 | else { | |
| 1320 | return Ok(Outcome::Ok(false)); | |
| 1321 | }; | |
| Merge update PRs close themselves: g1t closes its security and version updates once they are no longer needed, and deletes their branches | 1322 | // Moved since the caller looked: someone else's commits are on it. |
| 1323 | if a.head.as_deref().is_some_and(|head| head != old) { | |
| 1324 | return Ok(Outcome::fail(FailureCode::Conflict, format!("{} moved, so it was left alone.", a.branch))); | |
| 1325 | } | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1326 | let access = git.access(Scope::Write).await?; |
| 1327 | let deleted = land::delete_ref(&access, &a.branch, &old).await?; | |
| 1328 | self.refs_moved(&repo.id).await; | |
| 1329 | if let Err(reason) = deleted { | |
| 1330 | return Ok(Outcome::fail( | |
| 1331 | FailureCode::Conflict, | |
| 1332 | format!("{} could not be deleted: {reason}", a.branch), | |
| 1333 | )); | |
| 1334 | } | |
| 1335 | Ok(Outcome::Ok(true)) | |
| 1336 | } | |
| 1337 | ||
| 1338 | async fn fork_for_pull(&self, a: ForkArgs) -> Result<Outcome<Repo>> { | |
| 1339 | let viewer = Some(a.actor.clone()); | |
| 1340 | let Some(source) = self | |
| 1341 | .registry | |
| 1342 | .by_id(&a.source_id) | |
| 1343 | .await? | |
| 1344 | .filter(|repo| can_read(repo, &viewer)) | |
| 1345 | else { | |
| 1346 | return Ok(not_found()); | |
| 1347 | }; | |
| Merge branch 'mirroring' into artifacts-mode | 1348 | if let Some((code, message)) = lifecycle::read_only_refusal(&source) { |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1349 | return Ok(Outcome::fail(code, message)); |
| 1350 | } | |
| 1351 | // Its working copy is made in its namespace: not while it moves. | |
| 1352 | let source = match self.unpaused(source).await? { | |
| 1353 | Ok(source) => source, | |
| 1354 | Err((code, message)) => return Ok(Outcome::fail(code, message)), | |
| 1355 | }; | |
| 1356 | let now = now_ms(); | |
| 1357 | let fork = Repo { | |
| 1358 | id: new_id("rep", now), | |
| 1359 | namespace: PULLS_NAMESPACE.to_owned(), | |
| 1360 | name: a.pull_id.clone(), | |
| 1361 | description: None, | |
| 1362 | // A fork is exactly as visible as the repo it came from. | |
| 1363 | is_private: source.is_private, | |
| 1364 | owner_id: a.actor.id.clone(), | |
| 1365 | default_branch: source.default_branch.clone(), | |
| 1366 | fork_of: Some(source.id.clone()), | |
| 1367 | protected: false, | |
| 1368 | created_at: rfc3339(now), | |
| 1369 | topics: Vec::new(), | |
| 1370 | website: None, | |
| 1371 | archived_at: None, | |
| 1372 | }; | |
| 1373 | // Artifacts forks within a namespace: the copy goes where its | |
| 1374 | // repository is. | |
| 1375 | let (namespace, _) = store::locate(&store_key(&source)); | |
| 1376 | self.registry | |
| 1377 | .claim_store_key(&fork, Some(&namespace), &self.store.default_namespace()) | |
| 1378 | .await?; | |
| 1379 | self.store | |
| 1380 | .open(&store_key(&source)) | |
| 1381 | .await? | |
| 1382 | .fork(&store_key(&fork)) | |
| 1383 | .await?; | |
| 1384 | self.registry.insert(&fork).await?; | |
| 1385 | self.publish(NewEvent { | |
| 1386 | kind: "repo.forked", | |
| 1387 | source: SOURCE, | |
| 1388 | repo_id: Some(source.id.clone()), | |
| 1389 | actor: Some(a.actor.id), | |
| 1390 | data: RepoForked { | |
| 1391 | repo_id: fork.id.clone(), | |
| 1392 | source_repo_id: source.id, | |
| 1393 | pull_id: a.pull_id, | |
| 1394 | }, | |
| 1395 | }) | |
| 1396 | .await?; | |
| 1397 | Ok(Outcome::Ok(fork)) | |
| 1398 | } | |
| 1399 | ||
| 1400 | async fn git_access(&self, a: GitAccessArgs) -> Result<Outcome<GitAccess>> { | |
| 1401 | let found = self.registry.by_path(&a.path).await?; | |
| 1402 | Ok(match self.authorize_git(&a.path, &a.viewer, a.service, found).await? { | |
| 1403 | Outcome::Ok(repo) => { | |
| 1404 | self.live(&repo).await?; | |
| 1405 | let write = a.service == GitService::ReceivePack; | |
| 1406 | if write { | |
| 1407 | // A push with this credential would not pass through | |
| 1408 | // here, so nothing that lists the refs is kept until it | |
| 1409 | // has expired (see refs_cache.rs). | |
| 1410 | let until = now_ms() + store::CREDENTIAL_LIFE_MS + 60_000; | |
| 1411 | if let Err(error) = self.registry.refs_open(&repo.id, until).await { | |
| 1412 | // Before the column exists nothing is kept anyway. | |
| 1413 | if registry::refs_state(&repo.id).is_some() { | |
| 1414 | return Err(error); | |
| 1415 | } | |
| 1416 | } | |
| 1417 | } | |
| 1418 | let scope = if write { Scope::Write } else { Scope::Read }; | |
| 1419 | Outcome::Ok(self.store.handout(&store_key(&repo), scope).await?) | |
| 1420 | } | |
| 1421 | Outcome::Fail(failure) => Outcome::Fail(failure), | |
| 1422 | }) | |
| 1423 | } | |
| 1424 | ||
| 1425 | /// The repository at `path` (`found`, as just read), if the viewer may | |
| 1426 | /// use `service` on it: fetch from it, or push to it. A push to a path | |
| 1427 | /// with nothing there makes the repository, in a workspace the pusher | |
| 1428 | /// belongs to. | |
| 1429 | async fn authorize_git( | |
| 1430 | &self, | |
| 1431 | path: &RepoPath, | |
| 1432 | viewer: &Viewer, | |
| 1433 | service: GitService, | |
| 1434 | found: Option<Repo>, | |
| 1435 | ) -> Result<Outcome<Repo>> { | |
| 1436 | let mut a = GitAccessArgs { | |
| 1437 | path: path.clone(), | |
| 1438 | viewer: viewer.clone(), | |
| 1439 | service, | |
| 1440 | }; | |
| 1441 | let write = a.service == GitService::ReceivePack; | |
| 1442 | // An access token: pushing needs code:write, reading a private | |
| 1443 | // repository code:read. A public repository reads as it would for | |
| 1444 | // anyone. Which repositories a token reaches is its owner's, checked | |
| 1445 | // below as for anyone. | |
| 1446 | if let Some(access) = a.viewer.as_ref().and_then(|user| user.token.as_deref()).cloned() { | |
| Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca | 1447 | // A workflow job's token, and a deploy key, reach their own |
| 1448 | // repository only; a job's also the working copies of that | |
| 1449 | // repository's pull requests, where their heads are. | |
| 1450 | let name = format!("{}/{}", path.namespace, path.name); | |
| 1451 | let source = match found.as_ref().and_then(|repo| repo.fork_of.as_deref()) { | |
| 1452 | Some(source_id) if g1t_contracts::scopes::decide_repo(&access, &name).is_some() => self | |
| 1453 | .registry | |
| 1454 | .by_id(source_id) | |
| 1455 | .await? | |
| 1456 | .map(|source| format!("{}/{}", source.namespace, source.name)), | |
| 1457 | _ => None, | |
| 1458 | }; | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1459 | let public = found.as_ref().is_some_and(|repo| !repo.is_private); |
| Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca | 1460 | if let Some(why) = git_token_refusal(&access, &name, source.as_deref(), write, public, found.is_some()) { |
| 1461 | return Ok(Outcome::fail(FailureCode::Forbidden, format!("{why}\n"))); | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1462 | } |
| 1463 | if !write && !access.allows(g1t_contracts::scopes::Scope::CodeRead) { | |
| 1464 | a.viewer = None; | |
| 1465 | } | |
| 1466 | } | |
| 1467 | ||
| 1468 | // Anonymous callers are asked to authenticate whether or not the repo | |
| 1469 | // exists, so private repos cannot be told apart from missing ones. | |
| 1470 | let denied = || match &a.viewer { | |
| 1471 | Some(_) => not_found(), | |
| 1472 | None => Outcome::fail(FailureCode::Unauthenticated, "Authentication required."), | |
| 1473 | }; | |
| The work, events, webhooks, actions, repos and packages services no longer run g1t's own agents. Work drops the 35 calls that queued issues for g1t, delegated them, messaged and reviewed through the agent, planned, captured memories automatically, rated confidence and capped agent spend, and its pull request lifecycle no longer revises or reviews: a pull request only advances to a merge by the repository's auto-merge setting. Merging a pull request that is behind now catches it up itself, through the repository's branch update, and lands it when the push arrives; when it can't be caught up cleanly the merge says to merge the base in and try again. Recording a session on a pull request, memory recall and remember, overlaps, the merge queue and required checks stay as they were. | 1474 | // A run's token works through the API only: its sandbox has its |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1475 | // own way to push, to its own pull request. |
| 1476 | if a.viewer.as_ref().is_some_and(|user| user.kind == PrincipalKind::Agent) { | |
| 1477 | return Ok(Outcome::fail( | |
| 1478 | FailureCode::Forbidden, | |
| The work, events, webhooks, actions, repos and packages services no longer run g1t's own agents. Work drops the 35 calls that queued issues for g1t, delegated them, messaged and reviewed through the agent, planned, captured memories automatically, rated confidence and capped agent spend, and its pull request lifecycle no longer revises or reviews: a pull request only advances to a merge by the repository's auto-merge setting. Merging a pull request that is behind now catches it up itself, through the repository's branch update, and lands it when the push arrives; when it can't be caught up cleanly the merge says to merge the base in and try again. Recording a session on a pull request, memory recall and remember, overlaps, the merge queue and required checks stay as they were. | 1479 | "A run's token cannot be used with git this way.", |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1480 | )); |
| 1481 | } | |
| 1482 | if let (true, Some(user)) = (write, &a.viewer) | |
| 1483 | && !user.verified | |
| 1484 | { | |
| 1485 | return Ok(Outcome::fail(FailureCode::Forbidden, UNVERIFIED)); | |
| 1486 | } | |
| 1487 | let repo = match found { | |
| 1488 | Some(repo) => { | |
| 1489 | let allowed = if write { | |
| 1490 | can_write(&repo, &a.viewer) | |
| 1491 | } else { | |
| 1492 | self.may_read(&repo, &a.viewer).await? | |
| 1493 | }; | |
| 1494 | if !allowed { | |
| 1495 | return Ok(denied()); | |
| 1496 | } | |
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 1497 | // An archived repository, or a pull request's copy of one, |
| 1498 | // is read-only. | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1499 | if write { |
| 1500 | let archived = match &repo.fork_of { | |
| 1501 | Some(source) => self.registry.by_id(source).await?, | |
| 1502 | None => Some(repo.clone()), | |
| 1503 | }; | |
| 1504 | match archived { | |
| 1505 | Some(source) => { | |
| Merge branch 'mirroring' into artifacts-mode | 1506 | if let Some((code, message)) = lifecycle::read_only_refusal(&source) { |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1507 | return Ok(Outcome::fail(code, format!("{message}\n"))); |
| 1508 | } | |
| 1509 | } | |
| 1510 | // The repository it was copied from is deleted. | |
| 1511 | None => return Ok(denied()), | |
| 1512 | } | |
| 1513 | } | |
| 1514 | repo | |
| 1515 | } | |
| 1516 | None => { | |
| 1517 | // Push to create, in a workspace the pusher belongs to. | |
| 1518 | let owner = a | |
| 1519 | .viewer | |
| 1520 | .as_ref() | |
| 1521 | .filter(|user| write && user.is_member(&a.path.namespace.to_lowercase())); | |
| 1522 | let Some(owner) = owner else { | |
| 1523 | return Ok(denied()); | |
| 1524 | }; | |
| 1525 | let created = self.create(push_to_create(owner, &a.path)).await?; | |
| 1526 | match created { | |
| 1527 | Outcome::Ok(repo) => repo, | |
| 1528 | Outcome::Fail(failure) => return Ok(Outcome::Fail(failure)), | |
| 1529 | } | |
| 1530 | } | |
| 1531 | }; | |
| 1532 | // A push, or a credential to push with, waits while the repository | |
| 1533 | // moves between namespaces (moves.rs), and goes to where it is now. | |
| 1534 | if write { | |
| 1535 | return Ok(match self.unpaused(repo).await? { | |
| 1536 | Ok(repo) => Outcome::Ok(repo), | |
| 1537 | Err((code, message)) => Outcome::fail(code, format!("{message}\n")), | |
| 1538 | }); | |
| 1539 | } | |
| 1540 | Ok(Outcome::Ok(repo)) | |
| 1541 | } | |
| 1542 | ||
| 1543 | async fn land(&self, a: LandArgs) -> Result<Outcome<Landed>> { | |
| 1544 | let actor: Viewer = Some(a.actor.clone()); | |
| 1545 | let Some(source) = self.registry.by_id(&a.source_id).await? else { | |
| 1546 | return Ok(not_found()); | |
| 1547 | }; | |
| 1548 | // A fork lands on the repository it came from; a branch on its own. | |
| 1549 | let target = match &source.fork_of { | |
| 1550 | Some(id) => self.registry.by_id(id).await?, | |
| 1551 | None => Some(source.clone()), | |
| 1552 | }; | |
| 1553 | let Some(target) = target.filter(|repo| can_read(repo, &actor)) else { | |
| 1554 | return Ok(not_found()); | |
| 1555 | }; | |
| 1556 | if !registry::can(&target, &actor, Capability::Merge) { | |
| 1557 | return Ok(Outcome::fail( | |
| 1558 | FailureCode::Forbidden, | |
| 1559 | access::needs(Capability::Merge, &format!("{}/{}", target.namespace, target.name)), | |
| 1560 | )); | |
| 1561 | } | |
| 1562 | if !a.actor.verified { | |
| 1563 | return Ok(Outcome::fail(FailureCode::Forbidden, UNVERIFIED)); | |
| 1564 | } | |
| Merge branch 'mirroring' into artifacts-mode | 1565 | if let Some((code, message)) = lifecycle::read_only_refusal(&target) { |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1566 | return Ok(Outcome::fail(code, message)); |
| 1567 | } | |
| 1568 | // Moving between namespaces: wait for it (moves.rs). Both are read | |
| 1569 | // again once it is done, for their new keys. | |
| 1570 | let (source, target) = match (self.unpaused(source).await?, self.unpaused(target).await?) { | |
| 1571 | (Ok(source), Ok(target)) => (source, target), | |
| 1572 | (Err((code, message)), _) | (_, Err((code, message))) => return Ok(Outcome::fail(code, message)), | |
| 1573 | }; | |
| 1574 | ||
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1575 | let branch = &a.target_branch.clone().unwrap_or_else(|| target.default_branch.clone()); |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1576 | let from_fork = source.id != target.id; |
| 1577 | let source_branch = match a.branch { | |
| 1578 | Some(name) if !from_fork && name == *branch => { | |
| 1579 | return Ok(Outcome::fail( | |
| 1580 | FailureCode::Invalid, | |
| 1581 | format!("{branch} cannot be merged into itself."), | |
| 1582 | )); | |
| 1583 | } | |
| 1584 | Some(name) => name, | |
| 1585 | None if from_fork => branch.clone(), | |
| 1586 | None => { | |
| 1587 | return Ok(Outcome::fail( | |
| 1588 | FailureCode::Invalid, | |
| 1589 | "Say which branch to merge.", | |
| 1590 | )); | |
| 1591 | } | |
| 1592 | }; | |
| 1593 | ||
| 1594 | self.live(&source).await?; | |
| 1595 | let source_git = self.store.open(&store_key(&source)).await?; | |
| 1596 | let target_git = self.store.open(&store_key(&target)).await?; | |
| 1597 | let history = source_git.log(&source_branch, MAX_ANCESTRY).await?; | |
| 1598 | let Some(new) = history.first().map(|commit| commit.hash.clone()) else { | |
| 1599 | return Ok(Outcome::fail( | |
| 1600 | FailureCode::Conflict, | |
| 1601 | "This pull request has no commits to merge.", | |
| 1602 | )); | |
| 1603 | }; | |
| 1604 | let old = target_git | |
| 1605 | .log(branch, 1) | |
| 1606 | .await? | |
| 1607 | .into_iter() | |
| 1608 | .next() | |
| 1609 | .map(|commit| commit.hash); | |
| 1610 | ||
| 1611 | if old.as_deref() == Some(new.as_str()) { | |
| 1612 | return Ok(Outcome::Ok(Landed { | |
| 1613 | commit: new, | |
| 1614 | previous: None, | |
| 1615 | })); | |
| 1616 | } | |
| 1617 | // Moving the branch to a commit that does not descend from its | |
| 1618 | // current head would discard whatever landed in between. | |
| 1619 | if let Some(old) = &old | |
| 1620 | && !descends_from(&source_git, &history, old).await? | |
| 1621 | { | |
| 1622 | let remedy = if from_fork { | |
| 1623 | format!("Pull {branch} into the pull request's fork, push, and merge again.") | |
| 1624 | } else { | |
| 1625 | format!("Merge {branch} into {source_branch}, push, and merge again.") | |
| 1626 | }; | |
| 1627 | return Ok(Outcome::fail( | |
| 1628 | FailureCode::Conflict, | |
| 1629 | format!("{branch} has moved since this pull request was opened. {remedy}"), | |
| 1630 | )); | |
| 1631 | } | |
| 1632 | ||
| 1633 | // For a branch the objects are already in the target; sending them | |
| 1634 | // again is harmless and keeps one way of moving a ref. | |
| 1635 | let source_access = source_git.access(Scope::Read).await?; | |
| 1636 | let target_access = target_git.access(Scope::Write).await?; | |
| 1637 | let pushed = | |
| 1638 | land::fast_forward(&source_access, &target_access, branch, old.as_deref(), &new) | |
| 1639 | .await?; | |
| 1640 | self.refs_moved(&target.id).await; | |
| 1641 | if let Err(reason) = pushed { | |
| 1642 | // Most often another pull request landed between the check and the push. | |
| 1643 | return Ok(Outcome::fail( | |
| 1644 | FailureCode::Conflict, | |
| 1645 | format!("{branch} could not be updated: {reason}"), | |
| 1646 | )); | |
| 1647 | } | |
| 1648 | self.publish_push( | |
| 1649 | &target, | |
| 1650 | &format!("refs/heads/{branch}"), | |
| 1651 | old.as_deref(), | |
| 1652 | &new, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1653 | Some(&a.actor), |
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 1654 | None, |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1655 | ) |
| 1656 | .await?; | |
| 1657 | Ok(Outcome::Ok(Landed { | |
| 1658 | commit: new, | |
| 1659 | previous: old, | |
| 1660 | })) | |
| 1661 | } | |
| 1662 | ||
| 1663 | async fn compare(&self, a: CompareArgs) -> Result<Outcome<Comparison>> { | |
| 1664 | let Some(repo) = self | |
| 1665 | .visible(self.registry.by_id(&a.repo_id).await?, &a.viewer) | |
| 1666 | .await? | |
| 1667 | else { | |
| 1668 | return Ok(not_found()); | |
| 1669 | }; | |
| 1670 | let git = self.read_git(&repo).await?; | |
| 1671 | let head_ref = a.head.as_deref().unwrap_or(&repo.default_branch); | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1672 | // A pull request into another branch is compared from where it |
| 1673 | // left that branch. | |
| 1674 | let base_branch = a.base_branch.clone(); | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1675 | // The head's history is only searched when the base is worked out |
| 1676 | // from another branch. | |
| 1677 | let depth = if a.base.is_some() || is_commit_hash(head_ref) { 1 } else { MAX_ANCESTRY }; | |
| 1678 | let history = git.log(head_ref, depth).await?; | |
| 1679 | let Some(head) = history.first() else { | |
| 1680 | return Ok(Outcome::fail( | |
| 1681 | FailureCode::Conflict, | |
| 1682 | "There are no commits to compare.", | |
| 1683 | )); | |
| 1684 | }; | |
| 1685 | ||
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1686 | // Where the head's history meets the default branch of `against`, |
| 1687 | // or the branch asked for. | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1688 | let shared_with = async |against: &Repo| -> Result<Option<String>> { |
| 1689 | let against_git = self.read_git(against).await?; | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1690 | let branch = base_branch.as_deref().unwrap_or(&against.default_branch); |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1691 | let shared: HashSet<String> = against_git |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1692 | .log(branch, MAX_ANCESTRY) |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1693 | .await? |
| 1694 | .into_iter() | |
| 1695 | .map(|commit| commit.hash) | |
| 1696 | .collect(); | |
| 1697 | nearest_ancestor_in(&git, &history, &shared).await | |
| 1698 | }; | |
| 1699 | let base = match (a.base, &repo.fork_of) { | |
| 1700 | (Some(base), _) => Some(base), | |
| 1701 | // A fork is compared with the last commit it shares with the | |
| 1702 | // repository it came from. | |
| 1703 | (None, Some(target_id)) => match self.registry.by_id(target_id).await? { | |
| 1704 | Some(target) => shared_with(&target).await?, | |
| 1705 | None => None, | |
| 1706 | }, | |
| 1707 | // A branch, with the point where it left the default branch. | |
| 1708 | // A single commit, with its first parent. | |
| 1709 | (None, None) if is_commit_hash(head_ref) => head.parents.first().cloned(), | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 1710 | (None, None) if head_ref != base_branch.as_deref().unwrap_or(&repo.default_branch) => { |
| 1711 | shared_with(&repo).await? | |
| 1712 | } | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1713 | (None, None) => head.parents.first().cloned(), |
| 1714 | }; | |
| 1715 | let base_tree = match &base { | |
| 1716 | Some(base) => git | |
| 1717 | .log(base, 1) | |
| 1718 | .await? | |
| 1719 | .into_iter() | |
| 1720 | .next() | |
| 1721 | .map(|commit| commit.tree_hash), | |
| 1722 | None => None, | |
| 1723 | }; | |
| Merge branch 'worktree-agent-a432c4ed9deb834b1' | 1724 | // Only a few files of it, when asked: how the files a large |
| 1725 | // comparison lists without their lines are read, as a page reaches them. | |
| 1726 | let only = a.paths.as_deref().map(|paths| &paths[..paths.len().min(MAX_COMPARED_PATHS)]); | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1727 | let (files, truncated) = |
| Merge branch 'worktree-agent-a432c4ed9deb834b1' | 1728 | diff::compare_trees(&git, base_tree.as_deref(), &head.tree_hash, only).await?; |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1729 | Ok(Outcome::Ok(Comparison { |
| 1730 | base, | |
| 1731 | head: head.hash.clone(), | |
| 1732 | files, | |
| 1733 | truncated, | |
| 1734 | })) | |
| 1735 | } | |
| 1736 | ||
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1737 | /// Reports that `git_ref` of `repo` (a full ref) now points to `after`, |
| 1738 | /// moved by `actor` (marked when that was a workflow job's token). | |
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 1739 | /// `commits` is how many the move brought, when the caller knows: one |
| 1740 | /// for a file committed on the site or a branch brought up to date. | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1741 | async fn publish_push( |
| 1742 | &self, | |
| 1743 | repo: &Repo, | |
| 1744 | git_ref: &str, | |
| 1745 | before: Option<&str>, | |
| 1746 | after: &str, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1747 | actor: Option<&User>, |
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 1748 | commits: Option<u32>, |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1749 | ) -> Result<()> { |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1750 | let caused_by_job = actor.and_then(g1t_contracts::events::job_run_of).map(str::to_owned); |
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 1751 | self.publish_git_push(repo, git_ref, before, after, actor.map(|user| user.id.clone()), false, caused_by_job, commits) |
| Merge branch 'mirroring' into artifacts-mode | 1752 | .await |
| 1753 | } | |
| 1754 | ||
| Docs: the inbox, what lands in it and why, its tabs and actions | 1755 | /// `publish_push`, saying whether the push reached the store without |
| 1756 | /// being scanned for secrets first. | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1757 | #[allow(clippy::too_many_arguments)] |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1758 | async fn publish_git_push( |
| 1759 | &self, | |
| 1760 | repo: &Repo, | |
| 1761 | git_ref: &str, | |
| 1762 | before: Option<&str>, | |
| 1763 | after: &str, | |
| 1764 | actor: Option<String>, | |
| 1765 | unscanned: bool, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1766 | caused_by_job: Option<String>, |
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 1767 | commits: Option<u32>, |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1768 | ) -> Result<()> { |
| 1769 | self.publish(NewEvent { | |
| 1770 | kind: "git.push", | |
| 1771 | source: SOURCE, | |
| 1772 | repo_id: Some(repo.id.clone()), | |
| 1773 | actor, | |
| 1774 | data: GitPush { | |
| 1775 | repo_id: repo.id.clone(), | |
| 1776 | git_ref: git_ref.to_owned(), | |
| 1777 | before: before.map(str::to_owned), | |
| 1778 | after: after.to_owned(), | |
| 1779 | default_branch: git_ref.strip_prefix("refs/heads/") | |
| 1780 | == Some(repo.default_branch.as_str()), | |
| 1781 | unscanned, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 1782 | caused_by_job, |
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 1783 | commits, |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1784 | }, |
| 1785 | }) | |
| 1786 | .await | |
| 1787 | } | |
| 1788 | ||
| 1789 | /// Git over HTTPS. Only what decides the answer happens before it: | |
| 1790 | /// the repository, who is asking and whether they may, the free | |
| 1791 | /// workspace limits, push protection, and the store's own answer. The | |
| 1792 | /// audit entry and what a push changed are recorded once git has its | |
| 1793 | /// answer. Each answer says how long its steps took (`Server-Timing`). | |
| 1794 | async fn git_http(&self, request: Request, env: &Env, ctx: &Context) -> Result<Response> { | |
| 1795 | let mut timing = git_http::Timing::start(); | |
| 1796 | let Some(git) = git_http::parse(&request.url()?) else { | |
| 1797 | return Response::error("Not found", 404); | |
| 1798 | }; | |
| 1799 | let response = match self.answer_git(request, &git, env, ctx, &mut timing).await { | |
| 1800 | Ok(response) => response, | |
| 1801 | // The git store is busy: git hears when to try again. | |
| 1802 | Err(error) => match resilience::busy(&error.to_string()) { | |
| 1803 | Some(busy) => git_http::busy_response(busy)?, | |
| 1804 | None => return Err(error), | |
| 1805 | }, | |
| 1806 | }; | |
| 1807 | timing.apply(response) | |
| 1808 | } | |
| 1809 | ||
| 1810 | async fn answer_git( | |
| 1811 | &self, | |
| 1812 | request: Request, | |
| 1813 | git: &git_http::GitRequest, | |
| 1814 | env: &Env, | |
| 1815 | ctx: &Context, | |
| 1816 | timing: &mut git_http::Timing, | |
| 1817 | ) -> Result<Response> { | |
| 1818 | let write = git.service == GitService::ReceivePack; | |
| 1819 | let get = request.method() == Method::Get; | |
| 1820 | let identity = env.service("IDENTITY")?; | |
| 1821 | // The repository and the caller's credentials, at once. A fetch may | |
| 1822 | // go by the row as read a moment ago, for the same clone's next | |
| 1823 | // request; a push always reads it. Anonymous callers cost nothing. | |
| 1824 | let lookup = async { | |
| 1825 | if write { | |
| 1826 | self.registry.by_path(&git.path).await | |
| 1827 | } else { | |
| 1828 | self.registry.by_path_recent(&git.path).await | |
| 1829 | } | |
| 1830 | }; | |
| 1831 | let (found, viewer) = | |
| 1832 | futures_util::future::join(lookup, git_http::viewer(&request, &identity)).await; | |
| Merge branch 'worktree-agent-a8385d293d42c913a' | 1833 | let mut found = found?; |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1834 | timing.mark("repo"); |
| Merge branch 'worktree-agent-a8385d293d42c913a' | 1835 | // A workspace alias staff set (identity's aliases.rs: `g1t` for |
| 1836 | // `flagon-io`) is answered in place, as the repository under the | |
| 1837 | // workspace's slug: pushes and some clients do not follow | |
| 1838 | // redirects. Everything after this sees only the workspace's slug. | |
| 1839 | let aliased = match found { | |
| 1840 | Some(_) => None, | |
| 1841 | None => git_http::aliased(git, &identity).await?, | |
| 1842 | }; | |
| 1843 | if let Some(aliased) = &aliased { | |
| 1844 | found = if write { | |
| 1845 | self.registry.by_path(&aliased.path).await? | |
| 1846 | } else { | |
| 1847 | self.registry.by_path_recent(&aliased.path).await? | |
| 1848 | }; | |
| 1849 | timing.mark("alias"); | |
| 1850 | } | |
| 1851 | let git = aliased.as_ref().unwrap_or(git); | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1852 | if found.is_none() { |
| 1853 | // A workspace that was renamed: git follows a redirect when it | |
| 1854 | // first asks for refs, and uses the new address from then on. | |
| 1855 | // A repository transferred to another workspace: the same, to | |
| 1856 | // its new path. Fetches and pushes both follow either. | |
| 1857 | let url = request.url()?; | |
| 1858 | let (renamed, moved) = futures_util::future::join( | |
| 1859 | git_http::renamed(&url, &identity), | |
| 1860 | self.registry.resolve_moved(&git.path), | |
| 1861 | ) | |
| 1862 | .await; | |
| 1863 | timing.mark("moved"); | |
| 1864 | if let Some(location) = renamed? { | |
| 1865 | return git_http::moved(&location, get); | |
| 1866 | } | |
| 1867 | if let Some(now) = moved? | |
| 1868 | && let Some(location) = git_http::transferred(&url, &now) | |
| 1869 | { | |
| 1870 | return git_http::moved(&location, get); | |
| 1871 | } | |
| 1872 | } | |
| 1873 | let viewer = viewer?; | |
| Merge email confirmation gate: a code and a link, nothing until confirmed (identity 0036) | 1874 | // A person who has not confirmed their email address can do |
| 1875 | // nothing with git until they do: told so, not asked to sign in. | |
| 1876 | if viewer.as_ref().is_some_and(g1t_contracts::User::awaits_confirmation) { | |
| 1877 | let site = request.url()?.origin().ascii_serialization(); | |
| 1878 | return git_http::refuse(Outcome::<()>::fail( | |
| 1879 | FailureCode::Forbidden, | |
| 1880 | g1t_contracts::accounts::confirm_email_first(&site), | |
| 1881 | )); | |
| 1882 | } | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1883 | // A run credential is checked against its grants, then acts as the |
| 1884 | // person it works for. See run_access.rs. | |
| 1885 | let (request, viewer, audit) = match self.admit_git(request, git, viewer, found.as_ref()).await? { | |
| 1886 | run_access::Admitted::Go { request, viewer, entry } => (request, viewer, entry), | |
| 1887 | run_access::Admitted::Refused(response) => return Ok(response), | |
| 1888 | }; | |
| 1889 | let mut after = AfterGit { | |
| 1890 | audit, | |
| 1891 | status: 0, | |
| 1892 | message: None, | |
| 1893 | push: None, | |
| 1894 | }; | |
| 1895 | let repo = match self.authorize_git(&git.path, &viewer, git.service, found).await? { | |
| 1896 | Outcome::Ok(repo) => repo, | |
| 1897 | refused => { | |
| 1898 | let response = git_http::refuse(refused)?; | |
| 1899 | after.ended(response.status_code(), None); | |
| 1900 | after.spawn(env, ctx); | |
| 1901 | return Ok(response); | |
| 1902 | } | |
| 1903 | }; | |
| 1904 | // A pull request's working copy removed after it closed is made | |
| 1905 | // again before git uses it (forks.rs). | |
| 1906 | self.live(&repo).await?; | |
| 1907 | timing.mark("access"); | |
| 1908 | // Clones check out the default branch g1t keeps, which can have | |
| 1909 | // changed since the store made the repository. | |
| 1910 | let default_branch = repo.fork_of.is_none().then(|| repo.default_branch.clone()); | |
| 1911 | let key = store_key(&repo); | |
| 1912 | let scope = if write { Scope::Write } else { Scope::Read }; | |
| 1913 | let mut request = request; | |
| 1914 | let protocol = refs_cache::protocol(request.headers().get("git-protocol")?.as_deref()); | |
| 1915 | // A fetch's POST is read here, to tell an `ls-refs` from a fetch of | |
| 1916 | // objects; the store would have it read in full anyway. | |
| 1917 | let body = if !write && !get { Some(request.bytes().await?) } else { None }; | |
| 1918 | // What it asks the store, for the meters (meters.rs). | |
| 1919 | let call = git_ops::classify(git.service, git.endpoint, get, body.as_deref()); | |
| 1920 | // Answers kept from the usual store may name refs the fallback | |
| 1921 | // store does not have (fallback.rs): none are used, or kept. | |
| 1922 | let fallback = self.store.on_fallback(&key); | |
| 1923 | // An answer that lists refs may have been kept: see refs_cache.rs. | |
| 1924 | let kept_key = refs_cache::kind(git, get, protocol, body.as_deref()) | |
| 1925 | .filter(|_| !fallback) | |
| 1926 | .zip(refs_cache::usable(registry::refs_state(&repo.id), now_ms())) | |
| 1927 | .map(|(kind, version)| { | |
| 1928 | refs_cache::Key::new(&repo.id, version, default_branch.as_deref(), protocol, &kind) | |
| 1929 | }); | |
| 1930 | // A fresh clone's pack may have been kept too: see pack_cache.rs. | |
| 1931 | // Under the same refs version, so never across a change to them. | |
| 1932 | let pack_key = self | |
| 1933 | .packs | |
| 1934 | .as_ref() | |
| 1935 | .filter(|_| !fallback) | |
| 1936 | .and_then(|_| { | |
| 1937 | let encoding = request.headers().get("content-encoding").ok().flatten(); | |
| 1938 | pack_cache::cacheable(git, get, protocol, encoding.as_deref(), body.as_deref()) | |
| 1939 | }) | |
| 1940 | .zip(refs_cache::usable(registry::refs_state(&repo.id), now_ms())) | |
| 1941 | .map(|(normalized, version)| pack_cache::Key::new(&repo.id, version, &normalized)); | |
| 1942 | // A kept answer and the free workspace limits, with a kept | |
| 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 answer | 1943 | // credential and, for a push, what the repository holds looked up |
| 1944 | // alongside. A kept answer goes back without waiting for the | |
| 1945 | // credential, which it does not need. | |
| 1946 | let pushing = write && !get; | |
| 1947 | let ((answer, pack, (limited, held)), kept_access) = { | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1948 | let shared = self.shared.as_deref(); |
| 1949 | let answer_and_limits = std::pin::pin!(futures_util::future::join3( | |
| 1950 | async { | |
| 1951 | match &kept_key { | |
| 1952 | Some(kept_key) => refs_cache::get(shared, kept_key).await, | |
| 1953 | None => None, | |
| 1954 | } | |
| 1955 | }, | |
| 1956 | async { | |
| 1957 | match (&pack_key, self.packs.as_deref()) { | |
| 1958 | (Some(pack_key), Some(packs)) => pack_cache::get(packs, pack_key).await, | |
| 1959 | _ => None, | |
| 1960 | } | |
| 1961 | }, | |
| 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 answer | 1962 | futures_util::future::join(self.git_limits(call, git, &repo, env), async { |
| 1963 | if pushing { Some(self.held(&repo).await) } else { None } | |
| 1964 | }), | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1965 | )); |
| 1966 | let kept_access = std::pin::pin!(self.store.kept_access(&key, scope)); | |
| 1967 | match futures_util::future::select(answer_and_limits, kept_access).await { | |
| 1968 | futures_util::future::Either::Left((first, kept_access)) => { | |
| 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 answer | 1969 | let answered = first.0.is_some() || first.1.is_some() || matches!(first.2.0, Ok(Some(_)) | Err(_)); |
| Docs: the inbox, what lands in it and why, its tabs and actions | 1970 | (first, if answered { None } else { kept_access.await }) |
| 1971 | } | |
| 1972 | futures_util::future::Either::Right((kept_access, first)) => (first.await, kept_access), | |
| 1973 | } | |
| 1974 | }; | |
| 1975 | timing.mark("kept"); | |
| 1976 | // A kept pack first: it never reaches the store, so it is never an | |
| 1977 | // operation, and a free workspace past its operation cap still gets | |
| 1978 | // it. (The other limits are a push's, and a pack is only a fetch.) | |
| 1979 | if let Some(kept) = pack { | |
| 1980 | timing.note("pack", "hit"); | |
| 1981 | let sent = body.as_ref().map_or(0, |body| body.len() as u64); | |
| 1982 | meters::record(pack_cache::HIT, &key, sent, kept.size); | |
| 1983 | after.ended(200, None); | |
| 1984 | after.spawn(env, ctx); | |
| 1985 | return kept.response(); | |
| 1986 | } | |
| 1987 | if let Some((response, status, message)) = limited? { | |
| 1988 | after.ended(status, Some(message.to_owned())); | |
| 1989 | after.spawn(env, ctx); | |
| 1990 | return Ok(response); | |
| 1991 | } | |
| 1992 | if pack_key.is_some() { | |
| 1993 | timing.note("pack", "miss"); | |
| 1994 | } | |
| 1995 | if let (Some((entry, found)), Some(kept_key)) = (answer, &kept_key) { | |
| 1996 | timing.note("refs", found.as_str()); | |
| 1997 | if found == refs_cache::Found::Shared { | |
| 1998 | let (kept_key, entry) = (kept_key.clone(), entry.clone()); | |
| 1999 | ctx.wait_until(async move { refs_cache::keep_in_colo(&kept_key, &entry).await }); | |
| 2000 | } | |
| 2001 | // Never reached the store: never an operation. | |
| 2002 | meters::record(call.cached_meter(), &key, 0, entry.body.len() as u64); | |
| 2003 | after.ended(200, None); | |
| 2004 | after.spawn(env, ctx); | |
| 2005 | return entry.response(); | |
| 2006 | } | |
| 2007 | if kept_key.is_some() { | |
| 2008 | timing.note("refs", "miss"); | |
| 2009 | } | |
| Merge branch 'worktree-agent-a8752162fea25f63f' into spend-guardrails | 2010 | // An anonymous fetch the store is about to answer: an operation for |
| 2011 | // the repository's workspace, so limited per repository (limits.rs). | |
| 2012 | if limits::counts_as_anonymous_fetch(call, viewer.is_none()) | |
| 2013 | && g1t_kit::limits::check(env, limits::ANONYMOUS_FETCH, repo.id.clone()).await.limited() | |
| 2014 | { | |
| 2015 | let response = limits::too_many_anonymous_fetches(&format!("{}/{}", repo.namespace, repo.name))?; | |
| 2016 | after.ended(429, Some("Too many anonymous fetches.".to_owned())); | |
| 2017 | after.spawn(env, ctx); | |
| 2018 | return Ok(response); | |
| 2019 | } | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2020 | // The store's credential: one made a moment ago, here or in another |
| 2021 | // isolate (see store.rs), or a new one. | |
| 2022 | let access = match kept_access { | |
| 2023 | Some((access, from)) => { | |
| 2024 | timing.note("cred", from.as_str()); | |
| 2025 | access | |
| 2026 | } | |
| 2027 | None => { | |
| 2028 | let access = self.store.mint_access(&key, scope).await?; | |
| 2029 | timing.mark("mint"); | |
| 2030 | timing.note("cred", "mint"); | |
| 2031 | access | |
| 2032 | } | |
| 2033 | }; | |
| 2034 | // Should the store turn a kept credential down, a fetch's first | |
| 2035 | // request is tried again with a new one; the requests after it then | |
| 2036 | // have that one too. | |
| 2037 | let again = if get { Some(request.clone()?) } else { None }; | |
| 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 answer | 2038 | // A push's checks: workflow files, the rules of the branches and |
| 2039 | // tags it changes, saying which rule and why (rules.rs), and push | |
| 2040 | // protection, which refuses a push that adds a secret | |
| 2041 | // (secret_scan.rs). See push_checks.rs. | |
| 2042 | let checks = async |head: &[u8], whole: bool, scan: bool| self.check_receive(&repo, viewer.as_ref(), head, whole, scan).await; | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2043 | // What a push may bring (pack_limits.rs): the repository's size is |
| 2044 | // its own and its pull requests' working copies'. | |
| 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 answer | 2045 | let limits = if let Some(held) = held { |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2046 | git_http::PushLimits { |
| 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 answer | 2047 | held, |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2048 | repo_limit: self.repo_limit, |
| 2049 | large: self.large_pushes, | |
| 2050 | ..git_http::PushLimits::default() | |
| 2051 | } | |
| 2052 | } else { | |
| 2053 | git_http::PushLimits::default() | |
| 2054 | }; | |
| 2055 | let mut outcome = git_http::forward( | |
| 2056 | request, | |
| 2057 | body, | |
| 2058 | git, | |
| 2059 | &access, | |
| 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 answer | 2060 | checks, |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2061 | default_branch.as_deref(), |
| 2062 | limits, | |
| 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 step | 2063 | timing, |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2064 | ) |
| 2065 | .await?; | |
| 2066 | let turned_down = matches!( | |
| 2067 | &outcome, | |
| 2068 | git_http::Push::Forwarded(forwarded) if matches!(forwarded.response.status_code(), 401 | 403) | |
| 2069 | ); | |
| 2070 | if turned_down { | |
| 2071 | self.store.forget_access(&key).await; | |
| 2072 | if let Some(again) = again { | |
| 2073 | let access = self.store.mint_access(&key, scope).await?; | |
| 2074 | outcome = git_http::forward( | |
| 2075 | again, | |
| 2076 | None, | |
| 2077 | git, | |
| 2078 | &access, | |
| 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 answer | 2079 | async |_: &[u8], _: bool, _: bool| Ok(push_checks::Checks::clear()), |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2080 | default_branch.as_deref(), |
| 2081 | git_http::PushLimits::default(), | |
| 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 step | 2082 | timing, |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2083 | ) |
| 2084 | .await?; | |
| 2085 | } | |
| 2086 | } | |
| 2087 | let forwarded = | |
| 2088 | match outcome { | |
| 2089 | git_http::Push::Forwarded(forwarded) => forwarded, | |
| 2090 | git_http::Push::Refused(response) => { | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 2091 | after.ended(403, Some("The push was declined by rules.".to_owned())); |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2092 | after.spawn(env, ctx); |
| 2093 | return Ok(response); | |
| 2094 | } | |
| 2095 | git_http::Push::Blocked(response) => { | |
| 2096 | after.ended(403, Some("The push adds a secret.".to_owned())); | |
| 2097 | after.spawn(env, ctx); | |
| 2098 | return Ok(response); | |
| 2099 | } | |
| 2100 | git_http::Push::Declined(response, reason) => { | |
| 2101 | after.ended(403, Some(format!("The push was declined: {reason}."))); | |
| 2102 | after.spawn(env, ctx); | |
| 2103 | return Ok(response); | |
| 2104 | } | |
| 2105 | }; | |
| 2106 | if forwarded.from_store { | |
| 2107 | let received = forwarded | |
| 2108 | .response | |
| 2109 | .headers() | |
| 2110 | .get("content-length")? | |
| 2111 | .and_then(|length| length.parse().ok()) | |
| 2112 | .unwrap_or(0); | |
| 2113 | meters::record(call.meter(), &key, forwarded.sent, received); | |
| 2114 | } | |
| 2115 | timing.mark("store"); | |
| 2116 | let mut response = forwarded.response; | |
| 2117 | let status = response.status_code(); | |
| 2118 | if write && !get { | |
| 2119 | // A push: the store has moved its refs once it has answered in | |
| 2120 | // full, so the answer is read before the change is recorded, and | |
| 2121 | // only then goes back. Whoever fetches after it sees the push. | |
| 2122 | let headers = response.headers().clone(); | |
| 2123 | headers.delete("content-length")?; | |
| 2124 | let report = response.bytes().await?; | |
| 2125 | self.refs_moved(&repo.id).await; | |
| 2126 | timing.mark("refs"); | |
| 2127 | response = Response::from_bytes(report)?.with_headers(headers).with_status(status); | |
| 2128 | } else if let (Some(kept_key), 200) = (&kept_key, status) { | |
| 2129 | // A miss: this answer is kept for the next to ask. | |
| 2130 | let headers = response.headers().clone(); | |
| 2131 | headers.delete("content-length")?; | |
| 2132 | let body = response.bytes().await?; | |
| 2133 | if let Some(content_type) = headers.get("content-type")? { | |
| 2134 | let entry = refs_cache::Entry { content_type, body: body.clone() }; | |
| 2135 | if entry.keepable() { | |
| 2136 | let shared = self.shared.clone(); | |
| 2137 | let kept_key = kept_key.clone(); | |
| 2138 | ctx.wait_until(async move { refs_cache::keep(shared.as_deref(), &kept_key, &entry).await }); | |
| 2139 | } | |
| 2140 | } | |
| 2141 | response = Response::from_bytes(body)?.with_headers(headers).with_status(status); | |
| 2142 | } else if let (Some(pack_key), Some(packs), true) = (&pack_key, &self.packs, forwarded.from_store) { | |
| 2143 | // A fresh clone the bucket did not have: counted, and its pack | |
| 2144 | // kept as it streams to git, when it is a whole one. | |
| 2145 | meters::record(pack_cache::MISS, &key, forwarded.sent, 0); | |
| Merge branch 'worktree-agent-a8752162fea25f63f' into spend-guardrails | 2146 | // Past the repository's limit on writes to the bucket, the pack |
| 2147 | // goes to git without being kept (limits.rs). | |
| 2148 | if status == 200 && !g1t_kit::limits::check(env, limits::PACK_FILL, repo.id.clone()).await.limited() { | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2149 | let store_key = key.clone(); |
| 2150 | let measured = Box::new(move |bytes: u64| meters::record_bytes(pack_cache::MISS, &store_key, 0, bytes)); | |
| 2151 | let (teed, filling) = pack_cache::tee(response, packs.clone(), pack_key, measured)?; | |
| 2152 | response = teed; | |
| 2153 | if let Some(filling) = filling { | |
| 2154 | let pack_key = pack_key.clone(); | |
| 2155 | ctx.wait_until(async move { | |
| 2156 | let filled = filling.await; | |
| 2157 | if !matches!(filled, pack_cache::Filled::Kept { .. } | pack_cache::Filled::Abandoned) { | |
| 2158 | worker::console_warn!("pack {} not kept: {filled:?}", pack_key.as_str()); | |
| 2159 | } | |
| 2160 | }); | |
| 2161 | } | |
| 2162 | } | |
| 2163 | } | |
| 2164 | after.ended(status, None); | |
| Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9 | 2165 | if status == 200 && (forwarded.pack_bytes > 0 || !forwarded.pushed.is_empty() || !forwarded.deleted.is_empty()) { |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2166 | after.push = Some(PushDone { |
| 2167 | repo, | |
| 2168 | pushed: forwarded.pushed, | |
| Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9 | 2169 | deleted: forwarded.deleted, |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2170 | pack_bytes: forwarded.pack_bytes, |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 2171 | caused_by_job: viewer.as_ref().and_then(g1t_contracts::events::job_run_of).map(str::to_owned), |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 2172 | // The calendar credits people, never a job's, an agent's or a workspace's token. |
| 2173 | credit: viewer | |
| 2174 | .as_ref() | |
| 2175 | .filter(|user| user.kind == PrincipalKind::User && g1t_contracts::events::job_run_of(user).is_none()) | |
| 2176 | .map(|user| user.id.clone()), | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2177 | actor: viewer.map(|user: User| user.id), |
| 2178 | unscanned: forwarded.unscanned, | |
| 2179 | }); | |
| 2180 | } | |
| 2181 | after.spawn(env, ctx); | |
| 2182 | Ok(response) | |
| 2183 | } | |
| 2184 | ||
| 2185 | /// The answer for a request a free workspace's limits stop, or a push | |
| 2186 | /// to a full repository, with its status and reason for the audit log; | |
| 2187 | /// `None` to go on. | |
| 2188 | /// | |
| 2189 | /// A clone, fetch or push is a git operation, which the git store | |
| 2190 | /// charges g1t for: counted for billing once the answer has gone back | |
| 2191 | /// (meters.rs), and a free workspace far past its share is slowed down | |
| 2192 | /// rather than charged (see git_ops.rs). Whether it is past it is | |
| 2193 | /// decided from counts this isolate already holds: the database is not | |
| 2194 | /// asked on the way. A free workspace is never charged for private | |
| 2195 | /// storage: once its private repositories hold the free amount, pushes | |
| 2196 | /// to them stop, checked when a push begins so that git shows the | |
| 2197 | /// reason. So do pushes to a repository at the store's size limit. | |
| 2198 | async fn git_limits( | |
| 2199 | &self, | |
| 2200 | call: git_ops::GitCall, | |
| 2201 | git: &git_http::GitRequest, | |
| 2202 | repo: &Repo, | |
| 2203 | env: &Env, | |
| 2204 | ) -> Result<Option<(Response, u16, &'static str)>> { | |
| 2205 | let namespace = git.path.namespace.to_lowercase(); | |
| 2206 | if meters::mapping_now().billable(call.meter()) > 0.0 { | |
| 2207 | let now = now_ms(); | |
| 2208 | let hour = git_ops::hour_key(&rfc3339(now)); | |
| 2209 | let limits = git_ops::Limits::from_env(env); | |
| 2210 | if let Some((month, hour_ops)) = git_ops::standing(&namespace, &hour, now) | |
| 2211 | && git_ops::slow_down(month + 1, hour_ops + 1, limits.free_cap, limits.hourly) | |
| 2212 | && git_ops::is_free_kept(env.service("BILLING").ok().as_ref(), &namespace).await | |
| 2213 | { | |
| 2214 | return Ok(Some(( | |
| 2215 | git_ops::too_many(&namespace, limits.free_cap, limits.hourly)?, | |
| 2216 | 429, | |
| 2217 | "Too many git operations this hour.", | |
| 2218 | ))); | |
| 2219 | } | |
| 2220 | } | |
| 2221 | if git.service == GitService::ReceivePack && git.endpoint == "info/refs" { | |
| 2222 | let held = self.held(repo).await; | |
| 2223 | if held >= self.repo_limit { | |
| 2224 | let message = format!( | |
| 2225 | "{}/{} holds about {}, the most a repository may hold on g1t, so it takes no more pushes. Delete what you no longer need, or split it: https://docs.g1t.sh/guides/git/#size-limits\n", | |
| 2226 | repo.namespace, | |
| 2227 | repo.name, | |
| 2228 | pack_limits::megabytes(held) | |
| 2229 | ); | |
| 2230 | return Ok(Some((Response::error(message, 403)?, 403, "The repository is full."))); | |
| 2231 | } | |
| 2232 | } | |
| 2233 | if git.service == GitService::ReceivePack && git.endpoint == "info/refs" && repo.is_private { | |
| 2234 | let free = git_ops::free_private_bytes(env); | |
| 2235 | let held = self.registry.private_bytes(&namespace).await.unwrap_or(0); | |
| 2236 | if git_ops::storage_full(held, free) | |
| 2237 | && git_ops::is_free(env.service("BILLING").ok().as_ref(), &namespace).await | |
| 2238 | { | |
| 2239 | return Ok(Some(( | |
| 2240 | git_ops::storage_full_response(&namespace, held, free)?, | |
| 2241 | 403, | |
| 2242 | "Free private storage is full.", | |
| 2243 | ))); | |
| 2244 | } | |
| 2245 | } | |
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 2246 | if git.service == GitService::ReceivePack && git.endpoint == "info/refs" && !repo.is_private { |
| 2247 | let free = git_ops::free_public_bytes(env); | |
| 2248 | if free > 0 { | |
| 2249 | let held = self.registry.public_bytes(&namespace).await.unwrap_or(0); | |
| 2250 | if git_ops::public_storage_full(held, free) | |
| 2251 | && git_ops::is_free(env.service("BILLING").ok().as_ref(), &namespace).await | |
| 2252 | { | |
| 2253 | return Ok(Some(( | |
| 2254 | git_ops::public_storage_full_response(&namespace, held, free)?, | |
| 2255 | 403, | |
| 2256 | "Free public storage is full.", | |
| 2257 | ))); | |
| 2258 | } | |
| 2259 | } | |
| 2260 | } | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2261 | Ok(None) |
| 2262 | } | |
| 2263 | ||
| 2264 | /// What a repository and its pull requests' working copies hold, as | |
| 2265 | /// g1t counts it: read for a push's first request, kept a minute for | |
| 2266 | /// the rest of it. | |
| 2267 | async fn held(&self, repo: &Repo) -> u64 { | |
| 2268 | let root = repo.fork_of.clone().unwrap_or_else(|| repo.id.clone()); | |
| 2269 | let now = now_ms(); | |
| 2270 | if let Some(held) = HELD.with(|held| held.borrow().get(&root, now)) { | |
| 2271 | return held; | |
| 2272 | } | |
| 2273 | let held = self.registry.stored_bytes(&root).await.unwrap_or(0).max(0) as u64; | |
| 2274 | HELD.with(|kept| kept.borrow_mut().put(root, held, now)); | |
| 2275 | held | |
| 2276 | } | |
| 2277 | ||
| Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9 | 2278 | /// A `git.deleted` event for each branch or tag a push deleted that is |
| 2279 | /// gone from the store: the store can refuse one ref and accept | |
| 2280 | /// another. As many as a push announces of tags and branches it moves. | |
| 2281 | async fn publish_deleted( | |
| 2282 | &self, | |
| 2283 | repo: &Repo, | |
| 2284 | deleted: &[(String, String)], | |
| 2285 | actor: Option<&str>, | |
| 2286 | caused_by_job: Option<&str>, | |
| 2287 | ) -> Result<()> { | |
| 2288 | let stored = self.store.open(&store_key(repo)).await?; | |
| 2289 | let is_branch = |git_ref: &str| git_ref.starts_with("refs/heads/"); | |
| 2290 | let tags = deleted.iter().filter(|(git_ref, _)| !is_branch(git_ref)).count(); | |
| 2291 | let announced = deleted | |
| 2292 | .iter() | |
| 2293 | .filter(|(git_ref, _)| is_branch(git_ref)) | |
| 2294 | .take(MAX_PUSH_BRANCH_EVENTS) | |
| 2295 | .chain(deleted.iter().filter(|(git_ref, _)| !is_branch(git_ref) && tags <= MAX_PUSH_TAG_EVENTS)); | |
| 2296 | for (git_ref, before) in announced { | |
| 2297 | let named = git_ref.strip_prefix("refs/heads/").unwrap_or(git_ref); | |
| 2298 | let gone = match stored.log(named, 1).await { | |
| 2299 | Ok(head) => head.is_empty(), | |
| 2300 | Err(error) => { | |
| 2301 | worker::console_error!("whether {git_ref} of {} was deleted is not known: {error}", repo.name); | |
| 2302 | false | |
| 2303 | } | |
| 2304 | }; | |
| 2305 | if !gone { | |
| 2306 | continue; | |
| 2307 | } | |
| 2308 | self.publish(NewEvent { | |
| 2309 | kind: "git.deleted", | |
| 2310 | source: SOURCE, | |
| 2311 | repo_id: Some(repo.id.clone()), | |
| 2312 | actor: actor.map(str::to_owned), | |
| 2313 | data: GitDeleted { | |
| 2314 | repo_id: repo.id.clone(), | |
| 2315 | git_ref: git_ref.clone(), | |
| 2316 | before: before.clone(), | |
| 2317 | caused_by_job: caused_by_job.map(str::to_owned), | |
| 2318 | }, | |
| 2319 | }) | |
| 2320 | .await?; | |
| 2321 | } | |
| 2322 | Ok(()) | |
| 2323 | } | |
| 2324 | ||
| Docs: the inbox, what lands in it and why, its tabs and actions | 2325 | /// What a push changed, recorded once git has its answer. |
| 2326 | async fn record_push(&self, push: PushDone) -> Result<()> { | |
| 2327 | let PushDone { | |
| 2328 | repo, | |
| 2329 | pushed, | |
| Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9 | 2330 | deleted, |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2331 | pack_bytes, |
| 2332 | actor, | |
| 2333 | unscanned, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 2334 | caused_by_job, |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 2335 | credit, |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2336 | } = push; |
| 2337 | // What the push stored, for billing's storage meter. A failure only | |
| 2338 | // leaves the count short. | |
| 2339 | if pack_bytes > 0 | |
| 2340 | && let Err(error) = self.registry.add_stored_bytes(&repo, pack_bytes).await | |
| 2341 | { | |
| 2342 | worker::console_error!("stored bytes for {} not counted: {error}", repo.name); | |
| 2343 | } | |
| Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9 | 2344 | if !deleted.is_empty() { |
| 2345 | self.publish_deleted(&repo, &deleted, actor.as_deref(), caused_by_job.as_deref()).await?; | |
| 2346 | } | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2347 | if pushed.is_empty() { |
| 2348 | return Ok(()); | |
| 2349 | } | |
| 2350 | // Artifacts' own push notifications are per repository, which does | |
| 2351 | // not fit a repo per pull request, so the front end reports pushes | |
| 2352 | // itself: one event for each branch that moved. | |
| 2353 | let stored = self.store.open(&store_key(&repo)).await?; | |
| Merge branch 'worktree-agent-ad8a36dfcd4176015' into spend-guardrails | 2354 | let announced = announced_refs(&pushed, &repo.default_branch); |
| 2355 | if announced.len() < pushed.len() { | |
| 2356 | worker::console_log!( | |
| 2357 | "push to {}: {} of {} refs announced", | |
| 2358 | repo.name, | |
| 2359 | announced.len(), | |
| 2360 | pushed.len() | |
| 2361 | ); | |
| 2362 | } | |
| 2363 | for pushed in announced { | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2364 | // The store can refuse one ref and accept another, so each |
| 2365 | // branch is checked against where it actually is. A tag the | |
| 2366 | // store cannot read back is taken as pushed. | |
| 2367 | let moved = match pushed.branch() { | |
| 2368 | Some(branch) => stored | |
| 2369 | .log(branch, 1) | |
| 2370 | .await? | |
| 2371 | .first() | |
| 2372 | .is_some_and(|commit| commit.hash == pushed.after), | |
| 2373 | None => stored.log(&pushed.git_ref, 1).await.map_or(true, |head| { | |
| 2374 | head.first().is_none_or(|commit| commit.hash == pushed.after) | |
| 2375 | }), | |
| 2376 | }; | |
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 2377 | // How many commits a branch push brought (push_commits.rs), for |
| 2378 | // the event and the person's contribution calendar. A log the | |
| 2379 | // store cannot read leaves the event without a count and the | |
| 2380 | // day short. | |
| 2381 | let commits = match (moved, pushed.branch()) { | |
| 2382 | (true, Some(_)) => match stored.log(&pushed.after, push_commits::MOST_PER_PUSH + 1).await { | |
| 2383 | Ok(log) => Some(push_commits::new_commits(&log, pushed.before.as_deref())), | |
| 2384 | Err(error) => { | |
| 2385 | worker::console_error!("commits pushed to {} not counted: {error}", repo.name); | |
| 2386 | None | |
| 2387 | } | |
| 2388 | }, | |
| 2389 | _ => None, | |
| 2390 | }; | |
| 2391 | if let (Some(commits), Some(user_id), Some(branch)) = (commits, credit.as_deref(), pushed.branch()) | |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 2392 | && push_commits::counts(branch, &repo.default_branch) |
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 2393 | && let Err(error) = |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 2394 | push_commits::record(&self.registry.db, user_id, &repo.id, &rfc3339(now_ms())[..10], commits).await |
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 2395 | { |
| 2396 | worker::console_error!("commits pushed to {} not credited: {error}", repo.name); | |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 2397 | } |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2398 | if moved { |
| 2399 | self.publish_git_push( | |
| 2400 | &repo, | |
| 2401 | &pushed.git_ref, | |
| 2402 | pushed.before.as_deref(), | |
| 2403 | &pushed.after, | |
| 2404 | actor.clone(), | |
| 2405 | unscanned, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 2406 | caused_by_job.clone(), |
| Home says what people did as well as what agents did. Since you were last here now has a People column and an Agents column: who pushed how many commits to which projects, pull requests opened, merged and reviewed, issues opened and closed, docs edited, messages sent and deploys that went out, each line a link to where those are listed, with the agents' acceptance (first time, after review, didn't finish) kept as a row of their column; the sentence under the heading sums it up honestly, and says when a part of g1t could not be read, or that the span was quiet. Landed counts merged pull requests, commits pushed straight to a default branch, production deploys that went live, releases and packages, newest first; Running now adds workflow runs. Behind it, the events service answers an activity digest over a span in one round trip from its existing indexes, every push now records how many commits it carried, and chat counts the messages sent in the conversations you can read. The Home guide defines every line, and says how this scales. | 2407 | commits, |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2408 | ) |
| 2409 | .await?; | |
| 2410 | } | |
| 2411 | } | |
| 2412 | Ok(()) | |
| 2413 | } | |
| 2414 | } | |
| 2415 | ||
| Merge branch 'worktree-agent-ad8a36dfcd4176015' into spend-guardrails | 2416 | /// The most tags one push announces: a push of more (`git push --tags` |
| 2417 | /// into a new repository, say) announces none of them, so it starts no | |
| Merge main into rm-agents: mirroring removal, open signups, free-tier limits, the g1t CLI and runner fixes, with the agent removal kept. The CLI's table of operations is regenerated from the API without the agent, customize and AI credit operations; no CLI command used them. | 2418 | /// workflows or package reads, one per tag. |
| Merge branch 'worktree-agent-ad8a36dfcd4176015' into spend-guardrails | 2419 | const MAX_PUSH_TAG_EVENTS: usize = 3; |
| 2420 | /// The most branches one push announces. A push of more announces only | |
| 2421 | /// the default branch, if it moved, which the rest of g1t reads from. | |
| 2422 | const MAX_PUSH_BRANCH_EVENTS: usize = 1000; | |
| 2423 | ||
| 2424 | /// The refs of a push that are announced with a `git.push` event each. | |
| 2425 | /// Every ref is stored whatever this says; only the events are capped. | |
| 2426 | fn announced_refs<'a>(pushed: &'a [git_http::Pushed], default_branch: &str) -> Vec<&'a git_http::Pushed> { | |
| 2427 | let (branches, tags): (Vec<&git_http::Pushed>, Vec<&git_http::Pushed>) = | |
| 2428 | pushed.iter().partition(|pushed| pushed.branch().is_some()); | |
| 2429 | let mut announced = if branches.len() > MAX_PUSH_BRANCH_EVENTS { | |
| 2430 | branches.into_iter().filter(|pushed| pushed.branch() == Some(default_branch)).collect() | |
| 2431 | } else { | |
| 2432 | branches | |
| 2433 | }; | |
| 2434 | if tags.len() <= MAX_PUSH_TAG_EVENTS { | |
| 2435 | announced.extend(tags); | |
| 2436 | } | |
| 2437 | announced | |
| 2438 | } | |
| 2439 | ||
| Docs: the inbox, what lands in it and why, its tabs and actions | 2440 | /// A push the store accepted, to be recorded once git has its answer. |
| 2441 | struct PushDone { | |
| 2442 | repo: Repo, | |
| 2443 | pushed: Vec<git_http::Pushed>, | |
| Merge branch 'worktree-agent-a8b78c62188153aa3' into claude/cool-newton-7nm8r9 | 2444 | /// The branches and tags it asked to delete, each with the commit it |
| 2445 | /// pointed to. | |
| 2446 | deleted: Vec<(String, String)>, | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2447 | pack_bytes: u64, |
| 2448 | actor: Option<String>, | |
| 2449 | /// Too large to scan for secrets before it was stored. | |
| 2450 | unscanned: bool, | |
| Merge branch 'worktree-agent-a3abfcce648e87dca' | 2451 | /// The run whose job's token pushed, if one did: its push starts no |
| 2452 | /// workflows. | |
| 2453 | caused_by_job: Option<String>, | |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 2454 | /// The person credited with the push's commits on their contribution |
| 2455 | /// calendar: whoever pushed, when that is a person (push_commits.rs). | |
| 2456 | credit: Option<String>, | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2457 | } |
| 2458 | ||
| 2459 | /// What a git request leaves for after its answer: its audit entry, with | |
| 2460 | /// how the request ended, and what a push changed. | |
| 2461 | struct AfterGit { | |
| 2462 | audit: Option<Box<g1t_contracts::audit::NewAuditEntry>>, | |
| 2463 | status: u16, | |
| 2464 | message: Option<String>, | |
| 2465 | push: Option<PushDone>, | |
| 2466 | } | |
| 2467 | ||
| 2468 | impl AfterGit { | |
| 2469 | fn ended(&mut self, status: u16, message: Option<String>) { | |
| 2470 | self.status = status; | |
| 2471 | self.message = message; | |
| 2472 | } | |
| 2473 | ||
| 2474 | /// Does the work once the response is on its way. A failure is logged: | |
| 2475 | /// git has already been told how its request went. | |
| 2476 | fn spawn(self, env: &Env, ctx: &Context) { | |
| 2477 | if self.audit.is_none() && self.push.is_none() { | |
| 2478 | return; | |
| 2479 | } | |
| 2480 | let env = env.clone(); | |
| 2481 | ctx.wait_until(async move { | |
| 2482 | let repos = match service(&env) { | |
| 2483 | Ok(repos) => repos, | |
| 2484 | Err(error) => { | |
| 2485 | worker::console_error!("git request not recorded: {error}"); | |
| 2486 | return; | |
| 2487 | } | |
| 2488 | }; | |
| 2489 | repos.finish_git(self.audit, self.status, self.message).await; | |
| 2490 | if let Some(push) = self.push | |
| 2491 | && let Err(error) = repos.record_push(push).await | |
| 2492 | { | |
| 2493 | worker::console_error!("push not recorded: {error}"); | |
| 2494 | } | |
| 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 answer | 2495 | repos.deferred.settle().await; |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2496 | }); |
| 2497 | } | |
| 2498 | } | |
| 2499 | ||
| 2500 | fn service(env: &Env) -> Result<Repos<ArtifactsStore>> { | |
| 2501 | let shared = shared::Shared::from_env(env).map(Rc::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 answer | 2502 | let deferred = Rc::new(store::Deferred::default()); |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2503 | Ok(Repos { |
| 2504 | registry: Registry { db: env.d1("DB")? }, | |
| 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 answer | 2505 | store: ArtifactsStore::new(env, shared.clone(), deferred.clone())?, |
| 2506 | deferred, | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2507 | shared, |
| 2508 | packs: pack_cache::Packs::from_env(env).map(Rc::new), | |
| 2509 | events: env.service("EVENTS")?, | |
| 2510 | security: env.service("SECURITY").ok(), | |
| 2511 | billing: env.service("BILLING").ok(), | |
| 2512 | identity: env.service("IDENTITY").ok(), | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 2513 | work: env.service("WORK").ok(), |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2514 | free_private_bytes: git_ops::free_private_bytes(env), |
| 2515 | fork_days: forks::retention_days(env), | |
| 2516 | repo_limit: env | |
| 2517 | .var("REPO_STORAGE_LIMIT_BYTES") | |
| 2518 | .ok() | |
| 2519 | .and_then(|value| value.to_string().parse().ok()) | |
| 2520 | .unwrap_or(pack_limits::DEFAULT_REPO_LIMIT_BYTES), | |
| 2521 | large_pushes: git_http::LargePushes::from_var(env.var("LARGE_PUSHES").ok().map(|value| value.to_string()).as_deref()), | |
| 2522 | placement: shards::Placement::from_vars( | |
| The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook. | 2523 | env.var("GITSTORE_NEW_REPOS").ok().map(|value| value.to_string()).as_deref(), |
| 2524 | env.var("GITSTORE_EU_NAMESPACE").ok().map(|value| value.to_string()).as_deref(), | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2525 | ), |
| The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook. | 2526 | limits: shards::limits(env.var("GITSTORE_NAMESPACE_LIMITS").ok().map(|value| value.to_string()).as_deref()), |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2527 | }) |
| 2528 | } | |
| 2529 | ||
| 2530 | /// Writes what this isolate metered once the answer has gone back, every | |
| 2531 | /// few seconds at most: now, or once it is due, waiting in this request's | |
| 2532 | /// `wait_until` so nothing counted is left for a request that may never | |
| 2533 | /// come (meters.rs). | |
| 2534 | fn flush_later(env: &Env, ctx: &Context) { | |
| 2535 | let Some(wait) = meters::plan_flush() else { | |
| 2536 | return; | |
| 2537 | }; | |
| 2538 | if let Ok(db) = env.d1("DB") { | |
| 2539 | ctx.wait_until(async move { meters::flush_after(&db, wait).await }); | |
| 2540 | } | |
| 2541 | } | |
| 2542 | ||
| 2543 | /// `/backups/<job id>/parts/<number>`: the job and the part's number. | |
| 2544 | fn backup_part_path(path: &str) -> Option<(String, u16)> { | |
| 2545 | let rest = path.strip_prefix("/backups/")?; | |
| 2546 | let (job, number) = rest.split_once("/parts/")?; | |
| 2547 | let number = number.parse::<u16>().ok()?; | |
| 2548 | (!job.is_empty() && !job.contains('/')).then(|| (job.to_owned(), number)) | |
| 2549 | } | |
| 2550 | ||
| 2551 | fn backups_off<T>() -> Outcome<T> { | |
| 2552 | Outcome::fail(FailureCode::Conflict, "Backups are off on this installation: it has no storage for them.") | |
| 2553 | } | |
| 2554 | ||
| 2555 | /// One part of a backup's bundle, with the job's token in its header. | |
| 2556 | async fn backup_part(request: &mut Request, env: &Env, repos: &Repos<ArtifactsStore>, job_id: String, number: u16) -> Result<Response> { | |
| 2557 | let Some(blobs) = backups::storage(env) else { | |
| 2558 | return reply(&backups_off::<()>()); | |
| 2559 | }; | |
| 2560 | let token = request.headers().get(g1t_contracts::backups::TOKEN_HEADER)?.unwrap_or_default(); | |
| 2561 | let bytes = request.bytes().await?; | |
| 2562 | let job = g1t_contracts::backups::BackupJobArgs { job_id, token }; | |
| 2563 | reply(&backups::part(&repos.registry.db, &blobs, &job, number, bytes).await?) | |
| 2564 | } | |
| 2565 | ||
| 2566 | #[cfg(test)] | |
| 2567 | mod backup_path_tests { | |
| 2568 | use super::backup_part_path; | |
| 2569 | ||
| 2570 | #[test] | |
| 2571 | fn a_part_is_named_by_its_job_and_number() { | |
| 2572 | assert_eq!(backup_part_path("/backups/bkp_1/parts/3"), Some(("bkp_1".to_owned(), 3))); | |
| 2573 | assert_eq!(backup_part_path("/backups/bkp_1/parts/x"), None); | |
| 2574 | assert_eq!(backup_part_path("/backups//parts/1"), None); | |
| 2575 | assert_eq!(backup_part_path("/acme/rocket.git/info/refs"), None); | |
| 2576 | } | |
| 2577 | } | |
| 2578 | ||
| 2579 | /// Read methods whose answer is an `Outcome`: when the git store is busy, | |
| 2580 | /// the site is told so in words instead of failing the page. | |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 2581 | const OUTCOME_READS: [&str; 7] = ["tree", "blob", "log", "branches", "blame", "compare", "branch_drift"]; |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2582 | |
| 2583 | #[event(fetch)] | |
| 2584 | async fn fetch(mut request: Request, env: Env, ctx: Context) -> Result<Response> { | |
| 2585 | let mut repos = service(&env)?; | |
| 2586 | // A part of a backup's bundle, as the API passes it on from the | |
| 2587 | // sandbox: bytes, not JSON (backups.rs). | |
| 2588 | if request.method() == Method::Put | |
| 2589 | && let Some((job_id, number)) = backup_part_path(&request.path()) | |
| 2590 | { | |
| 2591 | let answered = backup_part(&mut request, &env, &repos, job_id, number).await; | |
| 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 answer | 2592 | repos.deferred.hand_over(&ctx); |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2593 | flush_later(&env, &ctx); |
| 2594 | return answered; | |
| 2595 | } | |
| 2596 | let Some(method) = rpc_method(&request) else { | |
| 2597 | let answered = repos.git_http(request, &env, &ctx).await; | |
| 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 answer | 2598 | repos.deferred.hand_over(&ctx); |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2599 | flush_later(&env, &ctx); |
| 2600 | return answered; | |
| 2601 | }; | |
| 2602 | // A replica near the caller when it asks for one (crates/kit/src/d1.rs). | |
| 2603 | // Git over HTTPS above always reads the primary. | |
| 2604 | let (db, served) = g1t_kit::d1::open(&env, "DB", &request)?; | |
| 2605 | repos.registry.db = db; | |
| 2606 | let body: serde_json::Value = request.json().await?; | |
| 2607 | ||
| 2608 | let answered = async { match method.as_str() { | |
| 2609 | "get" => reply(&repos.get(args(body)?).await?), | |
| 2610 | "get_by_id" => reply(&repos.get_by_id(args(body)?).await?), | |
| 2611 | "readable" => { | |
| 2612 | let a: ReadableArgs = args(body)?; | |
| 2613 | reply(&repos.registry.readable(&a.ids, &a.viewer).await?) | |
| 2614 | } | |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 2615 | // Work's contribution calendar: the commits a person pushed each |
| 2616 | // day, in repositories the viewer may read (push_commits.rs). | |
| 2617 | "commit_days" => { | |
| 2618 | let a: g1t_contracts::repos::CommitDaysArgs = args(body)?; | |
| 2619 | reply(&push_commits::days(&repos.registry, &a.user_id, &a.since, &a.viewer).await?) | |
| 2620 | } | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2621 | "public_namespaces" => { |
| 2622 | let a: PublicNamespacesArgs = args(body)?; | |
| 2623 | reply(&repos.registry.public_namespaces(&a.owner_id).await?) | |
| 2624 | } | |
| 2625 | "path_by_id" => { | |
| 2626 | let a: PathByIdArgs = args(body)?; | |
| 2627 | reply( | |
| 2628 | &repos | |
| 2629 | .registry | |
| 2630 | .by_id(&a.id) | |
| 2631 | .await? | |
| 2632 | .filter(|repo| repo.fork_of.is_none()) | |
| 2633 | .map(|repo| RepoPath { | |
| 2634 | namespace: repo.namespace, | |
| 2635 | name: repo.name, | |
| 2636 | }), | |
| 2637 | ) | |
| 2638 | } | |
| 2639 | "list" => { | |
| 2640 | let a: ListArgs = args(body)?; | |
| 2641 | reply( | |
| 2642 | &repos | |
| 2643 | .registry | |
| 2644 | .list( | |
| 2645 | &a.viewer, | |
| 2646 | a.query.as_deref(), | |
| 2647 | a.namespace.as_deref(), | |
| 2648 | a.member_only, | |
| 2649 | ) | |
| 2650 | .await?, | |
| 2651 | ) | |
| 2652 | } | |
| 2653 | "create" => reply(&repos.create(args(body)?).await?), | |
| 2654 | "transfer" => reply(&repos.transfer(args(body)?).await?), | |
| 2655 | // A repository's lifecycle: see lifecycle.rs. | |
| 2656 | "delete" => reply(&repos.delete(args(body)?).await?), | |
| 2657 | "deleted" => reply(&repos.deleted(args(body)?).await?), | |
| 2658 | "restore" => reply(&repos.restore(args(body)?).await?), | |
| 2659 | "purge" => reply(&repos.purge(args(body)?).await?), | |
| 2660 | "purge_due" => reply(&repos.purge_due(args(body)?).await?), | |
| 2661 | "rename" => reply(&repos.rename(args(body)?).await?), | |
| 2662 | "archive" => reply(&repos.archive(args(body)?).await?), | |
| 2663 | "set_visibility" => reply(&repos.set_visibility(args(body)?).await?), | |
| 2664 | "set_default_branch" => reply(&repos.set_default_branch(args(body)?).await?), | |
| 2665 | "rename_branch" => reply(&repos.rename_branch(args(body)?).await?), | |
| The Branches page does things to branches now. It has tabs (Overview, Yours, Active, Stale, All, kept in the address as ?tab=) and search; Overview shows the default branch, then the first five of your branches, the active ones and the stale ones, each linking to the rest. Each branch is a row with its name in a code chip with a copy button, who made its latest commit and when, that commit's checks, behind and ahead counts with bars drawn against the largest shown, and its pull request (the open one, else the newest merged or closed) with its state; on a phone the columns fold under the name. Yours is branches whose latest commit is the viewer's; stale is no commit in 90 days. | 2666 | "create_branch" => reply(&repos.create_branch(args(body)?).await?), |
| 2667 | "remove_branch" => reply(&repos.remove_branch(args(body)?).await?), | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2668 | "resolve_branch" => reply(&repos.resolve_branch(args(body)?).await?), |
| 2669 | "status_by_id" => reply(&repos.status_by_id(args(body)?).await?), | |
| 2670 | "resolve_path" => { | |
| 2671 | let a: ResolvePathArgs = args(body)?; | |
| 2672 | reply(&repos.registry.resolve_moved(&a.path).await?) | |
| 2673 | } | |
| 2674 | "namespace_count" => { | |
| 2675 | let a: NamespaceCountArgs = args(body)?; | |
| 2676 | reply(&repos.registry.count_in(&a.namespace).await?) | |
| 2677 | } | |
| 2678 | "update" => reply(&repos.update(args(body)?).await?), | |
| 2679 | "tree" => reply(&repos.tree(args(body)?).await?), | |
| 2680 | "blob" => reply(&repos.blob(args(body)?).await?), | |
| 2681 | "log" => reply(&repos.log(args(body)?).await?), | |
| 2682 | "blame" => reply(&repos.blame(args(body)?).await?), | |
| 2683 | "fork_for_pull" => reply(&repos.fork_for_pull(args(body)?).await?), | |
| 2684 | "git_access" => reply(&repos.git_access(args(body)?).await?), | |
| 2685 | "branches" => reply(&repos.branches(args(body)?).await?), | |
| 2686 | "last_commits" => reply(&repos.last_commits(args(body)?).await?), | |
| A file's history and a repository's failed runs are one read each, so Ask (and any agent) answers "why does this file exist" and "why did the last Deploy fail" from what happened rather than by paging. list_commits with `path` now gives that file's or directory's own history: the commits on the first-parent chain that changed it (added, changed, removed), newest first, with `last_changed` the newest and `complete` false when a long history was not walked to its start in time. The repos service's new path_log walks the history as last_commits does, reading ahead in batches, a tree only where the path's directory changed, within its read budget and 8 s. list_workflow_runs takes GitHub's `status`, a status (queued, in_progress, waiting, completed) or a conclusion (failure, success, cancelled…), on REST (status, and head_sha on a workflow's runs too) and MCP, and the actions service filters by either. Ask's system prompt finds failed runs with status failure, says a run still going has no result rather than calling it passed, judges health from the newest completed run and what is still going, and reads a file's history for why it exists, quoting its commit messages. The quality bar no longer reads a run's number, in a link to the run or a table of runs, as an issue's. Its last run on production scored 97.1% of checks and a judge mean of 3.81 over 27 questions, against 52.1% and 3.08 before Ask had tools; these address its weakest answers. Checked: path_log's walks over a file, a directory, a missing path, a limit, pages and a read budget; cargo test for the workspace (the actions crates on their own), the wasm builds, the TypeScript typecheck, every workspace's tests, the deploy tool's and the plugin's tests and the docs build; the OpenAPI document, the CLI's operations and its reference are regenerated, and the MCP reference says both. | 2687 | "path_log" => reply(&repos.path_log(args(body)?).await?), |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 2688 | "branch_drift" => reply(&repos.branch_drift(args(body)?).await?), |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2689 | "tags" => reply(&repos.tags(args(body)?).await?), |
| Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97 | 2690 | // The About: what the Files page shows beside the files (about.rs). |
| 2691 | // What is kept behind the head is worked out again after the answer. | |
| 2692 | "about" => { | |
| 2693 | let (answer, refresh) = repos.about(args(body)?).await?; | |
| 2694 | about::refresh_later(&env, &ctx, refresh); | |
| 2695 | reply(&answer) | |
| 2696 | } | |
| 2697 | "languages" => { | |
| 2698 | let (answer, refresh) = repos.languages(args(body)?).await?; | |
| 2699 | about::refresh_later(&env, &ctx, refresh); | |
| 2700 | reply(&answer) | |
| 2701 | } | |
| 2702 | "contributors" => { | |
| 2703 | let (answer, refresh) = repos.contributors(args(body)?).await?; | |
| 2704 | about::refresh_later(&env, &ctx, refresh); | |
| 2705 | reply(&answer) | |
| 2706 | } | |
| 2707 | "license" => { | |
| 2708 | let (answer, refresh) = repos.license(args(body)?).await?; | |
| 2709 | about::refresh_later(&env, &ctx, refresh); | |
| 2710 | reply(&answer) | |
| 2711 | } | |
| 2712 | "stars" => reply(&repos.stars(args(body)?).await?), | |
| 2713 | "star" => reply(&repos.star(args(body)?).await?), | |
| 2714 | "stargazers" => reply(&repos.stargazers(args(body)?).await?), | |
| 2715 | "starred" => reply(&repos.starred(args(body)?).await?), | |
| 2716 | "releases" => reply(&repos.releases(args(body)?).await?), | |
| 2717 | "release" => reply(&repos.release(args(body)?).await?), | |
| 2718 | "create_release" => reply(&repos.create_release(args(body)?).await?), | |
| 2719 | "update_release" => reply(&repos.update_release(args(body)?).await?), | |
| 2720 | "delete_release" => reply(&repos.delete_release(args(body)?).await?), | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2721 | "head" => reply(&repos.head(args(body)?).await?), |
| 2722 | "behind" => reply(&repos.behind(args(body)?).await?), | |
| 2723 | "divergence" => reply(&repos.divergence(args(body)?).await?), | |
| 2724 | "land" => reply(&repos.land(args(body)?).await?), | |
| 2725 | "update_pull_branch" => reply(&repos.update_pull_branch(args(body)?).await?), | |
| Merge remote-tracking branch 'origin/claude/cool-newton-7nm8r9' | 2726 | // Services only: a pull request's test merge commit (test_merge.rs). |
| 2727 | "pull_merge_ref" => reply(&repos.pull_merge_ref(args(body)?).await?), | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2728 | "delete_branch" => reply(&repos.delete_branch(args(body)?).await?), |
| 2729 | "commit_file" => reply(&repos.commit_file(args(body)?).await?), | |
| 2730 | "compare" => reply(&repos.compare(args(body)?).await?), | |
| Merge rulesets: branch and tag rules, agent-first, enforced on push and merge | 2731 | // Services only: a pull request's commits, as rules look at them (rules.rs). |
| 2732 | "inspect_commits" => reply(&repos.inspect_commits(args(body)?).await?), | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2733 | "scan_history" => reply(&repos.scan_history(args(body)?).await?), |
| 2734 | "find_lockfiles" => reply(&repos.find_lockfiles(args(body)?).await?), | |
| Teams and CODEOWNERS, labels and milestones, dependency updates, the security suite, and a clearer top bar | 2735 | "match_pattern" => reply(&repos.match_pattern(args(body)?).await?), |
| 2736 | "check_secret" => reply(&repos.check_secret(args(body)?).await?), | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2737 | "list_files" => reply(&repos.list_files(args(body)?).await?), |
| 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. | 2738 | // The API's tarball and zipball links, and the site opening them (archive_links.rs). |
| 2739 | "archive_link" => { | |
| 2740 | let sealer = env.secret("REPOS_KEY").ok().and_then(|key| g1t_secrets::Sealer::new(&key.to_string())); | |
| 2741 | reply(&repos.archive_link(args(body)?, sealer.as_ref()).await?) | |
| 2742 | } | |
| 2743 | "open_archive_token" => { | |
| 2744 | let sealer = env.secret("REPOS_KEY").ok().and_then(|key| g1t_secrets::Sealer::new(&key.to_string())); | |
| 2745 | reply(&repos.open_archive_token(args(body)?, sealer.as_ref()).await?) | |
| 2746 | } | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2747 | "changed_files" => reply(&repos.changed_files(args(body)?).await?), |
| 2748 | "read_blobs" => reply(&repos.read_blobs(args(body)?).await?), | |
| 2749 | // Services only: what the Composer registry builds packages from. | |
| 2750 | "refs" => reply(&repos.refs_of(args(body)?).await?), | |
| 2751 | "raw_file" => reply(&repos.raw_file(args(body)?).await?), | |
| 2752 | "raw_blobs" => reply(&repos.raw_blobs(args(body)?).await?), | |
| 2753 | "visibility" => { | |
| 2754 | let a: g1t_contracts::repos::VisibilityArgs = args(body)?; | |
| 2755 | reply(&repos.registry.visibility(&a.paths).await?) | |
| 2756 | } | |
| 2757 | "storage" => reply(&repos.registry.storage().await?), | |
| 2758 | "git_operations" => { | |
| 2759 | let a: GitOperationsArgs = args(body)?; | |
| 2760 | reply(&git_ops::totals(&repos.registry.db, &a.month, a.since.as_deref(), a.namespace.as_deref().map(str::to_lowercase).as_deref()).await?) | |
| 2761 | } | |
| Merge main (membership, two-factor, GitHub repo roles) into tokens | 2762 | // Identity, once: who created each repository (members.rs there). |
| 2763 | "repo_creators" => { | |
| 2764 | let a: AllIdsArgs = args(body)?; | |
| 2765 | let limit = a.limit.clamp(1, 500); | |
| 2766 | let repos = repos.registry.creators_after(a.after.as_deref(), limit).await?; | |
| 2767 | let next = (repos.len() == limit as usize).then(|| repos.last().map(|repo| repo.id.clone())).flatten(); | |
| 2768 | reply(&CreatorPage { repos, next }) | |
| 2769 | } | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2770 | "all_ids" => { |
| 2771 | let a: AllIdsArgs = args(body)?; | |
| 2772 | let limit = a.limit.clamp(1, 500); | |
| 2773 | let ids = repos.registry.ids_after(a.after.as_deref(), limit).await?; | |
| 2774 | let next = (ids.len() == limit as usize).then(|| ids.last().cloned()).flatten(); | |
| 2775 | reply(&IdPage { ids, next }) | |
| 2776 | } | |
| 2777 | // The raw meters of the git store, for reconciling with Cloudflare | |
| The artifacts service is services/artifacts, the Worker g1t-artifacts, bound as ARTIFACTS by the API, the site and the agents; its live rooms move to it with a Durable Object transfer from g1t-docs-service, and its database, bucket, indexes and queue keep their names. The git store's binding and settings are GITSTORE, its ops scripts gitstore-*, and workflow run artifacts keep their compatible API under run_artifacts modules. The deploy tool puts a Worker that has never deployed before the Workers in its stage that bind to it, and the deploy guide gives the cutover runbook. | 2778 | // (meters.rs, scripts/ops/gitstore-usage.mjs). |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2779 | "artifacts_usage" => { |
| 2780 | let a: meters::UsageArgs = args(body)?; | |
| 2781 | reply(&meters::usage(&repos.registry.db, &a).await?) | |
| 2782 | } | |
| 2783 | "operation_mapping" => reply(&meters::read_mapping(&repos.registry.db).await?), | |
| 2784 | // Billing: the workspace each pull request's working copy is counted | |
| 2785 | // for, so Cloudflare's own count of `pulls--<id>` shares out too. | |
| 2786 | "pull_owners" => { | |
| 2787 | #[derive(serde::Deserialize)] | |
| 2788 | struct PullOwnersArgs { | |
| 2789 | pulls: Vec<String>, | |
| 2790 | } | |
| 2791 | let a: PullOwnersArgs = args(body)?; | |
| 2792 | let pulls: Vec<String> = a.pulls.into_iter().take(500).collect(); | |
| 2793 | reply(&serde_json::json!({ "owners": meters::pull_owners(&repos.registry.db, &pulls).await? })) | |
| 2794 | } | |
| 2795 | // Services only: which meters are operations, changed without a deploy. | |
| 2796 | "set_operation_mapping" => { | |
| 2797 | let row: meters::MappingRow = args(body)?; | |
| 2798 | meters::set_mapping(&repos.registry.db, &row, &rfc3339(now_ms())).await?; | |
| 2799 | reply(&meters::read_mapping(&repos.registry.db).await?) | |
| 2800 | } | |
| 2801 | // Backups (backups.rs): the runner's sweep claims queued ones, and | |
| 2802 | // each sandbox, through the API, asks for its job and says how it went. | |
| 2803 | "claim_backups" => { | |
| 2804 | let a: g1t_contracts::backups::ClaimBackupsArgs = args(body)?; | |
| 2805 | let blobs = backups::storage(&env); | |
| 2806 | reply(&backups::claim(&repos.registry.db, blobs.as_ref(), &a, now_ms()).await?) | |
| 2807 | } | |
| 2808 | "backup_spec" => match backups::storage(&env) { | |
| 2809 | Some(blobs) => { | |
| 2810 | let a: g1t_contracts::backups::BackupJobArgs = args(body)?; | |
| 2811 | let every = backups::Settings::from_env(&env).full_every; | |
| 2812 | reply(&backups::spec(&repos.registry, &blobs, &repos.store, &a, every, now_ms()).await?) | |
| 2813 | } | |
| 2814 | None => reply(&backups_off::<bool>()), | |
| 2815 | }, | |
| 2816 | "backup_complete" => match backups::storage(&env) { | |
| 2817 | Some(blobs) => reply(&backups::complete(&repos.registry, &blobs, &args(body)?, now_ms()).await?), | |
| 2818 | None => reply(&backups_off::<bool>()), | |
| 2819 | }, | |
| 2820 | "backup_fail" => match backups::storage(&env) { | |
| 2821 | Some(blobs) => reply(&backups::fail(&repos.registry.db, &blobs, &args(body)?).await?), | |
| 2822 | None => reply(&backups_off::<bool>()), | |
| 2823 | }, | |
| 2824 | // How the git store has been answering, for the status page. | |
| 2825 | "store_health" => { | |
| 2826 | let a: meters::HealthArgs = args(body)?; | |
| 2827 | reply(&meters::health(&repos.registry.db, &a).await?) | |
| 2828 | } | |
| 2829 | // Where repositories may be kept, for a workspace's settings. | |
| 2830 | "storage_options" => reply(&repos.storage_options()), | |
| 2831 | // Services and operators only: how each namespace stands, and | |
| 2832 | // moving a repository between them (namespaces.rs, moves.rs). | |
| 2833 | "namespaces" => reply(&repos.standings().await?), | |
| 2834 | "move_repository" => reply(&repos.move_repository(args(body)?).await?), | |
| 2835 | "repository_moves" => { | |
| 2836 | let a: moves::ListMovesArgs = args(body)?; | |
| 2837 | reply(&repos.registry.moves(a.limit.unwrap_or(50)).await?) | |
| 2838 | } | |
| 2839 | _ => Response::error("Unknown method", 404), | |
| 2840 | } } | |
| 2841 | .await; | |
| 2842 | // The git store is busy: said in words, with when to try again. | |
| 2843 | let answered = match answered { | |
| 2844 | Err(error) => match resilience::busy(&error.to_string()) { | |
| 2845 | Some(busy) if OUTCOME_READS.contains(&method.as_str()) => { | |
| 2846 | reply(&Outcome::<()>::fail(FailureCode::Conflict, busy.message().trim())) | |
| 2847 | } | |
| 2848 | Some(busy) => { | |
| 2849 | let response = Response::error(busy.message(), 503)?; | |
| 2850 | response.headers().set("retry-after", &busy.retry_after.to_string())?; | |
| 2851 | Ok(response) | |
| 2852 | } | |
| 2853 | None => Err(error), | |
| 2854 | }, | |
| 2855 | answered => answered, | |
| 2856 | }; | |
| 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 answer | 2857 | repos.deferred.hand_over(&ctx); |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2858 | flush_later(&env, &ctx); |
| 2859 | served.finish(answered) | |
| 2860 | } | |
| 2861 | ||
| 2862 | /// The nightly cron in wrangler.jsonc: tonight's backups are queued. | |
| 2863 | const BACKUP_CRON: &str = "53 2 * * *"; | |
| 2864 | ||
| 2865 | /// The hourly sweep: deleted repositories whose time to be restored has | |
| 2866 | /// passed are purged. See lifecycle.rs. And, at [`BACKUP_CRON`], the | |
| 2867 | /// repositories whose refs moved are queued for a backup (backups.rs). | |
| 2868 | #[event(scheduled)] | |
| 2869 | async fn scheduled(event: ScheduledEvent, env: Env, _ctx: ScheduleContext) { | |
| 2870 | let repos = match service(&env) { | |
| 2871 | Ok(repos) => repos, | |
| 2872 | Err(error) => { | |
| 2873 | worker::console_error!("repos: the sweep could not start: {error}"); | |
| 2874 | return; | |
| 2875 | } | |
| 2876 | }; | |
| 2877 | if event.cron() == BACKUP_CRON { | |
| 2878 | let Some(blobs) = backups::storage(&env) else { return }; | |
| 2879 | match backups::nightly(&repos.registry.db, &blobs, backups::Settings::from_env(&env), now_ms()).await { | |
| 2880 | Ok(night) => worker::console_log!("repos: queued {} backups, removed {} of purged repositories", night.queued, night.pruned), | |
| 2881 | Err(error) => worker::console_error!("repos: backups could not be queued: {error}"), | |
| 2882 | } | |
| 2883 | return; | |
| 2884 | } | |
| 2885 | match repos.purge_due(PurgeDueArgs::default()).await { | |
| 2886 | Ok(0) => {} | |
| 2887 | Ok(count) => worker::console_log!("repos: purged {count} deleted repositories"), | |
| 2888 | Err(error) => worker::console_error!("repos: the purge sweep failed: {error}"), | |
| 2889 | } | |
| 2890 | // Pull requests' working copies whose time has come (forks.rs). | |
| 2891 | match repos.retire_due().await { | |
| 2892 | Ok(0) => {} | |
| 2893 | Ok(count) => worker::console_log!("repos: removed {count} pull request working copies"), | |
| 2894 | Err(error) => worker::console_error!("repos: the working copy sweep failed: {error}"), | |
| 2895 | } | |
| 2896 | // Repositories moving between namespaces, and old copies (moves.rs). | |
| 2897 | match repos.run_moves().await { | |
| 2898 | Ok(0) => {} | |
| 2899 | Ok(count) => worker::console_log!("repos: moved {count} repositories between namespaces"), | |
| 2900 | Err(error) => worker::console_error!("repos: the move sweep failed: {error}"), | |
| 2901 | } | |
| 2902 | meters::flush(&repos.registry.db).await; | |
| 2903 | } | |
| 2904 | ||
| 2905 | /// Events from the bus. A workspace's rename: its repositories move to the | |
| 2906 | /// workspace's current slug, asked of identity by id, so a repeated or late | |
| 2907 | /// delivery lands in the same place; their git store keys stay as they | |
| 2908 | /// were. A workspace's deletion: its repositories are deleted with it, | |
| 2909 | /// restored with it, or purged with it. | |
| 2910 | #[event(queue)] | |
| 2911 | async fn queue(batch: MessageBatch<Event>, env: Env, ctx: Context) -> Result<()> { | |
| 2912 | let registry = Registry { db: env.d1("DB")? }; | |
| 2913 | let identity = env.service("IDENTITY")?; | |
| 2914 | let handled = handle_events(&batch, &env, ®istry, &identity).await; | |
| 2915 | flush_later(&env, &ctx); | |
| 2916 | handled | |
| 2917 | } | |
| 2918 | ||
| Merge branch 'worktree-agent-ad8a36dfcd4176015' into spend-guardrails | 2919 | /// Each event is acknowledged or retried on its own, so one that fails is |
| 2920 | /// tried again without the others before and after it running twice. | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2921 | async fn handle_events(batch: &MessageBatch<Event>, env: &Env, registry: &Registry, identity: &Fetcher) -> Result<()> { |
| 2922 | for message in batch.messages()? { | |
| Merge branch 'worktree-agent-ad8a36dfcd4176015' into spend-guardrails | 2923 | match handle_event(message.body(), env, registry, identity).await { |
| 2924 | Ok(()) => message.ack(), | |
| 2925 | Err(error) => { | |
| 2926 | worker::console_error!("repos: event {} failed: {error}", message.body().id); | |
| 2927 | message.retry(); | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2928 | } |
| 2929 | } | |
| Merge branch 'worktree-agent-ad8a36dfcd4176015' into spend-guardrails | 2930 | } |
| 2931 | Ok(()) | |
| 2932 | } | |
| 2933 | ||
| 2934 | async fn handle_event(event: &Event, env: &Env, registry: &Registry, identity: &Fetcher) -> Result<()> { | |
| 2935 | // A pull request merged, closed or reopened: its working copy is | |
| 2936 | // kept or let go (forks.rs). | |
| 2937 | if let Some(change) = forks::pull_change(&event.kind) { | |
| 2938 | let Some(pull_id) = forks::pull_id_of(&event.data) else { | |
| 2939 | worker::console_error!("{} {} names no pull request", event.kind, event.id); | |
| 2940 | return Ok(()); | |
| 2941 | }; | |
| 2942 | let repos = service(env)?; | |
| 2943 | match change { | |
| 2944 | forks::PullChange::Settled => repos.pull_settled(&pull_id).await?, | |
| 2945 | forks::PullChange::Reopened => repos.pull_reopened(&pull_id).await?, | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2946 | } |
| Merge branch 'worktree-agent-ad8a36dfcd4176015' into spend-guardrails | 2947 | return Ok(()); |
| 2948 | } | |
| 2949 | // A workspace deleted, restored or purged: its repositories go with | |
| 2950 | // it, come back with it, or are purged with it (lifecycle.rs). | |
| 2951 | if event.kind == "workspace.deleting" { | |
| 2952 | match serde_json::from_value::<WorkspaceDeleting>(event.data.clone()) { | |
| 2953 | Ok(deleting) => service(env)?.delete_with_workspace(&deleting, &protected_workspaces(env)).await?, | |
| 2954 | Err(_) => worker::console_error!("workspace.deleting {} could not be read", event.id), | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2955 | } |
| Merge branch 'worktree-agent-ad8a36dfcd4176015' into spend-guardrails | 2956 | return Ok(()); |
| 2957 | } | |
| 2958 | if event.kind == "workspace.restored" { | |
| 2959 | match serde_json::from_value::<WorkspaceRestored>(event.data.clone()) { | |
| 2960 | Ok(restored) => service(env)?.restore_with_workspace(&restored).await?, | |
| 2961 | Err(_) => worker::console_error!("workspace.restored {} could not be read", event.id), | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2962 | } |
| Merge branch 'worktree-agent-ad8a36dfcd4176015' into spend-guardrails | 2963 | return Ok(()); |
| 2964 | } | |
| 2965 | if event.kind == "workspace.deleted" { | |
| 2966 | match serde_json::from_value::<WorkspaceDeleted>(event.data.clone()) { | |
| 2967 | Ok(deleted) => service(env)?.purge_workspace(&deleted, &protected_workspaces(env)).await?, | |
| 2968 | Err(_) => worker::console_error!("workspace.deleted {} could not be read", event.id), | |
| Merge leftovers: plan activity filtered in the query, pushes counted by account, ghost during the deletion window, profile time zones (identity 0039) | 2969 | } |
| Merge branch 'worktree-agent-ad8a36dfcd4176015' into spend-guardrails | 2970 | return Ok(()); |
| 2971 | } | |
| 2972 | // An account deleted or restored: kept contributors that name it | |
| 2973 | // (or ghost, for a restore) are worked out again, so it shows as | |
| 2974 | // ghost for its 30 days, and as itself again if restored. | |
| 2975 | if let Some(needle) = stats::shown_differently(&event.kind, &event.data) { | |
| 2976 | let db = env.d1("DB")?; | |
| 2977 | if let Err(error) = stats::rework_naming(&db, &needle).await { | |
| 2978 | worker::console_error!("{} {}: contributors not marked to be counted again: {error}", event.kind, event.id); | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 2979 | } |
| Merge branch 'worktree-agent-ad8a36dfcd4176015' into spend-guardrails | 2980 | return Ok(()); |
| 2981 | } | |
| 2982 | if event.kind != "workspace.renamed" { | |
| 2983 | return Ok(()); | |
| 2984 | } | |
| 2985 | let Ok(renamed) = serde_json::from_value::<WorkspaceRenamed>(event.data.clone()) else { | |
| 2986 | worker::console_error!("workspace.renamed {} could not be read", event.id); | |
| 2987 | return Ok(()); | |
| 2988 | }; | |
| 2989 | let names: HashMap<String, String> = g1t_kit::call( | |
| 2990 | identity, | |
| 2991 | "usernames", | |
| 2992 | &g1t_contracts::identity::UsernamesArgs { | |
| 2993 | ids: vec![renamed.workspace_id.clone()], | |
| 2994 | }, | |
| 2995 | ) | |
| 2996 | .await?; | |
| 2997 | let current = names | |
| 2998 | .get(&renamed.workspace_id) | |
| 2999 | .cloned() | |
| 3000 | .unwrap_or_else(|| renamed.to.clone()); | |
| 3001 | let left = registry | |
| 3002 | .rename_namespace(&renamed.stale_slugs(¤t), ¤t) | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 3003 | .await?; |
| Merge branch 'worktree-agent-ad8a36dfcd4176015' into spend-guardrails | 3004 | if left > 0 { |
| 3005 | worker::console_error!( | |
| 3006 | "{left} repositories stayed under {} or {}: {current} already has repositories of the same names", | |
| 3007 | renamed.from, | |
| 3008 | renamed.to | |
| 3009 | ); | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 3010 | } |
| 3011 | Ok(()) | |
| 3012 | } | |
| 3013 | ||
| 3014 | /// The workspaces whose repositories never go with a deletion, whatever is | |
| 3015 | /// published: `PROTECTED_WORKSPACES` if set here, and Flagon's always. | |
| 3016 | fn protected_workspaces(env: &Env) -> Vec<String> { | |
| 3017 | let configured = env.var("PROTECTED_WORKSPACES").ok().map(|v| v.to_string()); | |
| 3018 | g1t_contracts::identity::protected_names(configured.as_deref()) | |
| 3019 | } | |
| 3020 | ||
| Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca | 3021 | /// What a token's own limits say about git on the repository `repo` |
| 3022 | /// (`owner/name`), before anyone's role is asked: why it is refused, or | |
| 3023 | /// `None`. `source` is the repository a pull request's working copy at | |
| 3024 | /// `repo` belongs to, which a workflow job's token reaches too. `public` | |
| 3025 | /// is whether anyone may read it, and `exists` whether there is one. | |
| 3026 | /// | |
| 3027 | /// A job's token and a deploy key reach their own repository only. Its | |
| 3028 | /// scopes decide the rest: `code:read` to read a private repository, | |
| 3029 | /// `code:write` to push, which a read-only deploy key never has. A deploy | |
| 3030 | /// key never makes a repository by pushing to an empty address. | |
| 3031 | pub(crate) fn git_token_refusal( | |
| 3032 | access: &g1t_contracts::scopes::TokenAccess, | |
| 3033 | repo: &str, | |
| 3034 | source: Option<&str>, | |
| 3035 | write: bool, | |
| 3036 | public: bool, | |
| 3037 | exists: bool, | |
| 3038 | ) -> Option<String> { | |
| 3039 | if let Some(refused) = g1t_contracts::scopes::decide_repo(access, repo) | |
| 3040 | && !source.is_some_and(|source| access.reaches(source)) | |
| 3041 | { | |
| 3042 | return Some(refused.reason.unwrap_or_default()); | |
| 3043 | } | |
| 3044 | let decision = g1t_contracts::scopes::decide_git(access, write, public); | |
| 3045 | if !decision.allowed { | |
| 3046 | return Some(decision.reason.unwrap_or_default()); | |
| 3047 | } | |
| 3048 | if access.deploy_key.is_some() && !exists { | |
| 3049 | return Some(format!("This deploy key is for {repo}, which is not there any more.")); | |
| 3050 | } | |
| 3051 | None | |
| 3052 | } | |
| 3053 | ||
| Docs: the inbox, what lands in it and why, its tabs and actions | 3054 | /// The repository a push to a path that does not exist yet creates: private, |
| 3055 | /// so nothing pushed by mistake is published. An owner makes it public on | |
| 3056 | /// purpose (`POST /repos/{owner}/{repo}/visibility`). | |
| 3057 | fn push_to_create(owner: &User, path: &RepoPath) -> CreateArgs { | |
| 3058 | CreateArgs { | |
| 3059 | owner: owner.clone(), | |
| 3060 | namespace: path.namespace.clone(), | |
| 3061 | name: path.name.clone(), | |
| 3062 | description: None, | |
| 3063 | is_private: true, | |
| 3064 | import_url: None, | |
| 3065 | import_token: None, | |
| 3066 | } | |
| 3067 | } | |
| 3068 | ||
| Merge update PRs close themselves: g1t closes its security and version updates once they are no longer needed, and deletes their branches | 3069 | /// Whether `delete_branch` may remove `branch`: one of g1t's own |
| 3070 | /// (`g1t-…`), or one whose tip the caller names, such as a dependency | |
| 3071 | /// update's branch after its pull request closed. | |
| 3072 | fn deletable_branch(branch: &str, head: Option<&str>) -> bool { | |
| 3073 | !branch.is_empty() && (branch.starts_with(G1T_BRANCH_PREFIX) || head.is_some_and(|head| !head.is_empty())) | |
| 3074 | } | |
| 3075 | ||
| 3076 | #[cfg(test)] | |
| 3077 | mod delete_branch_tests { | |
| 3078 | use super::deletable_branch; | |
| 3079 | ||
| 3080 | #[test] | |
| 3081 | fn only_g1t_branches_or_a_named_tip_are_deleted() { | |
| 3082 | assert!(deletable_branch("g1t-queue-12", None)); | |
| 3083 | assert!(!deletable_branch("g1t/security/sharp-0.35.5", None)); | |
| 3084 | assert!(deletable_branch("g1t/security/sharp-0.35.5", Some("abc123"))); | |
| 3085 | assert!(!deletable_branch("feature", Some(""))); | |
| 3086 | assert!(!deletable_branch("", Some("abc123"))); | |
| 3087 | } | |
| 3088 | } | |
| 3089 | ||
| Docs: the inbox, what lands in it and why, its tabs and actions | 3090 | #[cfg(test)] |
| Merge branch 'worktree-agent-ad8a36dfcd4176015' into spend-guardrails | 3091 | mod announced_refs_tests { |
| 3092 | use super::*; | |
| 3093 | ||
| 3094 | fn pushed(git_ref: String) -> git_http::Pushed { | |
| 3095 | git_http::Pushed { git_ref, before: None, after: "abc".into() } | |
| 3096 | } | |
| 3097 | ||
| 3098 | fn refs(announced: Vec<&git_http::Pushed>) -> Vec<&str> { | |
| 3099 | announced.into_iter().map(|pushed| pushed.git_ref.as_str()).collect() | |
| 3100 | } | |
| 3101 | ||
| 3102 | #[test] | |
| 3103 | fn a_few_tags_are_announced_and_many_are_not() { | |
| 3104 | let few: Vec<_> = (1..=3).map(|n| pushed(format!("refs/tags/v{n}"))).chain([pushed("refs/heads/main".into())]).collect(); | |
| 3105 | assert_eq!(refs(announced_refs(&few, "main")), ["refs/heads/main", "refs/tags/v1", "refs/tags/v2", "refs/tags/v3"]); | |
| 3106 | let many: Vec<_> = (1..=10_000).map(|n| pushed(format!("refs/tags/v{n}"))).chain([pushed("refs/heads/main".into())]).collect(); | |
| 3107 | assert_eq!(refs(announced_refs(&many, "main")), ["refs/heads/main"]); | |
| 3108 | } | |
| 3109 | ||
| 3110 | #[test] | |
| 3111 | fn past_the_branch_cap_only_the_default_branch_is_announced() { | |
| 3112 | let at_cap: Vec<_> = (0..MAX_PUSH_BRANCH_EVENTS).map(|n| pushed(format!("refs/heads/b{n}"))).collect(); | |
| 3113 | assert_eq!(announced_refs(&at_cap, "main").len(), MAX_PUSH_BRANCH_EVENTS); | |
| 3114 | let over: Vec<_> = (0..=MAX_PUSH_BRANCH_EVENTS) | |
| 3115 | .map(|n| pushed(format!("refs/heads/b{n}"))) | |
| 3116 | .chain([pushed("refs/heads/main".into())]) | |
| 3117 | .collect(); | |
| 3118 | assert_eq!(refs(announced_refs(&over, "main")), ["refs/heads/main"]); | |
| 3119 | assert!(announced_refs(&over, "trunk").is_empty()); | |
| 3120 | } | |
| 3121 | } | |
| 3122 | ||
| 3123 | #[cfg(test)] | |
| Docs: the inbox, what lands in it and why, its tabs and actions | 3124 | mod push_to_create_tests { |
| 3125 | use super::*; | |
| 3126 | ||
| 3127 | #[test] | |
| 3128 | fn a_pushed_repository_starts_private() { | |
| 3129 | let owner: User = serde_json::from_value(serde_json::json!({ "id": "usr_1", "username": "ada" })).unwrap(); | |
| 3130 | let args = push_to_create(&owner, &RepoPath { namespace: "acme".into(), name: "site".into() }); | |
| 3131 | assert!(args.is_private); | |
| 3132 | assert_eq!((args.namespace.as_str(), args.name.as_str()), ("acme", "site")); | |
| 3133 | } | |
| 3134 | } | |
| Merge branch 'worktree-agent-a5a5de74d8863d554' into worktree-agent-a16631325aecf58ca | 3135 | |
| 3136 | #[cfg(test)] | |
| 3137 | mod deploy_key_git_tests { | |
| 3138 | use super::*; | |
| 3139 | use g1t_contracts::deploy_keys; | |
| 3140 | ||
| 3141 | fn key(read_only: bool) -> User { | |
| 3142 | deploy_keys::principal("wsp_acme", "acme", deploy_keys::access("dk_1", "CI", "acme/rocket", read_only)) | |
| 3143 | } | |
| 3144 | ||
| 3145 | fn rocket(private: bool) -> Repo { | |
| 3146 | serde_json::from_value(serde_json::json!({ | |
| 3147 | "id": "rep_rocket", | |
| 3148 | "namespace": "acme", | |
| 3149 | "name": "rocket", | |
| 3150 | "description": null, | |
| 3151 | "isPrivate": private, | |
| 3152 | "ownerId": "usr_owner", | |
| 3153 | "defaultBranch": "main", | |
| 3154 | "forkOf": null, | |
| 3155 | "protected": false, | |
| 3156 | "createdAt": "", | |
| 3157 | })) | |
| 3158 | .unwrap() | |
| 3159 | } | |
| 3160 | ||
| 3161 | fn refusal(user: &User, repo: &str, write: bool, exists: bool) -> Option<String> { | |
| 3162 | git_token_refusal(user.token.as_deref().unwrap(), repo, None, write, false, exists) | |
| 3163 | } | |
| 3164 | ||
| 3165 | #[test] | |
| 3166 | fn a_read_only_deploy_key_clones_its_repository_and_never_pushes() { | |
| 3167 | let user = key(true); | |
| 3168 | assert_eq!(refusal(&user, "acme/rocket", false, true), None); | |
| 3169 | assert!(refusal(&user, "acme/rocket", true, true).unwrap().contains("read-only")); | |
| 3170 | // Its role is a workspace token's: it reads a private repository. | |
| 3171 | assert!(registry::can_read(&rocket(true), &Some(user))); | |
| 3172 | } | |
| 3173 | ||
| 3174 | #[test] | |
| 3175 | fn a_deploy_key_with_write_access_pushes_to_its_repository() { | |
| 3176 | let user = key(false); | |
| 3177 | assert_eq!(refusal(&user, "acme/rocket", true, true), None); | |
| 3178 | assert!(registry::can_write(&rocket(true), &Some(user))); | |
| 3179 | } | |
| 3180 | ||
| 3181 | #[test] | |
| 3182 | fn a_deploy_key_reaches_no_other_repository() { | |
| 3183 | let user = key(false); | |
| 3184 | for other in ["acme/booster", "other/rocket"] { | |
| 3185 | for write in [false, true] { | |
| 3186 | let why = refusal(&user, other, write, true).expect(other); | |
| 3187 | assert!(why.contains("deploy key is for acme/rocket"), "{why}"); | |
| 3188 | } | |
| 3189 | } | |
| 3190 | // Not even a pull request's working copy of another repository. | |
| 3191 | let token = user.token.as_deref().unwrap(); | |
| 3192 | assert!(git_token_refusal(token, "pulls/pr_1", Some("acme/booster"), false, false, true).is_some()); | |
| 3193 | } | |
| 3194 | ||
| 3195 | #[test] | |
| 3196 | fn a_deploy_key_never_creates_a_repository() { | |
| 3197 | let why = refusal(&key(false), "acme/rocket", true, false).unwrap(); | |
| 3198 | assert!(why.contains("not there"), "{why}"); | |
| 3199 | } | |
| 3200 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.