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.

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