Skip to content
1,985 linesCodeBlameRaw

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

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

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