flagon-io/g1t

public

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/services/repos/src/catch_up.rs

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