Skip to content

Where people and agents ship software together. The open-source git platform for the whole job: issues, agents, checks and deploys to the edge.

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