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.

280 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/// [`Repos::pushed`] on one repository's commits.
36pub(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)]
100mod 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}