| 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 | /// The commit a pushed branch or tag is announced at, once the store has |
| 36 | /// the push: `None` when the store kept it elsewhere (it can refuse one |
| 37 | /// ref and accept another). |
| 38 | /// |
| 39 | /// A branch must be at the commit the push named. A tag reads back as the |
| 40 | /// commit it leads to, which for an annotated tag is not the tag object |
| 41 | /// the push named (`cli-v0.3.6`: tag accae3e8, commit 42a29574), so a tag |
| 42 | /// whose commit differs is taken as pushed unless what the push named is a |
| 43 | /// commit itself, and is announced at that commit, as `git.push` says its |
| 44 | /// `after` is. A tag the store cannot read back is taken as pushed. |
| 45 | pub(crate) async fn pushed_to<R: GitRepo>(git: &R, pushed: &crate::git_http::Pushed) -> Result<Option<String>> { |
| 46 | if let Some(branch) = pushed.branch() { |
| 47 | let head = git.log(branch, 1).await?; |
| 48 | return Ok(head.first().filter(|commit| commit.hash == pushed.after).map(|_| pushed.after.clone())); |
| 49 | } |
| 50 | let Ok(head) = git.log(&pushed.git_ref, 1).await else { |
| 51 | return Ok(Some(pushed.after.clone())); |
| 52 | }; |
| 53 | let Some(commit) = head.into_iter().next() else { |
| 54 | return Ok(Some(pushed.after.clone())); |
| 55 | }; |
| 56 | if commit.hash == pushed.after { |
| 57 | return Ok(Some(commit.hash)); |
| 58 | } |
| 59 | // A lightweight tag the store left at another commit. |
| 60 | if let Ok(Some(_)) = git.parents(&pushed.after).await { |
| 61 | return Ok(None); |
| 62 | } |
| 63 | Ok(Some(commit.hash)) |
| 64 | } |
| 65 | |
| 66 | /// [`Repos::pushed`] on one repository's commits. |
| 67 | pub(crate) async fn brought_in<R: GitRepo>(git: &R, a: PushedArgs) -> Result<Pushed> { |
| 68 | let before = match &a.before { |
| 69 | Some(before) => git.log(before, MAX_ANCESTRY).await?, |
| 70 | None => Vec::new(), |
| 71 | }; |
| 72 | // What the branch held: its line, and where each commit on it came from. |
| 73 | let mut held: HashSet<&str> = HashSet::new(); |
| 74 | for commit in &before { |
| 75 | held.insert(commit.hash.as_str()); |
| 76 | held.extend(commit.parents.iter().map(String::as_str)); |
| 77 | } |
| 78 | let mut known: HashMap<String, Commit> = git |
| 79 | .log(&a.after, MAX_PUSHED as u32 + 1) |
| 80 | .await? |
| 81 | .into_iter() |
| 82 | .map(|commit| (commit.hash.clone(), commit)) |
| 83 | .collect(); |
| 84 | let mut seen: HashSet<String> = HashSet::new(); |
| 85 | let mut new: Vec<Commit> = Vec::new(); |
| 86 | let mut queue: VecDeque<String> = VecDeque::from([a.after.clone()]); |
| 87 | while let Some(hash) = queue.pop_front() { |
| 88 | if held.contains(hash.as_str()) || !seen.insert(hash.clone()) { |
| 89 | continue; |
| 90 | } |
| 91 | if new.len() >= MAX_ANCESTRY as usize { |
| 92 | break; |
| 93 | } |
| 94 | if !known.contains_key(&hash) { |
| 95 | for commit in git.log(&hash, SIDE_READ).await? { |
| 96 | known.entry(commit.hash.clone()).or_insert(commit); |
| 97 | } |
| 98 | } |
| 99 | let Some(commit) = known.get(&hash) else { continue }; |
| 100 | queue.extend(commit.parents.iter().cloned()); |
| 101 | new.push(commit.clone()); |
| 102 | } |
| 103 | let brought: HashSet<&str> = new.iter().map(|commit| commit.hash.as_str()).collect(); |
| 104 | let mut landed = Vec::new(); |
| 105 | for head in &a.heads { |
| 106 | if landed.contains(head) || !brought.contains(head.as_str()) { |
| 107 | continue; |
| 108 | } |
| 109 | // Taken for new, but held all along off the old line. |
| 110 | if !before.is_empty() && descends_from(git, &before, head).await? { |
| 111 | continue; |
| 112 | } |
| 113 | landed.push(head.clone()); |
| 114 | } |
| 115 | // A new branch brought nothing to tell anyone about: its commits were |
| 116 | // pushed elsewhere first. The default branch's first push is the |
| 117 | // exception: nothing was pushed before it. |
| 118 | let commits = if a.messages && (a.before.is_some() || a.first) { |
| 119 | new.iter() |
| 120 | .take(MAX_PUSHED) |
| 121 | .rev() |
| 122 | .map(|commit| PushedCommit { hash: commit.hash.clone(), message: commit.message.clone() }) |
| 123 | .collect() |
| 124 | } else { |
| 125 | Vec::new() |
| 126 | }; |
| 127 | Ok(Pushed { landed, commits }) |
| 128 | } |
| 129 | |
| 130 | #[cfg(test)] |
| 131 | mod tests { |
| 132 | use std::cell::RefCell; |
| 133 | use std::future::Future; |
| 134 | use std::pin::pin; |
| 135 | use std::task::{Context, Poll, Waker}; |
| 136 | |
| 137 | use g1t_contracts::repos::{Branch, GitAccess, Signature, TreeEntry}; |
| 138 | |
| 139 | use super::*; |
| 140 | use crate::store::Scope; |
| 141 | |
| 142 | fn run<F: Future>(future: F) -> F::Output { |
| 143 | match pin!(future).as_mut().poll(&mut Context::from_waker(Waker::noop())) { |
| 144 | Poll::Ready(output) => output, |
| 145 | Poll::Pending => panic!("the fake store never waits"), |
| 146 | } |
| 147 | } |
| 148 | |
| 149 | fn commit(hash: &str, parents: &[&str]) -> Commit { |
| 150 | Commit { |
| 151 | hash: hash.into(), |
| 152 | tree_hash: format!("t{hash}"), |
| 153 | message: format!("commit {hash}"), |
| 154 | author: Signature { name: "a".into(), email: "a@example.com".into() }, |
| 155 | parents: parents.iter().map(|&p| p.to_owned()).collect(), |
| 156 | authored_at: "2026-10-14T00:00:00.000Z".into(), |
| 157 | } |
| 158 | } |
| 159 | |
| 160 | /// Commits by hash; `log` follows first parents only, as the store |
| 161 | /// does, and `parents` answers for any commit. Counts `log` reads. |
| 162 | #[derive(Default)] |
| 163 | struct Fake { |
| 164 | commits: HashMap<String, Commit>, |
| 165 | /// Branches and tags by name, at the commit each leads to, as the |
| 166 | /// store reads them back. |
| 167 | refs: HashMap<String, String>, |
| 168 | logs: RefCell<u32>, |
| 169 | } |
| 170 | |
| 171 | impl Fake { |
| 172 | fn with(commits: Vec<Commit>) -> Fake { |
| 173 | Fake { commits: commits.into_iter().map(|c| (c.hash.clone(), c)).collect(), ..Fake::default() } |
| 174 | } |
| 175 | } |
| 176 | |
| 177 | impl GitRepo for Fake { |
| 178 | async fn access(&self, _scope: Scope) -> Result<GitAccess> { |
| 179 | unimplemented!() |
| 180 | } |
| 181 | async fn branches(&self) -> Result<Vec<Branch>> { |
| 182 | Ok(Vec::new()) |
| 183 | } |
| 184 | async fn log(&self, git_ref: &str, limit: u32) -> Result<Vec<Commit>> { |
| 185 | *self.logs.borrow_mut() += 1; |
| 186 | let mut out = Vec::new(); |
| 187 | let git_ref = self.refs.get(git_ref).map_or(git_ref, String::as_str); |
| 188 | let mut at = self.commits.get(git_ref); |
| 189 | while let Some(commit) = at { |
| 190 | if out.len() as u32 >= limit { |
| 191 | break; |
| 192 | } |
| 193 | out.push(commit.clone()); |
| 194 | at = commit.parents.first().and_then(|parent| self.commits.get(parent)); |
| 195 | } |
| 196 | Ok(out) |
| 197 | } |
| 198 | async fn parents(&self, commit_hash: &str) -> Result<Option<Vec<String>>> { |
| 199 | Ok(self.commits.get(commit_hash).map(|commit| commit.parents.clone())) |
| 200 | } |
| 201 | async fn read_tree(&self, _tree_hash: &str) -> Result<Option<Vec<TreeEntry>>> { |
| 202 | Ok(None) |
| 203 | } |
| 204 | async fn read_blob(&self, _blob_hash: &str) -> Result<Option<Vec<u8>>> { |
| 205 | Ok(None) |
| 206 | } |
| 207 | async fn read_file(&self, _git_ref: &str, _path: &str) -> Result<Option<Vec<u8>>> { |
| 208 | Ok(None) |
| 209 | } |
| 210 | async fn fork(&self, _target_key: &str, _default_branch_only: bool) -> Result<()> { |
| 211 | Ok(()) |
| 212 | } |
| 213 | } |
| 214 | |
| 215 | fn args(before: Option<&str>, after: &str, heads: &[&str]) -> PushedArgs { |
| 216 | PushedArgs { |
| 217 | repo_id: "rep_1".into(), |
| 218 | before: before.map(str::to_owned), |
| 219 | after: after.into(), |
| 220 | heads: heads.iter().map(|&h| h.to_owned()).collect(), |
| 221 | messages: true, |
| 222 | first: false, |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | fn hashes(pushed: &Pushed) -> Vec<&str> { |
| 227 | pushed.commits.iter().map(|c| c.hash.as_str()).collect() |
| 228 | } |
| 229 | |
| 230 | /// m1 ← m2 on main; f1 ← f2 a branch off m2. |
| 231 | fn history() -> Vec<Commit> { |
| 232 | vec![commit("m1", &[]), commit("m2", &["m1"]), commit("f1", &["m2"]), commit("f2", &["f1"])] |
| 233 | } |
| 234 | |
| 235 | #[test] |
| 236 | fn a_fast_forward_brings_in_the_branch_and_its_head() { |
| 237 | let git = Fake::with(history()); |
| 238 | let pushed = run(brought_in(&git, args(Some("m2"), "f2", &["f2", "f1", "m2"]))).unwrap(); |
| 239 | // f1 is a head too (a pull request stacked under the other). |
| 240 | assert_eq!(pushed.landed, vec!["f2", "f1"]); |
| 241 | assert_eq!(hashes(&pushed), vec!["f1", "f2"]); |
| 242 | } |
| 243 | |
| 244 | #[test] |
| 245 | fn a_merge_commit_brings_in_its_second_parent() { |
| 246 | let mut commits = history(); |
| 247 | commits.extend([commit("m3", &["m2"]), commit("x", &["m3", "f2"])]); |
| 248 | let git = Fake::with(commits); |
| 249 | let pushed = run(brought_in(&git, args(Some("m3"), "x", &["f2"]))).unwrap(); |
| 250 | assert_eq!(pushed.landed, vec!["f2"]); |
| 251 | assert_eq!(hashes(&pushed), vec!["f1", "f2", "x"]); |
| 252 | } |
| 253 | |
| 254 | #[test] |
| 255 | fn a_head_the_branch_already_held_did_not_land() { |
| 256 | // g ← h merged into main long ago (m3); main went on to m4. A pull |
| 257 | // request whose head is still g, and a push that merges g again. |
| 258 | let commits = vec![ |
| 259 | commit("m1", &[]), |
| 260 | commit("g", &["m1"]), |
| 261 | commit("h", &["g"]), |
| 262 | commit("m2", &["m1"]), |
| 263 | commit("m3", &["m2", "h"]), |
| 264 | commit("m4", &["m3"]), |
| 265 | commit("y", &["m4", "g"]), |
| 266 | ]; |
| 267 | let git = Fake::with(commits); |
| 268 | let pushed = run(brought_in(&git, args(Some("m4"), "y", &["g", "m4"]))).unwrap(); |
| 269 | assert!(pushed.landed.is_empty()); |
| 270 | // A head that was never pushed elsewhere is not looked for at all. |
| 271 | assert!(!hashes(&pushed).contains(&"m4")); |
| 272 | } |
| 273 | |
| 274 | #[test] |
| 275 | fn nothing_new_lands_nothing() { |
| 276 | let git = Fake::with(history()); |
| 277 | let pushed = run(brought_in(&git, args(Some("f2"), "f2", &["f2"]))).unwrap(); |
| 278 | assert!(pushed.landed.is_empty() && pushed.commits.is_empty()); |
| 279 | // Moving back (a force push to an older commit) brings nothing in. |
| 280 | let back = run(brought_in(&git, args(Some("f2"), "m2", &["m2"]))).unwrap(); |
| 281 | assert!(back.landed.is_empty() && back.commits.is_empty()); |
| 282 | } |
| 283 | |
| 284 | #[test] |
| 285 | fn a_new_branch_lists_no_messages() { |
| 286 | let git = Fake::with(history()); |
| 287 | let pushed = run(brought_in(&git, args(None, "f2", &["f2"]))).unwrap(); |
| 288 | assert_eq!(pushed.landed, vec!["f2"]); |
| 289 | assert!(pushed.commits.is_empty()); |
| 290 | } |
| 291 | |
| 292 | #[test] |
| 293 | fn the_default_branchs_first_push_lists_its_messages() { |
| 294 | // An empty repository's first push: its `Closes #1` closes #1. |
| 295 | let git = Fake::with(vec![commit("m1", &[]), commit("m2", &["m1"])]); |
| 296 | let pushed = run(brought_in(&git, PushedArgs { first: true, ..args(None, "m2", &[]) })).unwrap(); |
| 297 | assert_eq!(hashes(&pushed), vec!["m1", "m2"]); |
| 298 | } |
| 299 | |
| 300 | #[test] |
| 301 | fn a_merged_branch_is_read_in_one_go() { |
| 302 | // Twenty commits on a side branch merged in: one read of main and |
| 303 | // one of the side line, not one per commit. |
| 304 | let mut commits = vec![commit("m1", &[]), commit("m2", &["m1"])]; |
| 305 | commits.push(commit("s1", &["m1"])); |
| 306 | for i in 2..=20 { |
| 307 | commits.push(commit(&format!("s{i}"), &[&format!("s{}", i - 1)])); |
| 308 | } |
| 309 | commits.push(commit("x", &["m2", "s20"])); |
| 310 | let git = Fake::with(commits); |
| 311 | let pushed = run(brought_in(&git, args(Some("m2"), "x", &[]))).unwrap(); |
| 312 | assert_eq!(pushed.commits.len(), 21); |
| 313 | assert_eq!(*git.logs.borrow(), 3); |
| 314 | } |
| 315 | |
| 316 | fn push(git_ref: &str, before: Option<&str>, after: &str) -> crate::git_http::Pushed { |
| 317 | crate::git_http::Pushed { git_ref: git_ref.into(), before: before.map(str::to_owned), after: after.into() } |
| 318 | } |
| 319 | |
| 320 | fn with_refs(refs: &[(&str, &str)]) -> Fake { |
| 321 | Fake { refs: refs.iter().map(|&(name, at)| (name.to_owned(), at.to_owned())).collect(), ..Fake::with(history()) } |
| 322 | } |
| 323 | |
| 324 | #[test] |
| 325 | fn an_annotated_tag_is_announced_at_its_commit() { |
| 326 | // `git push g1t cli-v0.3.6`: the push names the tag object, and the |
| 327 | // store reads the tag back as the commit it leads to, main's head. |
| 328 | let git = with_refs(&[("main", "m2"), ("refs/tags/cli-v0.3.6", "m2")]); |
| 329 | let tag = push("refs/tags/cli-v0.3.6", None, "accae3e8"); |
| 330 | assert_eq!(run(pushed_to(&git, &tag)).unwrap().as_deref(), Some("m2")); |
| 331 | // Moved by a new tag object. |
| 332 | let moved = push("refs/tags/cli-v0.3.6", Some("accae3e8"), "b0b0b0b0"); |
| 333 | assert_eq!(run(pushed_to(&git, &moved)).unwrap().as_deref(), Some("m2")); |
| 334 | } |
| 335 | |
| 336 | #[test] |
| 337 | fn a_lightweight_tag_is_announced_where_the_store_has_it() { |
| 338 | let git = with_refs(&[("refs/tags/v1", "f2")]); |
| 339 | assert_eq!(run(pushed_to(&git, &push("refs/tags/v1", None, "f2"))).unwrap().as_deref(), Some("f2")); |
| 340 | // The push named a commit the store did not move the tag to. |
| 341 | assert_eq!(run(pushed_to(&git, &push("refs/tags/v1", Some("f2"), "m1"))).unwrap(), None); |
| 342 | // A tag the store cannot read back is taken as pushed. |
| 343 | assert_eq!(run(pushed_to(&git, &push("refs/tags/v2", None, "m1"))).unwrap().as_deref(), Some("m1")); |
| 344 | } |
| 345 | |
| 346 | #[test] |
| 347 | fn a_branch_is_announced_only_at_the_commit_pushed() { |
| 348 | let git = with_refs(&[("main", "m2")]); |
| 349 | assert_eq!(run(pushed_to(&git, &push("refs/heads/main", Some("m1"), "m2"))).unwrap().as_deref(), Some("m2")); |
| 350 | assert_eq!(run(pushed_to(&git, &push("refs/heads/main", Some("m2"), "f2"))).unwrap(), None); |
| 351 | assert_eq!(run(pushed_to(&git, &push("refs/heads/gone", None, "f2"))).unwrap(), None); |
| 352 | } |
| 353 | } |