Skip to content

g1t/services/repos/src/lifecycle.rs

1,717 lines67,297 bytesCodeBlame

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

Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1//! A repository's lifecycle after it is made: renaming it, archiving it,
2//! making it public or private, changing or renaming its default branch,
3//! and deleting it with a window to restore it.
4//!
5//! Deleting is soft. The row gets `deleted_at` and `purge_after`
6//! ([`RESTORE_DAYS`] on), every read in the registry leaves it out, git
7//! refuses it, and `repo.deleted` tells every service to stop what runs for
8//! it and hide it. Restoring clears the columns (`repo.restored`). Purging,
9//! by an owner from the Recently deleted list or by the hourly sweep once
10//! `purge_after` has passed, removes the git data from the store, then the
11//! rows (its pull requests' working copies with it) and its redirects, and
12//! announces `repo.purged`, on which services drop what they keep for it.
13//! Until then its name stays taken, so a restore always has its path back.
14//!
15//! A rename is a path change like a transfer: the old path is kept in
16//! `repo_redirects`, the git store key never changes, the tokens of agents
17//! at work on it are moved with identity, and `repo.renamed` is handled by
18//! services with the same helper as `repo.transferred`
19//! (`g1t_kit::transfer`).
20
21use g1t_contracts::audit::{
22 AuditActor, AuditOutcome, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface,
23};
24use g1t_contracts::events::{
25 BranchRenamed, NewEvent, RepoArchived, RepoDefaultBranchChanged, RepoDeleted, RepoPurged,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member26 RepoRenamed, RepoRestored, RepoUpdated, RepoVisibilityChanged, WorkspaceDeleted,
27 WorkspaceDeleting, WorkspaceRestored,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look28};
29use g1t_contracts::identity::TransferRepoScopesArgs;
30use g1t_contracts::repos::{
31 ArchiveArgs, DeleteArgs, DeletedArgs, DeletedRepo, DeletedRepoArgs, PurgeDueArgs,
32 RESTORE_DAYS, RenameArgs, RenameBranchArgs, Repo, RepoPath, RepoStatus, ResolveBranchArgs,
33 SetDefaultBranchArgs, SetVisibilityArgs, StatusByIdArgs, archived_message,
34 is_valid_branch_name,
35};
36use g1t_contracts::access::{self, Capability, RepoRole};
37use g1t_contracts::time::rfc3339;
38use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, User, is_valid_repo_name, new_id};
39use g1t_kit::now_ms;
40use serde::Deserialize;
41use worker::Result;
42use worker::wasm_bindgen::JsValue;
43
44use crate::registry::{Registry, remember_store, store_key};
45use crate::store::{GitRepo, GitStore, Scope};
46use crate::{Repos, SOURCE, UNVERIFIED, git_ops, land, not_found};
47
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily48use crate::land::EMPTY_PACK;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look49
50/// How many pull request working copies follow a change of the default
51/// branch (newest first). Older ones keep the branch they were made with.
52const FORKS_FOLLOWING: u32 = 100;
53
54/// How many deleted repositories one sweep purges.
55const PURGES_PER_SWEEP: u32 = 25;
56
57type Refusal = (FailureCode, String);
58
59/// Who is asking, as far as an owner's or an admin's action cares.
60#[derive(Clone, Copy, Debug)]
61pub struct Asker {
62 /// A person, not a workspace's or an agent's token.
63 pub person: bool,
64 pub verified: bool,
65 /// Their role in the repository's workspace.
66 pub role: Option<Role>,
67 /// Their role on the repository itself (see g1t_contracts::access).
68 /// None where only the workspace is known, as for deleted ones.
69 pub repo_role: Option<RepoRole>,
70}
71
72impl Asker {
73 pub fn of(user: &User, namespace: &str) -> Self {
74 Asker {
75 person: user.kind == PrincipalKind::User,
76 verified: user.verified,
77 role: user.role_in(&namespace.to_lowercase()),
78 repo_role: None,
79 }
80 }
81
82 /// The asker, with their role on `repo`.
83 pub fn on(user: &User, repo: &Repo) -> Self {
84 Asker {
85 repo_role: crate::registry::role(repo, &Some(user.clone())),
86 ..Asker::of(user, &repo.namespace)
87 }
88 }
89}
90
91/// Whether `asker` may `what` ("rename", "archive"...) a repository of
92/// `namespace` that takes `capability`: a verified person with the role
93/// the permission table asks (Admin), and for transferring and deleting,
94/// an owner of the workspace as well.
95pub fn admin_only(asker: Asker, namespace: &str, what: &str, capability: Capability) -> std::result::Result<(), Refusal> {
96 if access::OWNER_ONLY.contains(&capability) {
97 // Someone who can see the repository is told why, not that it is missing.
98 if asker.role.is_none() && asker.repo_role.is_some() && asker.person {
99 return Err((
100 FailureCode::Forbidden,
101 format!("Only an owner of {namespace} can {what} its repositories."),
102 ));
103 }
104 return owner_only(asker, namespace, what);
105 }
106 if asker.role.is_none() && asker.repo_role.is_none() {
107 return Err((FailureCode::NotFound, "Repository not found.".into()));
108 }
109 if !asker.person {
110 return Err((
111 FailureCode::Forbidden,
112 format!("Only a person can {what} a repository. Sign in, or use a personal access token."),
113 ));
114 }
115 if !asker.repo_role.is_some_and(|role| access::allows(role, capability)) {
116 return Err((
117 FailureCode::Forbidden,
118 format!(
119 "You need the {} role on a repository of {namespace} to {what} it.",
120 access::least_role(capability).label()
121 ),
122 ));
123 }
124 if !asker.verified {
125 return Err((FailureCode::Forbidden, UNVERIFIED.into()));
126 }
127 Ok(())
128}
129
130/// Whether `asker` may `what` ("delete", "rename"...) a repository of
131/// `namespace`: a verified person who owns the workspace.
132pub fn owner_only(asker: Asker, namespace: &str, what: &str) -> std::result::Result<(), Refusal> {
133 if asker.role.is_none() {
134 return Err((FailureCode::NotFound, "Repository not found.".into()));
135 }
136 if !asker.person {
137 return Err((
138 FailureCode::Forbidden,
139 format!("Only a person can {what} a repository. Sign in, or use a personal access token."),
140 ));
141 }
142 if asker.role != Some(Role::Owner) {
143 return Err((
144 FailureCode::Forbidden,
145 format!("Only an owner of {namespace} can {what} its repositories."),
146 ));
147 }
148 if !asker.verified {
149 return Err((FailureCode::Forbidden, UNVERIFIED.into()));
150 }
151 Ok(())
152}
153
154/// Whether what was typed to confirm names the repository: its full name,
155/// `namespace/name`, in any case.
156pub fn confirmed(path: &RepoPath, typed: &str) -> bool {
157 typed.trim().to_lowercase() == format!("{}/{}", path.namespace, path.name).to_lowercase()
158}
159
160fn confirm_refusal(path: &RepoPath) -> Refusal {
161 (
162 FailureCode::Invalid,
163 format!("Type {}/{} to confirm.", path.namespace, path.name),
164 )
165}
166
167/// When a repository deleted at `now_ms` is purged.
168pub fn purge_after(now_ms: u64) -> String {
169 rfc3339(now_ms + RESTORE_DAYS * 86_400_000)
170}
171
172/// Whether a repository to be purged at `purge_after` can still be
173/// restored at `now` (both RFC 3339, which compare as text).
174pub fn restorable(purge_after: &str, now: &str) -> bool {
175 now < purge_after
176}
177
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member178/// Whether a workspace's repositories may go with it: never a protected
179/// workspace's (`protected` is `g1t_contracts::identity::protected_names`),
180/// however the event came to be published.
181pub fn may_go_with_workspace(namespace: &str, protected: &[String]) -> std::result::Result<(), String> {
182 if protected.iter().any(|name| name.eq_ignore_ascii_case(namespace)) {
183 return Err(g1t_contracts::identity::protected_refusal(namespace));
184 }
185 Ok(())
186}
187
188/// Whether a deleted repository comes back when the workspace
189/// `workspace_id` is restored: only if it went with that workspace
190/// (`deleted_with`), not if it was deleted on its own before.
191pub fn comes_back_with(deleted_with: Option<&str>, workspace_id: &str) -> bool {
192 deleted_with == Some(workspace_id)
193}
194
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look195/// Where a repository is in its life, from its row.
196#[derive(Clone, Copy, Debug, PartialEq, Eq)]
197pub enum State {
198 Active,
199 Archived,
200 /// Deleted, and restorable until purged.
201 Deleted,
202 /// Deleted, and due to be purged by the next sweep.
203 Due,
204}
205
206pub fn state(archived_at: Option<&str>, deleted: Option<(&str, &str)>, now: &str) -> State {
207 match deleted {
208 Some((_, purge_after)) if !restorable(purge_after, now) => State::Due,
209 Some(_) => State::Deleted,
210 None if archived_at.is_some() => State::Archived,
211 None => State::Active,
212 }
213}
214
215/// What holds a name in a workspace.
216#[derive(Clone, Copy, Debug, PartialEq, Eq)]
217pub enum Held {
218 Free,
219 ByRepo,
220 /// A repository deleted but not yet purged.
221 ByDeleted,
222}
223
224/// The name a repository at `current` can be renamed to, tidied, or why
225/// not.
226pub fn new_name(namespace: &str, current: &str, wanted: &str, held: Held) -> std::result::Result<String, Refusal> {
227 let name = wanted.trim().to_lowercase();
228 if !is_valid_repo_name(&name) {
229 return Err((
230 FailureCode::Invalid,
231 "Use letters, digits, dots, hyphens and underscores only.".into(),
232 ));
233 }
234 if name == current {
235 return Err((FailureCode::Invalid, format!("It is already called {name}.")));
236 }
237 match held {
238 Held::Free => Ok(name),
239 Held::ByRepo => Err((
240 FailureCode::Conflict,
241 format!("{namespace} already has a repository named {name}."),
242 )),
243 Held::ByDeleted => Err((
244 FailureCode::Conflict,
245 format!(
246 "{namespace}/{name} was deleted recently and can still be restored. Restore it and rename it, or delete it permanently from the workspace's Recently deleted list first."
247 ),
248 )),
249 }
250}
251
252/// Why a write to `repo` is refused because it is archived, if it is.
253pub fn archived_refusal(repo: &Repo) -> Option<Refusal> {
254 repo.archived()
255 .then(|| (FailureCode::Forbidden, archived_message(&repo.namespace, &repo.name)))
256}
257
258/// Everything that decides whether a repository may go private or public.
259#[derive(Debug, Default)]
260pub struct VisibilityFacts {
261 pub to_private: bool,
262 /// The workspace is on no plan, so its private storage is capped.
263 pub free: bool,
264 /// What its private repositories hold now.
265 pub private_bytes: i64,
266 /// What this repository holds.
267 pub bytes: i64,
268 /// What a free workspace's private repositories may hold.
269 pub free_private_bytes: i64,
270}
271
272/// Whether the visibility change `facts` describe may happen.
273pub fn visibility_check(namespace: &str, facts: &VisibilityFacts) -> std::result::Result<(), Refusal> {
274 if facts.to_private
275 && facts.free
276 && git_ops::storage_full(facts.private_bytes + facts.bytes, facts.free_private_bytes)
277 {
278 return Err((
279 FailureCode::PaymentRequired,
280 format!(
281 "{namespace}'s private repositories would hold {:.2} GB, more than the {:.0} GB a free workspace has. Start the g1t plan in {namespace}, or keep the repository public.",
282 (facts.private_bytes + facts.bytes) as f64 / 1e9,
283 facts.free_private_bytes as f64 / 1e9,
284 ),
285 ));
286 }
287 Ok(())
288}
289
290/// Whether `from` can be renamed to `to` in a repository whose branches
291/// are `branches`. `to` is tidied of surrounding space.
292pub fn branch_rename(from: &str, to: &str, branches: &[String]) -> std::result::Result<String, Refusal> {
293 let to = to.trim().to_owned();
294 if !branches.iter().any(|branch| branch == from) {
295 return Err((FailureCode::NotFound, format!("There is no branch named {from}.")));
296 }
297 if !is_valid_branch_name(&to) {
298 return Err((
299 FailureCode::Invalid,
300 format!("{to:?} cannot be a branch name. Use letters, digits, '/', '-', '_' and '.', and no spaces."),
301 ));
302 }
303 if to == from {
304 return Err((FailureCode::Invalid, format!("It is already called {to}.")));
305 }
306 if branches.contains(&to) {
307 return Err((FailureCode::Conflict, format!("There is already a branch named {to}.")));
308 }
309 Ok(to)
310}
311
312/// The row of a deleted repository.
313#[derive(Deserialize)]
314struct DeletedRow {
315 id: String,
316 namespace: String,
317 name: String,
318 description: Option<String>,
319 is_private: u8,
320 deleted_at: String,
321 #[serde(default)]
322 deleted_by: Option<String>,
323 purge_after: String,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member324 #[serde(default)]
325 deleted_with: Option<String>,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look326}
327
328impl From<DeletedRow> for DeletedRepo {
329 fn from(row: DeletedRow) -> Self {
330 DeletedRepo {
331 id: row.id,
332 namespace: row.namespace,
333 name: row.name,
334 description: row.description,
335 is_private: row.is_private != 0,
336 deleted_at: row.deleted_at,
337 deleted_by: row.deleted_by.unwrap_or_default(),
338 purge_after: row.purge_after,
339 }
340 }
341}
342
343const DELETED_COLUMNS: &str =
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member344 "id, namespace, name, description, is_private, deleted_at, deleted_by, purge_after, deleted_with";
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look345
346impl Registry {
347 /// Deletes a repository and its pull requests' working copies, softly.
348 pub async fn soft_delete(&self, id: &str, by: &str, at: &str, purge_after: &str) -> Result<()> {
349 self.db
350 .prepare(
351 "UPDATE repos SET deleted_at = ?1, deleted_by = ?2, purge_after = ?3
352 WHERE (id = ?4 OR fork_of = ?4) AND deleted_at IS NULL",
353 )
354 .bind(&[at.into(), by.into(), purge_after.into(), id.into()])?
355 .run()
356 .await?;
357 Ok(())
358 }
359
360 /// Brings a deleted repository and its working copies back.
361 pub async fn undelete(&self, id: &str) -> Result<()> {
362 self.db
363 .prepare(
364 "UPDATE repos SET deleted_at = NULL, deleted_by = NULL, purge_after = NULL
365 WHERE id = ?1 OR fork_of = ?1",
366 )
367 .bind(&[id.into()])?
368 .run()
369 .await?;
370 Ok(())
371 }
372
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member373 /// Deletes every live repository of `namespace`, and the working
374 /// copies of its repositories, softly, each marked as gone with the
375 /// workspace `workspace_id`. Those already deleted are left as they
376 /// are. Returns every repository so marked, this time or before, so a
377 /// delivery again tells services again.
378 pub async fn delete_with_workspace(
379 &self,
380 namespace: &str,
381 workspace_id: &str,
382 by: &str,
383 at: &str,
384 purge_after: &str,
385 ) -> Result<Vec<DeletedRepo>> {
386 self.db
387 .prepare(
388 "UPDATE repos SET deleted_at = ?1, deleted_by = ?2, purge_after = ?3, deleted_with = ?4
389 WHERE deleted_at IS NULL
390 AND (namespace = ?5 OR fork_of IN (SELECT id FROM repos WHERE namespace = ?5))",
391 )
392 .bind(&[at.into(), by.into(), purge_after.into(), workspace_id.into(), namespace.into()])?
393 .run()
394 .await?;
395 self.deleted_with(workspace_id).await
396 }
397
398 /// The repositories deleted with the workspace `workspace_id`.
399 pub async fn deleted_with(&self, workspace_id: &str) -> Result<Vec<DeletedRepo>> {
400 Ok(self
401 .db
402 .prepare(format!(
403 "SELECT {DELETED_COLUMNS} FROM repos WHERE deleted_with = ? AND fork_of IS NULL"
404 ))
405 .bind(&[workspace_id.into()])?
406 .all()
407 .await?
408 .results::<DeletedRow>()?
409 .into_iter()
410 .filter(|row| comes_back_with(row.deleted_with.as_deref(), workspace_id))
411 .map(DeletedRepo::from)
412 .collect())
413 }
414
415 /// Brings back the repositories, and their working copies, deleted
416 /// with the workspace `workspace_id`, and only those.
417 pub async fn undelete_with_workspace(&self, workspace_id: &str) -> Result<()> {
418 self.db
419 .prepare(
420 "UPDATE repos SET deleted_at = NULL, deleted_by = NULL, purge_after = NULL, deleted_with = NULL
421 WHERE deleted_with = ?",
422 )
423 .bind(&[workspace_id.into()])?
424 .run()
425 .await?;
426 Ok(())
427 }
428
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look429 /// A workspace's deleted repositories, newest first.
430 pub async fn deleted_in(&self, namespace: &str) -> Result<Vec<DeletedRepo>> {
431 Ok(self
432 .db
433 .prepare(format!(
434 "SELECT {DELETED_COLUMNS} FROM repos
435 WHERE namespace = ? AND deleted_at IS NOT NULL AND fork_of IS NULL
436 ORDER BY deleted_at DESC LIMIT 200"
437 ))
438 .bind(&[namespace.to_lowercase().into()])?
439 .all()
440 .await?
441 .results::<DeletedRow>()?
442 .into_iter()
443 .map(DeletedRepo::from)
444 .collect())
445 }
446
447 /// The deleted repository at `path`, if that is what holds it.
448 pub async fn deleted_at(&self, path: &RepoPath) -> Result<Option<DeletedRepo>> {
449 Ok(self
450 .db
451 .prepare(format!(
452 "SELECT {DELETED_COLUMNS} FROM repos
453 WHERE namespace = ? AND name = ? AND deleted_at IS NOT NULL AND fork_of IS NULL"
454 ))
455 .bind(&[
456 path.namespace.to_lowercase().into(),
457 path.name.to_lowercase().into(),
458 ])?
459 .first::<DeletedRow>(None)
460 .await?
461 .map(DeletedRepo::from))
462 }
463
464 /// Deleted repositories whose time to be restored has passed.
465 pub async fn due(&self, now: &str, limit: u32) -> Result<Vec<DeletedRepo>> {
466 Ok(self
467 .db
468 .prepare(format!(
469 "SELECT {DELETED_COLUMNS} FROM repos
470 WHERE deleted_at IS NOT NULL AND purge_after <= ? AND fork_of IS NULL
471 ORDER BY purge_after LIMIT ?"
472 ))
473 .bind(&[now.into(), limit.into()])?
474 .all()
475 .await?
476 .results::<DeletedRow>()?
477 .into_iter()
478 .map(DeletedRepo::from)
479 .collect())
480 }
481
482 /// Every deleted repository left in a workspace, for when the
483 /// workspace itself is deleted.
484 pub async fn deleted_ids_in(&self, namespace: &str) -> Result<Vec<DeletedRepo>> {
485 self.deleted_in(namespace).await
486 }
487
488 /// The git store keys of a repository and of its working copies.
489 pub async fn store_keys(&self, id: &str) -> Result<Vec<String>> {
490 #[derive(Deserialize)]
491 struct Row {
492 namespace: String,
493 name: String,
494 #[serde(default)]
495 store: Option<String>,
496 }
497 Ok(self
498 .db
499 .prepare("SELECT namespace, name, store FROM repos WHERE id = ?1 OR fork_of = ?1")
500 .bind(&[id.into()])?
501 .all()
502 .await?
503 .results::<Row>()?
504 .into_iter()
505 .map(|row| row.store.unwrap_or_else(|| format!("{}--{}", row.namespace, row.name)))
506 .collect())
507 }
508
509 /// Forgets a purged repository: its rows, its working copies' rows and
510 /// every redirect to it.
511 pub async fn erase(&self, id: &str) -> Result<()> {
512 self.db
513 .batch(vec![
514 self.db.prepare("DELETE FROM repos WHERE fork_of = ?1").bind(&[id.into()])?,
515 self.db.prepare("DELETE FROM repos WHERE id = ?1").bind(&[id.into()])?,
516 self.db
517 .prepare("DELETE FROM repo_redirects WHERE repo_id = ?1")
518 .bind(&[id.into()])?,
519 self.db
520 .prepare("DELETE FROM branch_redirects WHERE repo_id = ?1")
521 .bind(&[id.into()])?,
Merge branch 'main' into worktree-agent-a69aeabc4b0deeb97522 // Its About, stars and releases (about.rs).
523 self.db.prepare("DELETE FROM repo_stats WHERE repo_id = ?1").bind(&[id.into()])?,
524 self.db.prepare("DELETE FROM repo_stars WHERE repo_id = ?1").bind(&[id.into()])?,
525 self.db.prepare("DELETE FROM releases WHERE repo_id = ?1").bind(&[id.into()])?,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look526 ])
527 .await?;
528 Ok(())
529 }
530
531 /// Renames a repository, keeping its old path as a redirect. Any
532 /// redirect held by the new path gives way.
533 pub async fn rename(&self, repo: &Repo, name: &str) -> Result<()> {
534 let now = rfc3339(now_ms());
535 self.db
536 .batch(vec![
537 self.db
538 .prepare("UPDATE repos SET name = ? WHERE id = ? AND name = ?")
539 .bind(&[name.into(), repo.id.as_str().into(), repo.name.as_str().into()])?,
540 self.db
541 .prepare("DELETE FROM repo_redirects WHERE namespace = ? AND name = ?")
542 .bind(&[repo.namespace.as_str().into(), name.into()])?,
543 self.db
544 .prepare(
545 "INSERT OR REPLACE INTO repo_redirects (namespace, name, repo_id, created_at)
546 VALUES (?, ?, ?, ?)",
547 )
548 .bind(&[
549 repo.namespace.as_str().into(),
550 repo.name.as_str().into(),
551 repo.id.as_str().into(),
552 now.as_str().into(),
553 ])?,
554 ])
555 .await?;
556 Ok(())
557 }
558
559 pub async fn set_archived(&self, id: &str, at: Option<&str>) -> Result<()> {
560 self.db
561 .prepare("UPDATE repos SET archived_at = ? WHERE id = ?")
562 .bind(&[at.map_or(JsValue::NULL, JsValue::from), id.into()])?
563 .run()
564 .await?;
565 Ok(())
566 }
567
568 /// Makes a repository and its working copies public or private.
569 pub async fn set_private(&self, id: &str, private: bool) -> Result<()> {
570 self.db
571 .prepare("UPDATE repos SET is_private = ?1 WHERE id = ?2 OR fork_of = ?2")
572 .bind(&[u32::from(private).into(), id.into()])?
573 .run()
574 .await?;
575 Ok(())
576 }
577
578 pub async fn set_default_branch(&self, id: &str, branch: &str) -> Result<()> {
579 self.db
580 .prepare("UPDATE repos SET default_branch = ? WHERE id = ?")
581 .bind(&[branch.into(), id.into()])?
582 .run()
583 .await?;
584 Ok(())
585 }
586
587 /// Working copies of a repository, newest first, at most `limit`.
588 pub async fn forks_of(&self, id: &str, limit: u32) -> Result<Vec<Repo>> {
589 let rows = self
590 .db
591 .prepare(
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily592 "SELECT * FROM repos WHERE fork_of = ? AND deleted_at IS NULL AND retired_at IS NULL
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look593 ORDER BY created_at DESC LIMIT ?",
594 )
595 .bind(&[id.into(), limit.into()])?
596 .all()
597 .await?
598 .results::<crate::registry::RepoRow>()?;
599 Ok(rows.into_iter().map(Repo::from).collect())
600 }
601
602 /// Records that `from` is now called `to`. Redirects that pointed at
603 /// `from` point at `to`, and one held by `to` itself ends.
604 pub async fn add_branch_redirect(&self, repo_id: &str, from: &str, to: &str) -> Result<()> {
605 let now = rfc3339(now_ms());
606 self.db
607 .batch(vec![
608 self.db
609 .prepare("DELETE FROM branch_redirects WHERE repo_id = ? AND branch = ?")
610 .bind(&[repo_id.into(), to.into()])?,
611 self.db
612 .prepare("UPDATE branch_redirects SET now = ? WHERE repo_id = ? AND now = ?")
613 .bind(&[to.into(), repo_id.into(), from.into()])?,
614 self.db
615 .prepare(
616 "INSERT OR REPLACE INTO branch_redirects (repo_id, branch, now, created_at)
617 VALUES (?, ?, ?, ?)",
618 )
619 .bind(&[repo_id.into(), from.into(), to.into(), now.as_str().into()])?,
620 ])
621 .await?;
622 Ok(())
623 }
624
625 pub async fn branch_redirect(&self, repo_id: &str, branch: &str) -> Result<Option<String>> {
626 #[derive(Deserialize)]
627 struct Row {
628 now: String,
629 }
630 Ok(self
631 .db
632 .prepare("SELECT now FROM branch_redirects WHERE repo_id = ? AND branch = ?")
633 .bind(&[repo_id.into(), branch.into()])?
634 .first::<Row>(None)
635 .await?
636 .map(|row| row.now))
637 }
638
639 /// Whether a repository is archived or deleted; unknown is deleted.
640 pub async fn status(&self, id: &str) -> Result<RepoStatus> {
641 #[derive(Deserialize)]
642 struct Row {
643 #[serde(default)]
644 archived_at: Option<String>,
645 #[serde(default)]
646 deleted_at: Option<String>,
647 }
648 Ok(self
649 .db
650 .prepare("SELECT archived_at, deleted_at FROM repos WHERE id = ?")
651 .bind(&[id.into()])?
652 .first::<Row>(None)
653 .await?
654 .map_or(
655 RepoStatus {
656 archived: false,
657 deleted: true,
658 },
659 |row| RepoStatus {
660 archived: row.archived_at.is_some(),
661 deleted: row.deleted_at.is_some(),
662 },
663 ))
664 }
665}
666
667/// An audit entry for something done to a repository.
668fn entry(actor: AuditActor, action: &str, surface: Option<Surface>, path: &RepoPath, rule: &str, message: String) -> NewAuditEntry {
669 NewAuditEntry {
670 actor,
671 action: action.to_owned(),
672 surface: surface.unwrap_or(Surface::Web),
673 target: AuditTarget {
674 workspace: path.namespace.clone(),
675 repo: Some(format!("{}/{}", path.namespace, path.name)),
676 ..AuditTarget::default()
677 },
678 outcome: AuditOutcome::Allowed,
679 rule: rule.to_owned(),
680 result: Some("ok".to_owned()),
681 message: Some(message),
682 request_id: new_id("req", now_ms()),
683 }
684}
685
686/// g1t itself, as the actor of what its schedule does.
687fn g1t_actor() -> AuditActor {
Git storage hardened, pages in tens of milliseconds, honest security alerts, and costs reconciled daily688 AuditActor::system()
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look689}
690
691fn fail<T>((code, message): Refusal) -> Outcome<T> {
692 Outcome::fail(code, message)
693}
694
695fn path_of(repo: &Repo) -> RepoPath {
696 RepoPath {
697 namespace: repo.namespace.clone(),
698 name: repo.name.clone(),
699 }
700}
701
702impl<S: GitStore> Repos<S> {
703 pub(crate) async fn record(&self, entries: Vec<NewAuditEntry>) {
704 let recorded: Result<u32> =
705 g1t_kit::call(&self.events, "audit_record", &RecordAuditArgs { entries }).await;
706 if let Err(error) = recorded {
707 worker::console_error!("audit entries not recorded: {error}");
708 }
709 }
710
711 /// The repository at `path` an admin is acting on: found, not a
712 /// working copy, and the actor allowed `capability` on it (Admin, and
713 /// for deleting, an owner of its workspace).
714 async fn owned(
715 &self,
716 actor: &User,
717 path: &RepoPath,
718 what: &str,
719 capability: Capability,
720 ) -> Result<std::result::Result<Repo, Refusal>> {
721 let viewer = Some(actor.clone());
722 let Some(repo) = self.readable(path, &viewer).await? else {
723 return Ok(Err((FailureCode::NotFound, "Repository not found.".into())));
724 };
725 if repo.fork_of.is_some() {
726 return Ok(Err((FailureCode::NotFound, "Repository not found.".into())));
727 }
728 if let Err(refusal) = admin_only(Asker::on(actor, &repo), &repo.namespace, what, capability) {
729 return Ok(Err(refusal));
730 }
731 Ok(Ok(repo))
732 }
733
734 /// `delete`: see `g1t_contracts::repos::DeleteArgs`.
735 pub(crate) async fn delete(&self, a: DeleteArgs) -> Result<Outcome<DeletedRepo>> {
736 let repo = match self.owned(&a.actor, &a.path, "delete", Capability::Delete).await? {
737 Ok(repo) => repo,
738 Err(refusal) => return Ok(fail(refusal)),
739 };
740 let path = path_of(&repo);
741 if !confirmed(&path, &a.confirm) {
742 return Ok(fail(confirm_refusal(&path)));
743 }
744 let now = now_ms();
745 let at = rfc3339(now);
746 let purge = purge_after(now);
747 self.registry
748 .soft_delete(&repo.id, &a.actor.username, &at, &purge)
749 .await?;
750 self.publish(NewEvent {
751 kind: "repo.deleted",
752 source: SOURCE,
753 repo_id: Some(repo.id.clone()),
754 actor: Some(a.actor.id.clone()),
755 data: RepoDeleted {
756 repo_id: repo.id.clone(),
757 namespace: repo.namespace.clone(),
758 name: repo.name.clone(),
759 is_private: repo.is_private,
760 purge_after: purge.clone(),
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member761 with_workspace: false,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look762 },
763 })
764 .await?;
765 self.record(vec![entry(
766 AuditActor::of(&a.actor),
767 "repo.deleted",
768 a.surface,
769 &path,
770 "owner",
771 format!("Deleted; restorable until {purge}"),
772 )])
773 .await;
774 Ok(Outcome::Ok(DeletedRepo {
775 id: repo.id,
776 namespace: repo.namespace,
777 name: repo.name,
778 description: repo.description,
779 is_private: repo.is_private,
780 deleted_at: at,
781 deleted_by: a.actor.username,
782 purge_after: purge,
783 }))
784 }
785
786 /// `deleted`: see `g1t_contracts::repos::DeletedArgs`.
787 pub(crate) async fn deleted(&self, a: DeletedArgs) -> Result<Vec<DeletedRepo>> {
788 let namespace = a.namespace.to_lowercase();
789 let owner = a
790 .viewer
791 .as_ref()
792 .is_some_and(|user| user.role_in(&namespace) == Some(Role::Owner));
793 if !owner {
794 return Ok(Vec::new());
795 }
796 self.registry.deleted_in(&namespace).await
797 }
798
799 /// The deleted repository an owner is acting on.
800 async fn owned_deleted(
801 &self,
802 actor: &User,
803 path: &RepoPath,
804 what: &str,
805 ) -> Result<std::result::Result<DeletedRepo, Refusal>> {
806 if let Err(refusal) = owner_only(Asker::of(actor, &path.namespace), &path.namespace.to_lowercase(), what) {
807 return Ok(Err(refusal));
808 }
809 Ok(match self.registry.deleted_at(path).await? {
810 Some(deleted) => Ok(deleted),
811 None => Err((
812 FailureCode::NotFound,
813 format!(
814 "{}/{} is not among the workspace's recently deleted repositories.",
815 path.namespace, path.name
816 ),
817 )),
818 })
819 }
820
821 /// `restore`: see `g1t_contracts::repos::DeletedRepoArgs`.
822 pub(crate) async fn restore(&self, a: DeletedRepoArgs) -> Result<Outcome<Repo>> {
823 let deleted = match self.owned_deleted(&a.actor, &a.path, "restore").await? {
824 Ok(deleted) => deleted,
825 Err(refusal) => return Ok(fail(refusal)),
826 };
827 let deleted_state = state(
828 None,
829 Some((&deleted.deleted_at, &deleted.purge_after)),
830 &rfc3339(now_ms()),
831 );
832 if deleted_state == State::Due {
833 return Ok(Outcome::fail(
834 FailureCode::Conflict,
835 format!("{}/{} is being purged and can no longer be restored.", deleted.namespace, deleted.name),
836 ));
837 }
838 self.registry.undelete(&deleted.id).await?;
839 let Some(repo) = self.registry.by_id(&deleted.id).await? else {
840 return Ok(not_found());
841 };
842 self.publish(NewEvent {
843 kind: "repo.restored",
844 source: SOURCE,
845 repo_id: Some(repo.id.clone()),
846 actor: Some(a.actor.id.clone()),
847 data: RepoRestored {
848 repo_id: repo.id.clone(),
849 namespace: repo.namespace.clone(),
850 name: repo.name.clone(),
851 is_private: repo.is_private,
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member852 with_workspace: false,
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look853 },
854 })
855 .await?;
856 self.record(vec![entry(
857 AuditActor::of(&a.actor),
858 "repo.restored",
859 a.surface,
860 &path_of(&repo),
861 "owner",
862 format!("Restored; deleted by {} at {}", deleted.deleted_by, deleted.deleted_at),
863 )])
864 .await;
865 Ok(Outcome::Ok(repo))
866 }
867
868 /// `purge`: see `g1t_contracts::repos::DeletedRepoArgs`.
869 pub(crate) async fn purge(&self, a: DeletedRepoArgs) -> Result<Outcome<bool>> {
870 let deleted = match self.owned_deleted(&a.actor, &a.path, "permanently delete").await? {
871 Ok(deleted) => deleted,
872 Err(refusal) => return Ok(fail(refusal)),
873 };
874 let path = RepoPath {
875 namespace: deleted.namespace.clone(),
876 name: deleted.name.clone(),
877 };
878 if !confirmed(&path, a.confirm.as_deref().unwrap_or_default()) {
879 return Ok(fail(confirm_refusal(&path)));
880 }
881 self.purge_now(&deleted, Some(&a.actor.id)).await?;
882 self.record(vec![entry(
883 AuditActor::of(&a.actor),
884 "repo.purged",
885 a.surface,
886 &path,
887 "owner",
888 "Permanently deleted, with its git data".to_owned(),
889 )])
890 .await;
891 Ok(Outcome::Ok(true))
892 }
893
894 /// Removes a deleted repository for good: git data first, so a failure
895 /// leaves it to the next sweep, then its rows; then says so.
896 async fn purge_now(&self, deleted: &DeletedRepo, actor: Option<&str>) -> Result<()> {
897 for key in self.registry.store_keys(&deleted.id).await? {
898 self.store.delete(&key).await?;
899 }
900 self.registry.erase(&deleted.id).await?;
901 // Who had access to it goes with it.
902 if let Some(identity) = &self.identity {
903 let forgotten: Result<bool> = g1t_kit::call(
904 identity,
905 "forget_repo_access",
906 &g1t_contracts::access::ForgetRepoAccessArgs {
907 repo_id: deleted.id.clone(),
908 },
909 )
910 .await;
911 if let Err(error) = forgotten {
912 worker::console_error!("access to {} not forgotten: {error}", deleted.id);
913 }
914 }
915 self.publish(NewEvent {
916 kind: "repo.purged",
917 source: SOURCE,
918 repo_id: Some(deleted.id.clone()),
919 actor: actor.map(str::to_owned),
920 data: RepoPurged {
921 repo_id: deleted.id.clone(),
922 namespace: deleted.namespace.clone(),
923 name: deleted.name.clone(),
924 },
925 })
926 .await
927 }
928
929 /// `purge_due`: see `g1t_contracts::repos::PurgeDueArgs`.
930 pub(crate) async fn purge_due(&self, a: PurgeDueArgs) -> Result<u32> {
931 let limit = a.limit.unwrap_or(PURGES_PER_SWEEP).clamp(1, 100);
932 let due = self.registry.due(&rfc3339(now_ms()), limit).await?;
933 let mut purged = 0;
934 for deleted in due {
935 match self.purge_now(&deleted, None).await {
936 Ok(()) => {
937 purged += 1;
938 let path = RepoPath {
939 namespace: deleted.namespace.clone(),
940 name: deleted.name.clone(),
941 };
942 self.record(vec![entry(
943 g1t_actor(),
944 "repo.purged",
945 None,
946 &path,
947 "schedule",
948 format!("Purged {RESTORE_DAYS} days after {} deleted it", deleted.deleted_by),
949 )])
950 .await;
951 }
952 Err(error) => worker::console_error!("{} not purged: {error}", deleted.id),
953 }
954 }
955 Ok(purged)
956 }
957
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member958 /// `workspace.deleting`: every live repository of the workspace is
959 /// deleted with it, marked so its restore brings back exactly these,
960 /// and `repo.deleted` (with `with_workspace`) tells services to stop
961 /// what runs for each and hide it. They are purged with the workspace,
962 /// or by the sweep at the same `purge_after`. Never for a protected
963 /// workspace, whoever published it.
964 pub(crate) async fn delete_with_workspace(&self, deleting: &WorkspaceDeleting, protected: &[String]) -> Result<()> {
965 let namespace = deleting.slug.to_lowercase();
966 if let Err(why) = may_go_with_workspace(&namespace, protected) {
967 worker::console_error!("workspace.deleting for {namespace} ignored: {why}");
968 return Ok(());
969 }
970 let marked = self
971 .registry
972 .delete_with_workspace(
973 &namespace,
974 &deleting.workspace_id,
975 &deleting.by,
976 &rfc3339(now_ms()),
977 &deleting.purge_after,
978 )
979 .await?;
980 for repo in marked {
981 self.publish(NewEvent {
982 kind: "repo.deleted",
983 source: SOURCE,
984 repo_id: Some(repo.id.clone()),
985 actor: None,
986 data: RepoDeleted {
987 repo_id: repo.id.clone(),
988 namespace: repo.namespace.clone(),
989 name: repo.name.clone(),
990 is_private: repo.is_private,
991 purge_after: repo.purge_after.clone(),
992 with_workspace: true,
993 },
994 })
995 .await?;
996 }
997 Ok(())
998 }
999
1000 /// `workspace.restored`: the repositories deleted with the workspace
1001 /// come back, and `repo.restored` (with `with_workspace`) says so for
1002 /// each. Ones deleted on their own before stay deleted.
1003 pub(crate) async fn restore_with_workspace(&self, restored: &WorkspaceRestored) -> Result<()> {
1004 let marked = self.registry.deleted_with(&restored.workspace_id).await?;
1005 self.registry.undelete_with_workspace(&restored.workspace_id).await?;
1006 for deleted in marked {
1007 let Some(repo) = self.registry.by_id(&deleted.id).await? else {
1008 continue;
1009 };
1010 self.publish(NewEvent {
1011 kind: "repo.restored",
1012 source: SOURCE,
1013 repo_id: Some(repo.id.clone()),
1014 actor: None,
1015 data: RepoRestored {
1016 repo_id: repo.id.clone(),
1017 namespace: repo.namespace.clone(),
1018 name: repo.name.clone(),
1019 is_private: repo.is_private,
1020 with_workspace: true,
1021 },
1022 })
1023 .await?;
1024 }
1025 Ok(())
1026 }
1027
1028 /// `workspace.deleted`: the workspace is purged, and every repository
1029 /// it had goes with it: those deleted with it, those deleted before,
1030 /// and any still live (a `workspace.deleting` that never arrived).
1031 pub(crate) async fn purge_workspace(&self, deleted: &WorkspaceDeleted, protected: &[String]) -> Result<()> {
1032 let namespace = deleted.slug.to_lowercase();
1033 if let Err(why) = may_go_with_workspace(&namespace, protected) {
1034 worker::console_error!("workspace.deleted for {namespace} ignored: {why}");
1035 return Ok(());
1036 }
1037 let now = rfc3339(now_ms());
1038 self.registry
1039 .delete_with_workspace(&namespace, &deleted.workspace_id, "g1t", &now, &now)
1040 .await?;
1041 // A page at a time; one that fails is left to the hourly sweep.
1042 loop {
1043 let page = self.registry.deleted_ids_in(&namespace).await?;
1044 let mut purged = 0;
1045 for repo in &page {
1046 match self.purge_now(repo, None).await {
1047 Ok(()) => purged += 1,
1048 Err(error) => worker::console_error!("{} not purged with its workspace: {error}", repo.id),
1049 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1050 }
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1051 if purged == 0 {
1052 break;
1053 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1054 }
1055 Ok(())
1056 }
1057
1058 /// `rename`: see `g1t_contracts::repos::RenameArgs`.
1059 pub(crate) async fn rename(&self, a: RenameArgs) -> Result<Outcome<Repo>> {
1060 let repo = match self.owned(&a.actor, &a.path, "rename", Capability::Administer).await? {
1061 Ok(repo) => repo,
1062 Err(refusal) => return Ok(fail(refusal)),
1063 };
1064 let wanted = RepoPath {
1065 namespace: repo.namespace.clone(),
1066 name: a.name.trim().to_lowercase(),
1067 };
1068 let held = match self.registry.by_path_any(&wanted).await? {
1069 None => Held::Free,
1070 Some((_, None)) => Held::ByRepo,
1071 Some((_, Some(_))) => Held::ByDeleted,
1072 };
1073 let name = match new_name(&repo.namespace, &repo.name, &a.name, held) {
1074 Ok(name) => name,
1075 Err(refusal) => return Ok(fail(refusal)),
1076 };
1077 // The git store key stays what it was; the new path must not
1078 // change where it is read from.
1079 let key = store_key(&repo);
1080 self.registry.rename(&repo, &name).await?;
1081 let renamed = Repo {
1082 name: name.clone(),
1083 ..repo.clone()
1084 };
1085 remember_store(&renamed, &key);
1086 let from = path_of(&repo);
1087 let to = path_of(&renamed);
1088 if let Some(identity) = &self.identity {
1089 let moved: Result<bool> = g1t_kit::call(
1090 identity,
1091 "transfer_repo_scopes",
1092 &TransferRepoScopesArgs {
1093 from: from.clone(),
1094 to: to.clone(),
1095 },
1096 )
1097 .await;
1098 if let Err(error) = moved {
1099 worker::console_error!("agent scopes for {} not moved: {error}", repo.id);
1100 }
1101 }
1102 self.publish(NewEvent {
1103 kind: "repo.renamed",
1104 source: SOURCE,
1105 repo_id: Some(repo.id.clone()),
1106 actor: Some(a.actor.id.clone()),
1107 data: RepoRenamed {
1108 repo_id: repo.id.clone(),
1109 namespace: repo.namespace.clone(),
1110 from: repo.name.clone(),
1111 to: name.clone(),
1112 },
1113 })
1114 .await?;
1115 self.record(vec![entry(
1116 AuditActor::of(&a.actor),
1117 "repo.renamed",
1118 a.surface,
1119 &to,
1120 "owner",
1121 format!("Renamed from {}/{}", from.namespace, from.name),
1122 )])
1123 .await;
1124 Ok(Outcome::Ok(renamed))
1125 }
1126
1127 /// `archive`: see `g1t_contracts::repos::ArchiveArgs`.
1128 pub(crate) async fn archive(&self, a: ArchiveArgs) -> Result<Outcome<Repo>> {
1129 let what = if a.archived { "archive" } else { "unarchive" };
1130 let repo = match self.owned(&a.actor, &a.path, what, Capability::Administer).await? {
1131 Ok(repo) => repo,
1132 Err(refusal) => return Ok(fail(refusal)),
1133 };
1134 if repo.archived() == a.archived {
1135 return Ok(Outcome::Ok(repo));
1136 }
1137 let at = a.archived.then(|| rfc3339(now_ms()));
1138 self.registry.set_archived(&repo.id, at.as_deref()).await?;
1139 let changed = Repo {
1140 archived_at: at,
1141 ..repo
1142 };
1143 let kind = if a.archived { "repo.archived" } else { "repo.unarchived" };
1144 self.publish(NewEvent {
1145 kind,
1146 source: SOURCE,
1147 repo_id: Some(changed.id.clone()),
1148 actor: Some(a.actor.id.clone()),
1149 data: RepoArchived {
1150 repo_id: changed.id.clone(),
1151 namespace: changed.namespace.clone(),
1152 name: changed.name.clone(),
1153 archived: a.archived,
1154 },
1155 })
1156 .await?;
1157 self.record(vec![entry(
1158 AuditActor::of(&a.actor),
1159 kind,
1160 a.surface,
1161 &path_of(&changed),
1162 "owner",
1163 if a.archived {
1164 "Archived: read-only".to_owned()
1165 } else {
1166 "Unarchived".to_owned()
1167 },
1168 )])
1169 .await;
1170 Ok(Outcome::Ok(changed))
1171 }
1172
1173 /// `set_visibility`: see `g1t_contracts::repos::SetVisibilityArgs`.
1174 pub(crate) async fn set_visibility(&self, a: SetVisibilityArgs) -> Result<Outcome<Repo>> {
1175 let repo = match self.owned(&a.actor, &a.path, "change the visibility of", Capability::Administer).await? {
1176 Ok(repo) => repo,
1177 Err(refusal) => return Ok(fail(refusal)),
1178 };
1179 if !confirmed(&path_of(&repo), &a.confirm) {
1180 return Ok(fail(confirm_refusal(&path_of(&repo))));
1181 }
1182 self.change_visibility(repo, a.is_private, &a.actor, a.surface).await
1183 }
1184
1185 /// Makes `repo` public or private, if the workspace's storage allows,
1186 /// and says so: `repo.updated` and `repo.visibility_changed`. The
1187 /// caller has checked the actor may.
1188 pub(crate) async fn change_visibility(
1189 &self,
1190 repo: Repo,
1191 private: bool,
1192 actor: &User,
1193 surface: Option<Surface>,
1194 ) -> Result<Outcome<Repo>> {
1195 if repo.is_private == private {
1196 return Ok(Outcome::Ok(repo));
1197 }
1198 let facts = if private {
1199 VisibilityFacts {
1200 to_private: true,
1201 free: git_ops::is_free(self.billing.as_ref(), &repo.namespace).await,
1202 private_bytes: self.registry.private_bytes(&repo.namespace).await.unwrap_or(0),
1203 bytes: self.registry.stored_bytes(&repo.id).await?,
1204 free_private_bytes: self.free_private_bytes,
1205 }
1206 } else {
1207 VisibilityFacts::default()
1208 };
1209 if let Err(refusal) = visibility_check(&repo.namespace, &facts) {
1210 return Ok(fail(refusal));
1211 }
1212 self.registry.set_private(&repo.id, private).await?;
1213 let changed = Repo {
1214 is_private: private,
1215 ..repo
1216 };
1217 self.publish(NewEvent {
1218 kind: "repo.updated",
1219 source: SOURCE,
1220 repo_id: Some(changed.id.clone()),
1221 actor: Some(actor.id.clone()),
1222 data: RepoUpdated {
1223 repo_id: changed.id.clone(),
1224 namespace: changed.namespace.clone(),
1225 name: changed.name.clone(),
1226 is_private: private,
1227 visibility_changed: true,
1228 },
1229 })
1230 .await?;
1231 self.publish(NewEvent {
1232 kind: "repo.visibility_changed",
1233 source: SOURCE,
1234 repo_id: Some(changed.id.clone()),
1235 actor: Some(actor.id.clone()),
1236 data: RepoVisibilityChanged {
1237 repo_id: changed.id.clone(),
1238 is_private: private,
1239 },
1240 })
1241 .await?;
1242 self.record(vec![entry(
1243 AuditActor::of(actor),
1244 "repo.visibility_changed",
1245 surface,
1246 &path_of(&changed),
1247 "owner",
1248 if private { "Made private".to_owned() } else { "Made public".to_owned() },
1249 )])
1250 .await;
1251 Ok(Outcome::Ok(changed))
1252 }
1253
1254 /// The repository at `path` someone is changing the branches of:
1255 /// found, not a working copy, the actor allowed `capability` on it
1256 /// (Push to rename a branch, Administer to change the default),
1257 /// verified, and not archived.
1258 async fn writable_by(
1259 &self,
1260 actor: &User,
1261 path: &RepoPath,
1262 capability: Capability,
1263 ) -> Result<std::result::Result<Repo, Refusal>> {
1264 let viewer = Some(actor.clone());
1265 let Some(repo) = self.readable(path, &viewer).await? else {
1266 return Ok(Err((FailureCode::NotFound, "Repository not found.".into())));
1267 };
1268 if repo.fork_of.is_some() || !crate::registry::can(&repo, &viewer, capability) {
1269 return Ok(Err((
1270 FailureCode::Forbidden,
1271 access::needs(capability, &format!("{}/{}", repo.namespace, repo.name)),
1272 )));
1273 }
1274 if !actor.verified {
1275 return Ok(Err((FailureCode::Forbidden, UNVERIFIED.into())));
1276 }
1277 if let Some(refusal) = archived_refusal(&repo) {
1278 return Ok(Err(refusal));
1279 }
Merge branch 'worktree-agent-a2013627e5ea4ab13'1280 // Moving between namespaces: wait for it (moves.rs).
1281 self.unpaused(repo).await
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1282 }
1283
1284 /// `set_default_branch`: see `g1t_contracts::repos::SetDefaultBranchArgs`.
1285 pub(crate) async fn set_default_branch(&self, a: SetDefaultBranchArgs) -> Result<Outcome<Repo>> {
1286 let repo = match self.writable_by(&a.actor, &a.path, Capability::Administer).await? {
1287 Ok(repo) => repo,
1288 Err(refusal) => return Ok(fail(refusal)),
1289 };
1290 let branch = a.branch.trim().to_owned();
1291 if branch == repo.default_branch {
1292 return Ok(Outcome::Ok(repo));
1293 }
1294 let git = self.store.open(&store_key(&repo)).await?;
1295 if !git.branches().await?.iter().any(|b| b.name == branch) {
1296 return Ok(Outcome::fail(
1297 FailureCode::Invalid,
1298 format!("There is no branch named {branch}. Push it first."),
1299 ));
1300 }
1301 self.registry.set_default_branch(&repo.id, &branch).await?;
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms1302 // HEAD in what git is told follows it.
1303 self.refs_moved(&repo.id).await;
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1304 let from = repo.default_branch.clone();
1305 let changed = Repo {
1306 default_branch: branch.clone(),
1307 ..repo
1308 };
1309 self.forks_follow(&changed, &from, &branch, false).await;
1310 self.publish(NewEvent {
1311 kind: "repo.default_branch_changed",
1312 source: SOURCE,
1313 repo_id: Some(changed.id.clone()),
1314 actor: Some(a.actor.id.clone()),
1315 data: RepoDefaultBranchChanged {
1316 repo_id: changed.id.clone(),
1317 from: from.clone(),
1318 to: branch.clone(),
1319 renamed: false,
1320 },
1321 })
1322 .await?;
1323 self.record(vec![entry(
1324 AuditActor::of(&a.actor),
1325 "repo.default_branch_changed",
1326 a.surface,
1327 &path_of(&changed),
1328 "member",
1329 format!("Default branch changed from {from} to {branch}"),
1330 )])
1331 .await;
1332 Ok(Outcome::Ok(changed))
1333 }
1334
1335 /// `rename_branch`: see `g1t_contracts::repos::RenameBranchArgs`.
1336 pub(crate) async fn rename_branch(&self, a: RenameBranchArgs) -> Result<Outcome<Repo>> {
1337 let repo = match self.writable_by(&a.actor, &a.path, Capability::Push).await? {
1338 Ok(repo) => repo,
1339 Err(refusal) => return Ok(fail(refusal)),
1340 };
1341 let from = a.from.trim().to_owned();
1342 let is_default = from == repo.default_branch;
1343 if is_default
1344 && let Err(refusal) = admin_only(
1345 Asker::on(&a.actor, &repo),
1346 &repo.namespace,
1347 "rename the default branch of",
1348 Capability::Administer,
1349 )
1350 {
1351 return Ok(fail(refusal));
1352 }
1353 let git = self.store.open(&store_key(&repo)).await?;
1354 let branches = git.branches().await?;
1355 let names: Vec<String> = branches.iter().map(|b| b.name.clone()).collect();
1356 let to = match branch_rename(&from, &a.to, &names) {
1357 Ok(to) => to,
1358 Err(refusal) => return Ok(fail(refusal)),
1359 };
1360 let Some(head) = branches.iter().find(|b| b.name == from).map(|b| b.hash.clone()) else {
1361 return Ok(not_found());
1362 };
Merge rulesets: branch and tag rules, agent-first, enforced on push and merge1363 // A rename deletes one name and creates another: the rules of both
1364 // hold (rules.rs).
1365 let renamed = vec![
1366 g1t_rules::push::RefChange {
1367 git_ref: format!("refs/heads/{from}"),
1368 old: Some(head.clone()),
1369 new: None,
1370 complete: true,
1371 ..Default::default()
1372 },
1373 g1t_rules::push::RefChange {
1374 git_ref: format!("refs/heads/{to}"),
1375 old: None,
1376 new: Some(head.clone()),
1377 complete: true,
1378 ..Default::default()
1379 },
1380 ];
1381 if let crate::rules::Ruled::Refused { message, .. } =
1382 self.check_changes(&repo, &a.actor, g1t_contracts::rules::Action::RenameRef, renamed).await?
1383 {
1384 return Ok(Outcome::fail(FailureCode::Forbidden, message));
1385 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1386 let access = git.access(Scope::Write).await?;
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms1387 let made = land::push_pack(&access, &to, None, &head, EMPTY_PACK.to_vec()).await?;
1388 self.refs_moved(&repo.id).await;
1389 if let Err(reason) = made {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1390 return Ok(Outcome::fail(FailureCode::Conflict, format!("{to} could not be made: {reason}")));
1391 }
1392 // The default moves before the old name goes, so it never names a
1393 // branch that is not there.
1394 if is_default {
1395 self.registry.set_default_branch(&repo.id, &to).await?;
1396 }
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms1397 let removed = land::delete_ref(&access, &from, &head).await;
1398 self.refs_moved(&repo.id).await;
1399 if let Err(reason) = removed? {
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1400 worker::console_error!("{from} not removed after renaming it to {to}: {reason}");
1401 }
1402 self.registry.add_branch_redirect(&repo.id, &from, &to).await?;
1403 let changed = if is_default {
1404 Repo {
1405 default_branch: to.clone(),
1406 ..repo
1407 }
1408 } else {
1409 repo
1410 };
1411 if is_default {
1412 self.forks_follow(&changed, &from, &to, true).await;
1413 }
1414 self.publish(NewEvent {
1415 kind: "branch.renamed",
1416 source: SOURCE,
1417 repo_id: Some(changed.id.clone()),
1418 actor: Some(a.actor.id.clone()),
1419 data: BranchRenamed {
1420 repo_id: changed.id.clone(),
1421 from: from.clone(),
1422 to: to.clone(),
1423 default_branch: is_default,
1424 },
1425 })
1426 .await?;
1427 if is_default {
1428 self.publish(NewEvent {
1429 kind: "repo.default_branch_changed",
1430 source: SOURCE,
1431 repo_id: Some(changed.id.clone()),
1432 actor: Some(a.actor.id.clone()),
1433 data: RepoDefaultBranchChanged {
1434 repo_id: changed.id.clone(),
1435 from: from.clone(),
1436 to: to.clone(),
1437 renamed: true,
1438 },
1439 })
1440 .await?;
1441 }
1442 self.record(vec![entry(
1443 AuditActor::of(&a.actor),
1444 "branch.renamed",
1445 a.surface,
1446 &path_of(&changed),
1447 if is_default { "owner" } else { "member" },
1448 format!("Branch {from} renamed to {to}"),
1449 )])
1450 .await;
1451 Ok(Outcome::Ok(changed))
1452 }
1453
1454 /// Pull requests' working copies name their branch after the default
1455 /// branch of the repository they came from. When it changes, the
1456 /// newest of them get a branch of the new name at the same commit (and,
1457 /// for a rename, lose the old one), so agents and merges find it.
1458 /// Best effort: a copy that cannot follow is logged and left.
1459 async fn forks_follow(&self, repo: &Repo, from: &str, to: &str, renamed: bool) {
1460 let forks = match self.registry.forks_of(&repo.id, FORKS_FOLLOWING).await {
1461 Ok(forks) => forks,
1462 Err(error) => {
1463 worker::console_error!("working copies of {} not listed: {error}", repo.id);
1464 return;
1465 }
1466 };
1467 for fork in forks {
1468 let followed: Result<()> = async {
1469 let git = self.store.open(&store_key(&fork)).await?;
1470 let branches = git.branches().await?;
1471 let Some(head) = branches.iter().find(|b| b.name == from).map(|b| b.hash.clone()) else {
1472 return Ok(());
1473 };
1474 let access = git.access(Scope::Write).await?;
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms1475 if !branches.iter().any(|b| b.name == to) {
1476 let made = land::push_pack(&access, to, None, &head, EMPTY_PACK.to_vec()).await;
1477 self.refs_moved(&fork.id).await;
1478 if let Err(reason) = made? {
1479 worker::console_error!("working copy {} did not get {to}: {reason}", fork.id);
1480 return Ok(());
1481 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1482 }
1483 self.registry.set_default_branch(&fork.id, to).await?;
Mission control shows where you are needed and what agents landed without you; git answers in about 200ms1484 if renamed {
1485 let removed = land::delete_ref(&access, from, &head).await;
1486 self.refs_moved(&fork.id).await;
1487 if let Err(reason) = removed? {
1488 worker::console_error!("working copy {} kept {from}: {reason}", fork.id);
1489 }
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1490 }
1491 Ok(())
1492 }
1493 .await;
1494 if let Err(error) = followed {
1495 worker::console_error!("working copy {} did not follow {from} → {to}: {error}", fork.id);
1496 }
1497 }
1498 }
1499
1500 /// `resolve_branch`: see `g1t_contracts::repos::ResolveBranchArgs`.
1501 pub(crate) async fn resolve_branch(&self, a: ResolveBranchArgs) -> Result<Option<String>> {
1502 let Some(now) = self.registry.branch_redirect(&a.repo_id, &a.branch).await? else {
1503 return Ok(None);
1504 };
1505 // A branch made again under the old name ends the redirect.
1506 let Some(repo) = self.registry.by_id(&a.repo_id).await? else {
1507 return Ok(None);
1508 };
1509 let git = self.store.open(&store_key(&repo)).await?;
1510 let branches = git.branches().await?;
1511 if branches.iter().any(|b| b.name == a.branch) || !branches.iter().any(|b| b.name == now) {
1512 return Ok(None);
1513 }
1514 Ok(Some(now))
1515 }
1516
1517 /// `status_by_id`: see `g1t_contracts::repos::StatusByIdArgs`.
1518 pub(crate) async fn status_by_id(&self, a: StatusByIdArgs) -> Result<RepoStatus> {
1519 self.registry.status(&a.id).await
1520 }
1521}
1522
1523#[cfg(test)]
1524mod tests {
1525 use super::*;
1526
1527 fn owner() -> Asker {
1528 Asker {
1529 person: true,
1530 verified: true,
1531 role: Some(Role::Owner),
1532 repo_role: Some(RepoRole::Admin),
1533 }
1534 }
1535
1536 fn path() -> RepoPath {
1537 RepoPath {
1538 namespace: "acme".into(),
1539 name: "rocket".into(),
1540 }
1541 }
1542
1543 #[test]
1544 fn only_a_verified_person_who_owns_the_workspace_may() {
1545 assert!(owner_only(owner(), "acme", "delete").is_ok());
1546 let member = Asker { role: Some(Role::Member), ..owner() };
1547 let (code, message) = owner_only(member, "acme", "delete").unwrap_err();
1548 assert_eq!(code, FailureCode::Forbidden);
1549 assert_eq!(message, "Only an owner of acme can delete its repositories.");
1550 assert_eq!(owner_only(Asker { person: false, ..owner() }, "acme", "delete").unwrap_err().0, FailureCode::Forbidden);
1551 assert_eq!(owner_only(Asker { verified: false, ..owner() }, "acme", "delete").unwrap_err().0, FailureCode::Forbidden);
1552 // Outside the workspace, a private repository is not there at all.
1553 assert_eq!(owner_only(Asker { role: None, ..owner() }, "acme", "delete").unwrap_err().0, FailureCode::NotFound);
1554 }
1555
1556 /// Renaming, archiving and changing visibility take Admin on the
1557 /// repository, which a direct grant can give; deleting and
1558 /// transferring still take an owner of the workspace.
1559 #[test]
1560 fn admin_on_the_repository_may_administer_but_not_delete() {
1561 let admin = Asker { role: None, repo_role: Some(RepoRole::Admin), ..owner() };
1562 assert!(admin_only(admin, "acme", "rename", Capability::Administer).is_ok());
1563 let (code, message) = admin_only(admin, "acme", "delete", Capability::Delete).unwrap_err();
1564 assert_eq!(code, FailureCode::Forbidden);
1565 assert_eq!(message, "Only an owner of acme can delete its repositories.");
1566 let member_admin = Asker { role: Some(Role::Member), ..admin };
1567 assert_eq!(admin_only(member_admin, "acme", "delete", Capability::Delete).unwrap_err().0, FailureCode::Forbidden);
1568 // Write (the default base permission) cannot rename.
1569 let writer = Asker { role: Some(Role::Member), repo_role: Some(RepoRole::Write), ..owner() };
1570 let (code, message) = admin_only(writer, "acme", "rename", Capability::Administer).unwrap_err();
1571 assert_eq!(code, FailureCode::Forbidden);
1572 assert_eq!(message, "You need the Admin role on a repository of acme to rename it.");
1573 // Someone who can read a public repository is refused, not told it is missing.
1574 let reader = Asker { role: None, repo_role: Some(RepoRole::Read), ..owner() };
1575 assert_eq!(admin_only(reader, "acme", "archive", Capability::Administer).unwrap_err().0, FailureCode::Forbidden);
1576 let stranger = Asker { role: None, repo_role: None, ..owner() };
1577 assert_eq!(admin_only(stranger, "acme", "archive", Capability::Administer).unwrap_err().0, FailureCode::NotFound);
1578 assert_eq!(admin_only(Asker { person: false, ..owner() }, "acme", "rename", Capability::Administer).unwrap_err().0, FailureCode::Forbidden);
1579 }
1580
1581 #[test]
Packages, with a container registry on g1t.sh; workspaces deleted whole and kept 30 days; Members for every member1582 fn a_workspace_restore_brings_back_only_what_went_with_it() {
1583 assert!(comes_back_with(Some("wsp_1"), "wsp_1"));
1584 // Deleted on its own before the workspace was.
1585 assert!(!comes_back_with(None, "wsp_1"));
1586 // Went with another workspace (it was transferred since).
1587 assert!(!comes_back_with(Some("wsp_2"), "wsp_1"));
1588 }
1589
1590 #[test]
1591 fn a_protected_workspace_keeps_its_repositories_whatever_is_published() {
1592 let protected = g1t_contracts::identity::protected_names(None);
1593 assert_eq!(
1594 may_go_with_workspace("flagon-io", &protected).unwrap_err(),
1595 "flagon-io is protected and can never be deleted."
1596 );
1597 assert!(may_go_with_workspace("FLAGON-IO", &protected).is_err());
1598 assert!(may_go_with_workspace("acme", &protected).is_ok());
1599 let configured = g1t_contracts::identity::protected_names(Some("acme"));
1600 assert!(may_go_with_workspace("acme", &configured).is_err());
1601 }
1602
1603 #[test]
Invite-only launch: sign in with GitHub, repository access and lifecycle, many emails, a new look1604 fn the_full_name_confirms_in_any_case() {
1605 assert!(confirmed(&path(), "acme/rocket"));
1606 assert!(confirmed(&path(), " ACME/Rocket "));
1607 assert!(!confirmed(&path(), "rocket"));
1608 assert!(!confirmed(&path(), ""));
1609 }
1610
1611 #[test]
1612 fn a_deleted_repository_is_restorable_for_thirty_days_then_due() {
1613 let deleted_at = 1_790_000_000_000u64;
1614 let purge = purge_after(deleted_at);
1615 assert_eq!(purge, rfc3339(deleted_at + 30 * 86_400_000));
1616 let day = 86_400_000u64;
1617 let at = |ms: u64| rfc3339(ms);
1618 assert_eq!(state(None, None, &at(deleted_at)), State::Active);
1619 assert_eq!(state(Some("2026-10-01T00:00:00.000Z"), None, &at(deleted_at)), State::Archived);
1620 let deleted = Some((at(deleted_at), purge.clone()));
1621 let deleted = deleted.as_ref().map(|(a, b)| (a.as_str(), b.as_str()));
1622 assert_eq!(state(None, deleted, &at(deleted_at + day)), State::Deleted);
1623 assert_eq!(state(None, deleted, &at(deleted_at + 30 * day - 1)), State::Deleted);
1624 assert_eq!(state(None, deleted, &at(deleted_at + 30 * day)), State::Due);
1625 // Archived and then deleted: deleted is what counts.
1626 assert_eq!(state(Some("x"), deleted, &at(deleted_at + day)), State::Deleted);
1627 assert!(restorable(&purge, &at(deleted_at + 29 * day)));
1628 assert!(!restorable(&purge, &at(deleted_at + 31 * day)));
1629 }
1630
1631 #[test]
1632 fn a_rename_needs_a_valid_free_name() {
1633 assert_eq!(new_name("acme", "rocket", " Booster ", Held::Free).unwrap(), "booster");
1634 assert_eq!(new_name("acme", "rocket", "rocket", Held::Free).unwrap_err().0, FailureCode::Invalid);
1635 assert_eq!(new_name("acme", "rocket", "no spaces", Held::Free).unwrap_err().0, FailureCode::Invalid);
1636 assert_eq!(new_name("acme", "rocket", "x.git", Held::Free).unwrap_err().0, FailureCode::Invalid);
1637 let (code, message) = new_name("acme", "rocket", "booster", Held::ByRepo).unwrap_err();
1638 assert_eq!(code, FailureCode::Conflict);
1639 assert_eq!(message, "acme already has a repository named booster.");
1640 let (code, message) = new_name("acme", "rocket", "booster", Held::ByDeleted).unwrap_err();
1641 assert_eq!(code, FailureCode::Conflict);
1642 assert!(message.contains("deleted recently"));
1643 }
1644
1645 fn repo(archived: bool) -> Repo {
1646 Repo {
1647 id: "rep_1".into(),
1648 namespace: "acme".into(),
1649 name: "rocket".into(),
1650 description: None,
1651 is_private: false,
1652 owner_id: "usr_1".into(),
1653 default_branch: "main".into(),
1654 fork_of: None,
1655 protected: false,
1656 created_at: String::new(),
1657 topics: Vec::new(),
1658 website: None,
1659 archived_at: archived.then(|| "2026-10-05T00:00:00.000Z".to_owned()),
1660 }
1661 }
1662
1663 #[test]
1664 fn an_archived_repository_refuses_writes_with_the_reason() {
1665 assert!(archived_refusal(&repo(false)).is_none());
1666 let (code, message) = archived_refusal(&repo(true)).unwrap();
1667 assert_eq!(code, FailureCode::Forbidden);
1668 assert_eq!(message, "acme/rocket is archived, so it is read-only. An owner can unarchive it in its settings.");
1669 }
1670
1671 #[test]
1672 fn going_private_on_a_free_workspace_needs_room() {
1673 let full = VisibilityFacts {
1674 to_private: true,
1675 free: true,
1676 private_bytes: 900_000_000,
1677 bytes: 200_000_000,
1678 free_private_bytes: 1_000_000_000,
1679 };
1680 assert_eq!(visibility_check("acme", &full).unwrap_err().0, FailureCode::PaymentRequired);
1681 assert!(visibility_check("acme", &VisibilityFacts { free: false, ..full }).is_ok());
1682 let full = VisibilityFacts {
1683 to_private: true,
1684 free: true,
1685 private_bytes: 900_000_000,
1686 bytes: 200_000_000,
1687 free_private_bytes: 1_000_000_000,
1688 };
1689 // Going public is never refused.
1690 assert!(visibility_check("acme", &VisibilityFacts { to_private: false, ..full }).is_ok());
1691 let light = VisibilityFacts {
1692 to_private: true,
1693 free: true,
1694 private_bytes: 1_000,
1695 bytes: 1_000,
1696 free_private_bytes: 1_000_000_000,
1697 };
1698 assert!(visibility_check("acme", &light).is_ok());
1699 }
1700
1701 #[test]
1702 fn a_branch_is_renamed_to_a_free_valid_name() {
1703 let branches = vec!["main".to_owned(), "dev".to_owned()];
1704 assert_eq!(branch_rename("main", " trunk ", &branches).unwrap(), "trunk");
1705 assert_eq!(branch_rename("nope", "trunk", &branches).unwrap_err().0, FailureCode::NotFound);
1706 assert_eq!(branch_rename("main", "dev", &branches).unwrap_err().0, FailureCode::Conflict);
1707 assert_eq!(branch_rename("main", "main", &branches).unwrap_err().0, FailureCode::Invalid);
1708 assert_eq!(branch_rename("main", "a b", &branches).unwrap_err().0, FailureCode::Invalid);
1709 assert_eq!(branch_rename("main", "g1t-queue", &branches).unwrap_err().0, FailureCode::Invalid);
1710 }
1711
1712 #[test]
1713 fn the_empty_pack_is_well_formed() {
1714 assert_eq!(EMPTY_PACK.len(), 32);
1715 assert!(EMPTY_PACK.starts_with(b"PACK\0\0\0\x02\0\0\0\0"));
1716 }
1717}

This file's history is long; its oldest lines are credited to the oldest commit read.