Skip to content
562 linesCodeBlameRaw
1//! How far branches have moved from the default branch, for Active branches
2//! on a project's overview and the Branches page (`branch_drift`).
3//!
4//! The counts are `git rev-list --left-right --count main...branch`: every
5//! commit one head reaches and the other does not, merges and what they
6//! brought in included. The store lists histories by first parent only
7//! (docs/ARTIFACTS.md), so a merge's other parents are read on their own as
8//! the walk reaches them. The walk goes newest commit first from both
9//! heads, marking each commit with the heads that reach it, as git does,
10//! and stops once every commit still to look at is reached by both: what
11//! lies below is shared and counts on neither side.
12//!
13//! The default branch's history is read once for every branch, and every
14//! read is by commit hash, which the store keeps for good (store.rs); each
15//! answer is kept by the pair of heads (lib.rs), so only heads that moved
16//! cost a walk. Before 2026-10-08 the site did this itself: up to twenty
17//! `log` calls per view, each with its own access check and store handle.
18//!
19//! Until 2026-10-09 the count gave up whenever a commit on one side only
20//! had a parent the first-parent reads had not reached, which every merge
21//! has: a branch whose default branch took a merge since it left showed no
22//! counts, and that answer was kept for good.
23
24use std::collections::{BinaryHeap, HashMap, HashSet};
25
26use g1t_contracts::repos::{Commit, Drift};
27
28use crate::store::GitRepo;
29
30/// How much of the default branch's history is read first, once for every
31/// branch.
32pub const BASE_DEPTH: u32 = 120;
33/// How much of a first-parent chain each further read takes: a branch
34/// head's, or a merge's other parent's.
35pub const STEP: u32 = 16;
36/// Further reads for one branch before giving up on counting it: a branch
37/// that left the default branch hundreds of commits or merges ago.
38pub const MAX_READS: usize = 128;
39/// Commits a walk may know of before giving up.
40pub const MAX_COMMITS: usize = 4000;
41/// Commits looked at after everything left is shared, in case a commit is
42/// dated before its parent (rebased and amended commits keep their author
43/// dates), as git's own walk does.
44const SLOP: usize = 5;
45
46/// Branches walked at once, and missing parents read at once.
47const AT_ONCE: usize = 8;
48
49/// A branch head's commit and drift, and whether the answer may be kept:
50/// not when a read failed.
51#[derive(Clone, Debug)]
52pub struct Measured {
53 pub commit: Option<Commit>,
54 pub drift: Option<Drift>,
55 pub settled: bool,
56}
57
58/// How one walk ended.
59#[derive(Debug, PartialEq, Eq)]
60pub enum Walked {
61 Counted(Drift),
62 /// Past [`MAX_READS`] or [`MAX_COMMITS`]: no count, and that is the
63 /// answer for this pair.
64 TooFar,
65 /// A read failed or found nothing: no count, and not to be kept.
66 Failed,
67}
68
69/// The commits a walk knows: the default branch's history, shared by every
70/// walk, and what this walk read itself.
71struct Known<'a> {
72 base: &'a HashMap<String, Commit>,
73 own: HashMap<String, Commit>,
74 reads: usize,
75}
76
77impl Known<'_> {
78 fn get(&self, hash: &str) -> Option<&Commit> {
79 self.own.get(hash).or_else(|| self.base.get(hash))
80 }
81
82 fn has(&self, hash: &str) -> bool {
83 self.own.contains_key(hash) || self.base.contains_key(hash)
84 }
85
86 fn len(&self) -> usize {
87 self.own.len() + self.base.len()
88 }
89
90 fn parents(&self, hash: &str) -> Vec<String> {
91 self.get(hash).map(|commit| commit.parents.clone()).unwrap_or_default()
92 }
93
94 fn date(&self, hash: &str) -> String {
95 self.get(hash).map(|commit| commit.authored_at.clone()).unwrap_or_default()
96 }
97
98 /// Reads the first-parent chain from each of `hashes` not yet known, at
99 /// once. `Err` when a read failed or found nothing (the store lacks a
100 /// commit another names), `Ok(false)` when that would pass
101 /// [`MAX_READS`].
102 async fn read<R: GitRepo>(&mut self, git: &R, hashes: &[String]) -> Result<bool, ()> {
103 let mut wanted: Vec<&String> = Vec::new();
104 for hash in hashes {
105 if !self.has(hash) && !wanted.contains(&hash) {
106 wanted.push(hash);
107 }
108 }
109 if wanted.is_empty() {
110 return Ok(true);
111 }
112 if self.reads + wanted.len() > MAX_READS {
113 return Ok(false);
114 }
115 self.reads += wanted.len();
116 let found = futures_util::future::join_all(wanted.iter().map(|hash| git.log(hash, STEP))).await;
117 for read in found {
118 match read {
119 Ok(commits) if !commits.is_empty() => {
120 for commit in commits {
121 self.own.entry(commit.hash.clone()).or_insert(commit);
122 }
123 }
124 _ => return Err(()),
125 }
126 }
127 Ok(true)
128 }
129}
130
131const BRANCH: u8 = 1;
132const MAIN: u8 = 2;
133const BOTH: u8 = BRANCH | MAIN;
134
135/// Commits `branch` reaches that `main` does not (ahead), and the other way
136/// round (behind), with `branch`'s own commit when it was read. `base` is
137/// what was read of `main`'s history; the walk reads anything else it needs.
138pub async fn count<R: GitRepo>(git: &R, base: &HashMap<String, Commit>, branch: &str, main: &str) -> (Option<Commit>, Walked) {
139 let mut known = Known { base, own: HashMap::new(), reads: 0 };
140 let walked = walk(git, &mut known, branch, main).await;
141 (known.get(branch).cloned(), walked)
142}
143
144async fn walk<R: GitRepo>(git: &R, known: &mut Known<'_>, branch: &str, main: &str) -> Walked {
145 match known.read(git, &[branch.to_owned(), main.to_owned()]).await {
146 Ok(true) => {}
147 Ok(false) => return Walked::TooFar,
148 Err(()) => return Walked::Failed,
149 }
150 let mut marks: HashMap<String, u8> = HashMap::new();
151 // Newest first; ties by hash, which only decides the order.
152 let mut queue: BinaryHeap<(String, String)> = BinaryHeap::new();
153 let mut queued: HashSet<String> = HashSet::new();
154 for (head, mark) in [(branch, BRANCH), (main, MAIN)] {
155 *marks.entry(head.to_owned()).or_default() |= mark;
156 if queued.insert(head.to_owned()) {
157 queue.push((known.date(head), head.to_owned()));
158 }
159 }
160 let mut slop = SLOP;
161 while !queue.is_empty() {
162 if queue.iter().all(|(_, hash)| marks.get(hash) == Some(&BOTH)) {
163 if slop == 0 {
164 break;
165 }
166 slop -= 1;
167 }
168 let Some((_, hash)) = queue.pop() else { break };
169 queued.remove(&hash);
170 let parents = known.parents(&hash);
171 if parents.iter().any(|parent| !known.has(parent)) {
172 // This commit's missing parents, and a few more that commits
173 // waiting will need, in one round of reads.
174 let mut wanted: Vec<String> = parents.iter().filter(|parent| !known.has(parent)).cloned().collect();
175 let mut waiting: Vec<String> = queue
176 .iter()
177 .flat_map(|(_, waiting)| known.parents(waiting))
178 .filter(|parent| !known.has(parent) && !wanted.contains(parent))
179 .collect();
180 waiting.sort();
181 waiting.dedup();
182 wanted.extend(waiting.into_iter().take(AT_ONCE.saturating_sub(1)));
183 let read = match known.read(git, &wanted).await {
184 // Too many with the others' parents: this one's alone.
185 Ok(false) => known.read(git, &parents).await,
186 read => read,
187 };
188 match read {
189 Ok(true) => {}
190 Ok(false) => return Walked::TooFar,
191 Err(()) => return Walked::Failed,
192 }
193 }
194 if known.len() > MAX_COMMITS {
195 return Walked::TooFar;
196 }
197 let mark = marks.get(&hash).copied().unwrap_or_default();
198 for parent in parents {
199 let before = marks.get(&parent).copied().unwrap_or_default();
200 if before | mark == before {
201 continue;
202 }
203 marks.insert(parent.clone(), before | mark);
204 // A commit that gains a mark after it was looked at is looked
205 // at again, so the mark reaches what is below it.
206 if queued.insert(parent.clone()) {
207 queue.push((known.date(&parent), parent));
208 }
209 }
210 }
211 let ahead = marks.values().filter(|&&mark| mark == BRANCH).count() as u32;
212 let behind = marks.values().filter(|&&mark| mark == MAIN).count() as u32;
213 Walked::Counted(Drift { ahead, behind })
214}
215
216/// Each of `heads` measured against `base`, in the order given.
217pub async fn measure<R: GitRepo>(git: &R, base: &str, heads: &[String]) -> Vec<Measured> {
218 if heads.is_empty() {
219 return Vec::new();
220 }
221 let main: HashMap<String, Commit> = match git.log(base, BASE_DEPTH).await {
222 Ok(commits) if !commits.is_empty() => commits.into_iter().map(|commit| (commit.hash.clone(), commit)).collect(),
223 _ => return heads.iter().map(|_| Measured { commit: None, drift: None, settled: false }).collect(),
224 };
225 let mut out = Vec::with_capacity(heads.len());
226 for chunk in heads.chunks(AT_ONCE) {
227 let walks = futures_util::future::join_all(chunk.iter().map(|head| count(git, &main, head, base))).await;
228 out.extend(walks.into_iter().map(|(commit, walked)| match walked {
229 Walked::Counted(drift) => Measured { commit, drift: Some(drift), settled: true },
230 Walked::TooFar => Measured { commit, drift: None, settled: true },
231 Walked::Failed => Measured { commit, drift: None, settled: false },
232 }));
233 }
234 out
235}
236
237#[cfg(test)]
238mod tests {
239 use std::cell::RefCell;
240 use std::future::Future;
241 use std::pin::pin;
242 use std::task::{Context, Poll, Waker};
243
244 use g1t_contracts::repos::{Branch, BranchDrift, BranchDrifts, GitAccess, Signature, TreeEntry};
245 use worker::Result;
246
247 use super::*;
248 use crate::store::Scope;
249
250 fn run<F: Future>(future: F) -> F::Output {
251 match pin!(future).as_mut().poll(&mut Context::from_waker(Waker::noop())) {
252 Poll::Ready(output) => output,
253 Poll::Pending => panic!("the fake store never waits"),
254 }
255 }
256
257 /// A date `t` seconds into a day, as the store writes them.
258 fn at(t: u32) -> String {
259 format!("2026-10-{:02}T{:02}:{:02}:{:02}.000Z", 1 + t / 86_400, t % 86_400 / 3600, t % 3600 / 60, t % 60)
260 }
261
262 fn commit(hash: &str, parents: &[&str], t: u32) -> Commit {
263 Commit {
264 hash: hash.into(),
265 tree_hash: format!("t{hash}"),
266 message: format!("commit {hash}\n\nbody"),
267 author: Signature { name: "a".into(), email: "a@example.com".into() },
268 parents: parents.iter().map(|&p| p.to_owned()).collect(),
269 authored_at: at(t),
270 }
271 }
272
273 /// Commits by hash; `log` follows first parents only, as the store
274 /// does. Counts each read.
275 #[derive(Default)]
276 struct Fake {
277 commits: HashMap<String, Commit>,
278 reads: RefCell<Vec<(String, u32)>>,
279 fail: Option<String>,
280 }
281
282 impl Fake {
283 fn with(commits: Vec<Commit>) -> Fake {
284 Fake { commits: commits.into_iter().map(|c| (c.hash.clone(), c)).collect(), ..Fake::default() }
285 }
286
287 fn reads_of(&self, hash: &str) -> usize {
288 self.reads.borrow().iter().filter(|(read, _)| read == hash).count()
289 }
290 }
291
292 impl GitRepo for Fake {
293 async fn access(&self, _scope: Scope) -> Result<GitAccess> {
294 unimplemented!()
295 }
296 async fn branches(&self) -> Result<Vec<Branch>> {
297 Ok(Vec::new())
298 }
299 async fn log(&self, git_ref: &str, limit: u32) -> Result<Vec<Commit>> {
300 self.reads.borrow_mut().push((git_ref.to_owned(), limit));
301 if self.fail.as_deref() == Some(git_ref) {
302 return Err(worker::Error::RustError("store busy".into()));
303 }
304 let mut out = Vec::new();
305 let mut at = self.commits.get(git_ref);
306 while let Some(commit) = at {
307 if out.len() as u32 >= limit {
308 break;
309 }
310 out.push(commit.clone());
311 at = commit.parents.first().and_then(|parent| self.commits.get(parent));
312 }
313 Ok(out)
314 }
315 async fn parents(&self, _commit_hash: &str) -> Result<Option<Vec<String>>> {
316 Ok(None)
317 }
318 async fn read_tree(&self, _tree_hash: &str) -> Result<Option<Vec<TreeEntry>>> {
319 Ok(None)
320 }
321 async fn read_blob(&self, _blob_hash: &str) -> Result<Option<Vec<u8>>> {
322 Ok(None)
323 }
324 async fn read_file(&self, _git_ref: &str, _path: &str) -> Result<Option<Vec<u8>>> {
325 Ok(None)
326 }
327 async fn fork(&self, _target_key: &str) -> Result<()> {
328 Ok(())
329 }
330 }
331
332 /// A straight line of `n` commits named `{prefix}{i}`, the first on
333 /// `from`, dated `t0 + i * 10`.
334 fn line(prefix: &str, from: Option<&str>, n: usize, t0: u32) -> Vec<Commit> {
335 (1..=n)
336 .map(|i| {
337 let parent = if i == 1 { from.map(str::to_owned) } else { Some(format!("{prefix}{}", i - 1)) };
338 commit(&format!("{prefix}{i}"), &parent.iter().map(String::as_str).collect::<Vec<_>>(), t0 + i as u32 * 10)
339 })
340 .collect()
341 }
342
343 fn one(git: &Fake, main: &str, head: &str) -> Measured {
344 run(measure(git, main, &[head.to_owned()])).remove(0)
345 }
346
347 fn counted(ahead: u32, behind: u32) -> Option<Drift> {
348 Some(Drift { ahead, behind })
349 }
350
351 /// m1 ← m2 ← m3 on main.
352 fn main3() -> Vec<Commit> {
353 line("m", None, 3, 0)
354 }
355
356 #[test]
357 fn one_ahead_of_a_default_branch_that_has_not_moved() {
358 let mut history = main3();
359 history.push(commit("f1", &["m3"], 100));
360 let found = one(&Fake::with(history), "m3", "f1");
361 assert_eq!(found.drift, counted(1, 0));
362 assert_eq!(found.commit.map(|c| c.hash), Some("f1".into()));
363 assert!(found.settled);
364 }
365
366 #[test]
367 fn behind_only_and_level() {
368 let git = Fake::with(main3());
369 assert_eq!(one(&git, "m3", "m2").drift, counted(0, 1));
370 assert_eq!(one(&git, "m3", "m1").drift, counted(0, 2));
371 let level = one(&git, "m3", "m3");
372 assert_eq!(level.drift, counted(0, 0));
373 assert!(level.settled);
374 }
375
376 #[test]
377 fn diverged_counts_both_sides_from_where_they_forked() {
378 // b1 ← b2 left main at m2; main went on to m3.
379 let mut history = main3();
380 history.extend([commit("b1", &["m2"], 25), commit("b2", &["b1"], 26)]);
381 assert_eq!(one(&Fake::with(history), "m3", "b2").drift, counted(2, 1));
382 }
383
384 /// flagon-io/hello on 2026-10-08: `farewell` (bab14ff) is one commit on
385 /// the first commit (c2ef68d). Main took a merge whose first parent is
386 /// the branch merged (69796cb) and whose second is main as it was
387 /// (ebbaeb2), so main's first-parent history never lists ebbaeb2. The
388 /// old count gave up on that merge at every depth and kept "no count".
389 #[test]
390 fn a_merge_on_the_default_branch_does_not_hide_the_counts() {
391 let history = vec![
392 commit("root", &[], 0),
393 commit("farewell", &["root"], 5),
394 commit("old1", &["root"], 10),
395 commit("old2", &["old1"], 20),
396 commit("wave", &["old1"], 25),
397 commit("merge", &["wave", "old2"], 30),
398 commit("new1", &["merge"], 40),
399 ];
400 let git = Fake::with(history);
401 let found = one(&git, "new1", "farewell");
402 // Behind: old1, old2, wave, merge, new1.
403 assert_eq!(found.drift, counted(1, 5));
404 assert!(found.settled);
405 assert_eq!(git.reads_of("old2"), 1, "the merge's other parent is read on its own");
406 }
407
408 #[test]
409 fn merges_on_both_sides() {
410 // main: root ← m1 ← m2 ← m3 (merges pull p1, made on m1).
411 // branch: b1 on m1, b2 merges m2 into it, b3 on b2.
412 let history = vec![
413 commit("root", &[], 0),
414 commit("m1", &["root"], 10),
415 commit("m2", &["m1"], 20),
416 commit("p1", &["m1"], 15),
417 commit("m3", &["m2", "p1"], 40),
418 commit("b1", &["m1"], 12),
419 commit("b2", &["b1", "m2"], 30),
420 commit("b3", &["b2"], 35),
421 ];
422 // Ahead: b1, b2, b3. Behind: p1, m3 (m2 came in with b2's merge).
423 assert_eq!(one(&Fake::with(history), "m3", "b3").drift, counted(3, 2));
424 }
425
426 #[test]
427 fn a_pull_merged_and_built_on() {
428 // b1 merged into main by m2; b2 on b1 after.
429 let history = vec![
430 commit("m0", &[], 0),
431 commit("m1", &["m0"], 10),
432 commit("b1", &["m0"], 15),
433 commit("m2", &["m1", "b1"], 20),
434 commit("b2", &["b1"], 30),
435 ];
436 // b1 is on both; ahead b2, behind m1 and m2.
437 assert_eq!(one(&Fake::with(history), "m2", "b2").drift, counted(1, 2));
438 }
439
440 #[test]
441 fn a_fork_point_past_the_first_read() {
442 // Main moved 300 commits since l1..l5 left it at m10.
443 let mut history = line("m", None, 310, 0);
444 history.extend(line("l", Some("m10"), 5, 100));
445 let git = Fake::with(history);
446 let found = one(&git, "m310", "l5");
447 assert_eq!(found.drift, counted(5, 300));
448 assert!(found.settled);
449 assert_eq!(git.reads_of("m310"), 1, "{:?}", git.reads.borrow());
450 }
451
452 #[test]
453 fn a_fork_point_with_merges_past_the_first_read() {
454 // 200 pulls merged into main since the branch left m0, each a
455 // commit on the main it was made from.
456 let mut history = vec![commit("m0", &[], 0)];
457 for i in 1..=200u32 {
458 let before = format!("m{}", i - 1);
459 history.push(commit(&format!("p{i}"), &[&before], i * 10 + 5));
460 history.push(commit(&format!("m{i}"), &[&before, &format!("p{i}")], i * 10 + 8));
461 }
462 history.push(commit("b1", &["m0"], 3));
463 let git = Fake::with(history);
464 let found = one(&git, "m200", "b1");
465 // Everything on main but m0: 200 merges and 200 pulls. Each pull is
466 // a read of its own, more than a walk may make.
467 assert_eq!(found.drift, None);
468 assert!(found.settled);
469 // The same shape, 20 pulls deep, is counted.
470 let mut history = vec![commit("m0", &[], 0)];
471 for i in 1..=20u32 {
472 let before = format!("m{}", i - 1);
473 history.push(commit(&format!("p{i}"), &[&before], i * 10 + 5));
474 history.push(commit(&format!("m{i}"), &[&before, &format!("p{i}")], i * 10 + 8));
475 }
476 history.push(commit("b1", &["m0"], 3));
477 assert_eq!(one(&Fake::with(history), "m20", "b1").drift, counted(1, 40));
478 }
479
480 #[test]
481 fn too_far_is_settled_without_a_count() {
482 // The branch is 3,000 commits long: more reads than a walk may make.
483 let mut history = main3();
484 history.extend(line("x", Some("m1"), 3000, 100));
485 let found = one(&Fake::with(history), "m3", "x3000");
486 assert_eq!(found.drift, None);
487 assert_eq!(found.commit.map(|c| c.hash), Some("x3000".into()));
488 assert!(found.settled);
489 }
490
491 #[test]
492 fn a_commit_dated_before_its_parent() {
493 // b1 was rebased onto m5 and kept its author date, older than
494 // every commit on main.
495 let mut history = line("m", None, 5, 100);
496 history.push(commit("b1", &["m5"], 1));
497 assert_eq!(one(&Fake::with(history.clone()), "m5", "b1").drift, counted(1, 0));
498 history.push(commit("m6", &["m5"], 500));
499 assert_eq!(one(&Fake::with(history), "m6", "b1").drift, counted(1, 1));
500 }
501
502 #[test]
503 fn unrelated_histories_count_every_commit() {
504 let mut history = main3();
505 history.extend(line("x", None, 2, 0));
506 let found = one(&Fake::with(history), "m3", "x2");
507 assert_eq!(found.drift, counted(2, 3));
508 assert!(found.settled);
509 }
510
511 #[test]
512 fn the_default_branch_is_read_once_for_every_head() {
513 let mut history = main3();
514 history.extend([commit("b1", &["m2"], 25), commit("b2", &["b1"], 26), commit("f1", &["m3"], 40)]);
515 let git = Fake::with(history);
516 let heads: Vec<String> = ["b2", "m2", "m3", "f1"].map(str::to_owned).into();
517 let found = run(measure(&git, "m3", &heads));
518 let drifts: Vec<_> = found.iter().map(|m| m.drift).collect();
519 assert_eq!(drifts, [counted(2, 1), counted(0, 1), counted(0, 0), counted(1, 0)]);
520 assert!(found.iter().all(|m| m.settled));
521 assert_eq!(git.reads.borrow().iter().filter(|(hash, depth)| hash == "m3" && *depth == BASE_DEPTH).count(), 1);
522 }
523
524 #[test]
525 fn a_failed_read_is_not_kept() {
526 let mut history = main3();
527 history.extend([commit("b1", &["m2"], 25), commit("b2", &["b1"], 26)]);
528 let mut git = Fake::with(history);
529 git.fail = Some("b2".into());
530 let found = run(measure(&git, "m3", &["b2".to_owned(), "b1".to_owned()]));
531 assert!(!found[0].settled);
532 assert_eq!(found[0].drift, None);
533 assert_eq!(found[1].drift, counted(1, 1));
534 assert!(found[1].settled);
535 git.fail = Some("m3".into());
536 let found = one(&git, "m3", "b2");
537 assert!(!found.settled);
538 // A head the store does not have (yet) is not kept either.
539 git.fail = None;
540 assert!(!one(&git, "m3", "nope").settled);
541 }
542
543 /// The answer as the site reads it (packages/contracts/src/repos.ts
544 /// `BranchDrifts`, apps/web/app/lib/branches.ts): the same field names,
545 /// or every count is silently dropped.
546 #[test]
547 fn the_answer_has_the_names_the_site_reads() {
548 let answer = BranchDrifts {
549 base: Some(commit("m3", &["m2"], 30)),
550 branches: vec![BranchDrift { head: "f1".into(), commit: Some(commit("f1", &["m3"], 40)), drift: counted(1, 0) }],
551 };
552 let json = serde_json::to_value(&answer).unwrap();
553 let branch = &json["branches"][0];
554 assert_eq!(branch["head"], "f1");
555 assert_eq!(branch["drift"], serde_json::json!({ "ahead": 1, "behind": 0 }));
556 assert_eq!(branch["commit"]["authoredAt"], at(40));
557 assert_eq!(branch["commit"]["treeHash"], "tf1");
558 assert_eq!(json["base"]["hash"], "m3");
559 let none = serde_json::to_value(BranchDrift { head: "x".into(), commit: None, drift: None }).unwrap();
560 assert_eq!(none, serde_json::json!({ "head": "x", "commit": null, "drift": null }));
561 }
562}