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.

880 linesCodeBlameRaw
1//! Bringing a pull request up to date with the branch it would merge into,
2//! without a sandbox, when that is safe.
3//!
4//! By merge (the default): the files the pull request changed since the
5//! two last agreed are applied to the base branch's tree, a file both
6//! changed merged line by line (merge_methods.rs), and one merge commit on
7//! top whose parents are the pull request's head and the base branch's
8//! head. Only the trees on the way to changed files are rebuilt. They are
9//! written as a pack of whole objects and pushed to the pull request's
10//! branch, if it is still where it was.
11//!
12//! By rebase: the branch's commits are made again on the base branch's
13//! head (merge_methods.rs `replay`), and the branch is moved there.
14//!
15//! When lines both sides changed clash, or the change is beyond what g1t
16//! merges itself, the answer is that a sandbox is needed, and nothing is
17//! pushed.
18
19use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
20
21use futures_util::future::{try_join, try_join_all};
22use g1t_contracts::audit::{AuditActor, NewAuditEntry, Surface};
23use g1t_contracts::credentials::Decision;
24use g1t_contracts::repos::{
25 EntryKind, NeedsAgentReason, PullBranchUpdate, RepoPath, TreeEntry, UpdateMethod, UpdatePullBranchArgs,
26};
27use g1t_contracts::{FailureCode, Outcome};
28use g1t_kit::now_ms;
29use g1t_scan::pack::{ObjectKind, TreeItem, encode_tree, extend_pack, object_id, write_pack};
30use worker::Result;
31
32use crate::registry::{can_read, can_write, store_key};
33use crate::store::{GitRepo, GitStore, Scope};
34use crate::merge_methods::{Applied, Reader, apply};
35use crate::{MAX_ANCESTRY, Repos, UNVERIFIED, descends_from, land, nearest_ancestor_in, not_found};
36
37/// The mode git writes for an entry of each kind.
38fn mode(kind: EntryKind) -> &'static str {
39 match kind {
40 EntryKind::Tree => "40000",
41 EntryKind::Blob => "100644",
42 EntryKind::Exec => "100755",
43 EntryKind::Symlink => "120000",
44 EntryKind::Gitlink => "160000",
45 }
46}
47
48/// Git orders a tree's entries by name, comparing a subtree's name as if
49/// it ended in `/`.
50fn sort_key(entry: &TreeEntry) -> Vec<u8> {
51 let mut key = entry.name.as_bytes().to_vec();
52 if entry.kind == EntryKind::Tree {
53 key.push(b'/');
54 }
55 key
56}
57
58/// A tree object's bytes, its entries in git's order.
59pub(crate) fn encode_entries(entries: &[TreeEntry]) -> Vec<u8> {
60 let mut sorted: Vec<&TreeEntry> = entries.iter().collect();
61 sorted.sort_by_key(|entry| sort_key(entry));
62 let items: Vec<TreeItem> = sorted
63 .into_iter()
64 .map(|entry| TreeItem {
65 mode: mode(entry.kind).to_owned(),
66 name: entry.name.clone(),
67 id: entry.hash.clone(),
68 })
69 .collect();
70 encode_tree(&items)
71}
72
73/// The directories above a path, nearest the root first: `a/b/c` is in
74/// `a` and `a/b`.
75pub(crate) fn ancestors(path: &str) -> impl Iterator<Item = &str> {
76 path.match_indices('/').map(move |(at, _)| &path[..at])
77}
78
79/// The paths at which the two sides' changes meet, so that the merge is not
80/// a matter of taking each side's files: a file both changed, or a file on
81/// one side where the other has a directory (a file `a` against `a/b`).
82pub(crate) fn overlapping(ours: &[String], theirs: &[String]) -> Vec<String> {
83 let their_files: HashSet<&str> = theirs.iter().map(String::as_str).collect();
84 let their_dirs: HashSet<&str> = theirs.iter().flat_map(|path| ancestors(path)).collect();
85 let mut met: BTreeSet<String> = BTreeSet::new();
86 for path in ours {
87 if their_files.contains(path.as_str()) || their_dirs.contains(path.as_str()) {
88 met.insert(path.clone());
89 }
90 for dir in ancestors(path) {
91 if their_files.contains(dir) {
92 met.insert(dir.to_owned());
93 }
94 }
95 }
96 met.into_iter().collect()
97}
98
99/// One file the pull request changed, as it is on the pull request: its
100/// kind and blob, or `None` when it deleted it.
101#[derive(Clone, Debug)]
102pub(crate) struct Change {
103 pub path: String,
104 pub entry: Option<(EntryKind, String)>,
105}
106
107/// The merged tree's id, and the tree objects written for it.
108#[derive(Debug)]
109pub(crate) struct Merged {
110 pub tree: String,
111 pub objects: Vec<Vec<u8>>,
112}
113
114enum Node {
115 Leaf(EntryKind, String),
116 /// A subtree left as it is.
117 Subtree(String),
118 /// A subtree being changed.
119 Dir(Dir),
120}
121
122#[derive(Default)]
123struct Dir {
124 entries: BTreeMap<String, Node>,
125}
126
127/// A tree, ready to change. Its entries are checked to write back to
128/// exactly its id: a tree holding something this cannot write, such as an
129/// unusual file mode, is refused rather than changed.
130fn load(id: &str, trees: &HashMap<String, Vec<TreeEntry>>) -> std::result::Result<Dir, String> {
131 let entries = trees
132 .get(id)
133 .ok_or_else(|| format!("tree {id} was not read"))?;
134 if object_id(ObjectKind::Tree, &encode_entries(entries)) != id {
135 return Err(format!("tree {id} holds entries g1t cannot write back exactly"));
136 }
137 Ok(Dir {
138 entries: entries
139 .iter()
140 .map(|entry| {
141 let node = match entry.kind {
142 EntryKind::Tree => Node::Subtree(entry.hash.clone()),
143 kind => Node::Leaf(kind, entry.hash.clone()),
144 };
145 (entry.name.clone(), node)
146 })
147 .collect(),
148 })
149}
150
151/// The directory at `node`, read if it was not yet.
152fn open<'a>(
153 node: &'a mut Node,
154 trees: &HashMap<String, Vec<TreeEntry>>,
155) -> std::result::Result<&'a mut Dir, String> {
156 if let Node::Subtree(id) = node {
157 *node = Node::Dir(load(id, trees)?);
158 }
159 match node {
160 Node::Dir(dir) => Ok(dir),
161 _ => Err("a file is where a directory was expected".to_owned()),
162 }
163}
164
165/// Removes the file at `parts`, and any directory that leaves empty.
166fn remove(
167 dir: &mut Dir,
168 parts: &[&str],
169 trees: &HashMap<String, Vec<TreeEntry>>,
170) -> std::result::Result<(), String> {
171 let (name, rest) = parts.split_first().ok_or("an empty path")?;
172 if rest.is_empty() {
173 return match dir.entries.get(*name) {
174 Some(Node::Leaf(..)) => {
175 dir.entries.remove(*name);
176 Ok(())
177 }
178 Some(_) => Err(format!("{name} is a directory, not a file")),
179 None => Err(format!("{name} is not there to remove")),
180 };
181 }
182 let child = dir
183 .entries
184 .get_mut(*name)
185 .ok_or_else(|| format!("{name} is not there"))?;
186 let inner = open(child, trees)?;
187 remove(inner, rest, trees)?;
188 if inner.entries.is_empty() {
189 dir.entries.remove(*name);
190 }
191 Ok(())
192}
193
194/// Puts a file at `parts`, making the directories it needs.
195fn insert(
196 dir: &mut Dir,
197 parts: &[&str],
198 kind: EntryKind,
199 hash: &str,
200 trees: &HashMap<String, Vec<TreeEntry>>,
201) -> std::result::Result<(), String> {
202 let (name, rest) = parts.split_first().ok_or("an empty path")?;
203 if rest.is_empty() {
204 if matches!(dir.entries.get(*name), Some(Node::Subtree(_) | Node::Dir(_))) {
205 return Err(format!("{name} is a directory, not a file"));
206 }
207 dir.entries.insert((*name).to_owned(), Node::Leaf(kind, hash.to_owned()));
208 return Ok(());
209 }
210 let child = dir
211 .entries
212 .entry((*name).to_owned())
213 .or_insert_with(|| Node::Dir(Dir::default()));
214 insert(open(child, trees)?, rest, kind, hash, trees)
215}
216
217/// Writes a changed directory and those in it; returns its id.
218fn write(dir: Dir, objects: &mut Vec<Vec<u8>>) -> String {
219 let mut entries = Vec::with_capacity(dir.entries.len());
220 for (name, node) in dir.entries {
221 let (kind, hash) = match node {
222 Node::Leaf(kind, hash) => (kind, hash),
223 Node::Subtree(hash) => (EntryKind::Tree, hash),
224 // Git keeps no empty directories.
225 Node::Dir(inner) if inner.entries.is_empty() => continue,
226 Node::Dir(inner) => (EntryKind::Tree, write(inner, objects)),
227 };
228 entries.push(TreeEntry { name, hash, kind });
229 }
230 let bytes = encode_entries(&entries);
231 let id = object_id(ObjectKind::Tree, &bytes);
232 objects.push(bytes);
233 id
234}
235
236/// The tree of `base_root` with `changes` applied: deletions first, so a
237/// file can take the place of a directory the pull request emptied.
238/// `trees` holds every tree of the base on the way to a changed path.
239pub(crate) fn merge_tree(
240 base_root: &str,
241 trees: &HashMap<String, Vec<TreeEntry>>,
242 changes: &[Change],
243) -> std::result::Result<Merged, String> {
244 let mut root = load(base_root, trees)?;
245 for change in changes.iter().filter(|change| change.entry.is_none()) {
246 let parts: Vec<&str> = change.path.split('/').collect();
247 remove(&mut root, &parts, trees).map_err(|why| format!("{}: {why}", change.path))?;
248 }
249 for change in changes {
250 if let Some((kind, hash)) = &change.entry {
251 let parts: Vec<&str> = change.path.split('/').collect();
252 insert(&mut root, &parts, *kind, hash, trees)
253 .map_err(|why| format!("{}: {why}", change.path))?;
254 }
255 }
256 let mut objects = Vec::new();
257 let tree = write(root, &mut objects);
258 // A subtree that came out as it was is already stored.
259 let mut seen = HashSet::new();
260 objects.retain(|bytes| seen.insert(object_id(ObjectKind::Tree, bytes)));
261 Ok(Merged { tree, objects })
262}
263
264/// Who a commit is by, and when.
265pub(crate) struct Signature<'a> {
266 pub name: &'a str,
267 pub email: &'a str,
268 /// Seconds since the epoch, in UTC.
269 pub seconds: u64,
270}
271
272/// A commit object's bytes, authored and committed by `by`.
273pub(crate) fn commit_object(tree: &str, parents: &[&str], by: &Signature, message: &str) -> Vec<u8> {
274 commit_object_by(tree, parents, by, by, message)
275}
276
277/// A commit object's bytes, by `author`, committed by `committer`.
278pub(crate) fn commit_object_by(tree: &str, parents: &[&str], author: &Signature, committer: &Signature, message: &str) -> Vec<u8> {
279 let mut out = format!("tree {tree}\n");
280 for parent in parents {
281 out.push_str(&format!("parent {parent}\n"));
282 }
283 // Git takes everything up to `<` as the name.
284 let line = |by: &Signature| {
285 let name: String = by.name.chars().filter(|c| !matches!(c, '<' | '>' | '\n')).collect();
286 let email: String = by.email.chars().filter(|c| !matches!(c, '<' | '>' | '\n')).collect();
287 format!("{name} <{email}> {} +0000", by.seconds)
288 };
289 out.push_str(&format!("author {}\ncommitter {}\n\n{message}\n", line(author), line(committer)));
290 out.into_bytes()
291}
292
293/// What the merge commit says.
294pub(crate) fn merge_message(base: &str, branch: &str, number: u32) -> String {
295 if branch == base {
296 // A fork carries its change on a branch named like the default.
297 format!("Merge {base} into pull request #{number}")
298 } else {
299 format!("Merge {base} into {branch}")
300 }
301}
302
303/// The trees at `dirs` (and the root, `""`) under `root`, by path: each
304/// one's id and entries. A directory that is not there is left out. Each
305/// level is read at once.
306pub(crate) async fn read_dirs<R: GitRepo>(
307 repo: &R,
308 root: &str,
309 dirs: &BTreeSet<String>,
310) -> Result<HashMap<String, (String, Vec<TreeEntry>)>> {
311 let mut found: HashMap<String, (String, Vec<TreeEntry>)> = HashMap::new();
312 if let Some(entries) = repo.read_tree(root).await? {
313 found.insert(String::new(), (root.to_owned(), entries));
314 }
315 let depth = |path: &str| path.matches('/').count();
316 let deepest = dirs.iter().map(|dir| depth(dir)).max().unwrap_or(0);
317 for level in 0..=deepest {
318 let wanted: Vec<(String, String)> = dirs
319 .iter()
320 .filter(|dir| !dir.is_empty() && depth(dir) == level)
321 .filter_map(|dir| {
322 let (parent, name) = dir.rsplit_once('/').unwrap_or(("", dir.as_str()));
323 let (_, entries) = found.get(parent)?;
324 entries
325 .iter()
326 .find(|entry| entry.name == name && entry.kind == EntryKind::Tree)
327 .map(|entry| (dir.clone(), entry.hash.clone()))
328 })
329 .collect();
330 let read = try_join_all(wanted.iter().map(|(_, id)| repo.read_tree(id))).await?;
331 for ((dir, id), entries) in wanted.into_iter().zip(read) {
332 if let Some(entries) = entries {
333 found.insert(dir, (id, entries));
334 }
335 }
336 }
337 Ok(found)
338}
339
340/// g1t's own name and address, for a commit g1t's agent or g1t itself
341/// makes. None for anyone else.
342pub(crate) fn g1t_commit_identity(actor: &g1t_contracts::User) -> Option<g1t_contracts::accounts::CommitIdentity> {
343 use g1t_contracts::{PrincipalKind, system};
344 matches!(actor.kind, PrincipalKind::Agent | PrincipalKind::System).then(|| g1t_contracts::accounts::CommitIdentity {
345 name: system::USERNAME.to_owned(),
346 email: system::EMAIL.to_owned(),
347 })
348}
349
350fn needs_agent(reason: NeedsAgentReason, detail: impl Into<String>, paths: Vec<String>) -> Outcome<PullBranchUpdate> {
351 Outcome::Ok(PullBranchUpdate::NeedsAgent {
352 reason,
353 detail: detail.into(),
354 paths,
355 })
356}
357
358impl<S: GitStore> Repos<S> {
359 /// The name and address a commit made for `actor` carries: their
360 /// noreply address unless they chose to show their own. What g1t's
361 /// agent or g1t itself commits is g1t's, whoever it works for. Without
362 /// identity, the noreply address all the same.
363 pub(crate) async fn commit_identity(&self, actor: &g1t_contracts::User) -> g1t_contracts::accounts::CommitIdentity {
364 if let Some(g1t) = g1t_commit_identity(actor) {
365 return g1t;
366 }
367 let person = (actor.id.clone(), actor.username.clone());
368 let found = match &self.identity {
369 Some(identity) => g1t_kit::call::<_, Option<g1t_contracts::accounts::CommitIdentity>>(
370 identity,
371 "commit_identity",
372 &g1t_contracts::accounts::CommitIdentityArgs { user_id: person.0.clone() },
373 )
374 .await
375 .ok()
376 .flatten(),
377 None => None,
378 };
379 found.unwrap_or_else(|| g1t_contracts::accounts::CommitIdentity {
380 name: person.1.clone(),
381 email: g1t_contracts::accounts::noreply_address(&person.0, &person.1),
382 })
383 }
384
385 pub(crate) async fn update_pull_branch(&self, a: UpdatePullBranchArgs) -> Result<Outcome<PullBranchUpdate>> {
386 let actor = Some(a.actor.clone());
387 let Some(source) = self.registry.by_id(&a.source_id).await? else {
388 return Ok(not_found());
389 };
390 let target = self.pull_target(&source, a.target_id.as_deref()).await?;
391 let Some(target) = target.filter(|repo| can_read(repo, &actor)) else {
392 return Ok(not_found());
393 };
394 // The update is pushed as the person asking, so they must be able
395 // to push: to the working copy as whoever opened the pull request,
396 // or to the fork or repository holding the branch.
397 if !can_write(&source, &actor) {
398 return Ok(Outcome::fail(
399 FailureCode::Forbidden,
400 if source.fork_of.is_some() {
401 "Only whoever opened this pull request can update it.".to_owned()
402 } else {
403 g1t_contracts::access::needs(
404 g1t_contracts::access::Capability::Push,
405 &format!("{}/{}", source.namespace, source.name),
406 )
407 },
408 ));
409 }
410 if !a.actor.verified {
411 return Ok(Outcome::fail(FailureCode::Forbidden, UNVERIFIED));
412 }
413 if let Some((code, message)) = crate::lifecycle::read_only_refusal(&target) {
414 return Ok(Outcome::fail(code, message));
415 }
416 // Moving between namespaces: wait for it (moves.rs). Both are read
417 // again once it is done, for their new keys.
418 let (source, target) = match (self.unpaused(source).await?, self.unpaused(target).await?) {
419 (Ok(source), Ok(target)) => (source, target),
420 (Err((code, message)), _) | (_, Err((code, message))) => return Ok(Outcome::fail(code, message)),
421 };
422 let from_fork = source.id != target.id;
423 let base_branch = a.target_branch.clone().unwrap_or_else(|| target.default_branch.clone());
424 // A working copy holds the change on the branch it merges into.
425 let branch = match a.branch.clone() {
426 Some(branch) => branch,
427 None if source.fork_of.is_some() || !from_fork => target.default_branch.clone(),
428 None => return Ok(Outcome::fail(FailureCode::Invalid, "Say which branch of the fork to update.")),
429 };
430 if !from_fork && branch == base_branch {
431 return Ok(Outcome::fail(
432 FailureCode::Invalid,
433 format!("{base_branch} cannot be merged into itself."),
434 ));
435 }
436 match a.method {
437 UpdateMethod::Merge => {
438 let message = merge_message(&base_branch, &branch, a.number);
439 self.merge_up(source, target, branch, base_branch, &a.actor, message, a.expected_head.as_deref()).await
440 }
441 UpdateMethod::Rebase => {
442 self.rebase_up(source, target, branch, base_branch, &a.actor, a.expected_head.as_deref()).await
443 }
444 }
445 }
446
447 /// Merges `base_branch` of `target` into `branch` of `source` without a
448 /// sandbox, pushed as `actor` with `message`; `source` may be `target`
449 /// itself, a pull request's working copy, or a fork. Files only one
450 /// side changed are taken from it, and a file both changed is merged
451 /// line by line (merge_methods.rs); lines both changed differently
452 /// leave it to a sandbox, naming the files. Refused when the branch is
453 /// no longer at `expected_head`. The caller has checked who may do it.
454 #[allow(clippy::too_many_arguments)]
455 pub(crate) async fn merge_up(
456 &self,
457 source: g1t_contracts::repos::Repo,
458 target: g1t_contracts::repos::Repo,
459 branch: String,
460 base_branch: String,
461 actor: &g1t_contracts::User,
462 message: String,
463 expected_head: Option<&str>,
464 ) -> Result<Outcome<PullBranchUpdate>> {
465 let from_fork = source.id != target.id;
466 // A working copy removed after its pull request closed is made again.
467 self.live(&source).await?;
468 let source_git = self.store.open(&store_key(&source)).await?;
469 let target_git = self.store.open(&store_key(&target)).await?;
470 let (history, target_history) = try_join(
471 source_git.log(&branch, MAX_ANCESTRY),
472 target_git.log(&base_branch, MAX_ANCESTRY),
473 )
474 .await?;
475 let (Some(head), Some(base)) = (history.first(), target_history.first()) else {
476 return Ok(Outcome::fail(
477 FailureCode::Conflict,
478 format!("{branch} has no commits to bring up to date."),
479 ));
480 };
481 if let Some(refused) = moved_since(&branch, &head.hash, expected_head) {
482 return Ok(refused);
483 }
484 if descends_from(&source_git, &history, &base.hash).await? {
485 return Ok(Outcome::Ok(PullBranchUpdate::UpToDate {
486 commit: head.hash.clone(),
487 }));
488 }
489 let shared: HashSet<String> = target_history.iter().map(|commit| commit.hash.clone()).collect();
490 let merge_base = nearest_ancestor_in(&source_git, &history, &shared).await?;
491 let Some((merge_base, merge_base_tree)) = merge_base.and_then(|hash| {
492 target_history
493 .iter()
494 .find(|commit| commit.hash == hash)
495 .map(|commit| (hash, commit.tree_hash.clone()))
496 }) else {
497 return Ok(needs_agent(
498 NeedsAgentReason::Unsupported,
499 format!("g1t could not find where this pull request left {base_branch}."),
500 Vec::new(),
501 ));
502 };
503
504 // The pull request's changes, applied to the base branch's tree.
505 let reader = Reader::new(&source_git, from_fork.then_some(&target_git));
506 let mut objects: Vec<(ObjectKind, Vec<u8>)> = Vec::new();
507 let tree = match apply(&reader, Some(&merge_base_tree), &head.tree_hash, &base.tree_hash, &mut objects).await? {
508 Applied::Tree(tree) => tree,
509 Applied::Conflict(paths) => {
510 return Ok(needs_agent(
511 NeedsAgentReason::Conflicting,
512 format!("This pull request and {base_branch} changed the same lines of some files."),
513 paths,
514 ));
515 }
516 Applied::Unsupported(why) => {
517 return Ok(needs_agent(
518 NeedsAgentReason::Unsupported,
519 format!("g1t could not merge this itself: {why}."),
520 Vec::new(),
521 ));
522 }
523 };
524
525 let author = self.commit_identity(actor).await;
526 let commit = commit_object(
527 &tree,
528 &[&head.hash, &base.hash],
529 &Signature {
530 name: &author.name,
531 email: &author.email,
532 seconds: now_ms() / 1000,
533 },
534 &message,
535 );
536 let commit_id = object_id(ObjectKind::Commit, &commit);
537 objects.push((ObjectKind::Commit, commit));
538 let facts = vec![crate::rules::made_commit(&commit_id, &message, &author.email, 2, Vec::new())];
539 let update = BranchUpdate { branch: &branch, head: &head.hash, commit: &commit_id, fast_forward: true, commits: 1 };
540 // From another repository (a fork syncing, a pull request from a
541 // fork catching up), the merge also brings that repository's commits,
542 // which are not read here: a rule about what commits hold refuses it
543 // rather than pass them unseen.
544 if let Some(refused) = self.update_refused(&source, actor, &update, facts, !from_fork).await? {
545 return Ok(refused);
546 }
547 // A fork lacks what the default branch gained since it was made:
548 // those objects come from the repository, with the merge after them.
549 let pack = if from_fork {
550 let target_access = target_git.access(Scope::Read).await?;
551 let fetched = land::fetch_pack(&target_access, &base.hash, Some(&merge_base)).await?;
552 extend_pack(&fetched, &objects).map_err(worker::Error::RustError)?
553 } else {
554 write_pack(&objects)
555 };
556 self.push_update(&source, &source_git, actor, &update, pack).await
557 }
558
559 /// Whether the rules of `update.branch` refuse moving it as `update`
560 /// says, for a branch of a repository (not a pull request's working
561 /// copy, which has none), as for any push (rules.rs). A catch-up
562 /// brings nothing the base branch does not have, so no files.
563 pub(crate) async fn update_refused(
564 &self,
565 source: &g1t_contracts::repos::Repo,
566 actor: &g1t_contracts::User,
567 update: &BranchUpdate<'_>,
568 commits: Vec<g1t_contracts::rules::CommitFacts>,
569 complete: bool,
570 ) -> Result<Option<Outcome<PullBranchUpdate>>> {
571 if source.fork_of.is_some() {
572 return Ok(None);
573 }
574 let change = g1t_rules::push::RefChange {
575 git_ref: format!("refs/heads/{}", update.branch),
576 old: Some(update.head.to_owned()),
577 new: Some(update.commit.to_owned()),
578 fast_forward: Some(update.fast_forward),
579 commits,
580 complete,
581 };
582 Ok(match self.check_changes(source, actor, g1t_contracts::rules::Action::Push, vec![change]).await? {
583 crate::rules::Ruled::Refused { message, .. } => Some(Outcome::fail(FailureCode::Forbidden, message)),
584 _ => None,
585 })
586 }
587
588 /// Moves `update.branch` of `source` to `update.commit`, sending
589 /// `pack`, only if it is still at `update.head`: a push that landed
590 /// meanwhile is kept, and this is refused. Audited and announced as a
591 /// push by `actor`.
592 pub(crate) async fn push_update(
593 &self,
594 source: &g1t_contracts::repos::Repo,
595 source_git: &S::Repo,
596 actor: &g1t_contracts::User,
597 update: &BranchUpdate<'_>,
598 pack: Vec<u8>,
599 ) -> Result<Outcome<PullBranchUpdate>> {
600 let branch = update.branch;
601 let source_access = source_git.access(Scope::Write).await?;
602 let pushed = land::push_pack(&source_access, branch, Some(update.head), update.commit, pack).await?;
603 self.refs_moved(&source.id).await;
604 let git_ref = format!("refs/heads/{branch}");
605 let path = RepoPath {
606 namespace: source.namespace.clone(),
607 name: source.name.clone(),
608 };
609 let mut target_entry = self.audit_target(&path).await?;
610 target_entry.git_ref = Some(git_ref.clone());
611 let mut entry = NewAuditEntry::new(
612 AuditActor::of(actor),
613 "git.push",
614 Surface::Git,
615 target_entry,
616 &Decision::allow("person"),
617 g1t_contracts::new_id("req", now_ms()),
618 );
619 if let Err(reason) = pushed {
620 entry.result = Some("conflict".to_owned());
621 entry.message = Some(reason);
622 self.record_git(entry).await;
623 return Ok(Outcome::fail(
624 FailureCode::Conflict,
625 format!("{branch} moved while it was being brought up to date. Nothing was lost; try again."),
626 ));
627 }
628 entry.result = Some("ok".to_owned());
629 self.record_git(entry).await;
630 // As any push does: the pull request's head moves, its checks run
631 // again, and whether it merges cleanly is worked out anew.
632 self.publish_push(source, &git_ref, Some(update.head), update.commit, Some(actor), Some(update.commits))
633 .await?;
634 Ok(Outcome::Ok(PullBranchUpdate::Updated {
635 commit: update.commit.to_owned(),
636 previous: update.head.to_owned(),
637 }))
638 }
639}
640
641/// How a pull request's branch is being moved.
642pub(crate) struct BranchUpdate<'a> {
643 pub branch: &'a str,
644 /// Where it is, which it must still be when it moves.
645 pub head: &'a str,
646 /// Where it goes.
647 pub commit: &'a str,
648 /// Whether `commit` keeps `head` in its history (a merge does, a
649 /// rebase does not).
650 pub fast_forward: bool,
651 /// How many commits it makes.
652 pub commits: u32,
653}
654
655/// The refusal for a branch that is no longer at `expected`.
656pub(crate) fn moved_since(branch: &str, head: &str, expected: Option<&str>) -> Option<Outcome<PullBranchUpdate>> {
657 expected.filter(|expected| *expected != head).map(|_| {
658 Outcome::fail(
659 FailureCode::Conflict,
660 format!("{branch} has changed since it was last looked at. Look again, then update it."),
661 )
662 })
663}
664
665#[cfg(test)]
666mod tests {
667 use super::*;
668
669 fn entry(name: &str, kind: EntryKind, hash: &str) -> TreeEntry {
670 TreeEntry {
671 name: name.to_owned(),
672 hash: hash.to_owned(),
673 kind,
674 }
675 }
676
677 fn paths(list: &[&str]) -> Vec<String> {
678 list.iter().map(|path| (*path).to_owned()).collect()
679 }
680
681 #[test]
682 fn g1ts_commits_are_g1ts_whoever_asked() {
683 use g1t_contracts::{PrincipalKind, User};
684 let agent = User { id: "usr_g1t_agent".into(), username: "g1t".into(), kind: PrincipalKind::Agent, ..User::default() };
685 let made = g1t_commit_identity(&agent).unwrap();
686 assert_eq!((made.name.as_str(), made.email.as_str()), ("g1t", "g1t@users.noreply.g1t.sh"));
687 let made = g1t_commit_identity(&User::system("acme")).unwrap();
688 assert_eq!((made.name.as_str(), made.email.as_str()), ("g1t", "g1t@users.noreply.g1t.sh"));
689 let person = User { id: "usr_1".into(), username: "ana".into(), ..User::default() };
690 assert!(g1t_commit_identity(&person).is_none());
691 }
692
693 #[test]
694 fn a_branch_that_moved_since_it_was_looked_at_is_left_alone() {
695 assert!(moved_since("feature", "abc", None).is_none());
696 assert!(moved_since("feature", "abc", Some("abc")).is_none());
697 let Some(Outcome::Fail(failure)) = moved_since("feature", "abc", Some("def")) else { panic!("not refused") };
698 assert_eq!(failure.code, FailureCode::Conflict);
699 assert!(failure.message.starts_with("feature has changed"));
700 }
701
702 #[test]
703 fn different_files_do_not_meet() {
704 assert!(overlapping(&paths(&["src/a.rs", "README.md"]), &paths(&["src/b.rs", "docs/x.md"])).is_empty());
705 }
706
707 #[test]
708 fn the_same_file_meets() {
709 assert_eq!(overlapping(&paths(&["src/a.rs", "b"]), &paths(&["src/a.rs"])), ["src/a.rs"]);
710 }
711
712 #[test]
713 fn a_file_against_a_directory_meets() {
714 // Ours made `a` a file where theirs put files under `a/`.
715 assert_eq!(overlapping(&paths(&["a"]), &paths(&["a/b"])), ["a"]);
716 // And the other way round.
717 assert_eq!(overlapping(&paths(&["a/b/c"]), &paths(&["a/b"])), ["a/b"]);
718 // A shared prefix of a name is not a directory.
719 assert!(overlapping(&paths(&["ab"]), &paths(&["a/b"])).is_empty());
720 }
721
722 #[test]
723 fn modes_are_written_as_git_writes_them() {
724 assert_eq!(mode(EntryKind::Tree), "40000");
725 assert_eq!(mode(EntryKind::Blob), "100644");
726 assert_eq!(mode(EntryKind::Exec), "100755");
727 assert_eq!(mode(EntryKind::Symlink), "120000");
728 assert_eq!(mode(EntryKind::Gitlink), "160000");
729 }
730
731 // Ids below come from git itself (`git mktree --missing`,
732 // `git hash-object`, `git commit-tree`) in a scratch repository.
733 const EMPTY: &str = "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391";
734 const HELLO: &str = "ce013625030ba8dba906f756967f9e9ca394464a";
735 const SUBMODULE: &str = "1111111111111111111111111111111111111111";
736
737 #[test]
738 fn entries_sort_as_git_sorts_them() {
739 // `a` as a directory sorts after `a.b` and `a-c` but before `a0`:
740 // it compares as `a/`.
741 let entries = vec![
742 entry("a0", EntryKind::Exec, HELLO),
743 entry("a", EntryKind::Tree, "4b825dc642cb6eb9a060e54bf8d69288fbee4904"),
744 entry("a.b", EntryKind::Blob, EMPTY),
745 entry("a-c", EntryKind::Symlink, HELLO),
746 entry("vendor", EntryKind::Gitlink, SUBMODULE),
747 ];
748 let id = object_id(ObjectKind::Tree, &encode_entries(&entries));
749 assert_eq!(id, "59710ac869a643ad7e179b924af6fb3009b84859");
750 }
751
752 #[test]
753 fn the_empty_tree_is_gits() {
754 assert_eq!(
755 object_id(ObjectKind::Tree, &encode_entries(&[])),
756 "4b825dc642cb6eb9a060e54bf8d69288fbee4904"
757 );
758 }
759
760 /// A base with `README.md`, `bin/run` (executable), `docs/old.md`,
761 /// `src/lib.rs` and a submodule `vendor/dep`.
762 fn base() -> (String, HashMap<String, Vec<TreeEntry>>) {
763 let mut trees = HashMap::new();
764 let bin = vec![entry("run", EntryKind::Exec, HELLO)];
765 let docs = vec![entry("old.md", EntryKind::Blob, HELLO)];
766 let src = vec![entry("lib.rs", EntryKind::Blob, EMPTY)];
767 let vendor = vec![entry("dep", EntryKind::Gitlink, SUBMODULE)];
768 let mut id = |entries: Vec<TreeEntry>| {
769 let id = object_id(ObjectKind::Tree, &encode_entries(&entries));
770 trees.insert(id.clone(), entries);
771 id
772 };
773 let root = vec![
774 entry("README.md", EntryKind::Blob, HELLO),
775 entry("bin", EntryKind::Tree, &id(bin)),
776 entry("docs", EntryKind::Tree, &id(docs)),
777 entry("src", EntryKind::Tree, &id(src)),
778 entry("vendor", EntryKind::Tree, &id(vendor)),
779 ];
780 let root = id(root);
781 (root, trees)
782 }
783
784 #[test]
785 fn the_base_tree_matches_git() {
786 assert_eq!(base().0, "cdbaeedd6c31387975e65e67f9d453589d3c73d1");
787 }
788
789 #[test]
790 fn changes_are_applied_to_the_base() {
791 let (root, trees) = base();
792 let changes = vec![
793 // Added in a new directory.
794 Change { path: "src/net/http.rs".into(), entry: Some((EntryKind::Blob, HELLO.into())) },
795 // Made executable: the same blob.
796 Change { path: "src/lib.rs".into(), entry: Some((EntryKind::Exec, EMPTY.into())) },
797 // Deleted, leaving its directory empty.
798 Change { path: "docs/old.md".into(), entry: None },
799 // A symlink added at the top.
800 Change { path: "latest".into(), entry: Some((EntryKind::Symlink, HELLO.into())) },
801 ];
802 let merged = merge_tree(&root, &trees, &changes).unwrap();
803 assert_eq!(merged.tree, "3097aeb8d4a86f097eca63da84e432ebeb456158");
804 // The root, src and src/net; docs is gone, bin and vendor untouched.
805 assert_eq!(merged.objects.len(), 3);
806 }
807
808 #[test]
809 fn a_file_can_replace_a_directory_the_pull_request_emptied() {
810 let (root, trees) = base();
811 let changes = vec![
812 Change { path: "docs".into(), entry: Some((EntryKind::Blob, HELLO.into())) },
813 Change { path: "docs/old.md".into(), entry: None },
814 ];
815 let merged = merge_tree(&root, &trees, &changes).unwrap();
816 assert_eq!(merged.tree, "088fc1bb6d44a71fcd0c33d4022e7bb1628b7202");
817 }
818
819 #[test]
820 fn a_tree_that_cannot_be_written_back_is_refused() {
821 let (root, mut trees) = base();
822 // Entries that do not hash to the id they are filed under, as a
823 // tree with a mode g1t does not know would not.
824 let entries = trees.remove(&root).unwrap();
825 trees.insert(root.clone(), entries[1..].to_vec());
826 let changes = vec![Change { path: "x".into(), entry: Some((EntryKind::Blob, HELLO.into())) }];
827 assert!(merge_tree(&root, &trees, &changes).is_err());
828 }
829
830 #[test]
831 fn a_file_is_not_put_where_a_directory_still_is() {
832 let (root, trees) = base();
833 let changes = vec![Change { path: "src".into(), entry: Some((EntryKind::Blob, HELLO.into())) }];
834 assert!(merge_tree(&root, &trees, &changes).is_err());
835 }
836
837 #[test]
838 fn the_merge_commit_matches_git() {
839 let commit = commit_object(
840 "cdbaeedd6c31387975e65e67f9d453589d3c73d1",
841 &["74257edb70e8dc5d2f31af4b76fe898608f829da", "110bda84f45f58e44d3699be9efe91276381b43d"],
842 &Signature { name: "octo", email: "octo@users.g1t.sh", seconds: 1_700_000_000 },
843 &merge_message("main", "feature", 7),
844 );
845 assert_eq!(object_id(ObjectKind::Commit, &commit), "f8d35e9d454b11a179215ee1283ac71b7216f024");
846 }
847
848 #[test]
849 fn a_fork_on_the_default_branch_is_named_by_number() {
850 assert_eq!(merge_message("main", "main", 12), "Merge main into pull request #12");
851 assert_eq!(merge_message("main", "fix-login", 12), "Merge main into fix-login");
852 }
853
854 #[test]
855 fn a_pack_of_the_merge_reads_back() {
856 let (root, trees) = base();
857 let changes = vec![Change { path: "src/new.rs".into(), entry: Some((EntryKind::Blob, HELLO.into())) }];
858 let merged = merge_tree(&root, &trees, &changes).unwrap();
859 let mut objects: Vec<(ObjectKind, Vec<u8>)> =
860 merged.objects.iter().map(|bytes| (ObjectKind::Tree, bytes.clone())).collect();
861 let pack = write_pack(&objects);
862 let read = g1t_scan::pack::Pack::parse(&pack).unwrap();
863 assert!(read.tree(&merged.tree).is_some());
864
865 // Extended with a commit, it still reads, and its checksum holds.
866 let commit = commit_object(
867 &merged.tree,
868 &[&root],
869 &Signature { name: "octo", email: "octo@users.g1t.sh", seconds: 1 },
870 "m",
871 );
872 let commit_id = object_id(ObjectKind::Commit, &commit);
873 objects.clear();
874 objects.push((ObjectKind::Commit, commit));
875 let extended = extend_pack(&pack, &objects).unwrap();
876 let read = g1t_scan::pack::Pack::parse(&extended).unwrap();
877 assert!(read.tree(&merged.tree).is_some());
878 assert_eq!(read.commits(), [commit_id]);
879 }
880}