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.

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