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.

353 linesCodeBlameRaw
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
13use std::collections::{HashMap, HashSet, VecDeque};
14
15use g1t_contracts::repos::{Commit, MAX_PUSHED, Pushed, PushedArgs, PushedCommit};
16use worker::Result;
17
18use crate::store::{GitRepo, GitStore};
19use 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.
23const SIDE_READ: u32 = 50;
24
25impl<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.
45pub(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.
67pub(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)]
131mod 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}