flagon-io/g1t

public

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

g1t/services/repos/src/lifecycle.rs

1,496 lines57,926 bytesCodeBlame
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,
27};
28use g1t_contracts::identity::TransferRepoScopesArgs;
29use g1t_contracts::repos::{
30 ArchiveArgs, DeleteArgs, DeletedArgs, DeletedRepo, DeletedRepoArgs, PurgeDueArgs,
31 RESTORE_DAYS, RenameArgs, RenameBranchArgs, Repo, RepoPath, RepoStatus, ResolveBranchArgs,
32 SetDefaultBranchArgs, SetVisibilityArgs, StatusByIdArgs, archived_message,
33 is_valid_branch_name,
34};
35use g1t_contracts::access::{self, Capability, RepoRole};
36use g1t_contracts::time::rfc3339;
37use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, User, is_valid_repo_name, new_id};
38use g1t_kit::now_ms;
39use serde::Deserialize;
40use worker::Result;
41use worker::wasm_bindgen::JsValue;
42
43use crate::registry::{Registry, remember_store, store_key};
44use crate::store::{GitRepo, GitStore, Scope};
45use crate::{Repos, SOURCE, UNVERIFIED, git_ops, land, not_found};
46
47/// A pack with no objects: what a push that only creates a ref at a commit
48/// the repository already has sends.
49const EMPTY_PACK: &[u8] = &[
50 b'P', b'A', b'C', b'K', 0, 0, 0, 2, 0, 0, 0, 0, 0x02, 0x9d, 0x08, 0x82, 0x3b, 0xd8, 0xa8,
51 0xea, 0xb5, 0x10, 0xad, 0x6a, 0xc7, 0x5c, 0x82, 0x3c, 0xfd, 0x3e, 0xd3, 0x1e,
52];
53
54/// How many pull request working copies follow a change of the default
55/// branch (newest first). Older ones keep the branch they were made with.
56const FORKS_FOLLOWING: u32 = 100;
57
58/// How many deleted repositories one sweep purges.
59const PURGES_PER_SWEEP: u32 = 25;
60
61type Refusal = (FailureCode, String);
62
63/// Who is asking, as far as an owner's or an admin's action cares.
64#[derive(Clone, Copy, Debug)]
65pub struct Asker {
66 /// A person, not a workspace's or an agent's token.
67 pub person: bool,
68 pub verified: bool,
69 /// Their role in the repository's workspace.
70 pub role: Option<Role>,
71 /// Their role on the repository itself (see g1t_contracts::access).
72 /// None where only the workspace is known, as for deleted ones.
73 pub repo_role: Option<RepoRole>,
74}
75
76impl Asker {
77 pub fn of(user: &User, namespace: &str) -> Self {
78 Asker {
79 person: user.kind == PrincipalKind::User,
80 verified: user.verified,
81 role: user.role_in(&namespace.to_lowercase()),
82 repo_role: None,
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 transferring and deleting,
98/// an owner of the workspace as well.
99pub fn admin_only(asker: Asker, namespace: &str, what: &str, capability: Capability) -> std::result::Result<(), Refusal> {
100 if access::OWNER_ONLY.contains(&capability) {
101 // Someone who can see the repository is told why, not that it is missing.
102 if asker.role.is_none() && asker.repo_role.is_some() && asker.person {
103 return Err((
104 FailureCode::Forbidden,
105 format!("Only an owner of {namespace} can {what} its repositories."),
106 ));
107 }
108 return owner_only(asker, namespace, what);
109 }
110 if asker.role.is_none() && asker.repo_role.is_none() {
111 return Err((FailureCode::NotFound, "Repository not found.".into()));
112 }
113 if !asker.person {
114 return Err((
115 FailureCode::Forbidden,
116 format!("Only a person can {what} a repository. Sign in, or use a personal access token."),
117 ));
118 }
119 if !asker.repo_role.is_some_and(|role| access::allows(role, capability)) {
120 return Err((
121 FailureCode::Forbidden,
122 format!(
123 "You need the {} role on a repository of {namespace} to {what} it.",
124 access::least_role(capability).label()
125 ),
126 ));
127 }
128 if !asker.verified {
129 return Err((FailureCode::Forbidden, UNVERIFIED.into()));
130 }
131 Ok(())
132}
133
134/// Whether `asker` may `what` ("delete", "rename"...) a repository of
135/// `namespace`: a verified person who owns the workspace.
136pub fn owner_only(asker: Asker, namespace: &str, what: &str) -> std::result::Result<(), Refusal> {
137 if asker.role.is_none() {
138 return Err((FailureCode::NotFound, "Repository not found.".into()));
139 }
140 if !asker.person {
141 return Err((
142 FailureCode::Forbidden,
143 format!("Only a person can {what} a repository. Sign in, or use a personal access token."),
144 ));
145 }
146 if asker.role != Some(Role::Owner) {
147 return Err((
148 FailureCode::Forbidden,
149 format!("Only an owner of {namespace} can {what} its repositories."),
150 ));
151 }
152 if !asker.verified {
153 return Err((FailureCode::Forbidden, UNVERIFIED.into()));
154 }
155 Ok(())
156}
157
158/// Whether what was typed to confirm names the repository: its full name,
159/// `namespace/name`, in any case.
160pub fn confirmed(path: &RepoPath, typed: &str) -> bool {
161 typed.trim().to_lowercase() == format!("{}/{}", path.namespace, path.name).to_lowercase()
162}
163
164fn confirm_refusal(path: &RepoPath) -> Refusal {
165 (
166 FailureCode::Invalid,
167 format!("Type {}/{} to confirm.", path.namespace, path.name),
168 )
169}
170
171/// When a repository deleted at `now_ms` is purged.
172pub fn purge_after(now_ms: u64) -> String {
173 rfc3339(now_ms + RESTORE_DAYS * 86_400_000)
174}
175
176/// Whether a repository to be purged at `purge_after` can still be
177/// restored at `now` (both RFC 3339, which compare as text).
178pub fn restorable(purge_after: &str, now: &str) -> bool {
179 now < purge_after
180}
181
182/// Where a repository is in its life, from its row.
183#[derive(Clone, Copy, Debug, PartialEq, Eq)]
184pub enum State {
185 Active,
186 Archived,
187 /// Deleted, and restorable until purged.
188 Deleted,
189 /// Deleted, and due to be purged by the next sweep.
190 Due,
191}
192
193pub fn state(archived_at: Option<&str>, deleted: Option<(&str, &str)>, now: &str) -> State {
194 match deleted {
195 Some((_, purge_after)) if !restorable(purge_after, now) => State::Due,
196 Some(_) => State::Deleted,
197 None if archived_at.is_some() => State::Archived,
198 None => State::Active,
199 }
200}
201
202/// What holds a name in a workspace.
203#[derive(Clone, Copy, Debug, PartialEq, Eq)]
204pub enum Held {
205 Free,
206 ByRepo,
207 /// A repository deleted but not yet purged.
208 ByDeleted,
209}
210
211/// The name a repository at `current` can be renamed to, tidied, or why
212/// not.
213pub fn new_name(namespace: &str, current: &str, wanted: &str, held: Held) -> std::result::Result<String, Refusal> {
214 let name = wanted.trim().to_lowercase();
215 if !is_valid_repo_name(&name) {
216 return Err((
217 FailureCode::Invalid,
218 "Use letters, digits, dots, hyphens and underscores only.".into(),
219 ));
220 }
221 if name == current {
222 return Err((FailureCode::Invalid, format!("It is already called {name}.")));
223 }
224 match held {
225 Held::Free => Ok(name),
226 Held::ByRepo => Err((
227 FailureCode::Conflict,
228 format!("{namespace} already has a repository named {name}."),
229 )),
230 Held::ByDeleted => Err((
231 FailureCode::Conflict,
232 format!(
233 "{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."
234 ),
235 )),
236 }
237}
238
239/// Why a write to `repo` is refused because it is archived, if it is.
240pub fn archived_refusal(repo: &Repo) -> Option<Refusal> {
241 repo.archived()
242 .then(|| (FailureCode::Forbidden, archived_message(&repo.namespace, &repo.name)))
243}
244
245/// Everything that decides whether a repository may go private or public.
246#[derive(Debug, Default)]
247pub struct VisibilityFacts {
248 pub to_private: bool,
249 /// The workspace is on no plan, so its private storage is capped.
250 pub free: bool,
251 /// What its private repositories hold now.
252 pub private_bytes: i64,
253 /// What this repository holds.
254 pub bytes: i64,
255 /// What a free workspace's private repositories may hold.
256 pub free_private_bytes: i64,
257}
258
259/// Whether the visibility change `facts` describe may happen.
260pub fn visibility_check(namespace: &str, facts: &VisibilityFacts) -> std::result::Result<(), Refusal> {
261 if facts.to_private
262 && facts.free
263 && git_ops::storage_full(facts.private_bytes + facts.bytes, facts.free_private_bytes)
264 {
265 return Err((
266 FailureCode::PaymentRequired,
267 format!(
268 "{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.",
269 (facts.private_bytes + facts.bytes) as f64 / 1e9,
270 facts.free_private_bytes as f64 / 1e9,
271 ),
272 ));
273 }
274 Ok(())
275}
276
277/// Whether `from` can be renamed to `to` in a repository whose branches
278/// are `branches`. `to` is tidied of surrounding space.
279pub fn branch_rename(from: &str, to: &str, branches: &[String]) -> std::result::Result<String, Refusal> {
280 let to = to.trim().to_owned();
281 if !branches.iter().any(|branch| branch == from) {
282 return Err((FailureCode::NotFound, format!("There is no branch named {from}.")));
283 }
284 if !is_valid_branch_name(&to) {
285 return Err((
286 FailureCode::Invalid,
287 format!("{to:?} cannot be a branch name. Use letters, digits, '/', '-', '_' and '.', and no spaces."),
288 ));
289 }
290 if to == from {
291 return Err((FailureCode::Invalid, format!("It is already called {to}.")));
292 }
293 if branches.contains(&to) {
294 return Err((FailureCode::Conflict, format!("There is already a branch named {to}.")));
295 }
296 Ok(to)
297}
298
299/// The row of a deleted repository.
300#[derive(Deserialize)]
301struct DeletedRow {
302 id: String,
303 namespace: String,
304 name: String,
305 description: Option<String>,
306 is_private: u8,
307 deleted_at: String,
308 #[serde(default)]
309 deleted_by: Option<String>,
310 purge_after: String,
311}
312
313impl From<DeletedRow> for DeletedRepo {
314 fn from(row: DeletedRow) -> Self {
315 DeletedRepo {
316 id: row.id,
317 namespace: row.namespace,
318 name: row.name,
319 description: row.description,
320 is_private: row.is_private != 0,
321 deleted_at: row.deleted_at,
322 deleted_by: row.deleted_by.unwrap_or_default(),
323 purge_after: row.purge_after,
324 }
325 }
326}
327
328const DELETED_COLUMNS: &str =
329 "id, namespace, name, description, is_private, deleted_at, deleted_by, purge_after";
330
331impl Registry {
332 /// Deletes a repository and its pull requests' working copies, softly.
333 pub async fn soft_delete(&self, id: &str, by: &str, at: &str, purge_after: &str) -> Result<()> {
334 self.db
335 .prepare(
336 "UPDATE repos SET deleted_at = ?1, deleted_by = ?2, purge_after = ?3
337 WHERE (id = ?4 OR fork_of = ?4) AND deleted_at IS NULL",
338 )
339 .bind(&[at.into(), by.into(), purge_after.into(), id.into()])?
340 .run()
341 .await?;
342 Ok(())
343 }
344
345 /// Brings a deleted repository and its working copies back.
346 pub async fn undelete(&self, id: &str) -> Result<()> {
347 self.db
348 .prepare(
349 "UPDATE repos SET deleted_at = NULL, deleted_by = NULL, purge_after = NULL
350 WHERE id = ?1 OR fork_of = ?1",
351 )
352 .bind(&[id.into()])?
353 .run()
354 .await?;
355 Ok(())
356 }
357
358 /// A workspace's deleted repositories, newest first.
359 pub async fn deleted_in(&self, namespace: &str) -> Result<Vec<DeletedRepo>> {
360 Ok(self
361 .db
362 .prepare(format!(
363 "SELECT {DELETED_COLUMNS} FROM repos
364 WHERE namespace = ? AND deleted_at IS NOT NULL AND fork_of IS NULL
365 ORDER BY deleted_at DESC LIMIT 200"
366 ))
367 .bind(&[namespace.to_lowercase().into()])?
368 .all()
369 .await?
370 .results::<DeletedRow>()?
371 .into_iter()
372 .map(DeletedRepo::from)
373 .collect())
374 }
375
376 /// The deleted repository at `path`, if that is what holds it.
377 pub async fn deleted_at(&self, path: &RepoPath) -> Result<Option<DeletedRepo>> {
378 Ok(self
379 .db
380 .prepare(format!(
381 "SELECT {DELETED_COLUMNS} FROM repos
382 WHERE namespace = ? AND name = ? AND deleted_at IS NOT NULL AND fork_of IS NULL"
383 ))
384 .bind(&[
385 path.namespace.to_lowercase().into(),
386 path.name.to_lowercase().into(),
387 ])?
388 .first::<DeletedRow>(None)
389 .await?
390 .map(DeletedRepo::from))
391 }
392
393 /// Deleted repositories whose time to be restored has passed.
394 pub async fn due(&self, now: &str, limit: u32) -> Result<Vec<DeletedRepo>> {
395 Ok(self
396 .db
397 .prepare(format!(
398 "SELECT {DELETED_COLUMNS} FROM repos
399 WHERE deleted_at IS NOT NULL AND purge_after <= ? AND fork_of IS NULL
400 ORDER BY purge_after LIMIT ?"
401 ))
402 .bind(&[now.into(), limit.into()])?
403 .all()
404 .await?
405 .results::<DeletedRow>()?
406 .into_iter()
407 .map(DeletedRepo::from)
408 .collect())
409 }
410
411 /// Every deleted repository left in a workspace, for when the
412 /// workspace itself is deleted.
413 pub async fn deleted_ids_in(&self, namespace: &str) -> Result<Vec<DeletedRepo>> {
414 self.deleted_in(namespace).await
415 }
416
417 /// The git store keys of a repository and of its working copies.
418 pub async fn store_keys(&self, id: &str) -> Result<Vec<String>> {
419 #[derive(Deserialize)]
420 struct Row {
421 namespace: String,
422 name: String,
423 #[serde(default)]
424 store: Option<String>,
425 }
426 Ok(self
427 .db
428 .prepare("SELECT namespace, name, store FROM repos WHERE id = ?1 OR fork_of = ?1")
429 .bind(&[id.into()])?
430 .all()
431 .await?
432 .results::<Row>()?
433 .into_iter()
434 .map(|row| row.store.unwrap_or_else(|| format!("{}--{}", row.namespace, row.name)))
435 .collect())
436 }
437
438 /// Forgets a purged repository: its rows, its working copies' rows and
439 /// every redirect to it.
440 pub async fn erase(&self, id: &str) -> Result<()> {
441 self.db
442 .batch(vec![
443 self.db.prepare("DELETE FROM repos WHERE fork_of = ?1").bind(&[id.into()])?,
444 self.db.prepare("DELETE FROM repos WHERE id = ?1").bind(&[id.into()])?,
445 self.db
446 .prepare("DELETE FROM repo_redirects WHERE repo_id = ?1")
447 .bind(&[id.into()])?,
448 self.db
449 .prepare("DELETE FROM branch_redirects WHERE repo_id = ?1")
450 .bind(&[id.into()])?,
451 ])
452 .await?;
453 Ok(())
454 }
455
456 /// Renames a repository, keeping its old path as a redirect. Any
457 /// redirect held by the new path gives way.
458 pub async fn rename(&self, repo: &Repo, name: &str) -> Result<()> {
459 let now = rfc3339(now_ms());
460 self.db
461 .batch(vec![
462 self.db
463 .prepare("UPDATE repos SET name = ? WHERE id = ? AND name = ?")
464 .bind(&[name.into(), repo.id.as_str().into(), repo.name.as_str().into()])?,
465 self.db
466 .prepare("DELETE FROM repo_redirects WHERE namespace = ? AND name = ?")
467 .bind(&[repo.namespace.as_str().into(), name.into()])?,
468 self.db
469 .prepare(
470 "INSERT OR REPLACE INTO repo_redirects (namespace, name, repo_id, created_at)
471 VALUES (?, ?, ?, ?)",
472 )
473 .bind(&[
474 repo.namespace.as_str().into(),
475 repo.name.as_str().into(),
476 repo.id.as_str().into(),
477 now.as_str().into(),
478 ])?,
479 ])
480 .await?;
481 Ok(())
482 }
483
484 pub async fn set_archived(&self, id: &str, at: Option<&str>) -> Result<()> {
485 self.db
486 .prepare("UPDATE repos SET archived_at = ? WHERE id = ?")
487 .bind(&[at.map_or(JsValue::NULL, JsValue::from), id.into()])?
488 .run()
489 .await?;
490 Ok(())
491 }
492
493 /// Makes a repository and its working copies public or private.
494 pub async fn set_private(&self, id: &str, private: bool) -> Result<()> {
495 self.db
496 .prepare("UPDATE repos SET is_private = ?1 WHERE id = ?2 OR fork_of = ?2")
497 .bind(&[u32::from(private).into(), id.into()])?
498 .run()
499 .await?;
500 Ok(())
501 }
502
503 pub async fn set_default_branch(&self, id: &str, branch: &str) -> Result<()> {
504 self.db
505 .prepare("UPDATE repos SET default_branch = ? WHERE id = ?")
506 .bind(&[branch.into(), id.into()])?
507 .run()
508 .await?;
509 Ok(())
510 }
511
512 /// Working copies of a repository, newest first, at most `limit`.
513 pub async fn forks_of(&self, id: &str, limit: u32) -> Result<Vec<Repo>> {
514 let rows = self
515 .db
516 .prepare(
517 "SELECT * FROM repos WHERE fork_of = ? AND deleted_at IS NULL
518 ORDER BY created_at DESC LIMIT ?",
519 )
520 .bind(&[id.into(), limit.into()])?
521 .all()
522 .await?
523 .results::<crate::registry::RepoRow>()?;
524 Ok(rows.into_iter().map(Repo::from).collect())
525 }
526
527 /// Records that `from` is now called `to`. Redirects that pointed at
528 /// `from` point at `to`, and one held by `to` itself ends.
529 pub async fn add_branch_redirect(&self, repo_id: &str, from: &str, to: &str) -> Result<()> {
530 let now = rfc3339(now_ms());
531 self.db
532 .batch(vec![
533 self.db
534 .prepare("DELETE FROM branch_redirects WHERE repo_id = ? AND branch = ?")
535 .bind(&[repo_id.into(), to.into()])?,
536 self.db
537 .prepare("UPDATE branch_redirects SET now = ? WHERE repo_id = ? AND now = ?")
538 .bind(&[to.into(), repo_id.into(), from.into()])?,
539 self.db
540 .prepare(
541 "INSERT OR REPLACE INTO branch_redirects (repo_id, branch, now, created_at)
542 VALUES (?, ?, ?, ?)",
543 )
544 .bind(&[repo_id.into(), from.into(), to.into(), now.as_str().into()])?,
545 ])
546 .await?;
547 Ok(())
548 }
549
550 pub async fn branch_redirect(&self, repo_id: &str, branch: &str) -> Result<Option<String>> {
551 #[derive(Deserialize)]
552 struct Row {
553 now: String,
554 }
555 Ok(self
556 .db
557 .prepare("SELECT now FROM branch_redirects WHERE repo_id = ? AND branch = ?")
558 .bind(&[repo_id.into(), branch.into()])?
559 .first::<Row>(None)
560 .await?
561 .map(|row| row.now))
562 }
563
564 /// Whether a repository is archived or deleted; unknown is deleted.
565 pub async fn status(&self, id: &str) -> Result<RepoStatus> {
566 #[derive(Deserialize)]
567 struct Row {
568 #[serde(default)]
569 archived_at: Option<String>,
570 #[serde(default)]
571 deleted_at: Option<String>,
572 }
573 Ok(self
574 .db
575 .prepare("SELECT archived_at, deleted_at FROM repos WHERE id = ?")
576 .bind(&[id.into()])?
577 .first::<Row>(None)
578 .await?
579 .map_or(
580 RepoStatus {
581 archived: false,
582 deleted: true,
583 },
584 |row| RepoStatus {
585 archived: row.archived_at.is_some(),
586 deleted: row.deleted_at.is_some(),
587 },
588 ))
589 }
590}
591
592/// An audit entry for something done to a repository.
593fn entry(actor: AuditActor, action: &str, surface: Option<Surface>, path: &RepoPath, rule: &str, message: String) -> NewAuditEntry {
594 NewAuditEntry {
595 actor,
596 action: action.to_owned(),
597 surface: surface.unwrap_or(Surface::Web),
598 target: AuditTarget {
599 workspace: path.namespace.clone(),
600 repo: Some(format!("{}/{}", path.namespace, path.name)),
601 ..AuditTarget::default()
602 },
603 outcome: AuditOutcome::Allowed,
604 rule: rule.to_owned(),
605 result: Some("ok".to_owned()),
606 message: Some(message),
607 request_id: new_id("req", now_ms()),
608 }
609}
610
611/// g1t itself, as the actor of what its schedule does.
612fn g1t_actor() -> AuditActor {
613 AuditActor {
614 actor: "g1t".to_owned(),
615 actor_id: "g1t".to_owned(),
616 ..AuditActor::default()
617 }
618}
619
620fn fail<T>((code, message): Refusal) -> Outcome<T> {
621 Outcome::fail(code, message)
622}
623
624fn path_of(repo: &Repo) -> RepoPath {
625 RepoPath {
626 namespace: repo.namespace.clone(),
627 name: repo.name.clone(),
628 }
629}
630
631impl<S: GitStore> Repos<S> {
632 pub(crate) async fn record(&self, entries: Vec<NewAuditEntry>) {
633 let recorded: Result<u32> =
634 g1t_kit::call(&self.events, "audit_record", &RecordAuditArgs { entries }).await;
635 if let Err(error) = recorded {
636 worker::console_error!("audit entries not recorded: {error}");
637 }
638 }
639
640 /// The repository at `path` an admin is acting on: found, not a
641 /// working copy, and the actor allowed `capability` on it (Admin, and
642 /// for deleting, an owner of its workspace).
643 async fn owned(
644 &self,
645 actor: &User,
646 path: &RepoPath,
647 what: &str,
648 capability: Capability,
649 ) -> Result<std::result::Result<Repo, Refusal>> {
650 let viewer = Some(actor.clone());
651 let Some(repo) = self.readable(path, &viewer).await? else {
652 return Ok(Err((FailureCode::NotFound, "Repository not found.".into())));
653 };
654 if repo.fork_of.is_some() {
655 return Ok(Err((FailureCode::NotFound, "Repository not found.".into())));
656 }
657 if let Err(refusal) = admin_only(Asker::on(actor, &repo), &repo.namespace, what, capability) {
658 return Ok(Err(refusal));
659 }
660 Ok(Ok(repo))
661 }
662
663 /// `delete`: see `g1t_contracts::repos::DeleteArgs`.
664 pub(crate) async fn delete(&self, a: DeleteArgs) -> Result<Outcome<DeletedRepo>> {
665 let repo = match self.owned(&a.actor, &a.path, "delete", Capability::Delete).await? {
666 Ok(repo) => repo,
667 Err(refusal) => return Ok(fail(refusal)),
668 };
669 let path = path_of(&repo);
670 if !confirmed(&path, &a.confirm) {
671 return Ok(fail(confirm_refusal(&path)));
672 }
673 let now = now_ms();
674 let at = rfc3339(now);
675 let purge = purge_after(now);
676 self.registry
677 .soft_delete(&repo.id, &a.actor.username, &at, &purge)
678 .await?;
679 self.publish(NewEvent {
680 kind: "repo.deleted",
681 source: SOURCE,
682 repo_id: Some(repo.id.clone()),
683 actor: Some(a.actor.id.clone()),
684 data: RepoDeleted {
685 repo_id: repo.id.clone(),
686 namespace: repo.namespace.clone(),
687 name: repo.name.clone(),
688 is_private: repo.is_private,
689 purge_after: purge.clone(),
690 },
691 })
692 .await?;
693 self.record(vec![entry(
694 AuditActor::of(&a.actor),
695 "repo.deleted",
696 a.surface,
697 &path,
698 "owner",
699 format!("Deleted; restorable until {purge}"),
700 )])
701 .await;
702 Ok(Outcome::Ok(DeletedRepo {
703 id: repo.id,
704 namespace: repo.namespace,
705 name: repo.name,
706 description: repo.description,
707 is_private: repo.is_private,
708 deleted_at: at,
709 deleted_by: a.actor.username,
710 purge_after: purge,
711 }))
712 }
713
714 /// `deleted`: see `g1t_contracts::repos::DeletedArgs`.
715 pub(crate) async fn deleted(&self, a: DeletedArgs) -> Result<Vec<DeletedRepo>> {
716 let namespace = a.namespace.to_lowercase();
717 let owner = a
718 .viewer
719 .as_ref()
720 .is_some_and(|user| user.role_in(&namespace) == Some(Role::Owner));
721 if !owner {
722 return Ok(Vec::new());
723 }
724 self.registry.deleted_in(&namespace).await
725 }
726
727 /// The deleted repository an owner is acting on.
728 async fn owned_deleted(
729 &self,
730 actor: &User,
731 path: &RepoPath,
732 what: &str,
733 ) -> Result<std::result::Result<DeletedRepo, Refusal>> {
734 if let Err(refusal) = owner_only(Asker::of(actor, &path.namespace), &path.namespace.to_lowercase(), what) {
735 return Ok(Err(refusal));
736 }
737 Ok(match self.registry.deleted_at(path).await? {
738 Some(deleted) => Ok(deleted),
739 None => Err((
740 FailureCode::NotFound,
741 format!(
742 "{}/{} is not among the workspace's recently deleted repositories.",
743 path.namespace, path.name
744 ),
745 )),
746 })
747 }
748
749 /// `restore`: see `g1t_contracts::repos::DeletedRepoArgs`.
750 pub(crate) async fn restore(&self, a: DeletedRepoArgs) -> Result<Outcome<Repo>> {
751 let deleted = match self.owned_deleted(&a.actor, &a.path, "restore").await? {
752 Ok(deleted) => deleted,
753 Err(refusal) => return Ok(fail(refusal)),
754 };
755 let deleted_state = state(
756 None,
757 Some((&deleted.deleted_at, &deleted.purge_after)),
758 &rfc3339(now_ms()),
759 );
760 if deleted_state == State::Due {
761 return Ok(Outcome::fail(
762 FailureCode::Conflict,
763 format!("{}/{} is being purged and can no longer be restored.", deleted.namespace, deleted.name),
764 ));
765 }
766 self.registry.undelete(&deleted.id).await?;
767 let Some(repo) = self.registry.by_id(&deleted.id).await? else {
768 return Ok(not_found());
769 };
770 self.publish(NewEvent {
771 kind: "repo.restored",
772 source: SOURCE,
773 repo_id: Some(repo.id.clone()),
774 actor: Some(a.actor.id.clone()),
775 data: RepoRestored {
776 repo_id: repo.id.clone(),
777 namespace: repo.namespace.clone(),
778 name: repo.name.clone(),
779 is_private: repo.is_private,
780 },
781 })
782 .await?;
783 self.record(vec![entry(
784 AuditActor::of(&a.actor),
785 "repo.restored",
786 a.surface,
787 &path_of(&repo),
788 "owner",
789 format!("Restored; deleted by {} at {}", deleted.deleted_by, deleted.deleted_at),
790 )])
791 .await;
792 Ok(Outcome::Ok(repo))
793 }
794
795 /// `purge`: see `g1t_contracts::repos::DeletedRepoArgs`.
796 pub(crate) async fn purge(&self, a: DeletedRepoArgs) -> Result<Outcome<bool>> {
797 let deleted = match self.owned_deleted(&a.actor, &a.path, "permanently delete").await? {
798 Ok(deleted) => deleted,
799 Err(refusal) => return Ok(fail(refusal)),
800 };
801 let path = RepoPath {
802 namespace: deleted.namespace.clone(),
803 name: deleted.name.clone(),
804 };
805 if !confirmed(&path, a.confirm.as_deref().unwrap_or_default()) {
806 return Ok(fail(confirm_refusal(&path)));
807 }
808 self.purge_now(&deleted, Some(&a.actor.id)).await?;
809 self.record(vec![entry(
810 AuditActor::of(&a.actor),
811 "repo.purged",
812 a.surface,
813 &path,
814 "owner",
815 "Permanently deleted, with its git data".to_owned(),
816 )])
817 .await;
818 Ok(Outcome::Ok(true))
819 }
820
821 /// Removes a deleted repository for good: git data first, so a failure
822 /// leaves it to the next sweep, then its rows; then says so.
823 async fn purge_now(&self, deleted: &DeletedRepo, actor: Option<&str>) -> Result<()> {
824 for key in self.registry.store_keys(&deleted.id).await? {
825 self.store.delete(&key).await?;
826 }
827 self.registry.erase(&deleted.id).await?;
828 // Who had access to it goes with it.
829 if let Some(identity) = &self.identity {
830 let forgotten: Result<bool> = g1t_kit::call(
831 identity,
832 "forget_repo_access",
833 &g1t_contracts::access::ForgetRepoAccessArgs {
834 repo_id: deleted.id.clone(),
835 },
836 )
837 .await;
838 if let Err(error) = forgotten {
839 worker::console_error!("access to {} not forgotten: {error}", deleted.id);
840 }
841 }
842 self.publish(NewEvent {
843 kind: "repo.purged",
844 source: SOURCE,
845 repo_id: Some(deleted.id.clone()),
846 actor: actor.map(str::to_owned),
847 data: RepoPurged {
848 repo_id: deleted.id.clone(),
849 namespace: deleted.namespace.clone(),
850 name: deleted.name.clone(),
851 },
852 })
853 .await
854 }
855
856 /// `purge_due`: see `g1t_contracts::repos::PurgeDueArgs`.
857 pub(crate) async fn purge_due(&self, a: PurgeDueArgs) -> Result<u32> {
858 let limit = a.limit.unwrap_or(PURGES_PER_SWEEP).clamp(1, 100);
859 let due = self.registry.due(&rfc3339(now_ms()), limit).await?;
860 let mut purged = 0;
861 for deleted in due {
862 match self.purge_now(&deleted, None).await {
863 Ok(()) => {
864 purged += 1;
865 let path = RepoPath {
866 namespace: deleted.namespace.clone(),
867 name: deleted.name.clone(),
868 };
869 self.record(vec![entry(
870 g1t_actor(),
871 "repo.purged",
872 None,
873 &path,
874 "schedule",
875 format!("Purged {RESTORE_DAYS} days after {} deleted it", deleted.deleted_by),
876 )])
877 .await;
878 }
879 Err(error) => worker::console_error!("{} not purged: {error}", deleted.id),
880 }
881 }
882 Ok(purged)
883 }
884
885 /// Purges whatever deleted repositories a deleted workspace left.
886 pub(crate) async fn purge_workspace(&self, namespace: &str) -> Result<()> {
887 for deleted in self.registry.deleted_ids_in(namespace).await? {
888 if let Err(error) = self.purge_now(&deleted, None).await {
889 worker::console_error!("{} not purged with its workspace: {error}", deleted.id);
890 }
891 }
892 Ok(())
893 }
894
895 /// `rename`: see `g1t_contracts::repos::RenameArgs`.
896 pub(crate) async fn rename(&self, a: RenameArgs) -> Result<Outcome<Repo>> {
897 let repo = match self.owned(&a.actor, &a.path, "rename", Capability::Administer).await? {
898 Ok(repo) => repo,
899 Err(refusal) => return Ok(fail(refusal)),
900 };
901 let wanted = RepoPath {
902 namespace: repo.namespace.clone(),
903 name: a.name.trim().to_lowercase(),
904 };
905 let held = match self.registry.by_path_any(&wanted).await? {
906 None => Held::Free,
907 Some((_, None)) => Held::ByRepo,
908 Some((_, Some(_))) => Held::ByDeleted,
909 };
910 let name = match new_name(&repo.namespace, &repo.name, &a.name, held) {
911 Ok(name) => name,
912 Err(refusal) => return Ok(fail(refusal)),
913 };
914 // The git store key stays what it was; the new path must not
915 // change where it is read from.
916 let key = store_key(&repo);
917 self.registry.rename(&repo, &name).await?;
918 let renamed = Repo {
919 name: name.clone(),
920 ..repo.clone()
921 };
922 remember_store(&renamed, &key);
923 let from = path_of(&repo);
924 let to = path_of(&renamed);
925 if let Some(identity) = &self.identity {
926 let moved: Result<bool> = g1t_kit::call(
927 identity,
928 "transfer_repo_scopes",
929 &TransferRepoScopesArgs {
930 from: from.clone(),
931 to: to.clone(),
932 },
933 )
934 .await;
935 if let Err(error) = moved {
936 worker::console_error!("agent scopes for {} not moved: {error}", repo.id);
937 }
938 }
939 self.publish(NewEvent {
940 kind: "repo.renamed",
941 source: SOURCE,
942 repo_id: Some(repo.id.clone()),
943 actor: Some(a.actor.id.clone()),
944 data: RepoRenamed {
945 repo_id: repo.id.clone(),
946 namespace: repo.namespace.clone(),
947 from: repo.name.clone(),
948 to: name.clone(),
949 },
950 })
951 .await?;
952 self.record(vec![entry(
953 AuditActor::of(&a.actor),
954 "repo.renamed",
955 a.surface,
956 &to,
957 "owner",
958 format!("Renamed from {}/{}", from.namespace, from.name),
959 )])
960 .await;
961 Ok(Outcome::Ok(renamed))
962 }
963
964 /// `archive`: see `g1t_contracts::repos::ArchiveArgs`.
965 pub(crate) async fn archive(&self, a: ArchiveArgs) -> Result<Outcome<Repo>> {
966 let what = if a.archived { "archive" } else { "unarchive" };
967 let repo = match self.owned(&a.actor, &a.path, what, Capability::Administer).await? {
968 Ok(repo) => repo,
969 Err(refusal) => return Ok(fail(refusal)),
970 };
971 if repo.archived() == a.archived {
972 return Ok(Outcome::Ok(repo));
973 }
974 let at = a.archived.then(|| rfc3339(now_ms()));
975 self.registry.set_archived(&repo.id, at.as_deref()).await?;
976 let changed = Repo {
977 archived_at: at,
978 ..repo
979 };
980 let kind = if a.archived { "repo.archived" } else { "repo.unarchived" };
981 self.publish(NewEvent {
982 kind,
983 source: SOURCE,
984 repo_id: Some(changed.id.clone()),
985 actor: Some(a.actor.id.clone()),
986 data: RepoArchived {
987 repo_id: changed.id.clone(),
988 namespace: changed.namespace.clone(),
989 name: changed.name.clone(),
990 archived: a.archived,
991 },
992 })
993 .await?;
994 self.record(vec![entry(
995 AuditActor::of(&a.actor),
996 kind,
997 a.surface,
998 &path_of(&changed),
999 "owner",
1000 if a.archived {
1001 "Archived: read-only".to_owned()
1002 } else {
1003 "Unarchived".to_owned()
1004 },
1005 )])
1006 .await;
1007 Ok(Outcome::Ok(changed))
1008 }
1009
1010 /// `set_visibility`: see `g1t_contracts::repos::SetVisibilityArgs`.
1011 pub(crate) async fn set_visibility(&self, a: SetVisibilityArgs) -> Result<Outcome<Repo>> {
1012 let repo = match self.owned(&a.actor, &a.path, "change the visibility of", Capability::Administer).await? {
1013 Ok(repo) => repo,
1014 Err(refusal) => return Ok(fail(refusal)),
1015 };
1016 if !confirmed(&path_of(&repo), &a.confirm) {
1017 return Ok(fail(confirm_refusal(&path_of(&repo))));
1018 }
1019 self.change_visibility(repo, a.is_private, &a.actor, a.surface).await
1020 }
1021
1022 /// Makes `repo` public or private, if the workspace's storage allows,
1023 /// and says so: `repo.updated` and `repo.visibility_changed`. The
1024 /// caller has checked the actor may.
1025 pub(crate) async fn change_visibility(
1026 &self,
1027 repo: Repo,
1028 private: bool,
1029 actor: &User,
1030 surface: Option<Surface>,
1031 ) -> Result<Outcome<Repo>> {
1032 if repo.is_private == private {
1033 return Ok(Outcome::Ok(repo));
1034 }
1035 let facts = if private {
1036 VisibilityFacts {
1037 to_private: true,
1038 free: git_ops::is_free(self.billing.as_ref(), &repo.namespace).await,
1039 private_bytes: self.registry.private_bytes(&repo.namespace).await.unwrap_or(0),
1040 bytes: self.registry.stored_bytes(&repo.id).await?,
1041 free_private_bytes: self.free_private_bytes,
1042 }
1043 } else {
1044 VisibilityFacts::default()
1045 };
1046 if let Err(refusal) = visibility_check(&repo.namespace, &facts) {
1047 return Ok(fail(refusal));
1048 }
1049 self.registry.set_private(&repo.id, private).await?;
1050 let changed = Repo {
1051 is_private: private,
1052 ..repo
1053 };
1054 self.publish(NewEvent {
1055 kind: "repo.updated",
1056 source: SOURCE,
1057 repo_id: Some(changed.id.clone()),
1058 actor: Some(actor.id.clone()),
1059 data: RepoUpdated {
1060 repo_id: changed.id.clone(),
1061 namespace: changed.namespace.clone(),
1062 name: changed.name.clone(),
1063 is_private: private,
1064 visibility_changed: true,
1065 },
1066 })
1067 .await?;
1068 self.publish(NewEvent {
1069 kind: "repo.visibility_changed",
1070 source: SOURCE,
1071 repo_id: Some(changed.id.clone()),
1072 actor: Some(actor.id.clone()),
1073 data: RepoVisibilityChanged {
1074 repo_id: changed.id.clone(),
1075 is_private: private,
1076 },
1077 })
1078 .await?;
1079 self.record(vec![entry(
1080 AuditActor::of(actor),
1081 "repo.visibility_changed",
1082 surface,
1083 &path_of(&changed),
1084 "owner",
1085 if private { "Made private".to_owned() } else { "Made public".to_owned() },
1086 )])
1087 .await;
1088 Ok(Outcome::Ok(changed))
1089 }
1090
1091 /// The repository at `path` someone is changing the branches of:
1092 /// found, not a working copy, the actor allowed `capability` on it
1093 /// (Push to rename a branch, Administer to change the default),
1094 /// verified, and not archived.
1095 async fn writable_by(
1096 &self,
1097 actor: &User,
1098 path: &RepoPath,
1099 capability: Capability,
1100 ) -> Result<std::result::Result<Repo, Refusal>> {
1101 let viewer = Some(actor.clone());
1102 let Some(repo) = self.readable(path, &viewer).await? else {
1103 return Ok(Err((FailureCode::NotFound, "Repository not found.".into())));
1104 };
1105 if repo.fork_of.is_some() || !crate::registry::can(&repo, &viewer, capability) {
1106 return Ok(Err((
1107 FailureCode::Forbidden,
1108 access::needs(capability, &format!("{}/{}", repo.namespace, repo.name)),
1109 )));
1110 }
1111 if !actor.verified {
1112 return Ok(Err((FailureCode::Forbidden, UNVERIFIED.into())));
1113 }
1114 if let Some(refusal) = archived_refusal(&repo) {
1115 return Ok(Err(refusal));
1116 }
1117 Ok(Ok(repo))
1118 }
1119
1120 /// `set_default_branch`: see `g1t_contracts::repos::SetDefaultBranchArgs`.
1121 pub(crate) async fn set_default_branch(&self, a: SetDefaultBranchArgs) -> Result<Outcome<Repo>> {
1122 let repo = match self.writable_by(&a.actor, &a.path, Capability::Administer).await? {
1123 Ok(repo) => repo,
1124 Err(refusal) => return Ok(fail(refusal)),
1125 };
1126 let branch = a.branch.trim().to_owned();
1127 if branch == repo.default_branch {
1128 return Ok(Outcome::Ok(repo));
1129 }
1130 let git = self.store.open(&store_key(&repo)).await?;
1131 if !git.branches().await?.iter().any(|b| b.name == branch) {
1132 return Ok(Outcome::fail(
1133 FailureCode::Invalid,
1134 format!("There is no branch named {branch}. Push it first."),
1135 ));
1136 }
1137 self.registry.set_default_branch(&repo.id, &branch).await?;
1138 let from = repo.default_branch.clone();
1139 let changed = Repo {
1140 default_branch: branch.clone(),
1141 ..repo
1142 };
1143 self.forks_follow(&changed, &from, &branch, false).await;
1144 self.publish(NewEvent {
1145 kind: "repo.default_branch_changed",
1146 source: SOURCE,
1147 repo_id: Some(changed.id.clone()),
1148 actor: Some(a.actor.id.clone()),
1149 data: RepoDefaultBranchChanged {
1150 repo_id: changed.id.clone(),
1151 from: from.clone(),
1152 to: branch.clone(),
1153 renamed: false,
1154 },
1155 })
1156 .await?;
1157 self.record(vec![entry(
1158 AuditActor::of(&a.actor),
1159 "repo.default_branch_changed",
1160 a.surface,
1161 &path_of(&changed),
1162 "member",
1163 format!("Default branch changed from {from} to {branch}"),
1164 )])
1165 .await;
1166 Ok(Outcome::Ok(changed))
1167 }
1168
1169 /// `rename_branch`: see `g1t_contracts::repos::RenameBranchArgs`.
1170 pub(crate) async fn rename_branch(&self, a: RenameBranchArgs) -> Result<Outcome<Repo>> {
1171 let repo = match self.writable_by(&a.actor, &a.path, Capability::Push).await? {
1172 Ok(repo) => repo,
1173 Err(refusal) => return Ok(fail(refusal)),
1174 };
1175 let from = a.from.trim().to_owned();
1176 let is_default = from == repo.default_branch;
1177 if is_default
1178 && let Err(refusal) = admin_only(
1179 Asker::on(&a.actor, &repo),
1180 &repo.namespace,
1181 "rename the default branch of",
1182 Capability::Administer,
1183 )
1184 {
1185 return Ok(fail(refusal));
1186 }
1187 let git = self.store.open(&store_key(&repo)).await?;
1188 let branches = git.branches().await?;
1189 let names: Vec<String> = branches.iter().map(|b| b.name.clone()).collect();
1190 let to = match branch_rename(&from, &a.to, &names) {
1191 Ok(to) => to,
1192 Err(refusal) => return Ok(fail(refusal)),
1193 };
1194 let Some(head) = branches.iter().find(|b| b.name == from).map(|b| b.hash.clone()) else {
1195 return Ok(not_found());
1196 };
1197 let access = git.access(Scope::Write).await?;
1198 if let Err(reason) = land::push_pack(&access, &to, None, &head, EMPTY_PACK.to_vec()).await? {
1199 return Ok(Outcome::fail(FailureCode::Conflict, format!("{to} could not be made: {reason}")));
1200 }
1201 // The default moves before the old name goes, so it never names a
1202 // branch that is not there.
1203 if is_default {
1204 self.registry.set_default_branch(&repo.id, &to).await?;
1205 }
1206 if let Err(reason) = land::delete_ref(&access, &from, &head).await? {
1207 worker::console_error!("{from} not removed after renaming it to {to}: {reason}");
1208 }
1209 self.registry.add_branch_redirect(&repo.id, &from, &to).await?;
1210 let changed = if is_default {
1211 Repo {
1212 default_branch: to.clone(),
1213 ..repo
1214 }
1215 } else {
1216 repo
1217 };
1218 if is_default {
1219 self.forks_follow(&changed, &from, &to, true).await;
1220 }
1221 self.publish(NewEvent {
1222 kind: "branch.renamed",
1223 source: SOURCE,
1224 repo_id: Some(changed.id.clone()),
1225 actor: Some(a.actor.id.clone()),
1226 data: BranchRenamed {
1227 repo_id: changed.id.clone(),
1228 from: from.clone(),
1229 to: to.clone(),
1230 default_branch: is_default,
1231 },
1232 })
1233 .await?;
1234 if is_default {
1235 self.publish(NewEvent {
1236 kind: "repo.default_branch_changed",
1237 source: SOURCE,
1238 repo_id: Some(changed.id.clone()),
1239 actor: Some(a.actor.id.clone()),
1240 data: RepoDefaultBranchChanged {
1241 repo_id: changed.id.clone(),
1242 from: from.clone(),
1243 to: to.clone(),
1244 renamed: true,
1245 },
1246 })
1247 .await?;
1248 }
1249 self.record(vec![entry(
1250 AuditActor::of(&a.actor),
1251 "branch.renamed",
1252 a.surface,
1253 &path_of(&changed),
1254 if is_default { "owner" } else { "member" },
1255 format!("Branch {from} renamed to {to}"),
1256 )])
1257 .await;
1258 Ok(Outcome::Ok(changed))
1259 }
1260
1261 /// Pull requests' working copies name their branch after the default
1262 /// branch of the repository they came from. When it changes, the
1263 /// newest of them get a branch of the new name at the same commit (and,
1264 /// for a rename, lose the old one), so agents and merges find it.
1265 /// Best effort: a copy that cannot follow is logged and left.
1266 async fn forks_follow(&self, repo: &Repo, from: &str, to: &str, renamed: bool) {
1267 let forks = match self.registry.forks_of(&repo.id, FORKS_FOLLOWING).await {
1268 Ok(forks) => forks,
1269 Err(error) => {
1270 worker::console_error!("working copies of {} not listed: {error}", repo.id);
1271 return;
1272 }
1273 };
1274 for fork in forks {
1275 let followed: Result<()> = async {
1276 let git = self.store.open(&store_key(&fork)).await?;
1277 let branches = git.branches().await?;
1278 let Some(head) = branches.iter().find(|b| b.name == from).map(|b| b.hash.clone()) else {
1279 return Ok(());
1280 };
1281 let access = git.access(Scope::Write).await?;
1282 if !branches.iter().any(|b| b.name == to)
1283 && let Err(reason) = land::push_pack(&access, to, None, &head, EMPTY_PACK.to_vec()).await?
1284 {
1285 worker::console_error!("working copy {} did not get {to}: {reason}", fork.id);
1286 return Ok(());
1287 }
1288 self.registry.set_default_branch(&fork.id, to).await?;
1289 if renamed && let Err(reason) = land::delete_ref(&access, from, &head).await? {
1290 worker::console_error!("working copy {} kept {from}: {reason}", fork.id);
1291 }
1292 Ok(())
1293 }
1294 .await;
1295 if let Err(error) = followed {
1296 worker::console_error!("working copy {} did not follow {from} → {to}: {error}", fork.id);
1297 }
1298 }
1299 }
1300
1301 /// `resolve_branch`: see `g1t_contracts::repos::ResolveBranchArgs`.
1302 pub(crate) async fn resolve_branch(&self, a: ResolveBranchArgs) -> Result<Option<String>> {
1303 let Some(now) = self.registry.branch_redirect(&a.repo_id, &a.branch).await? else {
1304 return Ok(None);
1305 };
1306 // A branch made again under the old name ends the redirect.
1307 let Some(repo) = self.registry.by_id(&a.repo_id).await? else {
1308 return Ok(None);
1309 };
1310 let git = self.store.open(&store_key(&repo)).await?;
1311 let branches = git.branches().await?;
1312 if branches.iter().any(|b| b.name == a.branch) || !branches.iter().any(|b| b.name == now) {
1313 return Ok(None);
1314 }
1315 Ok(Some(now))
1316 }
1317
1318 /// `status_by_id`: see `g1t_contracts::repos::StatusByIdArgs`.
1319 pub(crate) async fn status_by_id(&self, a: StatusByIdArgs) -> Result<RepoStatus> {
1320 self.registry.status(&a.id).await
1321 }
1322}
1323
1324#[cfg(test)]
1325mod tests {
1326 use super::*;
1327
1328 fn owner() -> Asker {
1329 Asker {
1330 person: true,
1331 verified: true,
1332 role: Some(Role::Owner),
1333 repo_role: Some(RepoRole::Admin),
1334 }
1335 }
1336
1337 fn path() -> RepoPath {
1338 RepoPath {
1339 namespace: "acme".into(),
1340 name: "rocket".into(),
1341 }
1342 }
1343
1344 #[test]
1345 fn only_a_verified_person_who_owns_the_workspace_may() {
1346 assert!(owner_only(owner(), "acme", "delete").is_ok());
1347 let member = Asker { role: Some(Role::Member), ..owner() };
1348 let (code, message) = owner_only(member, "acme", "delete").unwrap_err();
1349 assert_eq!(code, FailureCode::Forbidden);
1350 assert_eq!(message, "Only an owner of acme can delete its repositories.");
1351 assert_eq!(owner_only(Asker { person: false, ..owner() }, "acme", "delete").unwrap_err().0, FailureCode::Forbidden);
1352 assert_eq!(owner_only(Asker { verified: false, ..owner() }, "acme", "delete").unwrap_err().0, FailureCode::Forbidden);
1353 // Outside the workspace, a private repository is not there at all.
1354 assert_eq!(owner_only(Asker { role: None, ..owner() }, "acme", "delete").unwrap_err().0, FailureCode::NotFound);
1355 }
1356
1357 /// Renaming, archiving and changing visibility take Admin on the
1358 /// repository, which a direct grant can give; deleting and
1359 /// transferring still take an owner of the workspace.
1360 #[test]
1361 fn admin_on_the_repository_may_administer_but_not_delete() {
1362 let admin = Asker { role: None, repo_role: Some(RepoRole::Admin), ..owner() };
1363 assert!(admin_only(admin, "acme", "rename", Capability::Administer).is_ok());
1364 let (code, message) = admin_only(admin, "acme", "delete", Capability::Delete).unwrap_err();
1365 assert_eq!(code, FailureCode::Forbidden);
1366 assert_eq!(message, "Only an owner of acme can delete its repositories.");
1367 let member_admin = Asker { role: Some(Role::Member), ..admin };
1368 assert_eq!(admin_only(member_admin, "acme", "delete", Capability::Delete).unwrap_err().0, FailureCode::Forbidden);
1369 // Write (the default base permission) cannot rename.
1370 let writer = Asker { role: Some(Role::Member), repo_role: Some(RepoRole::Write), ..owner() };
1371 let (code, message) = admin_only(writer, "acme", "rename", Capability::Administer).unwrap_err();
1372 assert_eq!(code, FailureCode::Forbidden);
1373 assert_eq!(message, "You need the Admin role on a repository of acme to rename it.");
1374 // Someone who can read a public repository is refused, not told it is missing.
1375 let reader = Asker { role: None, repo_role: Some(RepoRole::Read), ..owner() };
1376 assert_eq!(admin_only(reader, "acme", "archive", Capability::Administer).unwrap_err().0, FailureCode::Forbidden);
1377 let stranger = Asker { role: None, repo_role: None, ..owner() };
1378 assert_eq!(admin_only(stranger, "acme", "archive", Capability::Administer).unwrap_err().0, FailureCode::NotFound);
1379 assert_eq!(admin_only(Asker { person: false, ..owner() }, "acme", "rename", Capability::Administer).unwrap_err().0, FailureCode::Forbidden);
1380 }
1381
1382 #[test]
1383 fn the_full_name_confirms_in_any_case() {
1384 assert!(confirmed(&path(), "acme/rocket"));
1385 assert!(confirmed(&path(), " ACME/Rocket "));
1386 assert!(!confirmed(&path(), "rocket"));
1387 assert!(!confirmed(&path(), ""));
1388 }
1389
1390 #[test]
1391 fn a_deleted_repository_is_restorable_for_thirty_days_then_due() {
1392 let deleted_at = 1_790_000_000_000u64;
1393 let purge = purge_after(deleted_at);
1394 assert_eq!(purge, rfc3339(deleted_at + 30 * 86_400_000));
1395 let day = 86_400_000u64;
1396 let at = |ms: u64| rfc3339(ms);
1397 assert_eq!(state(None, None, &at(deleted_at)), State::Active);
1398 assert_eq!(state(Some("2026-10-01T00:00:00.000Z"), None, &at(deleted_at)), State::Archived);
1399 let deleted = Some((at(deleted_at), purge.clone()));
1400 let deleted = deleted.as_ref().map(|(a, b)| (a.as_str(), b.as_str()));
1401 assert_eq!(state(None, deleted, &at(deleted_at + day)), State::Deleted);
1402 assert_eq!(state(None, deleted, &at(deleted_at + 30 * day - 1)), State::Deleted);
1403 assert_eq!(state(None, deleted, &at(deleted_at + 30 * day)), State::Due);
1404 // Archived and then deleted: deleted is what counts.
1405 assert_eq!(state(Some("x"), deleted, &at(deleted_at + day)), State::Deleted);
1406 assert!(restorable(&purge, &at(deleted_at + 29 * day)));
1407 assert!(!restorable(&purge, &at(deleted_at + 31 * day)));
1408 }
1409
1410 #[test]
1411 fn a_rename_needs_a_valid_free_name() {
1412 assert_eq!(new_name("acme", "rocket", " Booster ", Held::Free).unwrap(), "booster");
1413 assert_eq!(new_name("acme", "rocket", "rocket", Held::Free).unwrap_err().0, FailureCode::Invalid);
1414 assert_eq!(new_name("acme", "rocket", "no spaces", Held::Free).unwrap_err().0, FailureCode::Invalid);
1415 assert_eq!(new_name("acme", "rocket", "x.git", Held::Free).unwrap_err().0, FailureCode::Invalid);
1416 let (code, message) = new_name("acme", "rocket", "booster", Held::ByRepo).unwrap_err();
1417 assert_eq!(code, FailureCode::Conflict);
1418 assert_eq!(message, "acme already has a repository named booster.");
1419 let (code, message) = new_name("acme", "rocket", "booster", Held::ByDeleted).unwrap_err();
1420 assert_eq!(code, FailureCode::Conflict);
1421 assert!(message.contains("deleted recently"));
1422 }
1423
1424 fn repo(archived: bool) -> Repo {
1425 Repo {
1426 id: "rep_1".into(),
1427 namespace: "acme".into(),
1428 name: "rocket".into(),
1429 description: None,
1430 is_private: false,
1431 owner_id: "usr_1".into(),
1432 default_branch: "main".into(),
1433 fork_of: None,
1434 protected: false,
1435 created_at: String::new(),
1436 topics: Vec::new(),
1437 website: None,
1438 archived_at: archived.then(|| "2026-10-05T00:00:00.000Z".to_owned()),
1439 }
1440 }
1441
1442 #[test]
1443 fn an_archived_repository_refuses_writes_with_the_reason() {
1444 assert!(archived_refusal(&repo(false)).is_none());
1445 let (code, message) = archived_refusal(&repo(true)).unwrap();
1446 assert_eq!(code, FailureCode::Forbidden);
1447 assert_eq!(message, "acme/rocket is archived, so it is read-only. An owner can unarchive it in its settings.");
1448 }
1449
1450 #[test]
1451 fn going_private_on_a_free_workspace_needs_room() {
1452 let full = VisibilityFacts {
1453 to_private: true,
1454 free: true,
1455 private_bytes: 900_000_000,
1456 bytes: 200_000_000,
1457 free_private_bytes: 1_000_000_000,
1458 };
1459 assert_eq!(visibility_check("acme", &full).unwrap_err().0, FailureCode::PaymentRequired);
1460 assert!(visibility_check("acme", &VisibilityFacts { free: false, ..full }).is_ok());
1461 let full = VisibilityFacts {
1462 to_private: true,
1463 free: true,
1464 private_bytes: 900_000_000,
1465 bytes: 200_000_000,
1466 free_private_bytes: 1_000_000_000,
1467 };
1468 // Going public is never refused.
1469 assert!(visibility_check("acme", &VisibilityFacts { to_private: false, ..full }).is_ok());
1470 let light = VisibilityFacts {
1471 to_private: true,
1472 free: true,
1473 private_bytes: 1_000,
1474 bytes: 1_000,
1475 free_private_bytes: 1_000_000_000,
1476 };
1477 assert!(visibility_check("acme", &light).is_ok());
1478 }
1479
1480 #[test]
1481 fn a_branch_is_renamed_to_a_free_valid_name() {
1482 let branches = vec!["main".to_owned(), "dev".to_owned()];
1483 assert_eq!(branch_rename("main", " trunk ", &branches).unwrap(), "trunk");
1484 assert_eq!(branch_rename("nope", "trunk", &branches).unwrap_err().0, FailureCode::NotFound);
1485 assert_eq!(branch_rename("main", "dev", &branches).unwrap_err().0, FailureCode::Conflict);
1486 assert_eq!(branch_rename("main", "main", &branches).unwrap_err().0, FailureCode::Invalid);
1487 assert_eq!(branch_rename("main", "a b", &branches).unwrap_err().0, FailureCode::Invalid);
1488 assert_eq!(branch_rename("main", "g1t-queue", &branches).unwrap_err().0, FailureCode::Invalid);
1489 }
1490
1491 #[test]
1492 fn the_empty_pack_is_well_formed() {
1493 assert_eq!(EMPTY_PACK.len(), 32);
1494 assert!(EMPTY_PACK.starts_with(b"PACK\0\0\0\x02\0\0\0\0"));
1495 }
1496}