Skip to content

Commit

Merge branch drift: count across merges the way git does; v2 cache key

syntaqxcommitted Parents0611189fe43dbbBrowse 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);