diff --git a/engine/crates/layerfs-api/tests/operator_history.rs b/engine/crates/layerfs-api/tests/operator_history.rs index 10b31ccba..7406cff5d 100644 --- a/engine/crates/layerfs-api/tests/operator_history.rs +++ b/engine/crates/layerfs-api/tests/operator_history.rs @@ -1,8 +1,9 @@ -//! `backend.store.project.get_history`: the read model's history half, read from the -//! Store with no daemon, so the dsh project page starts no Sandbox to show a -//! project. It lists the Branches, their Commits and the Layers, holds no -//! Workspaces, and `since` its version answers `unchanged` until history -//! moves. Disposable profile. +//! `backend.store.project.get_history` and the `snapshot` behind +//! `sandbox.list_workspaces`: the read model, read from the Store with no +//! daemon, so the dsh project page starts no Sandbox to show a project. It +//! lists the Branches, their Commits and the Layers, names the Branches the +//! Workspaces are mounted on, and `since` its version answers `unchanged` +//! until what it shows moves. Disposable profile. #[path = "support/installed_store.rs"] #[allow(dead_code)] mod installed; @@ -11,7 +12,8 @@ mod installed; mod support; use layerfs_bridge::control::{SdkCall, SdkValue}; -use layerfs_store_service::operator; +use layerfs_history::HistoryName; +use layerfs_store_service::operator::{self, WorkspaceView}; use std::fs; use support::{write, Fixture}; @@ -59,3 +61,57 @@ fn history_is_read_from_the_store_alone() { assert_eq!(moved.get("workspaces"), Some(&SdkValue::List(Vec::new()))); fixture.cleanup(); } + +#[test] +fn workspaces_name_branches_beyond_the_first_page() { + let fixture = Fixture::new("operator-history-branches", |source| { + fs::write(source.join("seed.txt"), b"seed\n").unwrap(); + 1 + }); + for n in 1..=70 { + fixture.fork(&format!("b{n:02}")); + } + let history = fixture.service.store.history(); + let installed = HistoryName::new("installed").unwrap(); + let stack = history + .layer_stack_named(&installed) + .unwrap() + .expect("installed stack") + .id; + let branch = history + .branch_named(stack, &HistoryName::new("b70").unwrap()) + .unwrap() + .expect("b70 exists"); + let binding = history + .branch_snapshot(branch.id) + .unwrap() + .expect("b70 snapshot"); + // Move the head after the Workspace's binding was taken, so the answer + // also proves it reads the Branch's current head and not the page's. + fixture.apply("b70", vec![write("/after-mount.txt", "moved\n")]); + let view = WorkspaceView { + workspace: "workspace-4".into(), + namespace: 4, + binding, + activity: "idle", + mounted: true, + directory: Some("/workspaces/4".into()), + dirty: None, + pending_merge: false, + revision: None, + }; + let args = SdkValue::Map(vec![("project".to_owned(), SdkValue::text("installed"))]); + let answer = operator::snapshot(&fixture.service.store, &args, &[view]).unwrap(); + assert_eq!(answer.get("moreBranches"), Some(&SdkValue::Bool(true))); + let workspaces = match answer.get("workspaces") { + Some(SdkValue::List(items)) => items, + other => panic!("workspaces: {other:?}"), + }; + assert_eq!(workspaces.len(), 1); + assert_eq!(workspaces[0].get_text("branch"), Some("b70")); + assert_eq!( + workspaces[0].get("headMovedSinceMount"), + Some(&SdkValue::Bool(true)) + ); + fixture.cleanup(); +} diff --git a/engine/crates/layerfs-store-service/src/operator/inspect.rs b/engine/crates/layerfs-store-service/src/operator/inspect.rs index d04019fcb..9dc541f56 100644 --- a/engine/crates/layerfs-store-service/src/operator/inspect.rs +++ b/engine/crates/layerfs-store-service/src/operator/inspect.rs @@ -129,7 +129,7 @@ pub fn snapshot(store: &Arc, args: &SdkValue, views: &[WorkspaceView]) -> .into_iter() .filter(|branch| branch.name.as_str() != MAIN) .collect::>(); - let names = branches + let mut names = branches .iter() .map(|branch| { ( @@ -138,6 +138,22 @@ pub fn snapshot(store: &Arc, args: &SdkValue, views: &[WorkspaceView]) -> ) }) .collect::>(); + // The page is bounded, but a Workspace may be mounted on a Branch outside + // it. Resolve just those mounted identities, so the listing does not + // depend on how many Branches the project has. The page still reports + // `moreBranches`; the Workspace answer needs the name and current head. + for view in views { + if view.binding.branch.stack != stack.id { + continue; + } + let id = view.binding.branch.id; + if names.contains_key(&id) { + continue; + } + if let Some(branch) = store.history().branch(id).map_err(args::history)? { + names.insert(id, (branch.name.as_str().to_owned(), branch.head_commit)); + } + } let mut spent = 0usize; let mut take = |value: SdkValue| { spent += cost(&value);