Skip to content

Commit

Active branches: count ahead/behind through merges; drop kept no-count answers

Ahead/behind never showed for a branch whose default branch took a merge since it left. The store lists first parents only, so a merge's second parent is never in either log; the count (drift.rs `drift`, ported unchanged from the site's branches.ts) returned no answer whenever a commit on one side only had a parent that was not read. Reading deeper cannot help: a first-parent log never lists it. Every depth gave up, the walk ended "settled" with no count, and branch_drift kept that for good. Production, flagon-io/hello: farewell (bab14ff) is one commit on the first commit c2ef68d; main's first-parent log (42 commits from 8407eba) lists the merge ac45e10 "Merge main into wave flag branch", whose second parent ebbaeb2 it never lists. The Branches page returned farewell's commit but drift null. flagon-io/g1t merges every pull request, so all of its branches failed the same way. The field names between repos and the site match (checked, and now tested on both sides). - drift.rs: the count is git's walk: newest commit first from both heads, each commit marked with the heads that reach it, a commit that gains a mark looked at again, stopping once everything left is reached by both (plus git's slop of 5 for commits dated before their parents). A merge's other parents are read by hash as the walk reaches them (16 commits each, kept for good by the store). The default branch's last 120 are read once for every head. Past 128 reads or 4,000 commits: no count. - lib.rs: the cache key is now drift.g1t.internal/v2/..., so the wrong answers kept for good are never read again; "too far to count" is kept a day, a count for good, a failed read not at all. - store.rs: spliced_log uses a kept history only when it starts at that commit and goes on to the next one the 16-commit read lists (`splice_first`), so a bad cache entry cannot repeat or skip commits. - Tests mirror the real shapes: one ahead of an unchanged main, behind, level, diverged, hello's merge, merges on both sides, a pull merged and built on, a fork point 300 commits back, 20 and 200 merges back, a rebased commit dated before its parent, failures not kept, and the answer's JSON names as the site reads them (Rust and TS). - docs/PERFORMANCE.md: how the count walks, the fix, the key version.

syntaqxcommitted Parent82db908Browse files
5 files+537−2090/5 viewed
+34−0
6161 assert.equal(shown.length, 3);
6262 assert.ok(shown.every((b) => b.commit == null && b.drift == null && b.pull == null));
6363 });
64+
65+test("repos' answer, as its JSON reads, keeps every count (flagon-io/hello's farewell)", () => {
66+ // What services/repos serializes for `branch_drift` (drift.rs
67+ // `the_answer_has_the_names_the_site_reads`): a name that differs here
68+ // would drop every count without an error.
69+ const wire = JSON.stringify({
70+ base: {
71+ hash: "8407eba",
72+ treeHash: "t1",
73+ message: "Use farewell() in the --both call",
74+ author: { name: "g1t agent", email: "agent@example.com" },
75+ parents: ["ac45e10"],
76+ authoredAt: "2026-10-03T09:11:34.000Z",
77+ },
78+ branches: [
79+ {
80+ head: "bab14ff",
81+ commit: {
82+ hash: "bab14ff",
83+ treeHash: "t2",
84+ message: "Add a farewell\n\nbody",
85+ author: { name: "syntaqx", email: "s@example.com" },
86+ parents: ["c2ef68d"],
87+ authoredAt: "2026-10-02T07:52:16.000Z",
88+ },
89+ drift: { ahead: 1, behind: 41 },
90+ },
91+ ],
92+ });
93+ const measured = JSON.parse(wire) as BranchDrifts;
94+ const shown = activeBranches([{ name: "farewell", hash: "bab14ff" }], measured, { pulls: [], previews: [] });
95+ assert.deepEqual(shown[0]?.drift, { ahead: 1, behind: 41 });
96+ assert.deepEqual(shown[0]?.commit, { hash: "bab14ff", message: "Add a farewell", author: "syntaqx", at: "2026-10-02T07:52:16.000Z" });
97+});
+20−7
189189 | Sidebar data (projects, spend, limit, entitlements) | per isolate (`lib/cache.server.ts`) | 15 s, per person and workspace | skipped during a write and for 30 s after the person's last one; failures not kept; only settled answers kept |
190190 | Registration mode | per isolate | 60 s | |
191191 | A commit's log by hash | repos' data-centre cache | for good | history from a commit never changes. One of 100 commits or more is put together from a 16-commit read and the history kept from any of those commits, when there is one (`store.rs` `spliced_log`): a default branch that moved by a merge costs 16 commits, not 120 or 1,000 |
192−| A branch's drift from the default branch (Active branches) | repos' data-centre cache (`branch_drift`) | for good | by repository and the pair of head commits; a failed read is not kept |
192+| A branch's drift from the default branch (Active branches) | repos' data-centre cache (`branch_drift`) | a count for good; "too far to count" a day | by repository and the pair of head commits (key version `v2`); a failed read is not kept |
193193 | A repository's tags | repos' data-centre cache | until the refs move, 5 min at most | as the branch list; not kept when a tag's commit could not be read |
194194 | Git objects, trees, refs | repos' caches | see services/repos | |
195195 | A branch's log, the branch list, a file by branch and path | repos' data-centre cache | until the repository's refs change (`refs_version`), 5 min at most | only while no handed-out push credential is live; by commit hash for good (docs/ARTIFACTS.md R9) |
314314
315315 - **One call.** `branch_drift` (services/repos/src/drift.rs) takes the
316316 default branch's head and every branch head, checks access once, opens
317− the store once, and reads the default branch's history once per depth
318− for all of them. Each answer is kept in repos' data-centre cache by the
317+ the store once, and reads the default branch's last 120 commits once
318+ for all of them. Each count is kept in repos' data-centre cache by the
319319 pair of hashes; only pairs that changed are walked. It also returns the
320320 default branch's head commit, which the site read with its own call.
321−- **Shallower first.** Depths are (branch, default branch) 12/120, then
322− 40/120, 40/1,000 and 1,000/1,000: most branches are a few commits
323− ahead and meet at the first.
321+- **Only what the count needs.** The walk goes newest commit first from
322+ both heads, as `git rev-list --left-right --count` does, and stops once
323+ everything left is reached by both. The store lists first parents only,
324+ so a merge's other parent is read on its own (16 commits, by hash, kept
325+ for good) when the walk reaches it; a branch a few commits from the
326+ default branch costs one read of its own. Past 128 reads or 4,000
327+ commits there is no count, and that answer is kept a day, not for good.
328+- **Fixed 2026-10-09: counts never showed.** The first version (and the
329+ site's before it) gave up when a commit on one side only had a parent
330+ not read, which every merge on the default branch has, and kept "no
331+ count" for good: no branch of flagon-io/hello or flagon-io/g1t showed
332+ counts. The cache key moved to `drift.g1t.internal/v2/`, so those
333+ answers are not read again.
324334 - **Long histories spliced.** A log by hash of 100 commits or more is a
325335 16-commit read plus the log kept from one of those commits (the
326336 first-parent chain from a commit never changes), so the default branch
327− after a merge costs 16 commits instead of 120 or 1,000.
337+ after a merge costs 16 commits instead of 120 or 1,000. A kept log is
338+ used only when it starts at that commit and goes on to the one the
339+ 16-commit read lists next (`splice_first`), so the join neither repeats
340+ nor skips a commit.
328341 - **Finished after the page.** The call runs in `waitUntil`, so repos
329342 keeps the answer even when the page stopped waiting for it.
330343 - **Crawlers wait 0.7 s** for the section (browsers 3.5 s, streamed);
+400−194
11 //! How far branches have moved from the default branch, for Active branches
22 //! on a project's overview and the Branches page (`branch_drift`).
33 //!
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.
1123
12−use std::collections::{HashMap, HashSet};
24+use std::collections::{BinaryHeap, HashMap, HashSet};
1325
1426 use g1t_contracts::repos::{Commit, Drift};
15−use worker::Result;
1627
1728 use crate::store::GitRepo;
1829
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;
2545
26−/// Branches read at once.
46+/// Branches walked at once, and missing parents read at once.
2747 const AT_ONCE: usize = 8;
2848
2949 /// A branch head's commit and drift, and whether the answer may be kept:
3555 pub settled: bool,
3656 }
3757
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))
4780 }
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)
5084 }
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+ }
58108 }
59− if above.iter().any(|parent| !parents.contains_key(parent.as_str())) {
60− return None;
109+ if wanted.is_empty() {
110+ return Ok(true);
61111 }
62− if on_branch {
63− ahead += 1;
64− } else {
65− behind += 1;
112+ if self.reads + wanted.len() > MAX_READS {
113+ return Ok(false);
66114 }
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(()),
81125 }
82126 }
127+ Ok(true)
83128 }
84− seen
85129 }
86130
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)
92142 }
93143
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;
104167 }
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,
113192 }
114193 }
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;
118202 }
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));
149208 }
150209 }
151− if main_failed {
152− break;
153− }
154210 }
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
167235 }
168236
169237 #[cfg(test)]
173241 use std::pin::pin;
174242 use std::task::{Context, Poll, Waker};
175243
176− use g1t_contracts::repos::{Branch, GitAccess, Signature, TreeEntry};
244+ use g1t_contracts::repos::{Branch, BranchDrift, BranchDrifts, GitAccess, Signature, TreeEntry};
245+ use worker::Result;
177246
178247 use super::*;
179248 use crate::store::Scope;
185254 }
186255 }
187256
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 {
189263 Commit {
190264 hash: hash.into(),
191265 tree_hash: format!("t{hash}"),
192266 message: format!("commit {hash}\n\nbody"),
193267 author: Signature { name: "a".into(), email: "a@example.com".into() },
194268 parents: parents.iter().map(|&p| p.to_owned()).collect(),
195− authored_at: String::new(),
269+ authored_at: at(t),
196270 }
197271 }
198272
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.
200275 #[derive(Default)]
201276 struct Fake {
202277 commits: HashMap<String, Commit>,
208283 fn with(commits: Vec<Commit>) -> Fake {
209284 Fake { commits: commits.into_iter().map(|c| (c.hash.clone(), c)).collect(), ..Fake::default() }
210285 }
286+
287+ fn reads_of(&self, hash: &str) -> usize {
288+ self.reads.borrow().iter().filter(|(read, _)| read == hash).count()
289+ }
211290 }
212291
213292 impl GitRepo for Fake {
248327 async fn fork(&self, _target_key: &str) -> Result<()> {
249328 Ok(())
250329 }
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"])]
256330 }
257331
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> {
260335 (1..=n)
261336 .map(|i| {
262337 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)
264339 })
265340 .collect()
266341 }
267342
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+
268356 #[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);
272364 }
273365
274366 #[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);
280374 }
281375
282376 #[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));
287382 }
288383
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".
289389 #[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");
301406 }
302407
303408 #[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));
309445 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());
317450 }
318451
319452 #[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));
323463 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));
330500 }
331501
332502 #[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)]);
337515 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);
342522 }
343523
344524 #[test]
345525 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);
347529 git.fail = Some("b2".into());
348530 let found = run(measure(&git, "m3", &["b2".to_owned(), "b1".to_owned()]));
349531 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));
351534 assert!(found[1].settled);
352535 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 }));
355561 }
356562 }
+10−4
945945 }
946946
947947 /// How far each branch head has moved from the default branch's head,
948− /// in one call (drift.rs). Each answer is kept in this colo's cache by
948+ /// in one call (drift.rs). Each count is kept in this colo's cache by
949949 /// repository and the pair of hashes, for good: neither history can
950− /// change. A head that moved is the only one walked.
950+ /// change. A head that moved is the only one walked. A failed read is
951+ /// not kept, and "too far to count" only for a day.
951952 async fn branch_drift(&self, a: BranchDriftArgs) -> Result<Outcome<BranchDrifts>> {
952953 let Some(repo) = self.readable(&a.path, &a.viewer).await? else {
953954 return Ok(not_found());
957958 }
958959 let heads: Vec<String> = a.heads.into_iter().take(MAX_DRIFT_HEADS).collect();
959960 let git = self.read_git(&repo).await?;
960− let key = |head: &str| format!("https://drift.g1t.internal/{}/{}/{head}", repo.id, a.base);
961+ // v2: answers kept before 2026-10-09 said "no count" for every
962+ // branch whose default branch took a merge since it left (drift.rs).
963+ let key = |head: &str| format!("https://drift.g1t.internal/v2/{}/{}/{head}", repo.id, a.base);
961964 let cache = worker::Cache::default();
962965 let (base, kept) = futures_util::future::join(
963966 git.log(&a.base, 1),
986989 if found.settled
987990 && let Ok(mut response) = worker::Response::from_json(&answer)
988991 {
989− let _ = response.headers_mut().set("cache-control", "public, max-age=31536000, immutable");
992+ // A count is kept for good; "too far to count" for a day, so
993+ // a change to how far a walk goes reaches it.
994+ let max_age = if answer.drift.is_some() { "public, max-age=31536000, immutable" } else { "public, max-age=86400" };
995+ let _ = response.headers_mut().set("cache-control", max_age);
990996 let _ = cache.put(key(&head).as_str(), response).await;
991997 }
992998 fresh.insert(head, answer);
+73−4
748748 out
749749 }
750750
751+/// The history from `short[0]`, from the newest of `kept` (each the history
752+/// kept from the commit of `short` at the same index, if any) that really
753+/// is the history from that commit: it starts there and goes on to the
754+/// next commit `short` lists, so the join repeats and skips nothing.
755+pub fn splice_first(short: &[Commit], kept: Vec<Option<Vec<Commit>>>, limit: u32) -> Option<Vec<Commit>> {
756+ kept.into_iter().enumerate().skip(1).find_map(|(at, kept)| {
757+ let kept = kept?;
758+ let starts = kept.first().is_some_and(|first| short.get(at).is_some_and(|commit| commit.hash == first.hash));
759+ let goes_on = match (short.get(at + 1), kept.get(1)) {
760+ (Some(next), Some(kept_next)) => next.hash == kept_next.hash,
761+ // `short` ends at `at`: it reached the first commit, or its limit.
762+ (None, _) => true,
763+ // The kept history ends where `short` goes on.
764+ (Some(_), None) => false,
765+ };
766+ (starts && goes_on).then(|| splice(short, at, kept, limit))
767+ })
768+}
769+
751770 /// Whether a ref is a full commit hash (SHA-1 or SHA-256), whose history
752771 /// can be kept for good.
753772 pub fn is_commit_hash(git_ref: &str) -> bool {
888907 // The whole history fits in the short read.
889908 return Ok(short);
890909 }
891− let kept = futures_util::future::join_all(short.iter().enumerate().skip(1).map(|(at, commit)| async move {
910+ let kept = futures_util::future::join_all(short.iter().enumerate().map(|(at, commit)| async move {
911+ if at == 0 {
912+ return None;
913+ }
892914 let Some(CacheKey::Forever(path)) = log_key(&commit.hash, limit, None) else {
893915 return None;
894916 };
895917 let bytes = self.peek(&path).await?;
896− serde_json::from_slice::<Vec<Commit>>(&bytes).ok().map(|kept| (at, kept))
918+ serde_json::from_slice::<Vec<Commit>>(&bytes).ok()
897919 }))
898920 .await;
899− match kept.into_iter().flatten().next() {
900− Some((at, kept)) => Ok(splice(&short, at, kept, limit)),
921+ match splice_first(&short, kept, limit) {
922+ Some(history) => Ok(history),
901923 None => self.read_log(hash, limit).await,
902924 }
903925 }
13031325 }
13041326
13051327 #[test]
1328+ fn a_splice_joins_at_the_newest_kept_history_without_repeats_or_gaps() {
1329+ // 16 read from c20; histories of 8 kept from c17 and c12.
1330+ let all: Vec<String> = (0..=20).rev().map(|i| format!("c{i}")).collect();
1331+ let names: Vec<&str> = all.iter().map(String::as_str).collect();
1332+ let short = chain(&names[..16]);
1333+ let kept_from = |name: &str| {
1334+ let at = names.iter().position(|n| *n == name).unwrap();
1335+ chain(&names[at..(at + 8).min(names.len())])
1336+ };
1337+ let mut kept: Vec<Option<Vec<Commit>>> = vec![None; short.len()];
1338+ kept[3] = Some(kept_from("c17"));
1339+ kept[8] = Some(kept_from("c12"));
1340+ let joined = splice_first(&short, kept.clone(), 8).unwrap();
1341+ assert_eq!(hashes(&joined), names[..8]);
1342+ // Newest first, every commit once, each the first parent of the one before.
1343+ let joined = splice_first(&short, kept, 11).unwrap();
1344+ assert_eq!(hashes(&joined), names[..11]);
1345+ for pair in joined.windows(2) {
1346+ assert_eq!(pair[0].parents.first(), Some(&pair[1].hash));
1347+ }
1348+ let unique: std::collections::HashSet<_> = joined.iter().map(|commit| &commit.hash).collect();
1349+ assert_eq!(unique.len(), joined.len());
1350+ }
1351+
1352+ #[test]
1353+ fn a_kept_history_that_does_not_fit_is_not_spliced() {
1354+ let short = chain(&["c5", "c4", "c3", "c2"]);
1355+ // Kept under c4's key, but from somewhere else.
1356+ let wrong = vec![None, Some(chain(&["x4", "x3"])), None, None];
1357+ assert!(splice_first(&short, wrong, 10).is_none());
1358+ // Starts at c4 but goes on to another commit.
1359+ let forked = vec![None, Some(chain(&["c4", "y3"])), None, None];
1360+ assert!(splice_first(&short, forked, 10).is_none());
1361+ // Ends at c4 where `short` goes on: not the history from c4.
1362+ let cut = vec![None, Some(chain(&["c4"])), None, None];
1363+ assert!(splice_first(&short, cut, 10).is_none());
1364+ // The commit read itself is never spliced onto.
1365+ let own = vec![Some(chain(&["c5", "c4"])), None, None, None];
1366+ assert!(splice_first(&short, own, 10).is_none());
1367+ // Nothing kept.
1368+ assert!(splice_first(&short, vec![None; 4], 10).is_none());
1369+ // The last commit read: anything kept from it fits.
1370+ let last = vec![None, None, None, Some(chain(&["c2", "c1", "c0"]))];
1371+ assert_eq!(hashes(&splice_first(&short, last, 10).unwrap()), ["c5", "c4", "c3", "c2", "c1", "c0"]);
1372+ }
1373+
1374+ #[test]
13061375 fn only_long_histories_by_hash_are_spliced() {
13071376 assert!(SPLICE_PROBE < SPLICE_FROM);
13081377 let hash = "a".repeat(40);