diff --git a/packages/python/e2e-attestations/bot-1480-matcher-speed-1015.json b/packages/python/e2e-attestations/bot-1480-matcher-speed-1015.json new file mode 100644 index 000000000..4eeeb8eb2 --- /dev/null +++ b/packages/python/e2e-attestations/bot-1480-matcher-speed-1015.json @@ -0,0 +1,7 @@ +{ + "command": "uv run python -m pytest tests/e2e/glob_speed_matchers_test.py tests/e2e/glob_speed_recursive_test.py tests/e2e/glob_speed_gitignore_test.py tests/e2e/glob_speed_depth_bound_test.py tests/e2e/glob_speed_one_level_test.py tests/e2e/query_contract_test.py -q", + "ran_at": 1792158925, + "exit_code": 0, + "commit": "e01da64bd1c48112f77059fab894961c5dc241ce", + "branch": "bot/1480-matcher-speed-1015" +} diff --git a/packages/rust/changelog.d/2026-10-15-matcher-glob-walk-speed.md b/packages/rust/changelog.d/2026-10-15-matcher-glob-walk-speed.md new file mode 100644 index 000000000..a0546aa0f --- /dev/null +++ b/packages/rust/changelog.d/2026-10-15-matcher-glob-walk-speed.md @@ -0,0 +1,3 @@ +**Changed** + +- **Recursive globs with a narrow file name list faster.** For globs such as `./**/file?.md` and `./**/[[:digit:]]*.md`, the walk drops a file whose name cannot match before building its path, skips the `.git` and `.gitignore` lookups in directories whose listing holds neither, and has its threads read the directories the output reaches next. Output and its order are unchanged. diff --git a/packages/rust/migrations.d/2026-10-15-matcher-glob-walk-speed.md b/packages/rust/migrations.d/2026-10-15-matcher-glob-walk-speed.md new file mode 100644 index 000000000..c70250f06 --- /dev/null +++ b/packages/rust/migrations.d/2026-10-15-matcher-glob-walk-speed.md @@ -0,0 +1,25 @@ +### Faster recursive globs with a narrow file name + +#### Summary + +The tree walk rejects a file by its name before building its path, checks for `.git` and `.gitignore` in the directory listing it already read, and reads directories ahead of the output in the order the output reaches them. Query results and their order are unchanged; nothing breaks. + +#### Required changes + +_None._ + +#### Deprecations removed + +_None._ + +#### Behavior changes without code changes + +_None._ + +#### Verification + +```bash +dirsql query "SELECT count(*) FROM './**/file?.md'" +``` + +Prints the same count as before, sooner. diff --git a/packages/rust/src/listing.rs b/packages/rust/src/listing.rs index 829e35eae..b9125daa2 100644 --- a/packages/rust/src/listing.rs +++ b/packages/rust/src/listing.rs @@ -100,6 +100,13 @@ impl Listing { }); } + /// Whether an entry is named `name`; the listing must be sorted. + pub(crate) fn holds(&self, name: &OsStr) -> bool { + self.entries + .binary_search_by(|listed| self.name(listed).cmp(name)) + .is_ok() + } + pub(crate) fn name(&self, listed: &Listed) -> &OsStr { self.names.get(listed.start, listed.end) } @@ -138,46 +145,17 @@ fn open_dir(dir: &Path) -> rustix::io::Result { rustix::fs::open(dir, flags, Mode::empty()) } -#[cfg(target_os = "linux")] -thread_local! { - /// Where a walk thread reads its directories' entries: big enough that a - /// directory of millions takes few `getdents` calls, and reused so a deep - /// tree of small directories pays for it once. - static ENTRY_BUFFER: std::cell::RefCell> = - std::cell::RefCell::new(Vec::with_capacity(1 << 20)); -} - -/// Reads entries straight out of the kernel's buffer, where `Dir` would copy -/// each name into its own allocation. -#[cfg(target_os = "linux")] +/// Uses `Dir`, not `RawDir`: on a deep tree of small directories the walk ran +/// about twice as slow reading through `RawDir`, with no gain on a flat +/// directory of a million entries. +#[cfg(unix)] fn read_into(dir: &Path, listing: &mut Listing) { - use rustix::fs::RawDir; - use std::os::unix::ffi::OsStrExt; - let Ok(fd) = open_dir(dir) else { - return; - }; - ENTRY_BUFFER.with_borrow_mut(|buffer| { - let mut entries = RawDir::new(&fd, buffer.spare_capacity_mut()); - while let Some(Ok(entry)) = entries.next() { - let name = entry.file_name().to_bytes(); - if name == b"." || name == b".." { - continue; - } - listing.push(OsStr::from_bytes(name), seen(entry.file_type())); - } - }); -} - -/// `read_into` for Unix systems without `RawDir`. Built under test too, so the -/// Linux unit run covers it. -#[cfg(all(unix, any(test, not(target_os = "linux"))))] -fn read_dir_with_copies_into(dir: &Path, listing: &mut Listing) { use rustix::fs::Dir; use std::os::unix::ffi::OsStrExt; let Ok(fd) = open_dir(dir) else { return; }; - let Ok(mut entries) = Dir::read_from(&fd) else { + let Ok(mut entries) = Dir::new(fd) else { return; }; while let Some(Ok(entry)) = entries.read() { @@ -214,8 +192,6 @@ fn seen_flags(symlink: bool, dir: bool, file: bool) -> Seen { #[cfg(not(unix))] use read_dir_into as read_into; -#[cfg(all(unix, not(target_os = "linux")))] -use read_dir_with_copies_into as read_into; /// A name's first eight bytes as a big-endian word, which orders names as /// their bytes do as far as those bytes go. @@ -271,6 +247,19 @@ mod tests { assert_eq!(names(&listing), ["first", "second"]); } + #[test] + fn a_sorted_listing_holds_exactly_the_names_pushed() { + let mut listing = Listing::default(); + for name in [".gitignore", "b", ".git", "a"] { + listing.push(OsStr::new(name), Seen::File); + } + listing.sort(); + assert!(listing.holds(OsStr::new(".git"))); + assert!(listing.holds(OsStr::new("a"))); + assert!(!listing.holds(OsStr::new(".gi"))); + assert!(!Listing::default().holds(OsStr::new(".git"))); + } + #[test] fn name_prefix_orders_names_as_their_bytes_do() { let mut names = ["abcdefghZ", "abcdefgh", "abcdefghA", "ab", "b", "abc", ""]; @@ -344,16 +333,6 @@ mod tests { assert_eq!(read_with(read_into, dir.path()), fixture_entries()); } - #[cfg(unix)] - #[test] - fn read_dir_with_copies_into_lists_the_entries_without_dot_and_dot_dot() { - let dir = fixture(); - assert_eq!( - read_with(read_dir_with_copies_into, dir.path()), - fixture_entries() - ); - } - #[test] fn read_dir_into_classifies_what_std_reports() { let dir = fixture(); diff --git a/packages/rust/src/scanner.rs b/packages/rust/src/scanner.rs index 996c91c42..3b9c87575 100644 --- a/packages/rust/src/scanner.rs +++ b/packages/rust/src/scanner.rs @@ -126,6 +126,7 @@ pub fn scan_dirs(root: &Path, start: &Path, ignore: &TableMatcher) -> Vec( root, root, walker, + &|name| glob.may_match_name(name), &|rel_path| is_wanted(glob, ignore, rel_path), &|rel_path, _| row(rel_path), visit, @@ -304,6 +307,8 @@ pub struct PathGlob { components: Vec, /// Where each word's components begin. starts: Vec, + /// Where each word's closing `End` sits. + ends: Vec, /// Whether any word spells a `.` or `..` component, which the walk has to /// enter itself: a directory listing never names them. spells_relative: bool, @@ -362,6 +367,18 @@ impl PathGlob { .all(|name| self.spells_dot_name(name)) } + /// Whether a file named `name` may match: false when no word could match + /// it, since only `**` crosses a `/` and so a word's last component, unless + /// it is `**`, has to match the name alone. + pub(crate) fn may_match_name(&self, name: &OsStr) -> bool { + self.ends + .iter() + .any(|&end| match &self.components[end - 1] { + Component::Name(p) | Component::DotName(p) => p.is_match(Path::new(name)), + Component::AnyDepth | Component::End => true, + }) + } + fn has_components(&self) -> bool { !self.components.is_empty() } @@ -465,10 +482,12 @@ pub fn compile_globs(patterns: &[&str]) -> Result { .collect(); let mut components = Vec::new(); let mut starts = Vec::new(); + let mut ends = Vec::new(); for word in &words { let word_components = compile_components(word)?; starts.push(components.len()); components.extend(word_components); + ends.push(components.len()); components.push(Component::End); } Ok(PathGlob { @@ -476,6 +495,7 @@ pub fn compile_globs(patterns: &[&str]) -> Result { spelled_dot_names, components, starts, + ends, spells_relative, named, }) @@ -547,6 +567,7 @@ fn walk( root: &Path, start: &Path, walker: Walk<'_>, + keep_name: &(dyn Fn(&OsStr) -> bool + Sync), keep: &(dyn Fn(&Path) -> bool + Sync), leaf: &(dyn Fn(PathBuf, &Path) -> L + Sync), visit: &mut dyn FnMut(L), @@ -566,7 +587,7 @@ fn walk( }; walk_in_order( Box::new(place), - &|place: Box>| (*place).explore(keep, leaf), + &|place: Box>| (*place).explore(keep_name, keep, leaf), visit, ); } @@ -585,9 +606,11 @@ struct Place<'a> { impl<'a> Place<'a> { /// The directory's entries in walk order: each file `keep` takes, made /// into a `leaf` with the directory it sits in, and each directory to - /// enter. + /// enter. A regular file whose name `keep_name` rejects is dropped before + /// its path is built. fn explore( self, + keep_name: &(dyn Fn(&OsStr) -> bool + Sync), keep: &(dyn Fn(&Path) -> bool + Sync), leaf: &(dyn Fn(PathBuf, &Path) -> L + Sync), ) -> Vec>, L>> { @@ -598,37 +621,35 @@ impl<'a> Place<'a> { states, linked, } = self; - if walk.gitignore && holds_git(&dir) { + let listing = Listing::read(&dir); + if walk.gitignore && listing.holds(OsStr::new(".git")) && holds_git(&dir) { walk.in_repo = true; walk.frames.clear(); } if walk.in_repo + && listing.holds(OsStr::new(".gitignore")) && let Some(frame) = Frame::load(&dir) { walk.frames.push(Arc::new(frame)); } - let listing = Listing::read(&dir); let taken = judge_all(listing.entries(), &|listed| { - walk.take( - &dir, - listing.name(listed), - listed.seen, - &rel, - &states, - linked, - ) - .and_then(|taken| match taken { - Taken::File(child) => keep(&child).then(|| Taken::File(leaf(child, &dir))), - Taken::Dir { - child, - next, - linked, - } => Some(Taken::Dir { - child, - next, - linked, - }), - }) + let name = listing.name(listed); + if listed.seen == Seen::File && !keep_name(name) { + return None; + } + walk.take(&dir, name, listed.seen, &rel, &states, linked) + .and_then(|taken| match taken { + Taken::File(child) => keep(&child).then(|| Taken::File(leaf(child, &dir))), + Taken::Dir { + child, + next, + linked, + } => Some(Taken::Dir { + child, + next, + linked, + }), + }) }); let mut steps: Vec>, L>> = if walk.dirs { taken @@ -1235,7 +1256,7 @@ mod tests { fn explored_dirs(place: Place<'_>) -> Vec> { place - .explore(&|_| true, &|_, _| ()) + .explore(&|_| true, &|_| true, &|_, _| ()) .into_iter() .filter_map(|step| match step { Step::Dir(child) => Some(*child), @@ -1269,6 +1290,65 @@ mod tests { ); } + #[test] + fn explore_drops_a_file_whose_name_is_rejected_before_judging_its_path() { + let root = tempfile::tempdir().unwrap(); + fs::write(root.path().join("a.md"), "").unwrap(); + fs::write(root.path().join("b.md"), "").unwrap(); + fs::create_dir(root.path().join("d")).unwrap(); + let ignore = TableMatcher::new(&[], &[]).unwrap(); + let top = Place { + walk: walk_with(&ignore, None, Vec::new()), + dir: root.path().to_path_buf(), + rel: PathBuf::new(), + states: Vec::new(), + linked: false, + }; + let judged = std::sync::Mutex::new(Vec::new()); + let steps = top.explore( + &|name| name != OsStr::new("b.md"), + &|rel| { + judged.lock().unwrap().push(rel.to_path_buf()); + true + }, + &|rel, _| rel, + ); + let leaves: Vec = steps + .into_iter() + .filter_map(|step| match step { + Step::Leaf(rel) => Some(rel), + Step::Dir(_) => None, + }) + .collect(); + assert_eq!(leaves, vec![PathBuf::from("a.md")]); + assert_eq!(judged.into_inner().unwrap(), vec![PathBuf::from("a.md")]); + } + + #[test] + fn explore_outside_gitignore_mode_ignores_a_repo_it_enters() { + let root = tempfile::tempdir().unwrap(); + fs::create_dir(root.path().join(".git")).unwrap(); + fs::write(root.path().join(".gitignore"), "x.md\n").unwrap(); + fs::write(root.path().join("x.md"), "").unwrap(); + let ignore = TableMatcher::new(&[], &[]).unwrap(); + let top = Place { + walk: walk_with(&ignore, None, Vec::new()), + dir: root.path().to_path_buf(), + rel: PathBuf::new(), + states: Vec::new(), + linked: false, + }; + let leaves: Vec = top + .explore(&|_| true, &|_| true, &|rel, _| rel) + .into_iter() + .filter_map(|step| match step { + Step::Leaf(rel) => Some(rel), + Step::Dir(_) => None, + }) + .collect(); + assert!(leaves.contains(&PathBuf::from("x.md")), "{leaves:?}"); + } + #[test] fn explore_enters_a_dirsql_directory_below_the_top_level() { let root = tempfile::tempdir().unwrap(); @@ -1686,6 +1766,21 @@ mod tests { }) } + #[test] + fn a_name_may_match_only_when_some_word_ends_in_a_component_matching_it() { + let glob = compile_glob("./**/{a,b}*.md").unwrap(); + assert!(glob.may_match_name(OsStr::new("a1.md"))); + assert!(glob.may_match_name(OsStr::new("b1.md"))); + assert!(!glob.may_match_name(OsStr::new("c1.md"))); + assert!(!glob.may_match_name(OsStr::new("a1.txt"))); + } + + #[test] + fn a_word_ending_in_a_double_star_may_match_any_name() { + let glob = compile_glob("{docs/**,*.md}").unwrap(); + assert!(glob.may_match_name(OsStr::new("a.txt"))); + } + #[test] fn a_double_star_starts_matched_or_not_yet_consumed() { assert_eq!(compile_glob("**/*.md").unwrap().start(), vec![0, 1]); diff --git a/packages/rust/src/tree_walk.rs b/packages/rust/src/tree_walk.rs index bc32d495a..5f26f65b6 100644 --- a/packages/rust/src/tree_walk.rs +++ b/packages/rust/src/tree_walk.rs @@ -5,8 +5,10 @@ //! calling thread hands out what they found in depth-first order. use std::cell::RefCell; +use std::cmp::Ordering as Order; +use std::collections::BinaryHeap; use std::sync::atomic::{AtomicUsize, Ordering}; -use std::sync::{Arc, Condvar, Mutex}; +use std::sync::{Arc, Condvar, Mutex, MutexGuard}; use std::thread; thread_local! { @@ -31,24 +33,66 @@ pub(crate) enum Step { /// cores; below it, spawning workers costs more than the exploring. const PARALLEL_DIRS: usize = 8; -enum Item { - Dir(usize), +enum Item { + Dir(Arc>), Leaf(L), } -struct Queue { - /// Each directory found, until a thread claims it. - unclaimed: Vec>, - /// Each explored directory's items, until the walk reaches it. - explored: Vec>>>, - /// Unclaimed directories, the next one the walk will want on top. - open: Vec, +/// One directory found: unexplored until a thread takes it, then its items +/// once that thread is through. Each directory has its own locks, so the walk +/// waiting on one never contends with workers exploring others. +struct Slot { + /// The directory's place in the walk: each step's index among its + /// parent's directories, root first, so keys order as the walk reaches + /// them. + key: Vec, + dir: Mutex>, + explored: Mutex>, + ready: Condvar, +} + +struct Explored { + items: Option>>, + /// Whether the walk is waiting on these items, so a worker signals only + /// then. + awaited: bool, +} + +/// Directories found and not yet claimed. Workers take the one the walk will +/// reach first, so they explore just ahead of it rather than deep in a subtree +/// it is far from, which would leave it to explore its next directory itself. +struct Open { + slots: BinaryHeap>, done: bool, } +/// A found directory, ordered so the heap's greatest is the earliest key. +struct Pending(Arc>); + +impl PartialEq for Pending { + fn eq(&self, other: &Self) -> bool { + self.0.key == other.0.key + } +} + +impl Eq for Pending {} + +impl PartialOrd for Pending { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for Pending { + fn cmp(&self, other: &Self) -> Order { + other.0.key.cmp(&self.0.key) + } +} + struct Shared<'e, D, L> { - queue: Mutex>, - changed: Condvar, + open: Mutex>, + found_dir: Condvar, + found: AtomicUsize, busy: Arc, explore: &'e (dyn Fn(D) -> Vec> + Sync), } @@ -61,24 +105,23 @@ pub(crate) fn walk_in_order( visit: &mut dyn FnMut(L), ) { let shared = Shared { - queue: Mutex::new(Queue { - unclaimed: vec![Some(root)], - explored: vec![None], - open: Vec::new(), + open: Mutex::new(Open { + slots: BinaryHeap::new(), done: false, }), - changed: Condvar::new(), + found_dir: Condvar::new(), + found: AtomicUsize::new(1), busy: Arc::default(), explore, }; thread::scope(|scope| { let mut spawned = false; - let mut stack = vec![shared.obtain(0).into_iter()]; + let mut stack = vec![shared.explore(root, &[]).into_iter()]; while let Some(items) = stack.last_mut() { match items.next() { Some(Item::Leaf(leaf)) => visit(leaf), - Some(Item::Dir(id)) => { - if !spawned && shared.found() >= PARALLEL_DIRS { + Some(Item::Dir(slot)) => { + if !spawned && shared.found.load(Ordering::Relaxed) >= PARALLEL_DIRS { spawned = true; let workers = thread::available_parallelism().map_or(1, usize::from); for _ in 1..workers { @@ -92,98 +135,118 @@ pub(crate) fn walk_in_order( } BUSY.set(Some(Arc::clone(&shared.busy))); } - stack.push(shared.obtain(id).into_iter()); + stack.push(shared.obtain(&slot).into_iter()); } None => { stack.pop(); } } } - shared.lock().done = true; - shared.changed.notify_all(); + shared.open_lock().done = true; + shared.found_dir.notify_all(); }); } -impl Shared<'_, D, L> { - fn lock(&self) -> std::sync::MutexGuard<'_, Queue> { - self.queue - .lock() - .expect("exploring a directory does not panic") - } +fn lock(mutex: &Mutex) -> MutexGuard<'_, T> { + mutex.lock().expect("exploring a directory does not panic") +} - fn found(&self) -> usize { - self.lock().unclaimed.len() +impl Shared<'_, D, L> { + fn open_lock(&self) -> MutexGuard<'_, Open> { + lock(&self.open) } - /// Directory `id`'s items: explored here if no worker has claimed it, + /// Directory `slot`'s items: explored here if no worker has claimed it, /// else once the worker that has is through. - fn obtain(&self, id: usize) -> Vec> { - let mut queue = self.lock(); + fn obtain(&self, slot: &Slot) -> Vec> { + let unclaimed = lock(&slot.dir).take(); + if let Some(dir) = unclaimed { + return self.explore(dir, &slot.key); + } + let mut explored = lock(&slot.explored); loop { - if let Some(items) = queue.explored[id].take() { + if let Some(items) = explored.items.take() { return items; } - if let Some(dir) = queue.unclaimed[id].take() { - drop(queue); - return self.explore(dir); - } - queue = self - .changed - .wait(queue) + explored.awaited = true; + explored = slot + .ready + .wait(explored) .expect("exploring a directory does not panic"); } } fn work(&self) { - let mut queue = self.lock(); + while let Some(slot) = self.claim() { + let unclaimed = lock(&slot.dir).take(); + let Some(dir) = unclaimed else { + continue; + }; + let items = self.explore(dir, &slot.key); + let mut explored = lock(&slot.explored); + explored.items = Some(items); + if explored.awaited { + slot.ready.notify_one(); + } + } + } + + /// The next directory found, or `None` once the walk is over. + fn claim(&self) -> Option>> { + let mut open = self.open_lock(); loop { - if queue.done { + if open.done { self.busy.fetch_sub(1, Ordering::Relaxed); - return; + return None; } - let claimed = queue.open.pop().map(|id| (id, queue.unclaimed[id].take())); - match claimed { - Some((id, Some(dir))) => { - drop(queue); - let items = self.explore(dir); - queue = self.lock(); - queue.explored[id] = Some(items); - self.changed.notify_all(); - } - Some((_, None)) => {} - None => { - self.busy.fetch_sub(1, Ordering::Relaxed); - queue = self - .changed - .wait(queue) - .expect("exploring a directory does not panic"); - self.busy.fetch_add(1, Ordering::Relaxed); - } + if let Some(Pending(slot)) = open.slots.pop() { + return Some(slot); } + self.busy.fetch_sub(1, Ordering::Relaxed); + open = self + .found_dir + .wait(open) + .expect("exploring a directory does not panic"); + self.busy.fetch_add(1, Ordering::Relaxed); } } - /// Explore `dir`, registering the directories it holds for any thread - /// to claim. - fn explore(&self, dir: D) -> Vec> { + /// Explore `dir`, at `key`, registering the directories it holds for any + /// thread to claim. + fn explore(&self, dir: D, key: &[u32]) -> Vec> { let steps = (self.explore)(dir); - let mut queue = self.lock(); let mut dirs = Vec::new(); - let items = steps + let items: Vec> = steps .into_iter() .map(|step| match step { Step::Leaf(leaf) => Item::Leaf(leaf), Step::Dir(dir) => { - let id = queue.unclaimed.len(); - queue.unclaimed.push(Some(dir)); - queue.explored.push(None); - dirs.push(id); - Item::Dir(id) + let index = u32::try_from(dirs.len()).unwrap_or(u32::MAX); + let slot = Arc::new(Slot { + key: [key, &[index]].concat(), + dir: Mutex::new(Some(dir)), + explored: Mutex::new(Explored { + items: None, + awaited: false, + }), + ready: Condvar::new(), + }); + dirs.push(Arc::clone(&slot)); + Item::Dir(slot) } }) .collect(); - queue.open.extend(dirs.into_iter().rev()); - self.changed.notify_all(); + if dirs.is_empty() { + return items; + } + self.found.fetch_add(dirs.len(), Ordering::Relaxed); + let found = dirs.len(); + self.open_lock().slots.extend(dirs.into_iter().map(Pending)); + // The thread that found them takes one next, so only the rest need + // a waiting worker. + for _ in 1..found { + self.found_dir.notify_one(); + } items } } @@ -251,6 +314,36 @@ mod tests { threads.into_inner().unwrap().len() } + fn pending(key: &[u32]) -> Pending<(), ()> { + Pending(Arc::new(Slot { + key: key.to_vec(), + dir: Mutex::new(None), + explored: Mutex::new(Explored { + items: None, + awaited: false, + }), + ready: Condvar::new(), + })) + } + + #[test] + fn open_directories_are_equal_exactly_when_their_keys_are() { + assert!(pending(&[0, 1]) == pending(&[0, 1])); + assert!(pending(&[0, 1]) != pending(&[0, 2])); + } + + #[test] + fn the_open_directories_yield_the_one_the_walk_reaches_first() { + let mut open = BinaryHeap::new(); + for key in [&[1][..], &[0, 2], &[0], &[0, 1, 5]] { + open.push(pending(key)); + } + let order: Vec> = std::iter::from_fn(|| open.pop()) + .map(|Pending(slot)| slot.key.clone()) + .collect(); + assert_eq!(order, vec![vec![0], vec![0, 1, 5], vec![0, 2], vec![1]]); + } + #[test] fn a_walk_of_few_directories_stays_on_the_calling_thread() { assert_eq!(threads_exploring(3, 1), 1); diff --git a/packages/ts/e2e-attestations/bot-1480-matcher-speed-1015.json b/packages/ts/e2e-attestations/bot-1480-matcher-speed-1015.json new file mode 100644 index 000000000..1b0e51cbb --- /dev/null +++ b/packages/ts/e2e-attestations/bot-1480-matcher-speed-1015.json @@ -0,0 +1,7 @@ +{ + "command": "pnpm test:e2e", + "ran_at": 1792159274, + "exit_code": 0, + "commit": "9cdbfeabf2b256d17a51bdc00b161efe438f5dd5", + "branch": "bot/1480-matcher-speed-1015" +}