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.

650 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 self.registry.claim_store_key(&fork, Some(&source_namespace), &default).await?;
219 self.store.open(&store_key(&source)).await?.fork(&store_key(&fork), a.default_branch_only).await?;
220 } else {
221 // Kept in the EU: copied there, never forked across.
222 let placed = match self.place(&fork).await? {
223 Ok(placed) => placed,
224 Err(unplaced) => return Ok(Outcome::fail(FailureCode::Conflict, unplaced.message())),
225 };
226 self.registry.claim_store_key(&fork, placed.as_deref(), &default).await?;
227 self.store.create(&store_key(&fork), fork.description.as_deref(), &fork.default_branch).await?;
228 let from = self.store.open(&store_key(&source)).await?.access(Scope::Read).await?;
229 let to = self.store.open(&store_key(&fork)).await?.access(Scope::Write).await?;
230 let copied = if a.default_branch_only {
231 let head = self.store.open(&store_key(&source)).await?.log(&source.default_branch, 1).await?;
232 match head.first() {
233 Some(head) => land::fast_forward(&from, &to, &fork.default_branch, None, &head.hash).await?,
234 None => Ok(()),
235 }
236 } else {
237 copy::copy(
238 &copy::Endpoint::bearer(&from.remote, &from.token),
239 &copy::Endpoint::bearer(&to.remote, &to.token),
240 )
241 .await?
242 .map(|_| ())
243 .map_err(|problem| problem.to_string())
244 };
245 if let Err(reason) = copied {
246 return Ok(Outcome::fail(FailureCode::Unavailable, format!("The fork could not be copied: {reason}")));
247 }
248 }
249 self.registry.insert(&fork).await?;
250 self.registry.set_stored_bytes(&fork.id, bytes).await?;
251 self.registry.drop_redirect(&RepoPath { namespace: fork.namespace.clone(), name: fork.name.clone() }).await?;
252 self.refs_moved(&fork.id).await;
253 // Whoever forks a repository is an Admin of the fork.
254 if a.actor.kind == PrincipalKind::User {
255 self.grant_creator(&fork, &a.actor.id).await;
256 }
257 let surface = a.surface.unwrap_or(Surface::Web);
258 self.record_fork(&a.actor, "repo.forked", surface, &fork, format!("Forked from {}/{}", source.namespace, source.name))
259 .await;
260 if source.namespace != fork.namespace {
261 self.record_fork(&a.actor, "repo.forked", surface, &source, format!("Forked to {}/{}", fork.namespace, fork.name))
262 .await;
263 }
264 self.publish(NewEvent {
265 kind: "repo.created",
266 source: SOURCE,
267 repo_id: Some(fork.id.clone()),
268 actor: Some(a.actor.id.clone()),
269 data: RepoCreated {
270 repo_id: fork.id.clone(),
271 namespace: fork.namespace.clone(),
272 name: fork.name.clone(),
273 is_private: fork.is_private,
274 parent_id: Some(source.id.clone()),
275 },
276 })
277 .await?;
278 self.publish(NewEvent {
279 kind: "repo.forked",
280 source: SOURCE,
281 repo_id: Some(source.id.clone()),
282 actor: Some(a.actor.id),
283 data: RepoForked { repo_id: fork.id.clone(), source_repo_id: source.id, pull_id: None },
284 })
285 .await?;
286 Ok(Outcome::Ok(fork))
287 }
288
289 /// `forks`: see `ForksArgs`.
290 pub(crate) async fn forks(&self, a: ForksArgs) -> Result<Outcome<Vec<Repo>>> {
291 let Some(repo) = self.readable(&a.path, &a.viewer).await? else {
292 return Ok(not_found());
293 };
294 let limit = a.limit.unwrap_or(30).clamp(1, MAX_LISTED);
295 let rows = self
296 .registry
297 .db
298 .prepare("SELECT * FROM repos WHERE parent_id = ? AND deleted_at IS NULL ORDER BY created_at DESC LIMIT ?")
299 .bind(&[repo.id.as_str().into(), (limit * 2).into()])?
300 .all()
301 .await?
302 .results::<RepoRow>()?;
303 let parent = RepoPath { namespace: repo.namespace.clone(), name: repo.name.clone() };
304 Ok(Outcome::Ok(
305 rows.into_iter()
306 .map(Repo::from)
307 .filter(|fork| can_read(fork, &a.viewer))
308 .take(limit as usize)
309 .map(|mut fork| {
310 fork.parent = Some(parent.clone());
311 fork
312 })
313 .collect(),
314 ))
315 }
316
317 /// Fills in where a fork's parent is, when the viewer may read it.
318 pub(crate) async fn with_parent(&self, mut repo: Repo, viewer: &Viewer) -> Result<Repo> {
319 if let Some(parent_id) = &repo.parent_id {
320 repo.parent = self
321 .registry
322 .by_id(parent_id)
323 .await?
324 .filter(|parent| can_read(parent, viewer))
325 .map(|parent| RepoPath { namespace: parent.namespace, name: parent.name });
326 }
327 Ok(repo)
328 }
329
330 /// `sync_fork`: see `SyncForkArgs`.
331 pub(crate) async fn sync_fork(&self, a: SyncForkArgs) -> Result<Outcome<SyncedFork>> {
332 let viewer = Some(a.actor.clone());
333 let Some(fork) = self.readable(&a.path, &viewer).await? else {
334 return Ok(not_found());
335 };
336 let full_name = format!("{}/{}", fork.namespace, fork.name);
337 let Some(parent_id) = fork.parent_id.clone() else {
338 return Ok(Outcome::fail(FailureCode::Invalid, format!("{full_name} is not a fork.")));
339 };
340 if !can_write(&fork, &viewer) {
341 return Ok(Outcome::fail(FailureCode::Forbidden, g1t_contracts::access::needs(Capability::Push, &full_name)));
342 }
343 if !a.actor.verified {
344 return Ok(Outcome::fail(FailureCode::Forbidden, UNVERIFIED));
345 }
346 if let Some((code, message)) = lifecycle::read_only_refusal(&fork) {
347 return Ok(Outcome::fail(code, message));
348 }
349 let Some(parent) = self.registry.by_id(&parent_id).await?.filter(|parent| can_read(parent, &viewer)) else {
350 return Ok(Outcome::fail(
351 FailureCode::NotFound,
352 format!("The repository {full_name} was forked from is gone, or you can no longer read it."),
353 ));
354 };
355 let (fork, parent) = match (self.unpaused(fork).await?, self.unpaused(parent).await?) {
356 (Ok(fork), Ok(parent)) => (fork, parent),
357 (Err((code, message)), _) | (_, Err((code, message))) => return Ok(Outcome::fail(code, message)),
358 };
359 let branch = a.branch.as_deref().map(str::trim).filter(|branch| !branch.is_empty()).unwrap_or(&fork.default_branch).to_owned();
360 let upstream = upstream_branch(&branch, &fork, &parent);
361 let fork_git = self.store.open(&store_key(&fork)).await?;
362 let parent_git = self.store.open(&store_key(&parent)).await?;
363 let (ours, theirs) = try_join(fork_git.log(&branch, MAX_ANCESTRY), parent_git.log(&upstream, MAX_ANCESTRY)).await?;
364 let Some(head) = ours.first().cloned() else {
365 return Ok(Outcome::fail(FailureCode::NotFound, format!("{full_name} has no branch named {branch}.")));
366 };
367 let Some(their_head) = theirs.first().cloned() else {
368 return Ok(Outcome::fail(
369 FailureCode::NotFound,
370 format!("{}/{} has no branch named {upstream} to sync from.", parent.namespace, parent.name),
371 ));
372 };
373 let (our_reach, their_reach) = try_join(reachable(&fork_git, &ours), reachable(&parent_git, &theirs)).await?;
374 let our_hashes: HashSet<&str> = our_reach.iter().map(String::as_str).collect();
375 let their_hashes: HashSet<&str> = their_reach.iter().map(String::as_str).collect();
376 let behind = not_in(&theirs, &our_hashes);
377 let ahead = not_in(&ours, &their_hashes);
378 let synced = |status, head: String, conflicts| SyncedFork { status, branch: branch.clone(), head, behind, ahead, conflicts };
379 if descends_from(&fork_git, &ours, &their_head.hash).await? {
380 return Ok(Outcome::Ok(synced(SyncStatus::UpToDate, head.hash, Vec::new())));
381 }
382 if descends_from(&parent_git, &theirs, &head.hash).await? {
383 // Only behind: the branch moves to where the parent's is, if it
384 // is still where it was, and if the fork's rules for the branch
385 // allow the push (rules.rs). Its commits are not read here, so a
386 // rule about what commits hold refuses it rather than pass.
387 let change = g1t_rules::push::RefChange {
388 git_ref: format!("refs/heads/{branch}"),
389 old: Some(head.hash.clone()),
390 new: Some(their_head.hash.clone()),
391 fast_forward: Some(true),
392 commits: Vec::new(),
393 complete: false,
394 };
395 if let crate::rules::Ruled::Refused { message, .. } =
396 self.check_changes(&fork, &a.actor, g1t_contracts::rules::Action::Push, vec![change]).await?
397 {
398 return Ok(Outcome::fail(FailureCode::Forbidden, message));
399 }
400 let from = parent_git.access(Scope::Read).await?;
401 let to = fork_git.access(Scope::Write).await?;
402 let moved = land::fast_forward(&from, &to, &branch, Some(&head.hash), &their_head.hash).await?;
403 self.refs_moved(&fork.id).await;
404 if moved.is_err() {
405 return Ok(Outcome::fail(FailureCode::Conflict, format!("{branch} moved while it was being synced. Nothing was lost; try again.")));
406 }
407 let git_ref = format!("refs/heads/{branch}");
408 self.publish_push(&fork, &git_ref, Some(&head.hash), &their_head.hash, Some(&a.actor), Some(behind)).await?;
409 return Ok(Outcome::Ok(synced(SyncStatus::FastForwarded, their_head.hash, Vec::new())));
410 }
411 let message = format!("Merge {}/{}:{upstream} into {branch}", parent.namespace, parent.name);
412 let merged = self.merge_up(fork, parent, branch.clone(), upstream, &a.actor, message, None).await?;
413 Ok(match merged {
414 Outcome::Ok(PullBranchUpdate::Updated { commit, .. }) => Outcome::Ok(synced(SyncStatus::Merged, commit, Vec::new())),
415 Outcome::Ok(PullBranchUpdate::UpToDate { commit }) => Outcome::Ok(synced(SyncStatus::UpToDate, commit, Vec::new())),
416 Outcome::Ok(PullBranchUpdate::NeedsAgent { paths, .. }) => {
417 Outcome::Ok(synced(SyncStatus::Conflicted, head.hash, paths.into_iter().take(MAX_CONFLICTS).collect()))
418 }
419 Outcome::Fail(failure) => Outcome::Fail(failure),
420 })
421 }
422
423 /// `detach_fork`: see `DetachForkArgs`.
424 pub(crate) async fn detach_fork(&self, a: DetachForkArgs) -> Result<Outcome<Repo>> {
425 let viewer = Some(a.actor.clone());
426 let Some(fork) = self.readable(&a.path, &viewer).await? else {
427 return Ok(not_found());
428 };
429 let full_name = format!("{}/{}", fork.namespace, fork.name);
430 if fork.parent_id.is_none() {
431 return Ok(Outcome::fail(FailureCode::Invalid, format!("{full_name} is not a fork.")));
432 }
433 if !can(&fork, &viewer, Capability::Administer) {
434 return Ok(Outcome::fail(FailureCode::Forbidden, g1t_contracts::access::needs(Capability::Administer, &full_name)));
435 }
436 if !a.actor.verified {
437 return Ok(Outcome::fail(FailureCode::Forbidden, UNVERIFIED));
438 }
439 self.registry.detach(&fork.id).await?;
440 if let Some(parent_id) = &fork.parent_id {
441 self.publish_detached(&fork.id, parent_id).await?;
442 }
443 self.record_fork(&a.actor, "repo.detached", a.surface.unwrap_or(Surface::Web), &fork, "Detached from its fork network".to_owned())
444 .await;
445 let mut detached = fork;
446 detached.parent_id = None;
447 detached.parent = None;
448 Ok(Outcome::Ok(detached))
449 }
450
451 /// The hourly check of private forks: one whose maker can no longer
452 /// read the repository it came from is deleted, restorable for
453 /// `RESTORE_DAYS` days, as its workspace's owners would delete it. A
454 /// fork made with a workspace's own token, or whose parent is gone or
455 /// public, is kept. Returns how many were deleted.
456 pub(crate) async fn check_private_forks(&self) -> Result<usize> {
457 let Some(identity) = &self.identity else { return Ok(0) };
458 let forks = self
459 .registry
460 .db
461 .prepare(
462 "SELECT * FROM repos WHERE parent_id IS NOT NULL AND is_private = 1 AND deleted_at IS NULL
463 ORDER BY COALESCE(fork_checked_ms, 0) LIMIT ?",
464 )
465 .bind(&[CHECKS_PER_SWEEP.into()])?
466 .all()
467 .await?
468 .results::<RepoRow>()?;
469 let now = now_ms();
470 let mut deleted = 0;
471 for fork in forks.into_iter().map(Repo::from) {
472 self.registry
473 .db
474 .prepare("UPDATE repos SET fork_checked_ms = ? WHERE id = ?")
475 .bind(&[(now as f64).into(), fork.id.as_str().into()])?
476 .run()
477 .await?;
478 let Some(parent_id) = &fork.parent_id else { continue };
479 let Some(parent) = self.registry.by_id(parent_id).await? else { continue };
480 if !fork.owner_id.starts_with("usr_") {
481 continue;
482 }
483 let readable: bool = g1t_kit::call(
484 identity,
485 "can_read_repo",
486 &g1t_contracts::access::CanReadRepoArgs { repo: parent.clone(), user_id: fork.owner_id.clone() },
487 )
488 .await?;
489 if readable {
490 continue;
491 }
492 let at = rfc3339(now);
493 let purge = lifecycle::purge_after(now);
494 self.registry.soft_delete(&fork.id, "g1t", &at, &purge).await?;
495 self.publish(NewEvent {
496 kind: "repo.deleted",
497 source: SOURCE,
498 repo_id: Some(fork.id.clone()),
499 actor: None,
500 data: g1t_contracts::events::RepoDeleted {
501 repo_id: fork.id.clone(),
502 namespace: fork.namespace.clone(),
503 name: fork.name.clone(),
504 is_private: true,
505 purge_after: purge.clone(),
506 with_workspace: false,
507 },
508 })
509 .await?;
510 self.record_fork_as(
511 AuditActor::system(),
512 "repo.deleted",
513 Surface::Web,
514 &fork,
515 format!(
516 "Deleted: whoever forked it can no longer read {}/{}; restorable until {purge}",
517 parent.namespace, parent.name
518 ),
519 )
520 .await;
521 deleted += 1;
522 }
523 Ok(deleted)
524 }
525
526 /// `repo.detached`, for a fork that left its parent's network.
527 pub(crate) async fn publish_detached(&self, repo_id: &str, parent_id: &str) -> Result<()> {
528 self.publish(NewEvent {
529 kind: "repo.detached",
530 source: SOURCE,
531 repo_id: Some(repo_id.to_owned()),
532 actor: None,
533 data: g1t_contracts::events::RepoDetached { repo_id: repo_id.to_owned(), parent_id: parent_id.to_owned() },
534 })
535 .await
536 }
537
538 /// One entry in `repo`'s workspace's audit log.
539 async fn record_fork(&self, actor: &g1t_contracts::User, action: &str, surface: Surface, repo: &Repo, message: String) {
540 self.record_fork_as(AuditActor::of(actor), action, surface, repo, message).await;
541 }
542
543 async fn record_fork_as(&self, actor: AuditActor, action: &str, surface: Surface, repo: &Repo, message: String) {
544 let entry = NewAuditEntry {
545 actor,
546 action: action.to_owned(),
547 surface,
548 target: AuditTarget {
549 workspace: repo.namespace.clone(),
550 repo: Some(format!("{}/{}", repo.namespace, repo.name)),
551 ..AuditTarget::default()
552 },
553 outcome: AuditOutcome::Allowed,
554 rule: "person".to_owned(),
555 result: Some("ok".to_owned()),
556 message: Some(message),
557 request_id: new_id("req", now_ms()),
558 };
559 let recorded: Result<u32> = g1t_kit::call(&self.events, "audit_record", &RecordAuditArgs { entries: vec![entry] }).await;
560 if let Err(error) = recorded {
561 worker::console_error!("{action} audit entry not recorded: {error}");
562 }
563 }
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569
570 fn repo(private: bool) -> Repo {
571 Repo {
572 id: "rep_1".into(),
573 namespace: "acme".into(),
574 name: "rocket".into(),
575 description: None,
576 is_private: private,
577 owner_id: "usr_1".into(),
578 default_branch: "main".into(),
579 fork_of: None,
580 parent_id: None,
581 forked_from: None,
582 parent: None,
583 allow_forking: false,
584 max_ref_updates: None,
585 protected: false,
586 created_at: String::new(),
587 topics: Vec::new(),
588 website: None,
589 archived_at: None,
590 }
591 }
592
593 #[test]
594 fn public_repositories_can_always_be_forked() {
595 assert_eq!(fork_refusal(&repo(false)), None);
596 }
597
598 #[test]
599 fn private_repositories_only_when_allowed() {
600 let (code, message) = fork_refusal(&repo(true)).unwrap();
601 assert_eq!(code, FailureCode::Forbidden);
602 assert!(message.contains("acme/rocket is private"), "{message}");
603 let mut allowed = repo(true);
604 allowed.allow_forking = true;
605 assert_eq!(fork_refusal(&allowed), None);
606 }
607
608 #[test]
609 fn working_copies_are_never_forked() {
610 let mut copy = repo(false);
611 copy.fork_of = Some("rep_0".into());
612 assert_eq!(fork_refusal(&copy).unwrap().0, FailureCode::Invalid);
613 }
614
615 #[test]
616 fn an_eu_workspace_forks_in_place_only_from_the_eu_namespace() {
617 assert!(forks_in_place("g1t-us-1", shards::Residency::Anywhere, Some("g1t-eu-1")));
618 assert!(forks_in_place("g1t-eu-1", shards::Residency::Eu, Some("g1t-eu-1")));
619 assert!(!forks_in_place("g1t-us-1", shards::Residency::Eu, Some("g1t-eu-1")));
620 assert!(!forks_in_place("g1t-us-1", shards::Residency::Eu, None));
621 }
622
623 #[test]
624 fn the_default_branch_follows_the_parents_default() {
625 let mut fork = repo(false);
626 fork.default_branch = "main".into();
627 let mut parent = repo(false);
628 parent.default_branch = "trunk".into();
629 assert_eq!(upstream_branch("main", &fork, &parent), "trunk");
630 assert_eq!(upstream_branch("feature", &fork, &parent), "feature");
631 }
632
633 #[test]
634 fn counts_what_one_side_lacks() {
635 let commit = |hash: &str| g1t_contracts::repos::Commit {
636 hash: hash.into(),
637 tree_hash: String::new(),
638 message: String::new(),
639 author: g1t_contracts::repos::Signature { name: String::new(), email: String::new() },
640 parents: Vec::new(),
641 authored_at: String::new(),
642 };
643 let ours = vec![commit("c"), commit("b"), commit("a")];
644 let theirs = vec![commit("e"), commit("d"), commit("b"), commit("a")];
645 let our_hashes: HashSet<&str> = ours.iter().map(|c| c.hash.as_str()).collect();
646 let their_hashes: HashSet<&str> = theirs.iter().map(|c| c.hash.as_str()).collect();
647 assert_eq!(not_in(&theirs, &our_hashes), 2);
648 assert_eq!(not_in(&ours, &their_hashes), 1);
649 }
650}