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