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.

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