| 1 | //! `pushed`: what a push to a branch brought into it. Work asks after each |
| 2 | //! push, to mark merged the pull requests whose heads it brought in, as a |
| 3 | //! merge from the site would, and to close the issues its commits' messages |
| 4 | //! name after a closing keyword (work's pushed.rs). |
| 5 | //! |
| 6 | //! One walk from the new head, every parent followed, stopping at what the |
| 7 | //! branch held before: its first-parent line and those commits' parents, |
| 8 | //! read in one `log`. A commit off that line that the branch already held |
| 9 | //! (a side branch merged long ago) can be taken for new when the push |
| 10 | //! merges it again; the pull request heads found are therefore checked |
| 11 | //! against the old head exactly before they are said to have landed. |
| 12 | |
| 13 | use std::collections::{HashMap, HashSet, VecDeque}; |
| 14 | |
| 15 | use g1t_contracts::repos::{Commit, MAX_PUSHED, Pushed, PushedArgs, PushedCommit}; |
| 16 | use worker::Result; |
| 17 | |
| 18 | use crate::store::{GitRepo, GitStore}; |
| 19 | use crate::{MAX_ANCESTRY, Repos, descends_from}; |
| 20 | |
| 21 | /// How many commits of a side line one read brings: a merged branch's |
| 22 | /// commits come in one `log` rather than one read each. |
| 23 | const SIDE_READ: u32 = 50; |
| 24 | |
| 25 | impl<S: GitStore> Repos<S> { |
| 26 | pub(crate) async fn pushed(&self, a: PushedArgs) -> Result<Pushed> { |
| 27 | let Some(repo) = self.registry.by_id(&a.repo_id).await? else { |
| 28 | return Ok(Pushed::default()); |
| 29 | }; |
| 30 | let git = self.read_git(&repo).await?; |
| 31 | brought_in(&git, a).await |
| 32 | } |
| 33 | } |
| 34 | |
| 35 | /// [`Repos::pushed`] on one repository's commits. |
| 36 | pub(crate) async fn brought_in<R: GitRepo>(git: &R, a: PushedArgs) -> Result<Pushed> { |
| 37 | let before = match &a.before { |
| 38 | Some(before) => git.log(before, MAX_ANCESTRY).await?, |
| 39 | None => Vec::new(), |
| 40 | }; |
| 41 | // What the branch held: its line, and where each commit on it came from. |
| 42 | let mut held: HashSet<&str> = HashSet::new(); |
| 43 | for commit in &before { |
| 44 | held.insert(commit.hash.as_str()); |
| 45 | held.extend(commit.parents.iter().map(String::as_str)); |
| 46 | } |
| 47 | let mut known: HashMap<String, Commit> = git |
| 48 | .log(&a.after, MAX_PUSHED as u32 + 1) |
| 49 | .await? |
| 50 | .into_iter() |
| 51 | .map(|commit| (commit.hash.clone(), commit)) |
| 52 | .collect(); |
| 53 | let mut seen: HashSet<String> = HashSet::new(); |
| 54 | let mut new: Vec<Commit> = Vec::new(); |
| 55 | let mut queue: VecDeque<String> = VecDeque::from([a.after.clone()]); |
| 56 | while let Some(hash) = queue.pop_front() { |
| 57 | if held.contains(hash.as_str()) || !seen.insert(hash.clone()) { |
| 58 | continue; |
| 59 | } |
| 60 | if new.len() >= MAX_ANCESTRY as usize { |
| 61 | break; |
| 62 | } |
| 63 | if !known.contains_key(&hash) { |
| 64 | for commit in git.log(&hash, SIDE_READ).await? { |
| 65 | known.entry(commit.hash.clone()).or_insert(commit); |
| 66 | } |
| 67 | } |
| 68 | let Some(commit) = known.get(&hash) else { continue }; |
| 69 | queue.extend(commit.parents.iter().cloned()); |
| 70 | new.push(commit.clone()); |
| 71 | } |
| 72 | let brought: HashSet<&str> = new.iter().map(|commit| commit.hash.as_str()).collect(); |
| 73 | let mut landed = Vec::new(); |
| 74 | for head in &a.heads { |
| 75 | if landed.contains(head) || !brought.contains(head.as_str()) { |
| 76 | continue; |
| 77 | } |
| 78 | // Taken for new, but held all along off the old line. |
| 79 | if !before.is_empty() && descends_from(git, &before, head).await? { |
| 80 | continue; |
| 81 | } |
| 82 | landed.push(head.clone()); |
| 83 | } |
| 84 | // A new branch brought nothing to tell anyone about: its commits were |
| 85 | // pushed elsewhere first. The default branch's first push is the |
| 86 | // exception: nothing was pushed before it. |
| 87 | let commits = if a.messages && (a.before.is_some() || a.first) { |
| 88 | new.iter() |
| 89 | .take(MAX_PUSHED) |
| 90 | .rev() |
| 91 | .map(|commit| PushedCommit { hash: commit.hash.clone(), message: commit.message.clone() }) |
| 92 | .collect() |
| 93 | } else { |
| 94 | Vec::new() |
| 95 | }; |
| 96 | Ok(Pushed { landed, commits }) |
| 97 | } |
| 98 | |
| 99 | #[cfg(test)] |
| 100 | mod tests { |
| 101 | use std::cell::RefCell; |
| 102 | use std::future::Future; |
| 103 | use std::pin::pin; |
| 104 | use std::task::{Context, Poll, Waker}; |
| 105 | |
| 106 | use g1t_contracts::repos::{Branch, GitAccess, Signature, TreeEntry}; |
| 107 | |
| 108 | use super::*; |
| 109 | use crate::store::Scope; |
| 110 | |
| 111 | fn run<F: Future>(future: F) -> F::Output { |
| 112 | match pin!(future).as_mut().poll(&mut Context::from_waker(Waker::noop())) { |
| 113 | Poll::Ready(output) => output, |
| 114 | Poll::Pending => panic!("the fake store never waits"), |
| 115 | } |
| 116 | } |
| 117 | |
| 118 | fn commit(hash: &str, parents: &[&str]) -> Commit { |
| 119 | Commit { |
| 120 | hash: hash.into(), |
| 121 | tree_hash: format!("t{hash}"), |
| 122 | message: format!("commit {hash}"), |
| 123 | author: Signature { name: "a".into(), email: "a@example.com".into() }, |
| 124 | parents: parents.iter().map(|&p| p.to_owned()).collect(), |
| 125 | authored_at: "2026-10-14T00:00:00.000Z".into(), |
| 126 | } |
| 127 | } |
| 128 | |
| 129 | /// Commits by hash; `log` follows first parents only, as the store |
| 130 | /// does, and `parents` answers for any commit. Counts `log` reads. |
| 131 | #[derive(Default)] |
| 132 | struct Fake { |
| 133 | commits: HashMap<String, Commit>, |
| 134 | logs: RefCell<u32>, |
| 135 | } |
| 136 | |
| 137 | impl Fake { |
| 138 | fn with(commits: Vec<Commit>) -> Fake { |
| 139 | Fake { commits: commits.into_iter().map(|c| (c.hash.clone(), c)).collect(), ..Fake::default() } |
| 140 | } |
| 141 | } |
| 142 | |
| 143 | impl GitRepo for Fake { |
| 144 | async fn access(&self, _scope: Scope) -> Result<GitAccess> { |
| 145 | unimplemented!() |
| 146 | } |
| 147 | async fn branches(&self) -> Result<Vec<Branch>> { |
| 148 | Ok(Vec::new()) |
| 149 | } |
| 150 | async fn log(&self, git_ref: &str, limit: u32) -> Result<Vec<Commit>> { |
| 151 | *self.logs.borrow_mut() += 1; |
| 152 | let mut out = Vec::new(); |
| 153 | let mut at = self.commits.get(git_ref); |
| 154 | while let Some(commit) = at { |
| 155 | if out.len() as u32 >= limit { |
| 156 | break; |
| 157 | } |
| 158 | out.push(commit.clone()); |
| 159 | at = commit.parents.first().and_then(|parent| self.commits.get(parent)); |
| 160 | } |
| 161 | Ok(out) |
| 162 | } |
| 163 | async fn parents(&self, commit_hash: &str) -> Result<Option<Vec<String>>> { |
| 164 | Ok(self.commits.get(commit_hash).map(|commit| commit.parents.clone())) |
| 165 | } |
| 166 | async fn read_tree(&self, _tree_hash: &str) -> Result<Option<Vec<TreeEntry>>> { |
| 167 | Ok(None) |
| 168 | } |
| 169 | async fn read_blob(&self, _blob_hash: &str) -> Result<Option<Vec<u8>>> { |
| 170 | Ok(None) |
| 171 | } |
| 172 | async fn read_file(&self, _git_ref: &str, _path: &str) -> Result<Option<Vec<u8>>> { |
| 173 | Ok(None) |
| 174 | } |
| 175 | async fn fork(&self, _target_key: &str, _default_branch_only: bool) -> Result<()> { |
| 176 | Ok(()) |
| 177 | } |
| 178 | } |
| 179 | |
| 180 | fn args(before: Option<&str>, after: &str, heads: &[&str]) -> PushedArgs { |
| 181 | PushedArgs { |
| 182 | repo_id: "rep_1".into(), |
| 183 | before: before.map(str::to_owned), |
| 184 | after: after.into(), |
| 185 | heads: heads.iter().map(|&h| h.to_owned()).collect(), |
| 186 | messages: true, |
| 187 | first: false, |
| 188 | } |
| 189 | } |
| 190 | |
| 191 | fn hashes(pushed: &Pushed) -> Vec<&str> { |
| 192 | pushed.commits.iter().map(|c| c.hash.as_str()).collect() |
| 193 | } |
| 194 | |
| 195 | /// m1 ← m2 on main; f1 ← f2 a branch off m2. |
| 196 | fn history() -> Vec<Commit> { |
| 197 | vec![commit("m1", &[]), commit("m2", &["m1"]), commit("f1", &["m2"]), commit("f2", &["f1"])] |
| 198 | } |
| 199 | |
| 200 | #[test] |
| 201 | fn a_fast_forward_brings_in_the_branch_and_its_head() { |
| 202 | let git = Fake::with(history()); |
| 203 | let pushed = run(brought_in(&git, args(Some("m2"), "f2", &["f2", "f1", "m2"]))).unwrap(); |
| 204 | // f1 is a head too (a pull request stacked under the other). |
| 205 | assert_eq!(pushed.landed, vec!["f2", "f1"]); |
| 206 | assert_eq!(hashes(&pushed), vec!["f1", "f2"]); |
| 207 | } |
| 208 | |
| 209 | #[test] |
| 210 | fn a_merge_commit_brings_in_its_second_parent() { |
| 211 | let mut commits = history(); |
| 212 | commits.extend([commit("m3", &["m2"]), commit("x", &["m3", "f2"])]); |
| 213 | let git = Fake::with(commits); |
| 214 | let pushed = run(brought_in(&git, args(Some("m3"), "x", &["f2"]))).unwrap(); |
| 215 | assert_eq!(pushed.landed, vec!["f2"]); |
| 216 | assert_eq!(hashes(&pushed), vec!["f1", "f2", "x"]); |
| 217 | } |
| 218 | |
| 219 | #[test] |
| 220 | fn a_head_the_branch_already_held_did_not_land() { |
| 221 | // g ← h merged into main long ago (m3); main went on to m4. A pull |
| 222 | // request whose head is still g, and a push that merges g again. |
| 223 | let commits = vec![ |
| 224 | commit("m1", &[]), |
| 225 | commit("g", &["m1"]), |
| 226 | commit("h", &["g"]), |
| 227 | commit("m2", &["m1"]), |
| 228 | commit("m3", &["m2", "h"]), |
| 229 | commit("m4", &["m3"]), |
| 230 | commit("y", &["m4", "g"]), |
| 231 | ]; |
| 232 | let git = Fake::with(commits); |
| 233 | let pushed = run(brought_in(&git, args(Some("m4"), "y", &["g", "m4"]))).unwrap(); |
| 234 | assert!(pushed.landed.is_empty()); |
| 235 | // A head that was never pushed elsewhere is not looked for at all. |
| 236 | assert!(!hashes(&pushed).contains(&"m4")); |
| 237 | } |
| 238 | |
| 239 | #[test] |
| 240 | fn nothing_new_lands_nothing() { |
| 241 | let git = Fake::with(history()); |
| 242 | let pushed = run(brought_in(&git, args(Some("f2"), "f2", &["f2"]))).unwrap(); |
| 243 | assert!(pushed.landed.is_empty() && pushed.commits.is_empty()); |
| 244 | // Moving back (a force push to an older commit) brings nothing in. |
| 245 | let back = run(brought_in(&git, args(Some("f2"), "m2", &["m2"]))).unwrap(); |
| 246 | assert!(back.landed.is_empty() && back.commits.is_empty()); |
| 247 | } |
| 248 | |
| 249 | #[test] |
| 250 | fn a_new_branch_lists_no_messages() { |
| 251 | let git = Fake::with(history()); |
| 252 | let pushed = run(brought_in(&git, args(None, "f2", &["f2"]))).unwrap(); |
| 253 | assert_eq!(pushed.landed, vec!["f2"]); |
| 254 | assert!(pushed.commits.is_empty()); |
| 255 | } |
| 256 | |
| 257 | #[test] |
| 258 | fn the_default_branchs_first_push_lists_its_messages() { |
| 259 | // An empty repository's first push: its `Closes #1` closes #1. |
| 260 | let git = Fake::with(vec![commit("m1", &[]), commit("m2", &["m1"])]); |
| 261 | let pushed = run(brought_in(&git, PushedArgs { first: true, ..args(None, "m2", &[]) })).unwrap(); |
| 262 | assert_eq!(hashes(&pushed), vec!["m1", "m2"]); |
| 263 | } |
| 264 | |
| 265 | #[test] |
| 266 | fn a_merged_branch_is_read_in_one_go() { |
| 267 | // Twenty commits on a side branch merged in: one read of main and |
| 268 | // one of the side line, not one per commit. |
| 269 | let mut commits = vec![commit("m1", &[]), commit("m2", &["m1"])]; |
| 270 | commits.push(commit("s1", &["m1"])); |
| 271 | for i in 2..=20 { |
| 272 | commits.push(commit(&format!("s{i}"), &[&format!("s{}", i - 1)])); |
| 273 | } |
| 274 | commits.push(commit("x", &["m2", "s20"])); |
| 275 | let git = Fake::with(commits); |
| 276 | let pushed = run(brought_in(&git, args(Some("m2"), "x", &[]))).unwrap(); |
| 277 | assert_eq!(pushed.commits.len(), 21); |
| 278 | assert_eq!(*git.logs.borrow(), 3); |
| 279 | } |
| 280 | } |