Skip to content

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

g1t.sh
663 linesCodeBlameRaw
1//! Forks people make into their workspaces: a fork network.
2//!
3//! A fork is an ordinary repository of the workspace it goes in, with its
4//! own access, settings, rules, issues and pull requests, that remembers the
5//! repository it came from (`parent_id`). That is all it shares: it can be
6//! synced from its parent and can open pull requests into it. It is not a
7//! pull request's working copy (`fork_of`, namespace `pulls`, forks.rs),
8//! which is hidden, belongs to one pull request and goes with its source.
9//!
10//! What a fork gets is what a fork gets elsewhere: the code (the default
11//! branch, or every branch and tag), and its project starts with the source
12//! project's description. Settings, secrets and variables, rules, webhooks,
13//! environments, domains and deployments
14//! stay behind, and the actions service keeps a fork's workflows off until
15//! someone turns them on.
16//!
17//! Where it is stored: an Artifacts fork stays in its source's namespace,
18//! so it is made there unless the workspace keeps its data in the EU and
19//! the source is not there; then the code is copied into the EU namespace
20//! instead. Either way its size counts toward the workspace it goes in, in
21//! full: whether Artifacts shares a fork's objects with its source is not
22//! documented, so a fork is never assumed to be cheaper than a copy.
23//!
24//! Who can see it: a fork is exactly as visible as its parent when it is
25//! made, and cannot change that while it is a fork. When the parent goes
26//! public or private, its forks are detached and keep what they were. A
27//! private repository is forked only when its admins allow it.
28
29use std::collections::HashSet;
30
31use futures_util::future::try_join;
32use g1t_contracts::access::Capability;
33use g1t_contracts::audit::{AuditActor, AuditOutcome, AuditTarget, NewAuditEntry, RecordAuditArgs, Surface};
34use g1t_contracts::events::{NewEvent, RepoCreated, RepoForked};
35use g1t_contracts::repos::{
36 DetachForkArgs, ForkRepoArgs, ForksArgs, PullBranchUpdate, Repo, RepoPath, SyncForkArgs, SyncStatus, SyncedFork,
37};
38use g1t_contracts::time::rfc3339;
39use g1t_contracts::{FailureCode, Outcome, PrincipalKind, Role, Viewer, is_valid_repo_name, new_id};
40use g1t_kit::now_ms;
41use worker::Result;
42
43use crate::lifecycle::{VisibilityFacts, visibility_check};
44use crate::registry::{RepoRow, can, can_read, can_write, store_key};
45use crate::store::{GitRepo, GitStore, Scope, locate};
46use crate::{MAX_ANCESTRY, Repos, SOURCE, UNVERIFIED, copy, descends_from, land, lifecycle, not_found, shards};
47
48/// The most forks `forks` lists.
49const MAX_LISTED: u32 = 100;
50/// The most conflicting files `sync_fork` names.
51const MAX_CONFLICTS: usize = 20;
52/// How many private forks one sweep checks, the least recently checked
53/// first.
54const CHECKS_PER_SWEEP: u32 = 50;
55
56type Refusal = (FailureCode, String);
57
58/// Why `source` may not be forked, if it may not. `None` when it may.
59pub fn fork_refusal(source: &Repo) -> Option<Refusal> {
60 if source.fork_of.is_some() {
61 return Some((FailureCode::Invalid, "A pull request's working copy cannot be forked; fork the repository it belongs to.".to_owned()));
62 }
63 if source.is_private && !source.allow_forking {
64 return Some((
65 FailureCode::Forbidden,
66 format!(
67 "{}/{} is private, and its admins have not allowed forking it. They can in its settings.",
68 source.namespace, source.name
69 ),
70 ));
71 }
72 None
73}
74
75/// Whether a fork made into `namespace` with store key `wanted` can be an
76/// Artifacts fork of a source kept in `source_namespace`: the same store
77/// namespace, and the workspace's residency allows it there.
78pub fn forks_in_place(source_namespace: &str, residency: shards::Residency, eu: Option<&str>) -> bool {
79 match residency {
80 shards::Residency::Anywhere => true,
81 shards::Residency::Eu => eu == Some(source_namespace),
82 }
83}
84
85/// The branch of the parent a fork's `branch` follows: the parent's
86/// default branch for the fork's default branch, the same name otherwise.
87pub fn upstream_branch(branch: &str, fork: &Repo, parent: &Repo) -> String {
88 if branch == fork.default_branch { parent.default_branch.clone() } else { branch.to_owned() }
89}
90
91/// How many commits of `history` (newest first) come before any of
92/// `known`: what one side has that the other lacks, as far as either was
93/// read.
94pub fn not_in(history: &[g1t_contracts::repos::Commit], known: &HashSet<&str>) -> u32 {
95 history.iter().take_while(|commit| !known.contains(commit.hash.as_str())).count() as u32
96}
97
98/// The most merge commits whose other parents `reachable` follows.
99const MERGES_FOLLOWED: usize = 10;
100
101/// The commits `history` (a first-parent log, newest first) reaches: those
102/// it lists, and the first-parent history of the other parents of its
103/// first [`MERGES_FOLLOWED`] merge commits, so commits a sync merged in
104/// count as had.
105async fn reachable<R: GitRepo>(git: &R, history: &[g1t_contracts::repos::Commit]) -> Result<HashSet<String>> {
106 let mut known: HashSet<String> = history.iter().map(|commit| commit.hash.clone()).collect();
107 let merged: Vec<&String> = history
108 .iter()
109 .filter(|commit| commit.parents.len() > 1)
110 .take(MERGES_FOLLOWED)
111 .flat_map(|commit| commit.parents.iter().skip(1))
112 .collect();
113 for parent in merged {
114 if known.contains(parent) {
115 continue;
116 }
117 known.extend(git.log(parent, MAX_ANCESTRY).await?.into_iter().map(|commit| commit.hash));
118 }
119 Ok(known)
120}
121
122impl<S: GitStore> Repos<S> {
123 /// `fork`: see `ForkRepoArgs`.
124 pub(crate) async fn fork(&self, a: ForkRepoArgs) -> Result<Outcome<Repo>> {
125 if !a.actor.verified {
126 return Ok(Outcome::fail(FailureCode::Forbidden, UNVERIFIED));
127 }
128 let viewer = Some(a.actor.clone());
129 let Some(source) = self.readable(&a.path, &viewer).await? else {
130 return Ok(not_found());
131 };
132 if let Some((code, message)) = fork_refusal(&source) {
133 return Ok(Outcome::fail(code, message));
134 }
135 let name = a.name.as_deref().map(str::trim).filter(|name| !name.is_empty()).unwrap_or(&source.name).to_lowercase();
136 if !is_valid_repo_name(&name) {
137 return Ok(Outcome::fail(FailureCode::Invalid, "Use letters, digits, dots, hyphens and underscores only."));
138 }
139 let namespace = a.namespace.trim().to_lowercase();
140 if namespace.is_empty() {
141 return Ok(Outcome::fail(FailureCode::Invalid, "Say which workspace to fork the repository into."));
142 }
143 let Some(role) = a.actor.role_in(&namespace) else {
144 return Ok(Outcome::fail(FailureCode::Forbidden, "You are not a member of that workspace."));
145 };
146 let role = if a.actor.kind == PrincipalKind::Workspace { Role::Owner } else { role };
147 if let Some(why) = a.actor.privileges_in(&namespace).creation_refusal(role, source.is_private, &namespace) {
148 return Ok(Outcome::fail(FailureCode::Forbidden, why));
149 }
150 let path = RepoPath { namespace: namespace.clone(), name };
151 if path.namespace == source.namespace && path.name == source.name {
152 return Ok(Outcome::fail(FailureCode::Conflict, "A repository cannot be forked onto itself. Choose another name or workspace."));
153 }
154 match self.registry.by_path_any(&path).await? {
155 Some((_, None)) => {
156 return Ok(Outcome::fail(FailureCode::Conflict, "That workspace already has a repository with that name."));
157 }
158 Some((_, Some(_))) => {
159 return Ok(Outcome::fail(
160 FailureCode::Conflict,
161 format!(
162 "{}/{} was deleted recently and can still be restored, so its name is taken. Restore it, or delete it permanently from the workspace's Recently deleted list.",
163 path.namespace, path.name
164 ),
165 ));
166 }
167 None => {}
168 }
169 // Its copy is made in the source's store namespace: not while that
170 // moves (moves.rs).
171 let source = match self.unpaused(source).await? {
172 Ok(source) => source,
173 Err((code, message)) => return Ok(Outcome::fail(code, message)),
174 };
175 // A fork counts toward its workspace in full, so a free workspace's
176 // private storage has to have room for all of it.
177 let bytes = self.registry.stored_bytes(&source.id).await?;
178 if source.is_private {
179 let facts = VisibilityFacts {
180 to_private: true,
181 free: crate::git_ops::is_free(self.billing.as_ref(), &namespace).await,
182 private_bytes: self.registry.private_bytes(&namespace).await.unwrap_or(0),
183 bytes,
184 free_private_bytes: self.free_private_bytes,
185 };
186 if let Err((code, message)) = visibility_check(&namespace, &facts) {
187 return Ok(Outcome::fail(code, message));
188 }
189 }
190
191 let now = now_ms();
192 let fork = Repo {
193 id: new_id("rep", now),
194 namespace: path.namespace,
195 name: path.name,
196 is_private: source.is_private,
197 owner_id: a.actor.id.clone(),
198 default_branch: source.default_branch.clone(),
199 fork_of: None,
200 parent_id: Some(source.id.clone()),
201 forked_from: Some(source.id.clone()),
202 parent: Some(RepoPath { namespace: source.namespace.clone(), name: source.name.clone() }),
203 allow_forking: false,
204 max_ref_updates: None,
205 protected: false,
206 created_at: rfc3339(now),
207 topics: Vec::new(),
208 website: None,
209 archived_at: None,
210 };
211 let default = self.store.default_namespace();
212 let (source_namespace, _) = locate(&store_key(&source));
213 let residency = self.residency_of(&fork.namespace).await?;
214 if forks_in_place(&source_namespace, residency, self.placement.eu.as_deref()) {
215 if !self.registry.lock_copy(&source, &fork).await? {
216 return Ok(Outcome::fail(FailureCode::Conflict, "This repository is already being copied. Try again after the copy finishes."));
217 }
218 let (measured, pending) = self.registry.copy_size(&source).await?;
219 let bytes = measured.saturating_add(pending);
220 self.registry.claim_store_key(&fork, Some(&source_namespace), &default).await?;
221 self.registry.insert(&fork).await?;
222 if !self.registry.reserve_public(&fork, bytes, self.free_public_bytes).await? {
223 self.registry.remove(&fork.id).await?;
224 self.registry.unlock_copy(&source, &fork).await?;
225 return Ok(Outcome::fail(FailureCode::Forbidden, "This fork would exceed the workspace's public storage limit."));
226 }
227 self.store.open(&store_key(&source)).await?.fork(&store_key(&fork), a.default_branch_only).await?;
228 self.registry.finish_public(&fork, measured, measured).await?;
229 self.registry.unlock_copy(&source, &fork).await?;
230 } else {
231 // Kept in the EU: copied there, never forked across.
232 let placed = match self.place(&fork).await? {
233 Ok(placed) => placed,
234 Err(unplaced) => return Ok(Outcome::fail(FailureCode::Conflict, unplaced.message())),
235 };
236 self.registry.claim_store_key(&fork, placed.as_deref(), &default).await?;
237 self.registry.insert(&fork).await?;
238 self.store.create(&store_key(&fork), None, &fork.default_branch).await?;
239 let from = self.store.open(&store_key(&source)).await?.access(Scope::Read).await?;
240 let to = self.store.open(&store_key(&fork)).await?.access(Scope::Write).await?;
241 let copied = if a.default_branch_only {
242 let head = self.store.open(&store_key(&source)).await?.log(&source.default_branch, 1).await?;
243 match head.first() {
244 Some(head) => land::fast_forward(&from, &to, &fork.default_branch, None, &head.hash, self.public_quota(&fork)).await?,
245 None => Ok(()),
246 }
247 } else {
248 match copy::copy(
249 &copy::Endpoint::bearer(&from.remote, &from.token),
250 &copy::Endpoint::bearer(&to.remote, &to.token),
251 |bytes| self.registry.reserve_public(&fork, bytes, self.free_public_bytes),
252 )
253 .await? {
254 Ok(copied) => {
255 self.registry.finish_public(&fork, copied.pack_bytes, copied.pack_bytes).await?;
256 Ok(())
257 }
258 Err(problem) => Err(problem.to_string()),
259 }
260 };
261 if let Err(reason) = copied {
262 return Ok(Outcome::fail(FailureCode::Unavailable, format!("The fork could not be copied: {reason}")));
263 }
264 }
265 self.registry.drop_redirect(&RepoPath { namespace: fork.namespace.clone(), name: fork.name.clone() }).await?;
266 self.refs_moved(&fork.id).await;
267 // Whoever forks a repository is an Admin of the fork.
268 if a.actor.kind == PrincipalKind::User {
269 self.grant_creator(&fork, &a.actor.id).await;
270 }
271 let surface = a.surface.unwrap_or(Surface::Web);
272 self.record_fork(&a.actor, "repo.forked", surface, &fork, format!("Forked from {}/{}", source.namespace, source.name))
273 .await;
274 if source.namespace != fork.namespace {
275 self.record_fork(&a.actor, "repo.forked", surface, &source, format!("Forked to {}/{}", fork.namespace, fork.name))
276 .await;
277 }
278 self.publish(NewEvent {
279 kind: "repo.created",
280 source: SOURCE,
281 repo_id: Some(fork.id.clone()),
282 actor: Some(a.actor.id.clone()),
283 data: RepoCreated {
284 repo_id: fork.id.clone(),
285 namespace: fork.namespace.clone(),
286 name: fork.name.clone(),
287 is_private: fork.is_private,
288 parent_id: Some(source.id.clone()),
289 },
290 })
291 .await?;
292 self.publish(NewEvent {
293 kind: "repo.forked",
294 source: SOURCE,
295 repo_id: Some(source.id.clone()),
296 actor: Some(a.actor.id),
297 data: RepoForked { repo_id: fork.id.clone(), source_repo_id: source.id, pull_id: None },
298 })
299 .await?;
300 Ok(Outcome::Ok(fork))
301 }
302
303 /// `forks`: see `ForksArgs`.
304 pub(crate) async fn forks(&self, a: ForksArgs) -> Result<Outcome<Vec<Repo>>> {
305 let Some(repo) = self.readable(&a.path, &a.viewer).await? else {
306 return Ok(not_found());
307 };
308 let limit = a.limit.unwrap_or(30).clamp(1, MAX_LISTED);
309 let rows = self
310 .registry
311 .db
312 .prepare("SELECT * FROM repos WHERE parent_id = ? AND deleted_at IS NULL ORDER BY created_at DESC LIMIT ?")
313 .bind(&[repo.id.as_str().into(), (limit * 2).into()])?
314 .all()
315 .await?
316 .results::<RepoRow>()?;
317 let parent = RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() };
318 Ok(Outcome::Ok(
319 rows.into_iter()
320 .map(Repo::from)
321 .filter(|fork| can_read(fork, &a.viewer))
322 .take(limit as usize)
323 .map(|mut fork| {
324 fork.parent = Some(parent.clone());
325 fork
326 })
327 .collect(),
328 ))
329 }
330
331 /// Fills in where a fork's parent is, when the viewer may read it.
332 pub(crate) async fn with_parent(&self, mut repo: Repo, viewer: &Viewer) -> Result<Repo> {
333 if let Some(parent_id) = &repo.parent_id {
334 repo.parent = self
335 .registry
336 .by_id(parent_id)
337 .await?
338 .filter(|parent| can_read(parent, viewer))
339 .map(|parent| RepoPath { namespace: parent.namespace, name: parent.name });
340 }
341 Ok(repo)
342 }
343
344 /// `sync_fork`: see `SyncForkArgs`.
345 pub(crate) async fn sync_fork(&self, a: SyncForkArgs) -> Result<Outcome<SyncedFork>> {
346 let viewer = Some(a.actor.clone());
347 let Some(fork) = self.readable(&a.path, &viewer).await? else {
348 return Ok(not_found());
349 };
350 let full_name = format!("{}/{}", fork.namespace, fork.name);
351 let Some(parent_id) = fork.parent_id.clone() else {
352 return Ok(Outcome::fail(FailureCode::Invalid, format!("{full_name} is not a fork.")));
353 };
354 if !can_write(&fork, &viewer) {
355 return Ok(Outcome::fail(FailureCode::Forbidden, g1t_contracts::access::needs(Capability::Push, &full_name)));
356 }
357 if !a.actor.verified {
358 return Ok(Outcome::fail(FailureCode::Forbidden, UNVERIFIED));
359 }
360 if let Some((code, message)) = lifecycle::read_only_refusal(&fork) {
361 return Ok(Outcome::fail(code, message));
362 }
363 let Some(parent) = self.registry.by_id(&parent_id).await?.filter(|parent| can_read(parent, &viewer)) else {
364 return Ok(Outcome::fail(
365 FailureCode::NotFound,
366 format!("The repository {full_name} was forked from is gone, or you can no longer read it."),
367 ));
368 };
369 let (fork, parent) = match (self.unpaused(fork).await?, self.unpaused(parent).await?) {
370 (Ok(fork), Ok(parent)) => (fork, parent),
371 (Err((code, message)), _) | (_, Err((code, message))) => return Ok(Outcome::fail(code, message)),
372 };
373 let branch = a.branch.as_deref().map(str::trim).filter(|branch| !branch.is_empty()).unwrap_or(&fork.default_branch).to_owned();
374 let upstream = upstream_branch(&branch, &fork, &parent);
375 let fork_git = self.store.open(&store_key(&fork)).await?;
376 let parent_git = self.store.open(&store_key(&parent)).await?;
377 let (ours, theirs) = try_join(fork_git.log(&branch, MAX_ANCESTRY), parent_git.log(&upstream, MAX_ANCESTRY)).await?;
378 let Some(head) = ours.first().cloned() else {
379 return Ok(Outcome::fail(FailureCode::NotFound, format!("{full_name} has no branch named {branch}.")));
380 };
381 let Some(their_head) = theirs.first().cloned() else {
382 return Ok(Outcome::fail(
383 FailureCode::NotFound,
384 format!("{}/{} has no branch named {upstream} to sync from.", parent.namespace, parent.name),
385 ));
386 };
387 let (our_reach, their_reach) = try_join(reachable(&fork_git, &ours), reachable(&parent_git, &theirs)).await?;
388 let our_hashes: HashSet<&str> = our_reach.iter().map(String::as_str).collect();
389 let their_hashes: HashSet<&str> = their_reach.iter().map(String::as_str).collect();
390 let behind = not_in(&theirs, &our_hashes);
391 let ahead = not_in(&ours, &their_hashes);
392 let synced = |status, head: String, conflicts| SyncedFork { status, branch: branch.clone(), head, behind, ahead, conflicts };
393 if descends_from(&fork_git, &ours, &their_head.hash).await? {
394 return Ok(Outcome::Ok(synced(SyncStatus::UpToDate, head.hash, Vec::new())));
395 }
396 if descends_from(&parent_git, &theirs, &head.hash).await? {
397 // Only behind: the branch moves to where the parent's is, if it
398 // is still where it was, and if the fork's rules for the branch
399 // allow the push (rules.rs). Its commits are not read here, so a
400 // rule about what commits hold refuses it rather than pass.
401 let change = g1t_rules::push::RefChange {
402 git_ref: format!("refs/heads/{branch}"),
403 old: Some(head.hash.clone()),
404 new: Some(their_head.hash.clone()),
405 fast_forward: Some(true),
406 commits: Vec::new(),
407 complete: false,
408 };
409 if let crate::rules::Ruled::Refused { message, .. } =
410 self.check_changes(&fork, &a.actor, g1t_contracts::rules::Action::Push, vec![change]).await?
411 {
412 return Ok(Outcome::fail(FailureCode::Forbidden, message));
413 }
414 let from = parent_git.access(Scope::Read).await?;
415 let to = fork_git.access(Scope::Write).await?;
416 let moved = land::fast_forward(&from, &to, &branch, Some(&head.hash), &their_head.hash, self.public_quota(&fork)).await?;
417 self.refs_moved(&fork.id).await;
418 if moved.is_err() {
419 return Ok(Outcome::fail(FailureCode::Conflict, format!("{branch} moved while it was being synced. Nothing was lost; try again.")));
420 }
421 let git_ref = format!("refs/heads/{branch}");
422 self.publish_push(&fork, &git_ref, Some(&head.hash), &their_head.hash, Some(&a.actor), Some(behind)).await?;
423 return Ok(Outcome::Ok(synced(SyncStatus::FastForwarded, their_head.hash, Vec::new())));
424 }
425 let message = format!("Merge {}/{}:{upstream} into {branch}", parent.namespace, parent.name);
426 let merged = self.merge_up(fork, parent, branch.clone(), upstream, &a.actor, message, None).await?;
427 Ok(match merged {
428 Outcome::Ok(PullBranchUpdate::Updated { commit, .. }) => Outcome::Ok(synced(SyncStatus::Merged, commit, Vec::new())),
429 Outcome::Ok(PullBranchUpdate::UpToDate { commit }) => Outcome::Ok(synced(SyncStatus::UpToDate, commit, Vec::new())),
430 Outcome::Ok(PullBranchUpdate::NeedsAgent { paths, .. }) => {
431 Outcome::Ok(synced(SyncStatus::Conflicted, head.hash, paths.into_iter().take(MAX_CONFLICTS).collect()))
432 }
433 Outcome::Fail(failure) => Outcome::Fail(failure),
434 })
435 }
436
437 /// `detach_fork`: see `DetachForkArgs`.
438 pub(crate) async fn detach_fork(&self, a: DetachForkArgs) -> Result<Outcome<Repo>> {
439 let viewer = Some(a.actor.clone());
440 let Some(fork) = self.readable(&a.path, &viewer).await? else {
441 return Ok(not_found());
442 };
443 let full_name = format!("{}/{}", fork.namespace, fork.name);
444 if fork.parent_id.is_none() {
445 return Ok(Outcome::fail(FailureCode::Invalid, format!("{full_name} is not a fork.")));
446 }
447 if !can(&fork, &viewer, Capability::Administer) {
448 return Ok(Outcome::fail(FailureCode::Forbidden, g1t_contracts::access::needs(Capability::Administer, &full_name)));
449 }
450 if !a.actor.verified {
451 return Ok(Outcome::fail(FailureCode::Forbidden, UNVERIFIED));
452 }
453 self.registry.detach(&fork.id).await?;
454 if let Some(parent_id) = &fork.parent_id {
455 self.publish_detached(&fork.id, parent_id).await?;
456 }
457 self.record_fork(&a.actor, "repo.detached", a.surface.unwrap_or(Surface::Web), &fork, "Detached from its fork network".to_owned())
458 .await;
459 let mut detached = fork;
460 detached.parent_id = None;
461 detached.parent = None;
462 Ok(Outcome::Ok(detached))
463 }
464
465 /// The hourly check of private forks: one whose maker can no longer
466 /// read the repository it came from is deleted, restorable for
467 /// `RESTORE_DAYS` days, as its workspace's owners would delete it. A
468 /// fork made with a workspace's own token, or whose parent is gone or
469 /// public, is kept. Returns how many were deleted.
470 pub(crate) async fn check_private_forks(&self) -> Result<usize> {
471 let Some(identity) = &self.identity else { return Ok(0) };
472 let forks = self
473 .registry
474 .db
475 .prepare(
476 "SELECT * FROM repos WHERE parent_id IS NOT NULL AND is_private = 1 AND deleted_at IS NULL
477 ORDER BY COALESCE(fork_checked_ms, 0) LIMIT ?",
478 )
479 .bind(&[CHECKS_PER_SWEEP.into()])?
480 .all()
481 .await?
482 .results::<RepoRow>()?;
483 let now = now_ms();
484 let mut deleted = 0;
485 for fork in forks.into_iter().map(Repo::from) {
486 self.registry
487 .db
488 .prepare("UPDATE repos SET fork_checked_ms = ? WHERE id = ?")
489 .bind(&[(now as f64).into(), fork.id.as_str().into()])?
490 .run()
491 .await?;
492 let Some(parent_id) = &fork.parent_id else { continue };
493 let Some(parent) = self.registry.by_id(parent_id).await? else { continue };
494 if !fork.owner_id.starts_with("usr_") {
495 continue;
496 }
497 let readable: bool = g1t_kit::call(
498 identity,
499 "can_read_repo",
500 &g1t_contracts::access::CanReadRepoArgs { repo: parent.clone(), user_id: fork.owner_id.clone() },
501 )
502 .await?;
503 if readable {
504 continue;
505 }
506 let at = rfc3339(now);
507 let purge = lifecycle::purge_after(now);
508 self.registry.soft_delete(&fork.id, "g1t", &at, &purge).await?;
509 self.publish(NewEvent {
510 kind: "repo.deleted",
511 source: SOURCE,
512 repo_id: Some(fork.id.clone()),
513 actor: None,
514 data: g1t_contracts::events::RepoDeleted {
515 repo_id: fork.id.clone(),
516 namespace: fork.namespace.clone(),
517 name: fork.name.clone(),
518 is_private: true,
519 purge_after: purge.clone(),
520 with_workspace: false,
521 },
522 })
523 .await?;
524 self.record_fork_as(
525 AuditActor::system(),
526 "repo.deleted",
527 Surface::Web,
528 &fork,
529 format!(
530 "Deleted: whoever forked it can no longer read {}/{}; restorable until {purge}",
531 parent.namespace, parent.name
532 ),
533 )
534 .await;
535 deleted += 1;
536 }
537 Ok(deleted)
538 }
539
540 /// `repo.detached`, for a fork that left its parent's network.
541 pub(crate) async fn publish_detached(&self, repo_id: &str, parent_id: &str) -> Result<()> {
542 self.publish(NewEvent {
543 kind: "repo.detached",
544 source: SOURCE,
545 repo_id: Some(repo_id.to_owned()),
546 actor: None,
547 data: g1t_contracts::events::RepoDetached { repo_id: repo_id.to_owned(), parent_id: parent_id.to_owned() },
548 })
549 .await
550 }
551
552 /// One entry in `repo`'s workspace's audit log.
553 async fn record_fork(&self, actor: &g1t_contracts::User, action: &str, surface: Surface, repo: &Repo, message: String) {
554 self.record_fork_as(AuditActor::of(actor), action, surface, repo, message).await;
555 }
556
557 async fn record_fork_as(&self, actor: AuditActor, action: &str, surface: Surface, repo: &Repo, message: String) {
558 let entry = NewAuditEntry {
559 actor,
560 action: action.to_owned(),
561 surface,
562 target: AuditTarget {
563 workspace: repo.namespace.clone(),
564 repo: Some(format!("{}/{}", repo.namespace, repo.name)),
565 ..AuditTarget::default()
566 },
567 outcome: AuditOutcome::Allowed,
568 rule: "person".to_owned(),
569 result: Some("ok".to_owned()),
570 message: Some(message),
571 request_id: new_id("req", now_ms()),
572 };
573 let recorded: Result<u32> = g1t_kit::call(&self.events, "audit_record", &RecordAuditArgs { entries: vec![entry] }).await;
574 if let Err(error) = recorded {
575 worker::console_error!("{action} audit entry not recorded: {error}");
576 }
577 }
578}
579
580#[cfg(test)]
581mod tests {
582 use super::*;
583
584 fn repo(private: bool) -> Repo {
585 Repo {
586 id: "rep_1".into(),
587 namespace: "acme".into(),
588 name: "rocket".into(),
589 is_private: private,
590 owner_id: "usr_1".into(),
591 default_branch: "main".into(),
592 fork_of: None,
593 parent_id: None,
594 forked_from: None,
595 parent: None,
596 allow_forking: false,
597 max_ref_updates: None,
598 protected: false,
599 created_at: String::new(),
600 topics: Vec::new(),
601 website: None,
602 archived_at: None,
603 }
604 }
605
606 #[test]
607 fn public_repositories_can_always_be_forked() {
608 assert_eq!(fork_refusal(&repo(false)), None);
609 }
610
611 #[test]
612 fn private_repositories_only_when_allowed() {
613 let (code, message) = fork_refusal(&repo(true)).unwrap();
614 assert_eq!(code, FailureCode::Forbidden);
615 assert!(message.contains("acme/rocket is private"), "{message}");
616 let mut allowed = repo(true);
617 allowed.allow_forking = true;
618 assert_eq!(fork_refusal(&allowed), None);
619 }
620
621 #[test]
622 fn working_copies_are_never_forked() {
623 let mut copy = repo(false);
624 copy.fork_of = Some("rep_0".into());
625 assert_eq!(fork_refusal(&copy).unwrap().0, FailureCode::Invalid);
626 }
627
628 #[test]
629 fn an_eu_workspace_forks_in_place_only_from_the_eu_namespace() {
630 assert!(forks_in_place("g1t-us-1", shards::Residency::Anywhere, Some("g1t-eu-1")));
631 assert!(forks_in_place("g1t-eu-1", shards::Residency::Eu, Some("g1t-eu-1")));
632 assert!(!forks_in_place("g1t-us-1", shards::Residency::Eu, Some("g1t-eu-1")));
633 assert!(!forks_in_place("g1t-us-1", shards::Residency::Eu, None));
634 }
635
636 #[test]
637 fn the_default_branch_follows_the_parents_default() {
638 let mut fork = repo(false);
639 fork.default_branch = "main".into();
640 let mut parent = repo(false);
641 parent.default_branch = "trunk".into();
642 assert_eq!(upstream_branch("main", &fork, &parent), "trunk");
643 assert_eq!(upstream_branch("feature", &fork, &parent), "feature");
644 }
645
646 #[test]
647 fn counts_what_one_side_lacks() {
648 let commit = |hash: &str| g1t_contracts::repos::Commit {
649 hash: hash.into(),
650 tree_hash: String::new(),
651 message: String::new(),
652 author: g1t_contracts::repos::Signature { name: String::new(), email: String::new() },
653 parents: Vec::new(),
654 authored_at: String::new(),
655 };
656 let ours = vec![commit("c"), commit("b"), commit("a")];
657 let theirs = vec![commit("e"), commit("d"), commit("b"), commit("a")];
658 let our_hashes: HashSet<&str> = ours.iter().map(|c| c.hash.as_str()).collect();
659 let their_hashes: HashSet<&str> = theirs.iter().map(|c| c.hash.as_str()).collect();
660 assert_eq!(not_in(&theirs, &our_hashes), 2);
661 assert_eq!(not_in(&ours, &their_hashes), 1);
662 }
663}