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