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