| 1 | //! A pull request's test merge commit, as GitHub makes one for every open |
| 2 | //! pull request: `refs/pull/<number>/head` at the pull request's head, and |
| 3 | //! `refs/pull/<number>/merge` at a commit merging that head into the tip of |
| 4 | //! the branch it targets. `pull_request` runs build that commit. |
| 5 | //! |
| 6 | //! It is made here, without a sandbox. A fork's head is first copied into |
| 7 | //! the repository (only the objects it lacks travel). Files only one side |
| 8 | //! changed since the two last agreed are taken from that side, as catching |
| 9 | //! up does (catch_up.rs); a file both changed is merged line by line, the |
| 10 | //! way git merges text, and changes to the same or neighbouring lines that |
| 11 | //! differ are a conflict, as is a binary file both changed differently. |
| 12 | //! Then there is no merge commit, as on GitHub, and the merge ref goes. |
| 13 | //! |
| 14 | //! What g1t cannot merge itself (a file one side renamed or deleted and the |
| 15 | //! other changed, a file against a directory, a change too large to list) |
| 16 | //! is answered as unavailable, and the caller decides what to do without it. |
| 17 | |
| 18 | use std::collections::{BTreeSet, HashMap}; |
| 19 | |
| 20 | use futures_util::future::{try_join, try_join_all}; |
| 21 | use g1t_contracts::repos::{EntryKind, PullMergeRef, PullMergeRefArgs, TreeEntry, pull_head_ref, pull_merge_ref}; |
| 22 | use g1t_contracts::time::parse_rfc3339; |
| 23 | use g1t_scan::pack::{ObjectKind, object_id, write_pack}; |
| 24 | use similar::{Algorithm, DiffTag, capture_diff_slices}; |
| 25 | use worker::Result; |
| 26 | |
| 27 | use crate::catch_up::{Change, Signature, ancestors, commit_object, merge_tree, overlapping, read_dirs}; |
| 28 | use crate::diff::{FileEntry, changed_entries}; |
| 29 | use crate::registry::store_key; |
| 30 | use crate::store::{GitRepo, GitStore, Scope}; |
| 31 | use crate::{MAX_ANCESTRY, Repos, descends_from, land, nearest_ancestor_in, refs}; |
| 32 | |
| 33 | /// Files larger than this are not merged line by line. |
| 34 | const MAX_MERGE_BYTES: usize = 1024 * 1024; |
| 35 | const ZERO_ID: &str = "0000000000000000000000000000000000000000"; |
| 36 | |
| 37 | /// One side's change to the base: its lines `start..end` become `lines`. |
| 38 | #[derive(Debug)] |
| 39 | struct Hunk<'a> { |
| 40 | start: usize, |
| 41 | end: usize, |
| 42 | lines: Vec<&'a str>, |
| 43 | } |
| 44 | |
| 45 | /// What `side` changed of `base`, as hunks in order, with no two touching. |
| 46 | fn hunks<'a>(base: &[&'a str], side: &[&'a str]) -> Vec<Hunk<'a>> { |
| 47 | let mut out: Vec<Hunk<'a>> = Vec::new(); |
| 48 | for op in capture_diff_slices(Algorithm::Myers, base, side) { |
| 49 | let (tag, old, new) = op.as_tag_tuple(); |
| 50 | if tag == DiffTag::Equal { |
| 51 | continue; |
| 52 | } |
| 53 | let lines = side[new].to_vec(); |
| 54 | match out.last_mut() { |
| 55 | Some(last) if last.end == old.start => { |
| 56 | last.end = old.end; |
| 57 | last.lines.extend(lines); |
| 58 | } |
| 59 | _ => out.push(Hunk { start: old.start, end: old.end, lines }), |
| 60 | } |
| 61 | } |
| 62 | out |
| 63 | } |
| 64 | |
| 65 | /// The base's lines `start..end` with `hunks` (all inside them) applied. |
| 66 | fn apply<'a>(base: &[&'a str], hunks: &[Hunk<'a>], start: usize, end: usize) -> Vec<&'a str> { |
| 67 | let mut out = Vec::new(); |
| 68 | let mut at = start; |
| 69 | for hunk in hunks { |
| 70 | out.extend_from_slice(&base[at..hunk.start]); |
| 71 | out.extend_from_slice(&hunk.lines); |
| 72 | at = hunk.end; |
| 73 | } |
| 74 | out.extend_from_slice(&base[at..end]); |
| 75 | out |
| 76 | } |
| 77 | |
| 78 | /// Merges two texts that each changed `base`, as git does: a change only |
| 79 | /// one side made is taken, and where the two sides' changes overlap or |
| 80 | /// touch (no unchanged line between them) they must be the same. `None` |
| 81 | /// when they conflict. |
| 82 | pub(crate) fn merge_text(base: &str, ours: &str, theirs: &str) -> Option<String> { |
| 83 | fn lines(text: &str) -> Vec<&str> { |
| 84 | text.split_inclusive('\n').collect() |
| 85 | } |
| 86 | let (base, ours, theirs) = (lines(base), lines(ours), lines(theirs)); |
| 87 | let (mine, other) = (hunks(&base, &ours), hunks(&base, &theirs)); |
| 88 | let mut out = String::new(); |
| 89 | let (mut i, mut j, mut at) = (0, 0, 0); |
| 90 | while i < mine.len() || j < other.len() { |
| 91 | // The earliest change starts a run; every change that overlaps or |
| 92 | // touches the run joins it. |
| 93 | let first = if j >= other.len() || (i < mine.len() && mine[i].start <= other[j].start) { &mine[i] } else { &other[j] }; |
| 94 | let (start, mut end) = (first.start, first.end); |
| 95 | let (from_mine, from_other) = (i, j); |
| 96 | loop { |
| 97 | if i < mine.len() && mine[i].start <= end { |
| 98 | end = end.max(mine[i].end); |
| 99 | i += 1; |
| 100 | } else if j < other.len() && other[j].start <= end { |
| 101 | end = end.max(other[j].end); |
| 102 | j += 1; |
| 103 | } else { |
| 104 | break; |
| 105 | } |
| 106 | } |
| 107 | let (mine_run, other_run) = (&mine[from_mine..i], &other[from_other..j]); |
| 108 | let merged = match (mine_run.is_empty(), other_run.is_empty()) { |
| 109 | (false, true) => apply(&base, mine_run, start, end), |
| 110 | (true, false) => apply(&base, other_run, start, end), |
| 111 | _ => { |
| 112 | let (a, b) = (apply(&base, mine_run, start, end), apply(&base, other_run, start, end)); |
| 113 | if a != b { |
| 114 | return None; |
| 115 | } |
| 116 | a |
| 117 | } |
| 118 | }; |
| 119 | out.extend(base[at..start].iter().copied()); |
| 120 | out.extend(merged); |
| 121 | at = end; |
| 122 | } |
| 123 | out.extend(base[at..].iter().copied()); |
| 124 | Some(out) |
| 125 | } |
| 126 | |
| 127 | /// What becomes of one path in the merge, the base branch's tree being |
| 128 | /// where the merge starts. |
| 129 | #[derive(Debug, PartialEq, Eq)] |
| 130 | pub(crate) enum Step { |
| 131 | /// Put the pull request's version there (`None`: remove it). |
| 132 | Take(Option<FileEntry>), |
| 133 | /// Both changed the file: merge the three texts, as `kind`. |
| 134 | Content { kind: EntryKind, base: Option<String>, ours: String, theirs: String }, |
| 135 | } |
| 136 | |
| 137 | /// What the files each side changed since the merge base come to, without |
| 138 | /// reading any file. |
| 139 | #[derive(Debug, PartialEq, Eq)] |
| 140 | pub(crate) enum Plan { |
| 141 | Steps(Vec<(String, Step)>), |
| 142 | Conflict(Vec<String>), |
| 143 | Unsupported(String), |
| 144 | } |
| 145 | |
| 146 | fn text_kind(kind: EntryKind) -> bool { |
| 147 | matches!(kind, EntryKind::Blob | EntryKind::Exec) |
| 148 | } |
| 149 | |
| 150 | type Changed = (String, Option<FileEntry>, Option<FileEntry>); |
| 151 | |
| 152 | /// The steps that make the merge, from the files the pull request |
| 153 | /// (`ours`) and the base branch (`theirs`) changed since they last agreed. |
| 154 | pub(crate) fn plan(ours: &[Changed], theirs: &[Changed]) -> Plan { |
| 155 | let their: HashMap<&str, &Changed> = theirs.iter().map(|change| (change.0.as_str(), change)).collect(); |
| 156 | let paths = |changes: &[Changed]| changes.iter().map(|change| change.0.clone()).collect::<Vec<_>>(); |
| 157 | // A file on one side where the other has a directory. |
| 158 | let ours_paths = paths(ours); |
| 159 | if let Some(met) = overlapping(&ours_paths, &paths(theirs)).into_iter().find(|path| !their.contains_key(path.as_str()) || !ours_paths.contains(path)) { |
| 160 | return Plan::Unsupported(format!("{met} is a file on one side and a directory on the other")); |
| 161 | } |
| 162 | let mut steps = Vec::new(); |
| 163 | let mut conflicts = Vec::new(); |
| 164 | for (path, base, mine) in ours { |
| 165 | let Some((_, _, other)) = their.get(path.as_str()) else { |
| 166 | steps.push((path.clone(), Step::Take(mine.clone()))); |
| 167 | continue; |
| 168 | }; |
| 169 | // Both made it the same, or the base branch's is already there. |
| 170 | if mine == other { |
| 171 | continue; |
| 172 | } |
| 173 | let (Some(mine), Some(other)) = (mine, other) else { |
| 174 | return Plan::Unsupported(format!("{path} was deleted or renamed on one side and changed on the other")); |
| 175 | }; |
| 176 | let base_kind = base.as_ref().map(|(kind, _)| *kind); |
| 177 | let kind = if mine.0 == other.0 { |
| 178 | mine.0 |
| 179 | } else if base_kind == Some(mine.0) { |
| 180 | other.0 |
| 181 | } else if base_kind == Some(other.0) { |
| 182 | mine.0 |
| 183 | } else { |
| 184 | conflicts.push(path.clone()); |
| 185 | continue; |
| 186 | }; |
| 187 | if mine.1 == other.1 { |
| 188 | steps.push((path.clone(), Step::Take(Some((kind, mine.1.clone()))))); |
| 189 | continue; |
| 190 | } |
| 191 | if !text_kind(mine.0) || !text_kind(other.0) || base_kind.is_some_and(|kind| !text_kind(kind)) { |
| 192 | conflicts.push(path.clone()); |
| 193 | continue; |
| 194 | } |
| 195 | steps.push(( |
| 196 | path.clone(), |
| 197 | Step::Content { kind, base: base.as_ref().map(|(_, hash)| hash.clone()), ours: mine.1.clone(), theirs: other.1.clone() }, |
| 198 | )); |
| 199 | } |
| 200 | if conflicts.is_empty() { Plan::Steps(steps) } else { Plan::Conflict(conflicts) } |
| 201 | } |
| 202 | |
| 203 | /// What GitHub's test merge commits say. |
| 204 | pub(crate) fn message(head: &str, base: &str) -> String { |
| 205 | format!("Merge {head} into {base}") |
| 206 | } |
| 207 | |
| 208 | /// A merge, worked out. |
| 209 | enum Made { |
| 210 | Merged { commit: String, objects: Vec<(ObjectKind, Vec<u8>)> }, |
| 211 | Conflict(Vec<String>), |
| 212 | Unsupported(String), |
| 213 | } |
| 214 | |
| 215 | /// A blob's text, for merging: `Err(true)` when it is binary, `Err(false)` |
| 216 | /// when it is too large or missing. |
| 217 | fn as_text(bytes: Option<Vec<u8>>) -> std::result::Result<String, bool> { |
| 218 | let bytes = bytes.ok_or(false)?; |
| 219 | if bytes.len() > MAX_MERGE_BYTES { |
| 220 | return Err(false); |
| 221 | } |
| 222 | if bytes.contains(&0) { |
| 223 | return Err(true); |
| 224 | } |
| 225 | String::from_utf8(bytes).map_err(|_| true) |
| 226 | } |
| 227 | |
| 228 | /// When a merge of two commits says it was made: the later of their |
| 229 | /// dates, so that the same two always make the same commit, however many |
| 230 | /// events ask for it, and a merge ref that is already right stays. |
| 231 | fn merged_at(head: &g1t_contracts::repos::Commit, base: &g1t_contracts::repos::Commit) -> u64 { |
| 232 | let at = |commit: &g1t_contracts::repos::Commit| parse_rfc3339(&commit.authored_at).unwrap_or(0) / 1000; |
| 233 | at(head).max(at(base)) |
| 234 | } |
| 235 | |
| 236 | /// Merges `head` into `base`, both in `git`. |
| 237 | async fn make<R: GitRepo>(git: &R, base: &str, head: &str) -> Result<Made> { |
| 238 | let (head_log, base_log) = try_join(git.log(head, MAX_ANCESTRY), git.log(base, MAX_ANCESTRY)).await?; |
| 239 | let (Some(head_commit), Some(base_commit)) = (head_log.first(), base_log.first()) else { |
| 240 | return Ok(Made::Unsupported("the head or the base commit could not be read".to_owned())); |
| 241 | }; |
| 242 | let seconds = merged_at(head_commit, base_commit); |
| 243 | let mut objects: Vec<(ObjectKind, Vec<u8>)> = Vec::new(); |
| 244 | let tree = if descends_from(git, &head_log, base).await? { |
| 245 | head_commit.tree_hash.clone() |
| 246 | } else { |
| 247 | let shared = base_log.iter().map(|commit| commit.hash.clone()).collect(); |
| 248 | let merge_base = nearest_ancestor_in(git, &head_log, &shared).await?; |
| 249 | let Some(merge_base_tree) = merge_base.and_then(|hash| base_log.iter().find(|commit| commit.hash == hash)).map(|commit| commit.tree_hash.clone()) else { |
| 250 | return Ok(Made::Unsupported("g1t could not find where the head left the base branch".to_owned())); |
| 251 | }; |
| 252 | let ((ours, ours_cut), (theirs, theirs_cut)) = try_join( |
| 253 | changed_entries(git, Some(&merge_base_tree), &head_commit.tree_hash), |
| 254 | changed_entries(git, Some(&merge_base_tree), &base_commit.tree_hash), |
| 255 | ) |
| 256 | .await?; |
| 257 | if ours_cut || theirs_cut { |
| 258 | return Ok(Made::Unsupported("the change is too large to merge without a sandbox".to_owned())); |
| 259 | } |
| 260 | let steps = match plan(&ours, &theirs) { |
| 261 | Plan::Steps(steps) => steps, |
| 262 | Plan::Conflict(paths) => return Ok(Made::Conflict(paths)), |
| 263 | Plan::Unsupported(why) => return Ok(Made::Unsupported(why)), |
| 264 | }; |
| 265 | // Files both changed, merged line by line. |
| 266 | let read = try_join_all(steps.iter().map(|(_, step)| async move { |
| 267 | let Step::Content { base, ours, theirs, .. } = step else { return Ok(None) }; |
| 268 | let base = async { |
| 269 | match base { |
| 270 | Some(hash) => git.read_blob(hash).await, |
| 271 | None => Ok(Some(Vec::new())), |
| 272 | } |
| 273 | }; |
| 274 | let (base, (ours, theirs)) = try_join(base, try_join(git.read_blob(ours), git.read_blob(theirs))).await?; |
| 275 | Ok::<_, worker::Error>(Some((base, ours, theirs))) |
| 276 | })) |
| 277 | .await?; |
| 278 | let mut changes = Vec::new(); |
| 279 | let mut conflicts = Vec::new(); |
| 280 | for ((path, step), texts) in steps.into_iter().zip(read) { |
| 281 | match (step, texts) { |
| 282 | (Step::Take(entry), _) => changes.push(Change { path, entry }), |
| 283 | (Step::Content { kind, .. }, Some((base, ours, theirs))) => { |
| 284 | let texts = (as_text(base), as_text(ours), as_text(theirs)); |
| 285 | let (Ok(base), Ok(ours), Ok(theirs)) = texts else { |
| 286 | if matches!(texts, (Err(true), _, _) | (_, Err(true), _) | (_, _, Err(true))) { |
| 287 | conflicts.push(path); |
| 288 | continue; |
| 289 | } |
| 290 | return Ok(Made::Unsupported(format!("{path} is too large to merge without a sandbox"))); |
| 291 | }; |
| 292 | match merge_text(&base, &ours, &theirs) { |
| 293 | Some(merged) => { |
| 294 | let blob = merged.into_bytes(); |
| 295 | let id = object_id(ObjectKind::Blob, &blob); |
| 296 | objects.push((ObjectKind::Blob, blob)); |
| 297 | changes.push(Change { path, entry: Some((kind, id)) }); |
| 298 | } |
| 299 | None => conflicts.push(path), |
| 300 | } |
| 301 | } |
| 302 | (Step::Content { .. }, None) => {} |
| 303 | } |
| 304 | } |
| 305 | if !conflicts.is_empty() { |
| 306 | return Ok(Made::Conflict(conflicts)); |
| 307 | } |
| 308 | let dirs: BTreeSet<String> = changes.iter().flat_map(|change| ancestors(&change.path).map(str::to_owned)).collect(); |
| 309 | let trees: HashMap<String, Vec<TreeEntry>> = read_dirs(git, &base_commit.tree_hash, &dirs).await?.into_values().collect(); |
| 310 | match merge_tree(&base_commit.tree_hash, &trees, &changes) { |
| 311 | Ok(merged) => { |
| 312 | objects.extend(merged.objects.into_iter().map(|bytes| (ObjectKind::Tree, bytes))); |
| 313 | merged.tree |
| 314 | } |
| 315 | Err(why) => return Ok(Made::Unsupported(why)), |
| 316 | } |
| 317 | }; |
| 318 | let commit = commit_object( |
| 319 | &tree, |
| 320 | &[base, head], |
| 321 | &Signature { name: g1t_contracts::system::USERNAME, email: g1t_contracts::system::EMAIL, seconds }, |
| 322 | &message(head, base), |
| 323 | ); |
| 324 | let id = object_id(ObjectKind::Commit, &commit); |
| 325 | objects.push((ObjectKind::Commit, commit)); |
| 326 | Ok(Made::Merged { commit: id, objects }) |
| 327 | } |
| 328 | |
| 329 | fn unavailable(reason: impl Into<String>) -> Result<PullMergeRef> { |
| 330 | Ok(PullMergeRef::Unavailable { reason: reason.into() }) |
| 331 | } |
| 332 | |
| 333 | impl<S: GitStore> Repos<S> { |
| 334 | /// `pull_merge_ref` (contracts `PullMergeRefArgs`). |
| 335 | pub(crate) async fn pull_merge_ref(&self, a: PullMergeRefArgs) -> Result<PullMergeRef> { |
| 336 | let Some(target) = self.registry.by_id(&a.repo_id).await? else { |
| 337 | return unavailable("the repository is gone"); |
| 338 | }; |
| 339 | if let Some((_, message)) = crate::lifecycle::read_only_refusal(&target) { |
| 340 | return unavailable(message); |
| 341 | } |
| 342 | let target = match self.unpaused(target).await? { |
| 343 | Ok(target) => target, |
| 344 | Err((_, message)) => return unavailable(message), |
| 345 | }; |
| 346 | let git = self.store.open(&store_key(&target)).await?; |
| 347 | let refs = refs::all(&git.access(Scope::Read).await?).await?; |
| 348 | let find = |refs: &[(String, String)], name: &str| refs.iter().find(|(known, _)| known == name).map(|(_, hash)| hash.clone()); |
| 349 | let (head_ref, merge_ref) = (pull_head_ref(a.number), pull_merge_ref(a.number)); |
| 350 | let base_branch = a.target_branch.clone().filter(|branch| !branch.is_empty()).unwrap_or_else(|| target.default_branch.clone()); |
| 351 | let Some(base) = find(&refs, &format!("refs/heads/{base_branch}")) else { |
| 352 | return unavailable(format!("{base_branch} has no commits")); |
| 353 | }; |
| 354 | let head = a.head.clone(); |
| 355 | |
| 356 | // The head, kept in the repository, its objects brought from the fork. |
| 357 | let held_head = find(&refs, &head_ref); |
| 358 | if held_head.as_deref() != Some(head.as_str()) { |
| 359 | let write = git.access(Scope::Write).await?; |
| 360 | let moved = if !git.log(&head, 1).await?.is_empty() { |
| 361 | land::push_ref(&write, &head_ref, held_head.as_deref(), &head, Some(land::EMPTY_PACK.to_vec())).await? |
| 362 | } else { |
| 363 | let source = match self.registry.by_id(&a.source_id).await? { |
| 364 | Some(source) if source.id != target.id => source, |
| 365 | _ => return unavailable("the head commit is not in the repository"), |
| 366 | }; |
| 367 | // A working copy removed after its pull request closed is made again. |
| 368 | self.live(&source).await?; |
| 369 | let source_read = self.store.open(&store_key(&source)).await?.access(Scope::Read).await?; |
| 370 | let command = (head_ref.clone(), held_head.clone(), head.clone()); |
| 371 | land::copy_refs(&source_read, &write, &[command], std::slice::from_ref(&head), std::slice::from_ref(&base)).await? |
| 372 | }; |
| 373 | self.refs_moved(&target.id).await; |
| 374 | if let Err(reason) = moved { |
| 375 | return unavailable(format!("{head_ref} could not be kept: {reason}")); |
| 376 | } |
| 377 | } |
| 378 | |
| 379 | let held_merge = find(&refs, &merge_ref); |
| 380 | match make(&git, &base, &head).await? { |
| 381 | Made::Unsupported(reason) => unavailable(reason), |
| 382 | Made::Conflict(paths) => { |
| 383 | // No merge commit, so no merge ref, as on GitHub. |
| 384 | if let Some(old) = &held_merge { |
| 385 | let write = git.access(Scope::Write).await?; |
| 386 | if let Err(reason) = land::push_ref(&write, &merge_ref, Some(old), ZERO_ID, None).await? { |
| 387 | worker::console_warn!("{merge_ref} of {} not removed: {reason}", target.id); |
| 388 | } |
| 389 | self.refs_moved(&target.id).await; |
| 390 | } |
| 391 | Ok(PullMergeRef::Conflict { head, base, paths }) |
| 392 | } |
| 393 | // Made the same for the same head and base: there already when |
| 394 | // an earlier event made it. |
| 395 | Made::Merged { commit, .. } if held_merge.as_deref() == Some(commit.as_str()) => { |
| 396 | Ok(PullMergeRef::Merged { head, base, merge: commit }) |
| 397 | } |
| 398 | Made::Merged { commit, objects } => { |
| 399 | let write = git.access(Scope::Write).await?; |
| 400 | let pushed = land::push_ref(&write, &merge_ref, held_merge.as_deref(), &commit, Some(write_pack(&objects))).await?; |
| 401 | self.refs_moved(&target.id).await; |
| 402 | if let Err(reason) = pushed { |
| 403 | // Another event for the same pair made it meanwhile. |
| 404 | let refs = refs::all(&git.access(Scope::Read).await?).await?; |
| 405 | if find(&refs, &merge_ref).as_deref() != Some(commit.as_str()) { |
| 406 | return unavailable(format!("{merge_ref} could not be moved: {reason}")); |
| 407 | } |
| 408 | } |
| 409 | Ok(PullMergeRef::Merged { head, base, merge: commit }) |
| 410 | } |
| 411 | } |
| 412 | } |
| 413 | } |
| 414 | |
| 415 | #[cfg(test)] |
| 416 | mod tests { |
| 417 | use super::*; |
| 418 | |
| 419 | fn blob(hash: &str) -> Option<FileEntry> { |
| 420 | Some((EntryKind::Blob, hash.to_owned())) |
| 421 | } |
| 422 | |
| 423 | fn changed(path: &str, base: Option<FileEntry>, now: Option<FileEntry>) -> Changed { |
| 424 | (path.to_owned(), base, now) |
| 425 | } |
| 426 | |
| 427 | #[test] |
| 428 | fn changes_to_different_lines_merge() { |
| 429 | let base = "a\nb\nc\nd\ne\n"; |
| 430 | let ours = "A\nb\nc\nd\ne\n"; |
| 431 | let theirs = "a\nb\nc\nd\nE\n"; |
| 432 | assert_eq!(merge_text(base, ours, theirs).as_deref(), Some("A\nb\nc\nd\nE\n")); |
| 433 | // Lines added at either end, and one taken out in the middle. |
| 434 | assert_eq!(merge_text(base, "top\na\nb\nc\nd\ne\n", "a\nb\nd\ne\nend\n").as_deref(), Some("top\na\nb\nd\ne\nend\n")); |
| 435 | } |
| 436 | |
| 437 | #[test] |
| 438 | fn the_same_change_on_both_sides_merges() { |
| 439 | let base = "a\nb\nc\n"; |
| 440 | assert_eq!(merge_text(base, "a\nB\nc\n", "a\nB\nc\n").as_deref(), Some("a\nB\nc\n")); |
| 441 | } |
| 442 | |
| 443 | #[test] |
| 444 | fn different_changes_to_the_same_or_touching_lines_conflict() { |
| 445 | let base = "a\nb\nc\nd\n"; |
| 446 | assert_eq!(merge_text(base, "a\nX\nc\nd\n", "a\nY\nc\nd\n"), None); |
| 447 | // Neighbouring lines, with no unchanged line between: git conflicts. |
| 448 | assert_eq!(merge_text(base, "a\nX\nc\nd\n", "a\nb\nY\nd\n"), None); |
| 449 | // Both added different files from nothing. |
| 450 | assert_eq!(merge_text("", "one\n", "two\n"), None); |
| 451 | // One unchanged line between them is enough. |
| 452 | assert_eq!(merge_text(base, "X\nb\nc\nd\n", "a\nb\nY\nd\n").as_deref(), Some("X\nb\nY\nd\n")); |
| 453 | } |
| 454 | |
| 455 | #[test] |
| 456 | fn a_missing_final_newline_is_kept() { |
| 457 | assert_eq!(merge_text("a\nb\nc", "A\nb\nc", "a\nb\nC").as_deref(), Some("A\nb\nC")); |
| 458 | } |
| 459 | |
| 460 | #[test] |
| 461 | fn files_only_one_side_changed_are_taken_from_it() { |
| 462 | let ours = [changed("src/a.rs", blob("1"), blob("2")), changed("gone.md", blob("3"), None)]; |
| 463 | let theirs = [changed("src/b.rs", blob("4"), blob("5"))]; |
| 464 | assert_eq!( |
| 465 | plan(&ours, &theirs), |
| 466 | Plan::Steps(vec![("src/a.rs".into(), Step::Take(blob("2"))), ("gone.md".into(), Step::Take(None))]) |
| 467 | ); |
| 468 | } |
| 469 | |
| 470 | #[test] |
| 471 | fn a_file_both_changed_is_merged_by_content() { |
| 472 | let ours = [changed("a", blob("1"), blob("2")), changed("same", blob("1"), blob("9"))]; |
| 473 | let theirs = [changed("a", blob("1"), blob("3")), changed("same", blob("1"), blob("9"))]; |
| 474 | assert_eq!( |
| 475 | plan(&ours, &theirs), |
| 476 | Plan::Steps(vec![( |
| 477 | "a".into(), |
| 478 | Step::Content { kind: EntryKind::Blob, base: Some("1".into()), ours: "2".into(), theirs: "3".into() } |
| 479 | )]) |
| 480 | ); |
| 481 | // One side made it executable, the other changed it. |
| 482 | let ours = [changed("run", blob("1"), Some((EntryKind::Exec, "1".into())))]; |
| 483 | let theirs = [changed("run", blob("1"), blob("2"))]; |
| 484 | assert_eq!( |
| 485 | plan(&ours, &theirs), |
| 486 | Plan::Steps(vec![( |
| 487 | "run".into(), |
| 488 | Step::Content { kind: EntryKind::Exec, base: Some("1".into()), ours: "1".into(), theirs: "2".into() } |
| 489 | )]) |
| 490 | ); |
| 491 | } |
| 492 | |
| 493 | #[test] |
| 494 | fn what_cannot_merge_says_so() { |
| 495 | // A submodule moved two ways. |
| 496 | let ours = [changed("vendor", Some((EntryKind::Gitlink, "1".into())), Some((EntryKind::Gitlink, "2".into())))]; |
| 497 | let theirs = [changed("vendor", Some((EntryKind::Gitlink, "1".into())), Some((EntryKind::Gitlink, "3".into())))]; |
| 498 | assert_eq!(plan(&ours, &theirs), Plan::Conflict(vec!["vendor".into()])); |
| 499 | // Deleted on one side, changed on the other: maybe a rename. |
| 500 | let ours = [changed("a", blob("1"), None)]; |
| 501 | let theirs = [changed("a", blob("1"), blob("2"))]; |
| 502 | assert!(matches!(plan(&ours, &theirs), Plan::Unsupported(_))); |
| 503 | // A file against a directory. |
| 504 | let ours = [changed("a", None, blob("1"))]; |
| 505 | let theirs = [changed("a/b", None, blob("2"))]; |
| 506 | assert!(matches!(plan(&ours, &theirs), Plan::Unsupported(_))); |
| 507 | } |
| 508 | |
| 509 | #[test] |
| 510 | fn binary_files_are_told_apart_from_text() { |
| 511 | assert_eq!(as_text(Some(b"text\n".to_vec())), Ok("text\n".to_owned())); |
| 512 | assert_eq!(as_text(Some(vec![0, 1, 2])), Err(true)); |
| 513 | assert_eq!(as_text(None), Err(false)); |
| 514 | assert_eq!(as_text(Some(vec![b'a'; MAX_MERGE_BYTES + 1])), Err(false)); |
| 515 | } |
| 516 | |
| 517 | /// A store of whole objects, made in the test: commits by hash, trees |
| 518 | /// and blobs by their real ids. |
| 519 | #[derive(Default)] |
| 520 | struct Store { |
| 521 | commits: HashMap<String, g1t_contracts::repos::Commit>, |
| 522 | trees: HashMap<String, Vec<TreeEntry>>, |
| 523 | blobs: HashMap<String, Vec<u8>>, |
| 524 | } |
| 525 | |
| 526 | impl Store { |
| 527 | fn blob(&mut self, text: &str) -> String { |
| 528 | let id = object_id(ObjectKind::Blob, text.as_bytes()); |
| 529 | self.blobs.insert(id.clone(), text.as_bytes().to_vec()); |
| 530 | id |
| 531 | } |
| 532 | |
| 533 | /// A tree of `files` (`path`, text), its subtrees made as needed. |
| 534 | fn tree(&mut self, files: &[(&str, &str)]) -> String { |
| 535 | let mut here: Vec<TreeEntry> = Vec::new(); |
| 536 | let mut dirs: std::collections::BTreeMap<&str, Vec<(&str, &str)>> = Default::default(); |
| 537 | for (path, text) in files { |
| 538 | match path.split_once('/') { |
| 539 | Some((dir, rest)) => dirs.entry(dir).or_default().push((rest, text)), |
| 540 | None => { |
| 541 | let hash = self.blob(text); |
| 542 | here.push(TreeEntry { name: (*path).to_owned(), hash, kind: EntryKind::Blob }); |
| 543 | } |
| 544 | } |
| 545 | } |
| 546 | for (dir, files) in dirs { |
| 547 | let hash = self.tree(&files); |
| 548 | here.push(TreeEntry { name: dir.to_owned(), hash, kind: EntryKind::Tree }); |
| 549 | } |
| 550 | let id = object_id(ObjectKind::Tree, &crate::catch_up::encode_entries(&here)); |
| 551 | self.trees.insert(id.clone(), here); |
| 552 | id |
| 553 | } |
| 554 | |
| 555 | fn commit(&mut self, hash: &str, parents: &[&str], files: &[(&str, &str)]) { |
| 556 | let tree_hash = self.tree(files); |
| 557 | self.commits.insert( |
| 558 | hash.to_owned(), |
| 559 | g1t_contracts::repos::Commit { |
| 560 | hash: hash.to_owned(), |
| 561 | tree_hash, |
| 562 | message: hash.to_owned(), |
| 563 | author: g1t_contracts::repos::Signature { name: "a".into(), email: "a@example.com".into() }, |
| 564 | parents: parents.iter().map(|&p| p.to_owned()).collect(), |
| 565 | authored_at: String::new(), |
| 566 | }, |
| 567 | ); |
| 568 | } |
| 569 | |
| 570 | /// The files of a tree in `objects` or the store, by path. |
| 571 | fn files(&self, tree: &str, written: &HashMap<String, Vec<TreeEntry>>, blobs: &HashMap<String, Vec<u8>>, prefix: &str) -> Vec<(String, String)> { |
| 572 | let entries = written.get(tree).or_else(|| self.trees.get(tree)).expect("tree"); |
| 573 | let mut out = Vec::new(); |
| 574 | for entry in entries { |
| 575 | let path = format!("{prefix}{}", entry.name); |
| 576 | if entry.kind == EntryKind::Tree { |
| 577 | out.extend(self.files(&entry.hash, written, blobs, &format!("{path}/"))); |
| 578 | } else { |
| 579 | let bytes = blobs.get(&entry.hash).or_else(|| self.blobs.get(&entry.hash)).expect("blob"); |
| 580 | out.push((path, String::from_utf8(bytes.clone()).unwrap())); |
| 581 | } |
| 582 | } |
| 583 | out |
| 584 | } |
| 585 | } |
| 586 | |
| 587 | impl GitRepo for Store { |
| 588 | async fn access(&self, _scope: Scope) -> Result<g1t_contracts::repos::GitAccess> { |
| 589 | unimplemented!() |
| 590 | } |
| 591 | async fn branches(&self) -> Result<Vec<g1t_contracts::repos::Branch>> { |
| 592 | Ok(Vec::new()) |
| 593 | } |
| 594 | async fn log(&self, git_ref: &str, limit: u32) -> Result<Vec<g1t_contracts::repos::Commit>> { |
| 595 | let mut out = Vec::new(); |
| 596 | let mut at = self.commits.get(git_ref); |
| 597 | while let Some(commit) = at.filter(|_| (out.len() as u32) < limit) { |
| 598 | out.push(commit.clone()); |
| 599 | at = commit.parents.first().and_then(|parent| self.commits.get(parent)); |
| 600 | } |
| 601 | Ok(out) |
| 602 | } |
| 603 | async fn parents(&self, commit_hash: &str) -> Result<Option<Vec<String>>> { |
| 604 | Ok(self.commits.get(commit_hash).map(|commit| commit.parents.clone())) |
| 605 | } |
| 606 | async fn read_tree(&self, tree_hash: &str) -> Result<Option<Vec<TreeEntry>>> { |
| 607 | Ok(self.trees.get(tree_hash).cloned()) |
| 608 | } |
| 609 | async fn read_blob(&self, blob_hash: &str) -> Result<Option<Vec<u8>>> { |
| 610 | Ok(self.blobs.get(blob_hash).cloned()) |
| 611 | } |
| 612 | async fn read_file(&self, _git_ref: &str, _path: &str) -> Result<Option<Vec<u8>>> { |
| 613 | Ok(None) |
| 614 | } |
| 615 | async fn fork(&self, _target_key: &str) -> Result<()> { |
| 616 | Ok(()) |
| 617 | } |
| 618 | } |
| 619 | |
| 620 | fn block_on<F: std::future::Future>(future: F) -> F::Output { |
| 621 | let mut future = std::pin::pin!(future); |
| 622 | let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); |
| 623 | loop { |
| 624 | if let std::task::Poll::Ready(output) = future.as_mut().poll(&mut cx) { |
| 625 | return output; |
| 626 | } |
| 627 | } |
| 628 | } |
| 629 | |
| 630 | /// The merge's files, read from the objects it wrote and the store. |
| 631 | fn merged_files(store: &Store, made: Made, base: &str, head: &str) -> Vec<(String, String)> { |
| 632 | let Made::Merged { commit, objects } = made else { panic!("not merged") }; |
| 633 | let mut trees = HashMap::new(); |
| 634 | let mut blobs = HashMap::new(); |
| 635 | let mut tree = None; |
| 636 | for (kind, bytes) in &objects { |
| 637 | let id = object_id(*kind, bytes); |
| 638 | match kind { |
| 639 | ObjectKind::Tree => { |
| 640 | let entries = g1t_scan::pack::parse_tree(bytes) |
| 641 | .into_iter() |
| 642 | .map(|item| TreeEntry { |
| 643 | kind: if item.mode == "40000" { EntryKind::Tree } else { EntryKind::Blob }, |
| 644 | name: item.name, |
| 645 | hash: item.id, |
| 646 | }) |
| 647 | .collect(); |
| 648 | trees.insert(id, entries); |
| 649 | } |
| 650 | ObjectKind::Blob => { |
| 651 | blobs.insert(id, bytes.clone()); |
| 652 | } |
| 653 | ObjectKind::Commit => { |
| 654 | assert_eq!(id, commit); |
| 655 | let text = String::from_utf8(bytes.clone()).unwrap(); |
| 656 | assert!(text.contains(&format!("parent {base}\nparent {head}\n")), "{text}"); |
| 657 | assert!(text.contains(&format!("\n\nMerge {head} into {base}\n")), "{text}"); |
| 658 | tree = text.lines().next().and_then(|line| line.strip_prefix("tree ")).map(str::to_owned); |
| 659 | } |
| 660 | ObjectKind::Tag => unreachable!(), |
| 661 | } |
| 662 | } |
| 663 | let mut files = store.files(&tree.expect("a commit"), &trees, &blobs, ""); |
| 664 | files.sort(); |
| 665 | files |
| 666 | } |
| 667 | |
| 668 | #[test] |
| 669 | fn a_head_behind_its_base_merges_both_sides() { |
| 670 | let mut store = Store::default(); |
| 671 | store.commit("m", &[], &[("README.md", "hi\n"), ("src/lib.rs", "a\nb\nc\nd\ne\n")]); |
| 672 | store.commit("h", &["m"], &[("README.md", "hi\n"), ("src/lib.rs", "A\nb\nc\nd\ne\n"), ("src/new.rs", "new\n")]); |
| 673 | store.commit("b", &["m"], &[("README.md", "hello\n"), ("src/lib.rs", "a\nb\nc\nd\nE\n")]); |
| 674 | let made = block_on(make(&store, "b", "h")).unwrap(); |
| 675 | assert_eq!( |
| 676 | merged_files(&store, made, "b", "h"), |
| 677 | [ |
| 678 | ("README.md".to_owned(), "hello\n".to_owned()), |
| 679 | ("src/lib.rs".to_owned(), "A\nb\nc\nd\nE\n".to_owned()), |
| 680 | ("src/new.rs".to_owned(), "new\n".to_owned()), |
| 681 | ] |
| 682 | ); |
| 683 | } |
| 684 | |
| 685 | #[test] |
| 686 | fn a_head_up_to_date_merges_to_its_own_files() { |
| 687 | let mut store = Store::default(); |
| 688 | store.commit("b", &[], &[("a", "1\n")]); |
| 689 | store.commit("h", &["b"], &[("a", "2\n")]); |
| 690 | let made = block_on(make(&store, "b", "h")).unwrap(); |
| 691 | assert_eq!(merged_files(&store, made, "b", "h"), [("a".to_owned(), "2\n".to_owned())]); |
| 692 | } |
| 693 | |
| 694 | #[test] |
| 695 | fn the_same_head_and_base_always_make_the_same_commit() { |
| 696 | let mut store = Store::default(); |
| 697 | store.commit("m", &[], &[("a", "1\n"), ("b", "x\n")]); |
| 698 | store.commit("h", &["m"], &[("a", "2\n"), ("b", "x\n")]); |
| 699 | store.commit("b", &["m"], &[("a", "1\n"), ("b", "y\n")]); |
| 700 | store.commits.get_mut("h").unwrap().authored_at = "2026-10-12T03:00:00.000Z".into(); |
| 701 | store.commits.get_mut("b").unwrap().authored_at = "2026-10-12T04:00:00.000Z".into(); |
| 702 | let id = |made: Made| match made { |
| 703 | Made::Merged { commit, objects } => (commit, String::from_utf8(objects.last().unwrap().1.clone()).unwrap()), |
| 704 | _ => panic!("not merged"), |
| 705 | }; |
| 706 | let (first, text) = id(block_on(make(&store, "b", "h")).unwrap()); |
| 707 | assert_eq!(id(block_on(make(&store, "b", "h")).unwrap()).0, first); |
| 708 | // Dated as the later of the two. |
| 709 | assert!(text.contains(" 1791777600 +0000\n"), "{text}"); |
| 710 | } |
| 711 | |
| 712 | #[test] |
| 713 | fn a_conflict_makes_no_merge() { |
| 714 | let mut store = Store::default(); |
| 715 | store.commit("m", &[], &[("a", "1\n"), ("b", "x\n")]); |
| 716 | store.commit("h", &["m"], &[("a", "2\n"), ("b", "x\n")]); |
| 717 | store.commit("b", &["m"], &[("a", "3\n"), ("b", "y\n")]); |
| 718 | let Made::Conflict(paths) = block_on(make(&store, "b", "h")).unwrap() else { panic!("no conflict") }; |
| 719 | assert_eq!(paths, ["a"]); |
| 720 | } |
| 721 | |
| 722 | #[test] |
| 723 | fn the_merge_commit_says_what_github_s_does() { |
| 724 | assert_eq!(message("abc", "def"), "Merge abc into def"); |
| 725 | assert_eq!(pull_merge_ref(7), "refs/pull/7/merge"); |
| 726 | assert_eq!(pull_head_ref(7), "refs/pull/7/head"); |
| 727 | } |
| 728 | } |