diff --git a/atomic-cli/src/commands/view/list.rs b/atomic-cli/src/commands/view/list.rs index e4aa32ca..e395da4c 100644 --- a/atomic-cli/src/commands/view/list.rs +++ b/atomic-cli/src/commands/view/list.rs @@ -1,7 +1,19 @@ //! The `view list` command for listing all views. //! -//! This module implements the `atomic view list` command, which shows all -//! views in the repository. The current view is marked with an asterisk (*). +//! This module implements the `atomic view list` command. Views are rendered +//! as an indented hierarchy following parent chains: a draft split from +//! another view is listed beneath its parent, so a chain like +//! `dev → baby-bird-123 → jumbo-tron-444` reads as a tree. Views with no +//! changes of their own are hidden by default to keep the listing focused +//! on active work: +//! +//! ```text +//! $ atomic view list +//! dev [shared] (3 changes) state: 2AAAAAAAA... +//! - baby-bird-123 [draft] (2 changes, 3 inherited) state: XYZABCDEF... parent: dev +//! +//! 1 view not shown because contained no changes. -a to view them. +//! ``` //! //! # Usage //! @@ -9,31 +21,27 @@ //! atomic view list [OPTIONS] //! //! Options: -//! -s, --short Show only view names (no metadata) -//! -h, --help Print help information +//! -s, --short Show only view names (no metadata) +//! -a, --all Show all views, including those with no changes +//! -h, --help Print help information //! ``` //! //! # Examples //! -//! List all views (default — shows metadata): -//! ```text -//! $ atomic view list -//! dev [shared] (3 changes) state: 2AAAAAAAA... -//! * feature-auth [draft] (2 changes, 3 inherited) state: XYZABCDEF... parent: dev -//! ``` -//! -//! Short listing (names only): +//! Short listing (names only, hierarchy preserved): //! ```text //! $ atomic view list --short -//! dev -//! * feature-auth +//! dev +//! - baby-bird-123 //! ``` +use std::collections::{HashMap, HashSet}; use std::time::Duration; use clap::Parser; use atomic_remote::{HttpRemote, HttpRemoteConfig, RemoteViewInfo}; +use atomic_repository::repository::ViewInfo; use atomic_repository::Repository; use crate::commands::auth::attach_identity; @@ -51,9 +59,11 @@ use std::path::PathBuf; /// List all views. /// -/// Shows all views in the repository with the current view marked -/// with an asterisk (*). By default, displays metadata (scope, change -/// count, state hash, parent). Use `--short` for names only. +/// Shows views as an indented hierarchy following parent chains, with the +/// current view marked with an asterisk (*). Views with no changes of their +/// own are hidden by default (with a trailing summary count); pass `-a` to +/// list every view. By default, displays metadata (scope, change count, +/// state hash, parent). Use `--short` for names only. #[derive(Parser, Debug, Default)] #[command(name = "list")] pub struct List { @@ -61,6 +71,14 @@ pub struct List { #[arg(long, short = 's')] pub short: bool, + /// Show all views, including those with no changes of their own. + /// + /// By default, views without own changes are hidden from the listing + /// (counted in a trailing summary line) unless they anchor a hierarchy + /// that does have changes or are the current view. + #[arg(short = 'a', long)] + pub all: bool, + /// Show additional details (state hash, change count). /// /// This is now the default behavior. Kept for backward compatibility. @@ -94,6 +112,7 @@ impl List { pub fn new() -> Self { Self { short: false, + all: false, verbose: false, remote: None, identity: None, @@ -108,6 +127,254 @@ impl List { } } +// View entry + hierarchy rendering +// +// The tree building, filtering, and line rendering below are pure functions +// over plain data so the layout can be unit-tested without a repository. + +/// A view prepared for rendering, carrying the metadata the tree needs. +#[derive(Debug, Clone)] +struct ViewEntry { + name: String, + parent: Option, + /// Changes recorded into this view that are not visible through the + /// parent chain. Zero means the view has nothing of its own to show. + own_change_count: u64, + change_count: u64, + inherited_change_count: u64, + kind: &'static str, + state_short: String, + is_current: bool, + /// False when `get_view_info` failed; the line renders name-only. + has_info: bool, +} + +impl ViewEntry { + fn from_info(info: ViewInfo, current: &str) -> Self { + let kind = match info.kind_label() { + "draft" => "draft", + _ => "shared", + }; + let is_current = info.name == current; + let state_short = info.state_short(); + ViewEntry { + name: info.name, + parent: info.parent_name, + own_change_count: info.own_change_count, + change_count: info.change_count, + inherited_change_count: info.inherited_change_count, + kind, + state_short, + is_current, + has_info: true, + } + } + + /// A name-only entry used when view metadata is unavailable. Treated as + /// having changes so it is never hidden from the listing. + fn bare(name: &str, current: &str) -> Self { + ViewEntry { + name: name.to_string(), + parent: None, + own_change_count: 1, + change_count: 0, + inherited_change_count: 0, + kind: "shared", + state_short: "-".to_string(), + is_current: name == current, + has_info: false, + } + } +} + +/// Decide which views are shown. +/// +/// With `show_all`, every view is visible. Otherwise a view is visible when +/// it has changes of its own, or it is the current view, or it is an +/// ancestor of such a view (so the hierarchy stays connected). Returns the +/// visible name set and the hidden count. +fn compute_visibility(entries: &[ViewEntry], show_all: bool) -> (HashSet, usize) { + if show_all { + let all = entries.iter().map(|e| e.name.clone()).collect(); + return (all, 0); + } + + let by_name: HashMap<&str, &ViewEntry> = entries.iter().map(|e| (e.name.as_str(), e)).collect(); + + let mut visible: HashSet = HashSet::new(); + let mut stack: Vec<&str> = entries + .iter() + .filter(|e| e.own_change_count > 0 || e.is_current) + .map(|e| e.name.as_str()) + .collect(); + + while let Some(name) = stack.pop() { + // Revisiting a name means a parent cycle — stop walking that chain. + if !visible.insert(name.to_string()) { + continue; + } + if let Some(entry) = by_name.get(name) { + if let Some(parent) = &entry.parent { + if by_name.contains_key(parent.as_str()) { + stack.push(parent.as_str()); + } + } + } + } + + let hidden = entries.len() - visible.len(); + (visible, hidden) +} + +/// Order the visible views depth-first along the parent chains. +/// +/// Returns `(depth, entry)` pairs in render order: roots (views with no +/// parent, or with a parent missing from the listing) at depth 0, each +/// child level one step deeper. Roots and siblings sort alphabetically. +/// Cycles in the parent data are cut by a visited set. +fn tree_order<'a>( + entries: &'a [ViewEntry], + visible: &HashSet, +) -> Vec<(usize, &'a ViewEntry)> { + let by_name: HashMap<&str, &ViewEntry> = entries.iter().map(|e| (e.name.as_str(), e)).collect(); + + let mut children: HashMap<&str, Vec<&ViewEntry>> = HashMap::new(); + let mut roots: Vec<&ViewEntry> = Vec::new(); + for entry in entries { + match entry.parent.as_deref().and_then(|p| by_name.get(p)) { + Some(parent) => children + .entry(parent.name.as_str()) + .or_default() + .push(entry), + None => roots.push(entry), + } + } + for kids in children.values_mut() { + kids.sort_by(|a, b| a.name.cmp(&b.name)); + } + roots.sort_by(|a, b| a.name.cmp(&b.name)); + + fn visit<'a>( + entry: &'a ViewEntry, + depth: usize, + children: &HashMap<&str, Vec<&'a ViewEntry>>, + visited: &mut HashSet<&'a str>, + visible: &HashSet, + ordered: &mut Vec<(usize, &'a ViewEntry)>, + ) { + if !visited.insert(entry.name.as_str()) { + return; // cut parent cycles + } + if visible.contains(&entry.name) { + ordered.push((depth, entry)); + } + if let Some(kids) = children.get(entry.name.as_str()) { + for kid in kids { + visit(kid, depth + 1, children, visited, visible, ordered); + } + } + } + + let mut ordered = Vec::new(); + let mut visited: HashSet<&str> = HashSet::new(); + for root in &roots { + visit(root, 0, &children, &mut visited, visible, &mut ordered); + } + + // Defensive: views caught in a parent cycle are never roots, so the + // walk above never reaches them. Render any remainder in name order + // from depth 0 so nothing silently vanishes from the listing. + let mut leftovers: Vec<&ViewEntry> = entries + .iter() + .filter(|e| !visited.contains(e.name.as_str())) + .collect(); + leftovers.sort_by(|a, b| a.name.cmp(&b.name)); + for entry in &leftovers { + visit(entry, 0, &children, &mut visited, visible, &mut ordered); + } + + ordered +} + +/// The tree prefix for a line at the given depth. +/// +/// Roots start at column 0 (`dev`, or `* dev` when current); children are +/// indented two spaces per level with a `- ` bullet (` - baby-bird-123`). +fn tree_prefix(entry: &ViewEntry, depth: usize) -> String { + match depth { + 0 if entry.is_current => "* ".to_string(), + 0 => String::new(), + _ => format!( + "{}- {}", + " ".repeat(depth), + if entry.is_current { "* " } else { "" } + ), + } +} + +/// Render one view line for the default (metadata) mode. +fn render_line(entry: &ViewEntry, depth: usize, width: usize) -> String { + let prefix = tree_prefix(entry, depth); + let name = style_view(&entry.name); + + if !entry.has_info { + return format!("{}{}", prefix, name); + } + + let kind_tag = if entry.kind == "draft" { + "[draft]" + } else { + "[shared]" + }; + // Show own changes for views with a parent; show total for root views. + let change_display = if entry.parent.is_some() { + if entry.own_change_count == 1 { + format!( + "({} change, {} inherited)", + entry.own_change_count, entry.inherited_change_count + ) + } else { + format!( + "({} changes, {} inherited)", + entry.own_change_count, entry.inherited_change_count + ) + } + } else if entry.change_count == 1 { + "(1 change)".to_string() + } else { + format!("({} changes)", entry.change_count) + }; + let parent_info = match &entry.parent { + Some(p) => format!(" parent: {}", style_view(p)), + None => String::new(), + }; + + format!( + "{}{: String { + format!("{}{}", tree_prefix(entry, depth), style_view(&entry.name)) +} + +/// The trailing hint describing hidden views. +fn summary_line(hidden: usize) -> String { + let noun = if hidden == 1 { "view" } else { "views" }; + format!( + "{} {} not shown because contained no changes. -a to view them.", + hidden, noun + ) +} + impl List { /// List views on a remote repository. /// @@ -238,7 +505,7 @@ impl Command for List { // Get list of views let views = repo.list_views().map_err(CliError::Repository)?; - let current = repo.current_view(); + let current = repo.current_view().to_string(); if views.is_empty() { println!( @@ -248,62 +515,38 @@ impl Command for List { return Ok(()); } - // Sort views alphabetically, but keep current view considerations - let mut sorted_views = views; - sorted_views.sort(); - - // Calculate padding for alignment - let max_name_len = sorted_views.iter().map(|s| s.len()).max().unwrap_or(0); - - for view in sorted_views { - let is_current = view == current; - let marker = if is_current { "*" } else { " " }; - - if self.short { - println!("{} {}", marker, style_view(&view)); + // Collect metadata for every view; fall back to name-only entries + // when info is unavailable so the names still render. + let entries: Vec = views + .iter() + .map(|name| { + repo.get_view_info(name) + .map(|info| ViewEntry::from_info(info, ¤t)) + .unwrap_or_else(|_| ViewEntry::bare(name, ¤t)) + }) + .collect(); + + // Filter empty views (unless --all), then render the hierarchy. + let (visible, hidden) = compute_visibility(&entries, self.all); + let ordered = tree_order(&entries, &visible); + + let max_name_len = ordered + .iter() + .map(|(_, entry)| entry.name.len()) + .max() + .unwrap_or(0); + + for (depth, entry) in &ordered { + let line = if self.short { + render_short_line(entry, *depth) } else { - match repo.get_view_info(&view) { - Ok(info) => { - let kind_tag = match info.kind_label() { - "draft" => "[draft]", - _ => "[shared]", - }; - let parent_info = match &info.parent_name { - Some(p) => format!(" parent: {}", style_view(p)), - None => String::new(), - }; - // Show own changes for views with a parent; - // show total for root views. - let change_display = if info.parent_name.is_some() { - let own = info.own_change_count; - let inherited = info.inherited_change_count; - if own == 1 { - format!("({} change, {} inherited)", own, inherited) - } else { - format!("({} changes, {} inherited)", own, inherited) - } - } else if info.change_count == 1 { - format!("({} change)", info.change_count) - } else { - format!("({} changes)", info.change_count) - }; - println!( - "{} {: { - // Fall back to simple output if we can't get info - println!("{} {}", marker, style_view(&view)); - } - } - } + render_line(entry, *depth, max_name_len) + }; + println!("{}", line); + } + + if hidden > 0 { + println!("{}", hint(&summary_line(hidden))); } Ok(()) @@ -347,6 +590,7 @@ mod tests { fn test_default() { let cmd = List::default(); assert!(!cmd.short); + assert!(!cmd.all); assert!(!cmd.verbose); } @@ -354,6 +598,7 @@ mod tests { fn test_new() { let cmd = List::new(); assert!(!cmd.short); + assert!(!cmd.all); } #[test] @@ -362,6 +607,186 @@ mod tests { assert!(cmd.verbose); } + // ------------------------------------------------------------------------- + // Hierarchy rendering helpers (pure functions) + // ------------------------------------------------------------------------- + + /// Build a `ViewEntry` for tests. Roots count their own changes as + /// `change_count` (matching `get_view_info` for parentless views). + fn entry(name: &str, parent: Option<&str>, own: u64, current: &str) -> ViewEntry { + ViewEntry { + name: name.to_string(), + parent: parent.map(|p| p.to_string()), + own_change_count: own, + change_count: own, + inherited_change_count: if parent.is_some() { 3 } else { 0 }, + kind: if parent.is_some() { "draft" } else { "shared" }, + state_short: "AAAAAAAAAAAA".to_string(), + is_current: name == current, + has_info: true, + } + } + + #[test] + fn test_hierarchy_layout_three_deep() { + // dev → baby-bird-123 → jumbo-tron-444 (empty leaf) + let entries = vec![ + entry("dev", None, 3, "dev"), + entry("baby-bird-123", Some("dev"), 2, "dev"), + entry("jumbo-tron-444", Some("baby-bird-123"), 0, "dev"), + ]; + + let (visible, hidden) = compute_visibility(&entries, false); + assert_eq!(hidden, 1); + assert!(visible.contains("dev")); + assert!(visible.contains("baby-bird-123")); + assert!(!visible.contains("jumbo-tron-444")); + + let ordered = tree_order(&entries, &visible); + let lines: Vec = ordered + .iter() + .map(|(depth, e)| render_short_line(e, *depth)) + .collect(); + assert_eq!(lines, vec!["* dev", " - baby-bird-123"]); + } + + #[test] + fn test_all_flag_lists_every_view() { + let entries = vec![ + entry("dev", None, 3, "dev"), + entry("baby-bird-123", Some("dev"), 2, "dev"), + entry("jumbo-tron-444", Some("baby-bird-123"), 0, "dev"), + ]; + + let (visible, hidden) = compute_visibility(&entries, true); + assert_eq!(hidden, 0); + assert_eq!(visible.len(), entries.len()); + assert!(visible.contains("jumbo-tron-444")); + + let ordered = tree_order(&entries, &visible); + let lines: Vec = ordered + .iter() + .map(|(depth, e)| render_short_line(e, *depth)) + .collect(); + assert_eq!( + lines, + vec!["* dev", " - baby-bird-123", " - jumbo-tron-444"] + ); + } + + #[test] + fn test_summary_line_counts() { + assert_eq!( + summary_line(1), + "1 view not shown because contained no changes. -a to view them." + ); + assert_eq!( + summary_line(2), + "2 views not shown because contained no changes. -a to view them." + ); + } + + #[test] + fn test_current_empty_view_stays_visible() { + // The current view is empty; it must still be listed, and its + // parent chain anchors the hierarchy. + let entries = vec![ + entry("dev", None, 3, "wren"), + entry("wren", Some("dev"), 0, "wren"), + ]; + + let (visible, hidden) = compute_visibility(&entries, false); + assert_eq!(hidden, 0); + assert!(visible.contains("wren")); + assert!(visible.contains("dev")); + + let ordered = tree_order(&entries, &visible); + let lines: Vec = ordered + .iter() + .map(|(depth, e)| render_short_line(e, *depth)) + .collect(); + assert_eq!(lines, vec!["dev", " - * wren"]); + } + + #[test] + fn test_empty_sibling_draft_hidden() { + let entries = vec![ + entry("dev", None, 3, "dev"), + entry("hawk", Some("dev"), 1, "dev"), + entry("wren", Some("dev"), 0, "dev"), // empty, not current → hidden + ]; + + let (visible, hidden) = compute_visibility(&entries, false); + assert_eq!(hidden, 1); + assert!(!visible.contains("wren")); + + let ordered = tree_order(&entries, &visible); + let names: Vec<&str> = ordered.iter().map(|(_, e)| e.name.as_str()).collect(); + assert_eq!(names, vec!["dev", "hawk"]); + } + + #[test] + fn test_default_mode_line_keeps_metadata() { + let e = entry("baby-bird-123", Some("dev"), 2, "dev"); + let line = render_line(&e, 1, 13); + assert!(line.starts_with(" - baby-bird-123")); + assert!(line.contains("[draft]")); + assert!(line.contains("(2 changes, 3 inherited)")); + assert!(line.contains("state: AAAAAAAAAAAA")); + assert!(line.contains("parent: dev")); + } + + #[test] + fn test_root_line_format() { + let dev = entry("dev", None, 3, "dev"); + let line = render_line(&dev, 0, 3); + assert!(line.starts_with("* dev")); + + let main = entry("main", None, 2, "dev"); + let line = render_line(&main, 0, 4); + assert!(line.starts_with("main")); + } + + #[test] + fn test_missing_parent_renders_as_root() { + let entries = vec![entry("orphan", Some("ghost"), 2, "orphan")]; + + let (visible, _) = compute_visibility(&entries, false); + assert!(visible.contains("orphan")); + + let ordered = tree_order(&entries, &visible); + assert_eq!(ordered.len(), 1); + assert_eq!(ordered[0].0, 0); // rendered at depth 0 + } + + #[test] + fn test_parent_cycle_terminates() { + let a = entry("a", Some("b"), 1, "a"); + let b = entry("b", Some("a"), 1, "a"); + let entries = vec![a, b]; + + let (visible, _) = compute_visibility(&entries, false); + assert_eq!(visible.len(), 2); + + // Must terminate (visited set cuts the a→b→a cycle). + let ordered = tree_order(&entries, &visible); + assert_eq!(ordered.len(), 2); + } + + #[test] + fn test_children_sort_alphabetically() { + let entries = vec![ + entry("dev", None, 3, "dev"), + entry("zeta", Some("dev"), 1, "dev"), + entry("alpha", Some("dev"), 1, "dev"), + ]; + + let (visible, _) = compute_visibility(&entries, false); + let ordered = tree_order(&entries, &visible); + let names: Vec<&str> = ordered.iter().map(|(_, e)| e.name.as_str()).collect(); + assert_eq!(names, vec!["dev", "alpha", "zeta"]); + } + // ------------------------------------------------------------------------- // Integration Tests (require temp repository) // ------------------------------------------------------------------------- diff --git a/atomic-repository/src/content_search.rs b/atomic-repository/src/content_search.rs index ab523a03..69fe211a 100644 --- a/atomic-repository/src/content_search.rs +++ b/atomic-repository/src/content_search.rs @@ -22,28 +22,34 @@ use syntext::{Config, IndexError, SearchOptions}; /// build artifacts, dependencies, and Atomic internals. We reuse syntext's /// walker (for its symlink resolution and size handling), then drop the paths /// Atomic excludes before handing the corpus to `build_from_file_records`. +/// +/// Transient lock conflicts (e.g. a parallel test or a just-dropped `Index` +/// handle still releasing its flock) are retried with backoff; only a +/// persistent conflict surfaces as an error. pub fn build_content_index(repo_root: &Path) -> Result<(), ContentSearchError> { - let config = content_config(repo_root); - - let (files, _skips) = syntext::index::walk::enumerate_files(&config)?; - let ignore_rules = crate::ignore::IgnoreRules::load_for_enrichment(repo_root); - let records: Vec = files - .into_iter() - .filter(|(_absolute, relative, _size)| { - !crate::ignore::is_enrichment_internal(relative) - && !ignore_rules.is_ignored(relative, false) - }) - .map( - |(absolute_path, relative_path, size_bytes)| ExternalFileRecord { - absolute_path, - relative_path, - size_bytes, - }, - ) - .collect(); + with_lock_retry(|| { + let config = content_config(repo_root); + + let (files, _skips) = syntext::index::walk::enumerate_files(&config)?; + let ignore_rules = crate::ignore::IgnoreRules::load_for_enrichment(repo_root); + let records: Vec = files + .into_iter() + .filter(|(_absolute, relative, _size)| { + !crate::ignore::is_enrichment_internal(relative) + && !ignore_rules.is_ignored(relative, false) + }) + .map( + |(absolute_path, relative_path, size_bytes)| ExternalFileRecord { + absolute_path, + relative_path, + size_bytes, + }, + ) + .collect(); - let _index = Index::build_from_file_records(config, records)?; - Ok(()) + let _index = Index::build_from_file_records(config, records)?; + Ok(()) + }) } /// Incrementally update the content index after file changes. @@ -101,41 +107,51 @@ where return Ok(()); } - let ignore_rules = crate::ignore::IgnoreRules::load_for_enrichment(repo_root); - let config = content_config(repo_root); - let index = Index::open(config)?; - - let mut any = false; - for path in paths { - let rel = path.as_ref(); - if crate::ignore::is_enrichment_internal(rel) || ignore_rules.is_ignored(rel, false) { - continue; + // The open + compact below race syntext's own lock-downgrade windows and + // any concurrent reader/writer on the same index dir, so retry the whole + // update on a lock conflict rather than treating it as a hard failure. + // Collect the paths once: the retry closure may run multiple times. + let paths: Vec = paths + .into_iter() + .map(|p| p.as_ref().to_path_buf()) + .collect(); + with_lock_retry(|| { + let ignore_rules = crate::ignore::IgnoreRules::load_for_enrichment(repo_root); + let config = content_config(repo_root); + let index = Index::open(config)?; + + let mut any = false; + for path in &paths { + let rel = path.as_path(); + if crate::ignore::is_enrichment_internal(rel) || ignore_rules.is_ignored(rel, false) { + continue; + } + // syntext strips `repo_root` to derive the relative path, so hand it an + // absolute path. The file need not exist — a missing file is treated as + // a deletion and removed from the index. + let absolute = repo_root.join(rel); + index.notify_change(&absolute)?; + any = true; } - // syntext strips `repo_root` to derive the relative path, so hand it an - // absolute path. The file need not exist — a missing file is treated as - // a deletion and removed from the index. - let absolute = repo_root.join(rel); - index.notify_change(&absolute)?; - any = true; - } - if !any { - return Ok(()); - } + if !any { + return Ok(()); + } - // Commit the pending overlay and fold it into on-disk base segments so the - // change survives to the next `Index::open`. When the changed set is large - // relative to the index (syntext caps the overlay at 50% of base docs), - // `compact` reports `OverlayFull`; the sanctioned recovery is a full - // (filtered) rebuild. - match index.compact() { - Ok(()) => Ok(()), - Err(IndexError::OverlayFull { .. }) => { - drop(index); - build_content_index(repo_root) + // Commit the pending overlay and fold it into on-disk base segments so the + // change survives to the next `Index::open`. When the changed set is large + // relative to the index (syntext caps the overlay at 50% of base docs), + // `compact` reports `OverlayFull`; the sanctioned recovery is a full + // (filtered) rebuild. + match index.compact() { + Ok(()) => Ok(()), + Err(IndexError::OverlayFull { .. }) => { + drop(index); + build_content_index(repo_root) + } + Err(e) => Err(e.into()), } - Err(e) => Err(e.into()), - } + }) } /// Search the content index. @@ -426,6 +442,40 @@ pub struct ContentIndexStats { pub pending_edits: usize, } +// --------------------------------------------------------------------------- +// Lock-conflict retry +// --------------------------------------------------------------------------- + +/// How many times to retry an operation that failed with a lock conflict, +/// and the base delay between attempts (doubled each time). +const LOCK_RETRY_ATTEMPTS: u32 = 8; +const LOCK_RETRY_BASE_DELAY_MS: u64 = 25; + +/// Run `op`, retrying with exponential backoff when it fails with a lock +/// conflict. +/// +/// syntext documents lock downgrades in `build`/`compact` as brief windows +/// where a concurrent `open` can surface `LockConflict`, with the caller +/// expected to retry. Locks also live as long as the `Index` handle, so an +/// immediately preceding call in this process (e.g. `build` right before an +/// `update`) may still be holding one while its handle drops or a parallel +/// test's handles overlap. A transient conflict must not fail the operation. +fn with_lock_retry( + op: impl Fn() -> Result, +) -> Result { + let mut delay_ms = LOCK_RETRY_BASE_DELAY_MS; + for attempt in 0..=LOCK_RETRY_ATTEMPTS { + match op() { + Err(ContentSearchError::LockConflict) if attempt < LOCK_RETRY_ATTEMPTS => { + std::thread::sleep(std::time::Duration::from_millis(delay_ms)); + delay_ms *= 2; + } + other => return other, + } + } + unreachable!("loop returns on the final attempt") +} + // --------------------------------------------------------------------------- // Error type // ---------------------------------------------------------------------------