| 1 | 1 | | //! How far branches have moved from the default branch, for Active branches |
| 2 | 2 | | //! on a project's overview and the Branches page (`branch_drift`). |
| 3 | 3 | | //! |
| 4 | | − | //! Each branch head's history and the default branch's are read to a depth, |
| 5 | | − | //! in turn deeper, until they meet; the default branch's history is read |
| 6 | | − | //! once per depth for every branch. Histories are read by commit hash, which |
| 7 | | − | //! the store keeps for good (store.rs), and each answer is kept by the pair |
| 8 | | − | //! of heads (lib.rs), so only heads that moved cost a walk. Before |
| 9 | | − | //! 2026-10-08 the site did this itself: up to twenty `log` calls per view, |
| 10 | | − | //! each with its own access check and store handle. |
| 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. |
| 11 | 23 | | |
| 12 | | − | use std::collections::{HashMap, HashSet}; |
| 24 | + | use std::collections::{BinaryHeap, HashMap, HashSet}; |
| 13 | 25 | | |
| 14 | 26 | | use g1t_contracts::repos::{Commit, Drift}; |
| 15 | | − | use worker::Result; |
| 16 | 27 | | |
| 17 | 28 | | use crate::store::GitRepo; |
| 18 | 29 | | |
| 19 | | − | /// How deep each history is read, in turn: (branch, default branch). Most |
| 20 | | − | /// branches are a few commits ahead of where they left a default branch |
| 21 | | − | /// that has moved on a little; one left long ago needs the default |
| 22 | | − | /// branch's history further back; one far from both reads both deeply. |
| 23 | | − | /// Past the last, there is no answer. |
| 24 | | − | pub const DEPTHS: [(u32, u32); 4] = [(12, 120), (40, 120), (40, 1000), (1000, 1000)]; |
| 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; |
| 25 | 45 | | |
| 26 | | − | /// Branches read at once. |
| 46 | + | /// Branches walked at once, and missing parents read at once. |
| 27 | 47 | | const AT_ONCE: usize = 8; |
| 28 | 48 | | |
| 29 | 49 | | /// A branch head's commit and drift, and whether the answer may be kept: |
| ⋯ |
| 35 | 55 | | pub settled: bool, |
| 36 | 56 | | } |
| 37 | 57 | | |
| 38 | | − | /// Commits `branch` has that `main` does not (ahead) and the other way |
| 39 | | − | /// round (behind), from what was read of the two histories (`commits`, in |
| 40 | | − | /// any order, repeats allowed). `None` when either head is missing, or when |
| 41 | | − | /// a commit only one side reaches has a parent that was not read: that |
| 42 | | − | /// parent's history could change either count. |
| 43 | | − | pub fn drift<'a>(branch: &str, main: &str, commits: impl IntoIterator<Item = &'a Commit>) -> Option<Drift> { |
| 44 | | − | let mut parents: HashMap<&str, &[String]> = HashMap::new(); |
| 45 | | − | for commit in commits { |
| 46 | | − | parents.insert(commit.hash.as_str(), commit.parents.as_slice()); |
| 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)) |
| 47 | 80 | | } |
| 48 | | − | if !parents.contains_key(branch) || !parents.contains_key(main) { |
| 49 | | − | return None; |
| 81 | + | |
| 82 | + | fn has(&self, hash: &str) -> bool { |
| 83 | + | self.own.contains_key(hash) || self.base.contains_key(hash) |
| 50 | 84 | | } |
| 51 | | − | let from_branch = reach(branch, &parents); |
| 52 | | − | let from_main = reach(main, &parents); |
| 53 | | − | let (mut ahead, mut behind) = (0, 0); |
| 54 | | − | for (hash, above) in &parents { |
| 55 | | − | let on_branch = from_branch.contains(hash); |
| 56 | | − | if on_branch == from_main.contains(hash) { |
| 57 | | − | continue; |
| 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 | + | } |
| 58 | 108 | | } |
| 59 | | − | if above.iter().any(|parent| !parents.contains_key(parent.as_str())) { |
| 60 | | − | return None; |
| 109 | + | if wanted.is_empty() { |
| 110 | + | return Ok(true); |
| 61 | 111 | | } |
| 62 | | − | if on_branch { |
| 63 | | − | ahead += 1; |
| 64 | | − | } else { |
| 65 | | − | behind += 1; |
| 112 | + | if self.reads + wanted.len() > MAX_READS { |
| 113 | + | return Ok(false); |
| 66 | 114 | | } |
| 67 | | − | } |
| 68 | | − | Some(Drift { ahead, behind }) |
| 69 | | − | } |
| 70 | | − | |
| 71 | | − | /// Every commit read that `head` descends from, itself included. |
| 72 | | − | fn reach<'a>(head: &'a str, parents: &HashMap<&'a str, &'a [String]>) -> HashSet<&'a str> { |
| 73 | | − | let mut seen = HashSet::from([head]); |
| 74 | | − | let mut next = vec![head]; |
| 75 | | − | while let Some(hash) = next.pop() { |
| 76 | | − | for parent in parents.get(hash).copied().unwrap_or_default() { |
| 77 | | − | if let Some((&known, _)) = parents.get_key_value(parent.as_str()) |
| 78 | | − | && seen.insert(known) |
| 79 | | − | { |
| 80 | | − | next.push(known); |
| 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(()), |
| 81 | 125 | | } |
| 82 | 126 | | } |
| 127 | + | Ok(true) |
| 83 | 128 | | } |
| 84 | | − | seen |
| 85 | 129 | | } |
| 86 | 130 | | |
| 87 | | − | /// What was read of one branch's history so far. |
| 88 | | − | struct Reading { |
| 89 | | − | commits: Vec<Commit>, |
| 90 | | − | depth: u32, |
| 91 | | − | answer: Option<Measured>, |
| 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) |
| 92 | 142 | | } |
| 93 | 143 | | |
| 94 | | − | /// Each of `heads` measured against `base`, in the order given. |
| 95 | | − | pub async fn measure<R: GitRepo>(git: &R, base: &str, heads: &[String]) -> Vec<Measured> { |
| 96 | | − | let mut readings: Vec<Reading> = heads.iter().map(|_| Reading { commits: Vec::new(), depth: 0, answer: None }).collect(); |
| 97 | | − | // The default branch's history, read once per depth and not deeper |
| 98 | | − | // once a read reached its start. |
| 99 | | − | let mut main: Option<(u32, Vec<Commit>)> = None; |
| 100 | | − | let mut main_failed = false; |
| 101 | | − | for (branch_depth, main_depth) in DEPTHS { |
| 102 | | − | if readings.iter().all(|reading| reading.answer.is_some()) { |
| 103 | | − | break; |
| 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; |
| 104 | 167 | | } |
| 105 | | − | let deeper = match &main { |
| 106 | | − | Some((read_to, commits)) => *read_to < main_depth && commits.len() as u32 >= *read_to, |
| 107 | | − | None => true, |
| 108 | | − | }; |
| 109 | | − | if deeper && !main_failed { |
| 110 | | − | match git.log(base, main_depth).await { |
| 111 | | − | Ok(commits) if !commits.is_empty() => main = Some((main_depth, commits)), |
| 112 | | − | _ => main_failed = true, |
| 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, |
| 113 | 192 | | } |
| 114 | 193 | | } |
| 115 | | − | let Some((_, main_commits)) = &main else { |
| 116 | | − | for reading in readings.iter_mut().filter(|reading| reading.answer.is_none()) { |
| 117 | | − | reading.answer = Some(Measured { commit: None, drift: None, settled: false }); |
| 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; |
| 118 | 202 | | } |
| 119 | | − | break; |
| 120 | | − | }; |
| 121 | | − | let open: Vec<usize> = (0..heads.len()).filter(|&index| readings[index].answer.is_none()).collect(); |
| 122 | | − | for chunk in open.chunks(AT_ONCE) { |
| 123 | | − | let reads = futures_util::future::join_all(chunk.iter().map(|&index| { |
| 124 | | − | let reading = &readings[index]; |
| 125 | | − | // Read again only when deeper, and only when the last read |
| 126 | | − | // did not already reach the start. |
| 127 | | − | let again = reading.depth == 0 || (branch_depth > reading.depth && reading.commits.len() as u32 >= reading.depth); |
| 128 | | − | let head = heads[index].as_str(); |
| 129 | | − | async move { if again { Some(git.log(head, branch_depth).await) } else { None } } |
| 130 | | − | })) |
| 131 | | − | .await; |
| 132 | | − | for (&index, read) in chunk.iter().zip(reads) { |
| 133 | | − | let reading = &mut readings[index]; |
| 134 | | − | match read { |
| 135 | | − | Some(Ok(commits)) if !commits.is_empty() => { |
| 136 | | − | reading.commits = commits; |
| 137 | | − | reading.depth = branch_depth; |
| 138 | | − | } |
| 139 | | − | Some(_) => { |
| 140 | | − | reading.answer = Some(Measured { commit: None, drift: None, settled: false }); |
| 141 | | − | continue; |
| 142 | | − | } |
| 143 | | − | None => {} |
| 144 | | − | } |
| 145 | | − | let head = heads[index].as_str(); |
| 146 | | − | if let Some(counted) = drift(head, base, main_commits.iter().chain(reading.commits.iter())) { |
| 147 | | − | reading.answer = Some(Measured { commit: reading.commits.first().cloned(), drift: Some(counted), settled: true }); |
| 148 | | − | } |
| 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)); |
| 149 | 208 | | } |
| 150 | 209 | | } |
| 151 | | − | if main_failed { |
| 152 | | − | break; |
| 153 | | − | } |
| 154 | 210 | | } |
| 155 | | − | readings |
| 156 | | − | .into_iter() |
| 157 | | − | .map(|reading| { |
| 158 | | − | reading.answer.unwrap_or_else(|| Measured { |
| 159 | | − | commit: reading.commits.first().cloned(), |
| 160 | | − | drift: None, |
| 161 | | − | // Read to the last depth without meeting: that is the answer |
| 162 | | − | // for this pair, and it will not change. |
| 163 | | − | settled: !main_failed && reading.depth > 0, |
| 164 | | − | }) |
| 165 | | − | }) |
| 166 | | − | .collect() |
| 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 |
| 167 | 235 | | } |
| 168 | 236 | | |
| 169 | 237 | | #[cfg(test)] |
| ⋯ |
| 173 | 241 | | use std::pin::pin; |
| 174 | 242 | | use std::task::{Context, Poll, Waker}; |
| 175 | 243 | | |
| 176 | | − | use g1t_contracts::repos::{Branch, GitAccess, Signature, TreeEntry}; |
| 244 | + | use g1t_contracts::repos::{Branch, BranchDrift, BranchDrifts, GitAccess, Signature, TreeEntry}; |
| 245 | + | use worker::Result; |
| 177 | 246 | | |
| 178 | 247 | | use super::*; |
| 179 | 248 | | use crate::store::Scope; |
| ⋯ |
| 185 | 254 | | } |
| 186 | 255 | | } |
| 187 | 256 | | |
| 188 | | − | fn commit(hash: &str, parents: &[&str]) -> Commit { |
| 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 { |
| 189 | 263 | | Commit { |
| 190 | 264 | | hash: hash.into(), |
| 191 | 265 | | tree_hash: format!("t{hash}"), |
| 192 | 266 | | message: format!("commit {hash}\n\nbody"), |
| 193 | 267 | | author: Signature { name: "a".into(), email: "a@example.com".into() }, |
| 194 | 268 | | parents: parents.iter().map(|&p| p.to_owned()).collect(), |
| 195 | | − | authored_at: String::new(), |
| 269 | + | authored_at: at(t), |
| 196 | 270 | | } |
| 197 | 271 | | } |
| 198 | 272 | | |
| 199 | | − | /// Commits by hash; `log` follows first parents. Counts each read. |
| 273 | + | /// Commits by hash; `log` follows first parents only, as the store |
| 274 | + | /// does. Counts each read. |
| 200 | 275 | | #[derive(Default)] |
| 201 | 276 | | struct Fake { |
| 202 | 277 | | commits: HashMap<String, Commit>, |
| ⋯ |
| 208 | 283 | | fn with(commits: Vec<Commit>) -> Fake { |
| 209 | 284 | | Fake { commits: commits.into_iter().map(|c| (c.hash.clone(), c)).collect(), ..Fake::default() } |
| 210 | 285 | | } |
| 286 | + | |
| 287 | + | fn reads_of(&self, hash: &str) -> usize { |
| 288 | + | self.reads.borrow().iter().filter(|(read, _)| read == hash).count() |
| 289 | + | } |
| 211 | 290 | | } |
| 212 | 291 | | |
| 213 | 292 | | impl GitRepo for Fake { |
| ⋯ |
| 248 | 327 | | async fn fork(&self, _target_key: &str) -> Result<()> { |
| 249 | 328 | | Ok(()) |
| 250 | 329 | | } |
| 251 | | − | } |
| 252 | | − | |
| 253 | | − | /// m1 ← m2 ← m3 on main; b1 ← b2 branched from m2. |
| 254 | | − | fn forked() -> Vec<Commit> { |
| 255 | | − | vec![commit("m1", &[]), commit("m2", &["m1"]), commit("m3", &["m2"]), commit("b1", &["m2"]), commit("b2", &["b1"])] |
| 256 | 330 | | } |
| 257 | 331 | | |
| 258 | | − | /// A straight line of `n` commits named `{prefix}{i}`, the first on `from`. |
| 259 | | − | fn line(prefix: &str, from: Option<&str>, n: usize) -> Vec<Commit> { |
| 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> { |
| 260 | 335 | | (1..=n) |
| 261 | 336 | | .map(|i| { |
| 262 | 337 | | let parent = if i == 1 { from.map(str::to_owned) } else { Some(format!("{prefix}{}", i - 1)) }; |
| 263 | | − | commit(&format!("{prefix}{i}"), &parent.iter().map(String::as_str).collect::<Vec<_>>()) |
| 338 | + | commit(&format!("{prefix}{i}"), &parent.iter().map(String::as_str).collect::<Vec<_>>(), t0 + i as u32 * 10) |
| 264 | 339 | | }) |
| 265 | 340 | | .collect() |
| 266 | 341 | | } |
| 267 | 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 | + | |
| 268 | 356 | | #[test] |
| 269 | | − | fn counts_both_sides_from_where_they_forked() { |
| 270 | | − | assert_eq!(drift("b2", "m3", &forked()), Some(Drift { ahead: 2, behind: 1 })); |
| 271 | | − | assert_eq!(drift("m3", "m3", &forked()), Some(Drift { ahead: 0, behind: 0 })); |
| 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); |
| 272 | 364 | | } |
| 273 | 365 | | |
| 274 | 366 | | #[test] |
| 275 | | − | fn a_merge_from_main_is_not_ahead() { |
| 276 | | − | // b3 merges m3 into the branch. |
| 277 | | − | let mut history = forked(); |
| 278 | | − | history.push(commit("b3", &["b2", "m3"])); |
| 279 | | − | assert_eq!(drift("b3", "m3", &history), Some(Drift { ahead: 3, behind: 0 })); |
| 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); |
| 280 | 374 | | } |
| 281 | 375 | | |
| 282 | 376 | | #[test] |
| 283 | | − | fn no_answer_when_the_histories_were_not_read_far_enough() { |
| 284 | | − | let history = vec![commit("m3", &["m2"]), commit("b2", &["b1"])]; |
| 285 | | − | assert_eq!(drift("b2", "m3", &history), None); |
| 286 | | − | assert_eq!(drift("b9", "m3", &forked()), None); |
| 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)); |
| 287 | 382 | | } |
| 288 | 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". |
| 289 | 389 | | #[test] |
| 290 | | − | fn measures_every_head_with_one_read_of_main() { |
| 291 | | − | let git = Fake::with(forked()); |
| 292 | | − | let found = run(measure(&git, "m3", &["b2".to_owned(), "m2".to_owned(), "m3".to_owned()])); |
| 293 | | − | assert_eq!(found[0].drift, Some(Drift { ahead: 2, behind: 1 })); |
| 294 | | − | assert_eq!(found[0].commit.as_ref().map(|c| c.hash.as_str()), Some("b2")); |
| 295 | | − | assert_eq!(found[1].drift, Some(Drift { ahead: 0, behind: 1 })); |
| 296 | | − | assert_eq!(found[2].drift, Some(Drift { ahead: 0, behind: 0 })); |
| 297 | | − | assert!(found.iter().all(|m| m.settled)); |
| 298 | | − | let reads = git.reads.borrow(); |
| 299 | | − | assert_eq!(reads.iter().filter(|(hash, _)| hash == "m3").count(), 2, "main once, plus m3 as a head: {reads:?}"); |
| 300 | | − | assert!(reads.iter().all(|(_, depth)| *depth == 12 || *depth == 120)); |
| 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"); |
| 301 | 406 | | } |
| 302 | 407 | | |
| 303 | 408 | | #[test] |
| 304 | | − | fn reads_deeper_only_for_a_branch_that_needs_it() { |
| 305 | | − | // main: 150 commits; "long" is 30 ahead of m100 (50 behind); "short" is 1 ahead of m149. |
| 306 | | − | let mut history = line("m", None, 150); |
| 307 | | − | history.extend(line("l", Some("m100"), 30)); |
| 308 | | − | history.push(commit("s1", &["m149"])); |
| 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)); |
| 309 | 445 | | let git = Fake::with(history); |
| 310 | | − | let found = run(measure(&git, "m150", &["l30".to_owned(), "s1".to_owned()])); |
| 311 | | − | assert_eq!(found[0].drift, Some(Drift { ahead: 30, behind: 50 })); |
| 312 | | − | assert_eq!(found[1].drift, Some(Drift { ahead: 1, behind: 1 })); |
| 313 | | − | let reads = git.reads.borrow(); |
| 314 | | − | assert_eq!(reads.iter().filter(|(hash, _)| hash == "s1").count(), 1); |
| 315 | | − | assert_eq!(*reads.iter().filter(|(hash, _)| hash == "l30").map(|(_, depth)| depth).max().unwrap(), 40); |
| 316 | | − | assert!(!reads.iter().any(|(_, depth)| *depth == 1000), "{reads:?}"); |
| 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()); |
| 317 | 450 | | } |
| 318 | 451 | | |
| 319 | 452 | | #[test] |
| 320 | | − | fn unrelated_histories_read_to_their_start_count_every_commit() { |
| 321 | | − | let mut history = line("m", None, 3); |
| 322 | | − | history.extend(line("x", None, 2)); |
| 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)); |
| 323 | 463 | | let git = Fake::with(history); |
| 324 | | − | let found = run(measure(&git, "m3", &["x2".to_owned()])); |
| 325 | | − | assert_eq!(found[0].drift, Some(Drift { ahead: 2, behind: 3 })); |
| 326 | | − | assert_eq!(found[0].commit.as_ref().map(|c| c.hash.as_str()), Some("x2")); |
| 327 | | − | assert!(found[0].settled); |
| 328 | | − | // Both reached their start at the first depth: never read again. |
| 329 | | − | assert_eq!(git.reads.borrow().len(), 2); |
| 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)); |
| 330 | 500 | | } |
| 331 | 501 | | |
| 332 | 502 | | #[test] |
| 333 | | − | fn past_the_last_depth_the_answer_is_settled_without_a_count() { |
| 334 | | − | // The branch is 1,200 commits long: no depth reaches where it left main. |
| 335 | | − | let mut history = line("m", None, 3); |
| 336 | | − | history.extend(line("x", Some("m1"), 1200)); |
| 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)]); |
| 337 | 515 | | let git = Fake::with(history); |
| 338 | | − | let found = run(measure(&git, "m3", &["x1200".to_owned()])); |
| 339 | | − | assert_eq!(found[0].drift, None); |
| 340 | | − | assert_eq!(found[0].commit.as_ref().map(|c| c.hash.as_str()), Some("x1200")); |
| 341 | | − | assert!(found[0].settled); |
| 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); |
| 342 | 522 | | } |
| 343 | 523 | | |
| 344 | 524 | | #[test] |
| 345 | 525 | | fn a_failed_read_is_not_kept() { |
| 346 | | − | let mut git = Fake::with(forked()); |
| 526 | + | let mut history = main3(); |
| 527 | + | history.extend([commit("b1", &["m2"], 25), commit("b2", &["b1"], 26)]); |
| 528 | + | let mut git = Fake::with(history); |
| 347 | 529 | | git.fail = Some("b2".into()); |
| 348 | 530 | | let found = run(measure(&git, "m3", &["b2".to_owned(), "b1".to_owned()])); |
| 349 | 531 | | assert!(!found[0].settled); |
| 350 | | − | assert_eq!(found[1].drift, Some(Drift { ahead: 1, behind: 1 })); |
| 532 | + | assert_eq!(found[0].drift, None); |
| 533 | + | assert_eq!(found[1].drift, counted(1, 1)); |
| 351 | 534 | | assert!(found[1].settled); |
| 352 | 535 | | git.fail = Some("m3".into()); |
| 353 | | − | let found = run(measure(&git, "m3", &["b2".to_owned()])); |
| 354 | | − | assert!(!found[0].settled); |
| 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 })); |
| 355 | 561 | | } |
| 356 | 562 | | } |