Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 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 | //! | |
| Merge branch drift: count across merges the way git does; v2 cache key | 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. | |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 23 | |
| Merge branch drift: count across merges the way git does; v2 cache key | 24 | use std::collections::{BinaryHeap, HashMap, HashSet}; |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 25 | |
| 26 | use g1t_contracts::repos::{Commit, Drift}; | |
| 27 | ||
| 28 | use crate::store::GitRepo; | |
| 29 | ||
| Merge branch drift: count across merges the way git does; v2 cache key | 30 | /// How much of the default branch's history is read first, once for every |
| 31 | /// branch. | |
| 32 | pub 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. | |
| 35 | pub 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. | |
| 38 | pub const MAX_READS: usize = 128; | |
| 39 | /// Commits a walk may know of before giving up. | |
| 40 | pub 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. | |
| 44 | const SLOP: usize = 5; | |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 45 | |
| Merge branch drift: count across merges the way git does; v2 cache key | 46 | /// Branches walked at once, and missing parents read at once. |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 47 | const 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)] | |
| 52 | pub struct Measured { | |
| 53 | pub commit: Option<Commit>, | |
| 54 | pub drift: Option<Drift>, | |
| 55 | pub settled: bool, | |
| 56 | } | |
| 57 | ||
| Merge branch drift: count across merges the way git does; v2 cache key | 58 | /// How one walk ended. |
| 59 | #[derive(Debug, PartialEq, Eq)] | |
| 60 | pub 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. | |
| 71 | struct Known<'a> { | |
| 72 | base: &'a HashMap<String, Commit>, | |
| 73 | own: HashMap<String, Commit>, | |
| 74 | reads: usize, | |
| 75 | } | |
| 76 | ||
| 77 | impl Known<'_> { | |
| 78 | fn get(&self, hash: &str) -> Option<&Commit> { | |
| 79 | self.own.get(hash).or_else(|| self.base.get(hash)) | |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 80 | } |
| Merge branch drift: count across merges the way git does; v2 cache key | 81 | |
| 82 | fn has(&self, hash: &str) -> bool { | |
| 83 | self.own.contains_key(hash) || self.base.contains_key(hash) | |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 84 | } |
| Merge branch drift: count across merges the way git does; v2 cache key | 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 | } | |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 108 | } |
| Merge branch drift: count across merges the way git does; v2 cache key | 109 | if wanted.is_empty() { |
| 110 | return Ok(true); | |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 111 | } |
| Merge branch drift: count across merges the way git does; v2 cache key | 112 | if self.reads + wanted.len() > MAX_READS { |
| 113 | return Ok(false); | |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 114 | } |
| Merge branch drift: count across merges the way git does; v2 cache key | 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(()), | |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 125 | } |
| 126 | } | |
| Merge branch drift: count across merges the way git does; v2 cache key | 127 | Ok(true) |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 128 | } |
| 129 | } | |
| 130 | ||
| Merge branch drift: count across merges the way git does; v2 cache key | 131 | const BRANCH: u8 = 1; |
| 132 | const MAIN: u8 = 2; | |
| 133 | const 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. | |
| 138 | pub 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) | |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 142 | } |
| 143 | ||
| Merge branch drift: count across merges the way git does; v2 cache key | 144 | async 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; | |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 167 | } |
| Merge branch drift: count across merges the way git does; v2 cache key | 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, | |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 192 | } |
| 193 | } | |
| Merge branch drift: count across merges the way git does; v2 cache key | 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; | |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 202 | } |
| Merge branch drift: count across merges the way git does; v2 cache key | 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)); | |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 208 | } |
| 209 | } | |
| 210 | } | |
| Merge branch drift: count across merges the way git does; v2 cache key | 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. | |
| 217 | pub 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 | |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 235 | } |
| 236 | ||
| 237 | #[cfg(test)] | |
| 238 | mod tests { | |
| 239 | use std::cell::RefCell; | |
| 240 | use std::future::Future; | |
| 241 | use std::pin::pin; | |
| 242 | use std::task::{Context, Poll, Waker}; | |
| 243 | ||
| Merge branch drift: count across merges the way git does; v2 cache key | 244 | use g1t_contracts::repos::{Branch, BranchDrift, BranchDrifts, GitAccess, Signature, TreeEntry}; |
| 245 | use worker::Result; | |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 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 | ||
| Merge branch drift: count across merges the way git does; v2 cache key | 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 { | |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 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(), | |
| Merge branch drift: count across merges the way git does; v2 cache key | 269 | authored_at: at(t), |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 270 | } |
| 271 | } | |
| 272 | ||
| Merge branch drift: count across merges the way git does; v2 cache key | 273 | /// Commits by hash; `log` follows first parents only, as the store |
| 274 | /// does. Counts each read. | |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 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 | } | |
| Merge branch drift: count across merges the way git does; v2 cache key | 286 | |
| 287 | fn reads_of(&self, hash: &str) -> usize { | |
| 288 | self.reads.borrow().iter().filter(|(read, _)| read == hash).count() | |
| 289 | } | |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 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 | ||
| Merge branch drift: count across merges the way git does; v2 cache key | 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> { | |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 335 | (1..=n) |
| 336 | .map(|i| { | |
| 337 | let parent = if i == 1 { from.map(str::to_owned) } else { Some(format!("{prefix}{}", i - 1)) }; | |
| Merge branch drift: count across merges the way git does; v2 cache key | 338 | commit(&format!("{prefix}{i}"), &parent.iter().map(String::as_str).collect::<Vec<_>>(), t0 + i as u32 * 10) |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 339 | }) |
| 340 | .collect() | |
| 341 | } | |
| 342 | ||
| Merge branch drift: count across merges the way git does; v2 cache key | 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 | ||
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 356 | #[test] |
| Merge branch drift: count across merges the way git does; v2 cache key | 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); | |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 364 | } |
| 365 | ||
| 366 | #[test] | |
| Merge branch drift: count across merges the way git does; v2 cache key | 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); | |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 374 | } |
| 375 | ||
| 376 | #[test] | |
| Merge branch drift: count across merges the way git does; v2 cache key | 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)); | |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 382 | } |
| 383 | ||
| Merge branch drift: count across merges the way git does; v2 cache key | 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". | |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 389 | #[test] |
| Merge branch drift: count across merges the way git does; v2 cache key | 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"); | |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 406 | } |
| 407 | ||
| 408 | #[test] | |
| Merge branch drift: count across merges the way git does; v2 cache key | 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)); | |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 445 | let git = Fake::with(history); |
| Merge branch drift: count across merges the way git does; v2 cache key | 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()); | |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 450 | } |
| 451 | ||
| 452 | #[test] | |
| Merge branch drift: count across merges the way git does; v2 cache key | 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)); | |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 463 | let git = Fake::with(history); |
| Merge branch drift: count across merges the way git does; v2 cache key | 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)); | |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 500 | } |
| 501 | ||
| 502 | #[test] | |
| Merge branch drift: count across merges the way git does; v2 cache key | 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)]); | |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 515 | let git = Fake::with(history); |
| Merge branch drift: count across merges the way git does; v2 cache key | 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); | |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 522 | } |
| 523 | ||
| 524 | #[test] | |
| 525 | fn a_failed_read_is_not_kept() { | |
| Merge branch drift: count across merges the way git does; v2 cache key | 526 | let mut history = main3(); |
| 527 | history.extend([commit("b1", &["m2"], 25), commit("b2", &["b1"], 26)]); | |
| 528 | let mut git = Fake::with(history); | |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 529 | git.fail = Some("b2".into()); |
| 530 | let found = run(measure(&git, "m3", &["b2".to_owned(), "b1".to_owned()])); | |
| 531 | assert!(!found[0].settled); | |
| Merge branch drift: count across merges the way git does; v2 cache key | 532 | assert_eq!(found[0].drift, None); |
| 533 | assert_eq!(found[1].drift, counted(1, 1)); | |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 534 | assert!(found[1].settled); |
| 535 | git.fail = Some("m3".into()); | |
| Merge branch drift: count across merges the way git does; v2 cache key | 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 })); | |
| Merge project overview: one branch_drift call, spliced histories, cached tags, 6 repos calls instead of 25 | 561 | } |
| 562 | } |
This file's history is long; its oldest lines are credited to the oldest commit read.