use std::path::{Path, PathBuf};
use serde::Serialize;
use crate::error::{Result, ShoreError};
use crate::git::{git_common_dir, git_worktree_list};
use crate::session::event::ShoreEvent;
use crate::session::store::backend::StoreBackend;
use crate::session::store::event_store::EventStore;
use crate::session::store::store_config::{StoreMode, resolve_family_binding, resolve_store_mode};
use crate::session::store::store_init::{
ShoreStorePaths, prepare_store_writer_at, worktree_local_store_is_populated,
};
use crate::session::store::user_level::{read_family_manifest, user_level_store_dir};
use crate::storage::LocalStorage;
const STORE_REF_LOCAL: &str = "local";
#[derive(Clone, Debug)]
pub(crate) enum ResolvedTier {
Ephemeral,
CloneLocal,
UserLevel {
family_ref: String,
clone_ref: String,
},
}
#[derive(Clone, Debug)]
pub(crate) struct StoreResolution {
store_dir: PathBuf,
backend: StoreBackend,
resolved_tier: ResolvedTier,
}
impl StoreResolution {
pub(crate) fn store_dir(&self) -> &Path {
&self.store_dir
}
pub(crate) fn backend(&self) -> &StoreBackend {
&self.backend
}
pub(crate) fn command_view(&self) -> StoreResolutionView {
match &self.resolved_tier {
ResolvedTier::CloneLocal => StoreResolutionView {
mode: "local",
store_ref: STORE_REF_LOCAL.to_owned(),
clone_ref: None,
repository_family_ref: None,
},
ResolvedTier::Ephemeral => StoreResolutionView {
mode: "ephemeral",
store_ref: STORE_REF_LOCAL.to_owned(),
clone_ref: None,
repository_family_ref: None,
},
ResolvedTier::UserLevel {
family_ref,
clone_ref,
} => StoreResolutionView {
mode: "user-level",
store_ref: family_ref.clone(),
clone_ref: Some(clone_ref.clone()),
repository_family_ref: Some(family_ref.clone()),
},
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub(crate) struct StoreResolutionView {
pub mode: &'static str,
pub store_ref: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub clone_ref: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub repository_family_ref: Option<String>,
}
#[derive(Clone, Debug)]
pub(crate) struct ReadStore {
pub resolution: StoreResolution,
}
impl ReadStore {
pub(crate) fn store_dir(&self) -> &Path {
self.resolution.store_dir()
}
pub(crate) fn backend(&self) -> &StoreBackend {
self.resolution.backend()
}
#[cfg(test)]
pub(crate) fn for_test(store_dir: PathBuf, backend: StoreBackend) -> Self {
ReadStore {
resolution: StoreResolution {
store_dir,
backend,
resolved_tier: ResolvedTier::CloneLocal,
},
}
}
}
pub(crate) fn resolve_read_store(repo: impl AsRef<Path>) -> Result<ReadStore> {
Ok(ReadStore {
resolution: resolve_store(repo)?,
})
}
pub fn event_log_head_marker(repo: impl AsRef<Path>) -> Result<u64> {
resolve_read_store(repo)?.backend().journal().head_marker()
}
pub fn family_link_advisory(repo: impl AsRef<Path>) -> Result<Option<String>> {
let repo = repo.as_ref();
if !matches!(resolve_store(repo)?.resolved_tier, ResolvedTier::CloneLocal) {
return Ok(None);
}
let Ok(worktrees) = git_worktree_list(repo) else {
return Ok(None);
};
for worktree in worktrees {
if let Ok(Some(binding)) = resolve_family_binding(&worktree.path) {
return Ok(Some(family_split_advisory_message(&binding.family_ref)));
}
}
Ok(None)
}
fn family_split_advisory_message(slug: &str) -> String {
format!(
"a family store `{slug}` is linked for another worktree of this clone, but this \
worktree is unlinked and writing to the clone-local store (.git/shore). Run \
`shore store link {slug}` to join it, or re-link the other worktree once to bind \
every worktree of this clone."
)
}
#[derive(Clone, Debug)]
pub(crate) struct WriteValidationStore {
read_store: ReadStore,
}
impl WriteValidationStore {
pub(crate) fn backend(&self) -> &StoreBackend {
self.read_store.backend()
}
pub(crate) fn validation_events(&self) -> Result<Vec<ShoreEvent>> {
EventStore::from_backend(self.backend()).list_events()
}
}
pub(crate) fn resolve_write_validation_store(
repo: impl AsRef<Path>,
) -> Result<WriteValidationStore> {
Ok(WriteValidationStore {
read_store: resolve_read_store(repo)?,
})
}
#[derive(Clone, Debug)]
pub(crate) struct WriteStore {
store_dir: PathBuf,
worktree_root: PathBuf,
backend: StoreBackend,
}
impl WriteStore {
pub(crate) fn store_dir(&self) -> &Path {
&self.store_dir
}
pub(crate) fn worktree_root(&self) -> &Path {
&self.worktree_root
}
pub(crate) fn backend(&self) -> &StoreBackend {
&self.backend
}
}
pub(crate) fn resolve_write_store(repo: impl AsRef<Path>) -> Result<WriteStore> {
let paths = ShoreStorePaths::resolve(repo.as_ref())?;
let resolution = resolve_store(repo.as_ref())?;
Ok(WriteStore {
store_dir: resolution.store_dir().to_path_buf(),
worktree_root: paths.worktree_root().to_path_buf(),
backend: resolution.backend().clone(),
})
}
pub(crate) fn prepare_write_landing(
write_store: &WriteStore,
storage: &LocalStorage,
) -> Result<()> {
prepare_store_writer_at(
storage,
write_store.store_dir(),
write_store.worktree_root(),
)
}
pub(crate) fn resolve_store(repo: impl AsRef<Path>) -> Result<StoreResolution> {
let paths = ShoreStorePaths::resolve(repo.as_ref())?;
let binding = resolve_family_binding(paths.worktree_root())?;
if resolve_store_mode(paths.worktree_root())? == StoreMode::Ephemeral {
return store_resolution_for(paths.store_dir().to_path_buf(), ResolvedTier::Ephemeral);
}
if worktree_local_store_is_populated(paths.store_dir()) {
return Err(ShoreError::Message(
"a worktree-local .shore/data/ review store from before the shared-store default \
was detected. Reads and writes now use the shared store under .git/shore, so this \
worktree-local store is no longer read automatically. Complete the switch in one \
command with `shore store migrate --retire-source`, which copies its events and \
artifacts into the shared store, independently verifies the fold, and then deletes \
.shore/data/. Or take it in two steps: (1) run `shore store migrate` to copy \
non-destructively, leaving .shore/data/ in place so you can verify the result \
first; then (2) delete the .shore/data/ directory. This message keeps appearing \
until .shore/data/ is removed, by design, so the original store is never discarded \
before the migration is confirmed. (If this worktree is meant to stay isolated and \
discardable instead, run `shore store mode ephemeral` and its .shore/data/ store is \
used as-is.)"
.to_owned(),
));
}
if let Some(binding) = binding {
let family_dir = user_level_store_dir(&binding.family_ref)?;
if read_family_manifest(&family_dir)?.is_none() {
return Err(ShoreError::Message(format!(
"this clone is linked to the user-level family store `{}`, but that store no longer \
exists at {} (it was forgotten, or the directory was removed). Re-create and \
re-link it with `shore store link {}`, or detach this clone with \
`shore store unlink`.",
binding.family_ref,
family_dir.display(),
binding.family_ref,
)));
}
return store_resolution_for(
family_dir,
ResolvedTier::UserLevel {
family_ref: binding.family_ref,
clone_ref: binding.clone_ref,
},
);
}
store_resolution_for(
clone_local_store_dir(paths.worktree_root())?,
ResolvedTier::CloneLocal,
)
}
fn store_resolution_for(store_dir: PathBuf, tier: ResolvedTier) -> Result<StoreResolution> {
let backend = select_backend(store_dir.clone())?;
Ok(StoreResolution {
store_dir,
backend,
resolved_tier: tier,
})
}
const STORE_BACKEND_ENV: &str = "SHORE_BACKEND";
fn select_backend(store_dir: PathBuf) -> Result<StoreBackend> {
classify_backend(std::env::var(STORE_BACKEND_ENV), store_dir)
}
fn classify_backend(
value: std::result::Result<String, std::env::VarError>,
store_dir: PathBuf,
) -> Result<StoreBackend> {
match value.as_deref() {
Ok("local") | Err(std::env::VarError::NotPresent) => Ok(StoreBackend::Local(store_dir)),
Ok("memory") => Err(ShoreError::Message(
"the in-memory store backend is not selectable via SHORE_BACKEND; it is reachable only \
through in-process injection (a spawned `shore` child would otherwise inherit an empty, \
lost-on-exit store). Unset SHORE_BACKEND or set it to `local`."
.to_owned(),
)),
Ok(other) => Err(ShoreError::Message(format!(
"unknown SHORE_BACKEND value `{other}`; the only supported value is `local`, which is \
also the default when SHORE_BACKEND is unset"
))),
Err(std::env::VarError::NotUnicode(_)) => Err(ShoreError::Message(
"SHORE_BACKEND is set to a non-UTF-8 value; the only supported value is `local`, which \
is also the default when SHORE_BACKEND is unset"
.to_owned(),
)),
}
}
pub(crate) fn clone_local_store_dir(worktree_root: &Path) -> Result<PathBuf> {
Ok(git_common_dir(worktree_root)?.join("shore"))
}
#[cfg(test)]
mod tests {
use std::ffi::{OsStr, OsString};
use std::fs;
use std::path::{Path, PathBuf};
use tempfile::TempDir;
use super::*;
use crate::git::git_common_dir;
use crate::model::JournalId;
use crate::session::event::{
EventTarget, EventType, ReviewInitializedPayload, ShoreEvent, Writer,
};
use crate::session::store::store_config::write_store_config;
use crate::session::store::store_init::ShoreStorePaths;
#[test]
fn fresh_unregistered_worktree_resolves_common_dir_by_default() {
let repo = GitRepo::new();
let resolution = resolve_store(repo.path()).unwrap();
let expected = git_common_dir(repo.path()).unwrap().join("shore");
assert_existing_paths_eq(resolution.store_dir(), &expected);
assert_ne!(
resolution.store_dir(),
ShoreStorePaths::resolve(repo.path()).unwrap().store_dir()
);
}
#[test]
fn fresh_unregistered_worktree_read_write_and_validation_all_resolve_common_dir() {
let repo = GitRepo::new();
let expected = git_common_dir(repo.path()).unwrap().join("shore");
let read = resolve_read_store(repo.path()).unwrap();
assert_existing_paths_eq(read.store_dir(), &expected);
let write = resolve_write_store(repo.path()).unwrap();
assert_existing_paths_eq(write.store_dir(), &expected);
let validation = resolve_write_validation_store(repo.path()).unwrap();
let _ = validation.validation_events().unwrap();
}
#[test]
fn linked_worktree_resolves_shared_common_dir_without_registration() {
let fixture = LinkedWorktreeFixture::new();
let expected = git_common_dir(fixture.main.path()).unwrap().join("shore");
let main = resolve_store(fixture.main.path()).unwrap();
let linked = resolve_store(&fixture.linked_path).unwrap();
assert_existing_paths_eq(main.store_dir(), &expected);
assert_existing_paths_eq(linked.store_dir(), &expected);
assert_eq!(main.store_dir(), linked.store_dir());
}
#[test]
fn ephemeral_mode_resolves_worktree_local_after_flip() {
let repo = GitRepo::new();
write_store_config(repo.path(), StoreMode::Ephemeral).unwrap();
let resolution = resolve_store(repo.path()).unwrap();
assert_eq!(
resolution.store_dir(),
ShoreStorePaths::resolve(repo.path()).unwrap().store_dir()
);
assert_eq!(path_file_name(resolution.store_dir()), "data");
}
#[test]
fn ephemeral_mode_pins_read_write_and_validation_to_worktree_local() {
let repo = GitRepo::new();
write_store_config(repo.path(), StoreMode::Ephemeral).unwrap();
let worktree_local = ShoreStorePaths::resolve(repo.path()).unwrap();
let read = resolve_read_store(repo.path()).unwrap();
assert_eq!(read.store_dir(), worktree_local.store_dir());
let write = resolve_write_store(repo.path()).unwrap();
assert_eq!(write.store_dir(), worktree_local.store_dir());
}
#[test]
fn resolve_store_ignores_a_leftover_registration_file_after_flip() {
let repo = GitRepo::new();
let shore = repo.path().join(".shore/data");
fs::create_dir_all(&shore).unwrap();
fs::write(shore.join("store-registration.json"), "{}").unwrap();
let resolution = resolve_store(repo.path()).unwrap();
let expected = git_common_dir(repo.path()).unwrap().join("shore");
assert_existing_paths_eq(resolution.store_dir(), &expected);
}
#[test]
fn legacy_worktree_local_store_after_flip_returns_migrate_hint() {
let repo = GitRepo::new();
fs::create_dir_all(repo.path().join(".shore/data/events")).unwrap();
fs::write(repo.path().join(".shore/data/events/aaaa.json"), "{}").unwrap();
let err = resolve_store(repo.path())
.expect_err("a populated worktree-local store after the flip must be a loud error");
let message = err.to_string();
assert!(
message.contains("store migrate"),
"names the fix (`shore store migrate`); got: {message}"
);
assert!(
message.contains("--retire-source"),
"names the one-command completion; got: {message}"
);
assert!(
message.contains(".shore/data"),
"names the legacy worktree-local store; got: {message}"
);
}
#[test]
fn raw_path_resolution_does_not_trip_the_legacy_guard() {
let repo = GitRepo::new();
fs::create_dir_all(repo.path().join(".shore/data/events")).unwrap();
fs::write(repo.path().join(".shore/data/events/aaaa.json"), "{}").unwrap();
ShoreStorePaths::resolve(repo.path())
.expect("raw path resolution of a nested store is unblocked (migration uses this)");
}
#[test]
fn ephemeral_worktree_with_local_store_does_not_trip_the_legacy_guard() {
let repo = GitRepo::new();
write_store_config(repo.path(), StoreMode::Ephemeral).unwrap();
fs::create_dir_all(repo.path().join(".shore/data/events")).unwrap();
fs::write(repo.path().join(".shore/data/events/aaaa.json"), "{}").unwrap();
let resolution =
resolve_store(repo.path()).expect("ephemeral resolves its worktree-local store");
assert_eq!(path_file_name(resolution.store_dir()), "data");
}
#[test]
fn read_store_resolves_the_single_common_dir_store() {
let repo = GitRepo::new();
let read = resolve_read_store(repo.path()).unwrap();
let expected = git_common_dir(repo.path()).unwrap().join("shore");
assert_existing_paths_eq(read.store_dir(), &expected);
}
#[test]
fn event_log_head_marker_equals_the_event_count() {
let repo = GitRepo::new();
let store_dir = git_common_dir(repo.path()).unwrap().join("shore");
record_review_initialized(&store_dir, "session:a");
record_review_initialized(&store_dir, "session:b");
record_review_initialized(&store_dir, "session:c");
let marker = event_log_head_marker(repo.path()).unwrap();
let direct = EventStore::open(&store_dir).list_events().unwrap().len() as u64;
assert_eq!(marker, direct);
assert_eq!(marker, 3);
}
#[test]
fn event_log_head_marker_is_zero_for_a_fresh_repo() {
let repo = GitRepo::new();
assert_eq!(event_log_head_marker(repo.path()).unwrap(), 0);
}
#[test]
fn write_validation_events_are_exactly_the_single_store_events() {
let repo = GitRepo::new();
let store_dir = git_common_dir(repo.path()).unwrap().join("shore");
record_review_initialized(&store_dir, "session:a");
record_review_initialized(&store_dir, "session:b");
let validation = resolve_write_validation_store(repo.path()).unwrap();
let events = validation.validation_events().unwrap();
let direct = EventStore::open(&store_dir).list_events().unwrap();
assert_eq!(events.len(), direct.len());
assert_eq!(events.len(), 2);
}
#[test]
fn command_view_reports_the_single_store_without_registration_refs() {
let repo = GitRepo::new();
let resolution = resolve_store(repo.path()).unwrap();
let json = serde_json::to_string(&resolution.command_view()).unwrap();
assert!(!json.contains("\"cloneRef\""));
assert!(!json.contains("\"repositoryFamilyRef\""));
assert!(json.contains("\"mode\":\"local\""));
}
#[test]
fn write_and_read_resolve_the_same_store() {
let repo = GitRepo::new();
let write = resolve_write_store(repo.path()).unwrap();
let read = resolve_read_store(repo.path()).unwrap();
assert_eq!(write.store_dir(), read.store_dir());
}
#[test]
fn command_view_maps_clone_local_tier_to_local_mode() {
let resolution =
store_resolution_for(PathBuf::from("/tmp/cl"), ResolvedTier::CloneLocal).unwrap();
let view = resolution.command_view();
assert_eq!(view.mode, "local");
assert_eq!(view.store_ref, "local");
assert!(view.clone_ref.is_none());
assert!(view.repository_family_ref.is_none());
}
#[test]
fn command_view_maps_ephemeral_tier_to_ephemeral_mode() {
let resolution =
store_resolution_for(PathBuf::from("/tmp/eph"), ResolvedTier::Ephemeral).unwrap();
let view = resolution.command_view();
assert_eq!(view.mode, "ephemeral");
assert_eq!(view.store_ref, "local");
assert!(view.clone_ref.is_none());
assert!(view.repository_family_ref.is_none());
}
#[test]
fn command_view_maps_user_level_tier_to_family_refs() {
let resolution = store_resolution_for(
PathBuf::from("/tmp/fam"),
ResolvedTier::UserLevel {
family_ref: "acme-web".to_owned(),
clone_ref: "0123abcd4567ef89".to_owned(),
},
)
.unwrap();
let view = resolution.command_view();
assert_eq!(view.mode, "user-level");
assert_eq!(view.store_ref, "acme-web");
assert_eq!(view.repository_family_ref.as_deref(), Some("acme-web"));
assert_eq!(view.clone_ref.as_deref(), Some("0123abcd4567ef89"));
}
#[test]
fn user_level_binding_write_and_read_resolve_the_same_family_store() {
use crate::session::store::store_config::set_family_binding_for_repo;
use crate::session::store::user_level::{
ensure_family_store_scaffold, user_level_store_dir,
};
let repo = GitRepo::new();
let home = TempDir::new().unwrap();
unsafe {
std::env::set_var("SHORE_HOME", home.path());
}
let slug = "acme-web";
let family_dir = user_level_store_dir(slug).unwrap();
ensure_family_store_scaffold(&family_dir, slug, &[]).unwrap();
set_family_binding_for_repo(repo.path(), slug, "0123abcd4567ef89").unwrap();
let write = resolve_write_store(repo.path()).unwrap();
let read = resolve_read_store(repo.path()).unwrap();
unsafe {
std::env::remove_var("SHORE_HOME");
}
assert_eq!(write.store_dir(), read.store_dir());
assert_existing_paths_eq(write.store_dir(), &family_dir);
}
#[test]
fn a_worktree_of_a_linked_clone_resolves_the_family_store() {
use crate::session::store::store_config::write_common_dir_binding;
use crate::session::store::user_level::{
ensure_family_store_scaffold, user_level_store_dir,
};
let fixture = LinkedWorktreeFixture::new();
let home = TempDir::new().unwrap();
unsafe {
std::env::set_var("SHORE_HOME", home.path());
}
let slug = "fam";
let family_dir = user_level_store_dir(slug).unwrap();
ensure_family_store_scaffold(&family_dir, slug, &[]).unwrap();
let common = git_common_dir(fixture.main.path()).unwrap();
write_common_dir_binding(&common, slug, "0123abcd4567ef89").unwrap();
let main_read = resolve_read_store(fixture.main.path()).unwrap();
let wt_read = resolve_read_store(&fixture.linked_path).unwrap();
let wt_write = resolve_write_store(&fixture.linked_path).unwrap();
let wt_validation = resolve_write_validation_store(&fixture.linked_path).unwrap();
let _ = wt_validation.validation_events().unwrap();
unsafe {
std::env::remove_var("SHORE_HOME");
}
assert_existing_paths_eq(main_read.store_dir(), &family_dir);
assert_existing_paths_eq(wt_read.store_dir(), &family_dir);
assert_existing_paths_eq(wt_write.store_dir(), &family_dir);
assert_ne!(
wt_read.store_dir(),
git_common_dir(fixture.main.path()).unwrap().join("shore")
);
}
#[test]
fn an_ephemeral_worktree_still_escapes_even_with_a_common_dir_binding() {
use crate::session::store::store_config::write_common_dir_binding;
let repo = GitRepo::new();
write_store_config(repo.path(), StoreMode::Ephemeral).unwrap();
let common = git_common_dir(repo.path()).unwrap();
write_common_dir_binding(&common, "fam", "0123abcd4567ef89").unwrap();
let resolution = resolve_store(repo.path()).unwrap();
assert_eq!(path_file_name(resolution.store_dir()), "data");
}
#[test]
fn advisory_fires_for_an_unbound_worktree_of_a_legacy_linked_clone() {
let fixture = LinkedWorktreeFixture::new();
fs::create_dir_all(fixture.main.path().join(".shore")).unwrap();
fs::write(
fixture.main.path().join(".shore/store.local.json"),
r#"{"schema":"shore.store-config","version":1,"mode":"shared","familyRef":"shoreline","cloneRef":"deadbeefdeadbeef"}"#,
)
.unwrap();
let advisory = family_link_advisory(&fixture.linked_path)
.unwrap()
.expect("advisory fires");
assert!(
advisory.contains("shoreline"),
"names the family: {advisory}"
);
assert!(
advisory.contains("shore store link"),
"actionable: {advisory}"
);
}
#[test]
fn advisory_is_silent_for_a_fresh_unlinked_clone() {
let fixture = LinkedWorktreeFixture::new();
assert!(
family_link_advisory(&fixture.linked_path)
.unwrap()
.is_none()
);
assert!(family_link_advisory(fixture.main.path()).unwrap().is_none());
}
#[test]
fn advisory_is_silent_for_an_ephemeral_worktree() {
let repo = GitRepo::new();
write_store_config(repo.path(), StoreMode::Ephemeral).unwrap();
assert!(family_link_advisory(repo.path()).unwrap().is_none());
}
#[test]
fn advisory_is_silent_when_this_worktree_already_resolves_the_family() {
use crate::session::store::store_config::write_common_dir_binding;
use crate::session::store::user_level::{
ensure_family_store_scaffold, user_level_store_dir,
};
let repo = GitRepo::new();
let home = TempDir::new().unwrap();
unsafe {
std::env::set_var("SHORE_HOME", home.path());
}
let slug = "fam";
ensure_family_store_scaffold(&user_level_store_dir(slug).unwrap(), slug, &[]).unwrap();
write_common_dir_binding(
&git_common_dir(repo.path()).unwrap(),
slug,
"0123abcd4567ef89",
)
.unwrap();
let advisory = family_link_advisory(repo.path()).unwrap();
unsafe {
std::env::remove_var("SHORE_HOME");
}
assert!(
advisory.is_none(),
"a family-resolved worktree is not advised"
);
}
#[test]
fn ephemeral_mode_outranks_a_family_binding() {
let repo = GitRepo::new();
fs::create_dir_all(repo.path().join(".shore")).unwrap();
fs::write(
repo.path().join(".shore/store.local.json"),
r#"{"schema":"shore.store-config","version":1,"mode":"ephemeral","familyRef":"acme-web","cloneRef":"0123abcd4567ef89"}"#,
)
.unwrap();
let resolution = resolve_store(repo.path()).unwrap();
assert_eq!(path_file_name(resolution.store_dir()), "data");
}
#[test]
fn a_committed_family_binding_hard_errors_even_under_ephemeral_mode() {
let repo = GitRepo::new();
fs::create_dir_all(repo.path().join(".shore")).unwrap();
fs::write(
repo.path().join(".shore/store.json"),
r#"{"schema":"shore.store-config","version":1,"mode":"shared","familyRef":"acme-web","cloneRef":"0123abcd4567ef89"}"#,
)
.unwrap();
fs::write(
repo.path().join(".shore/store.local.json"),
r#"{"schema":"shore.store-config","version":1,"mode":"ephemeral"}"#,
)
.unwrap();
let err = resolve_store(repo.path())
.expect_err("a committed binding is a hard error regardless of mode");
assert!(
err.to_string().contains("store.json"),
"names the committed file: {err}"
);
}
#[test]
fn legacy_populated_store_outranks_a_family_binding() {
let repo = GitRepo::new();
fs::create_dir_all(repo.path().join(".shore/data/events")).unwrap();
fs::write(repo.path().join(".shore/data/events/aaaa.json"), "{}").unwrap();
fs::write(
repo.path().join(".shore/store.local.json"),
r#"{"schema":"shore.store-config","version":1,"mode":"shared","familyRef":"acme-web","cloneRef":"0123abcd4567ef89"}"#,
)
.unwrap();
let err = resolve_store(repo.path())
.expect_err("the legacy guard fires before the user-level arm");
assert!(err.to_string().contains("store migrate"), "got: {err}");
}
#[test]
fn a_dangling_family_binding_is_a_hard_error_naming_both_fixes() {
let repo = GitRepo::new();
let home = TempDir::new().unwrap();
unsafe {
std::env::set_var("SHORE_HOME", home.path());
}
fs::create_dir_all(repo.path().join(".shore")).unwrap();
fs::write(
repo.path().join(".shore/store.local.json"),
r#"{"schema":"shore.store-config","version":1,"mode":"shared","familyRef":"acme-web","cloneRef":"0123abcd4567ef89"}"#,
)
.unwrap();
let result = resolve_store(repo.path());
unsafe {
std::env::remove_var("SHORE_HOME");
}
let message = result
.expect_err("a dangling family_ref is a hard error")
.to_string();
assert!(
message.contains("shore store link"),
"names the re-link fix: {message}"
);
assert!(
message.contains("unlink"),
"names the unlink fix: {message}"
);
assert!(
message.contains("acme-web"),
"names the forgotten family: {message}"
);
}
#[test]
fn classify_backend_defaults_to_local_when_unset_or_local() {
let dir = PathBuf::from("/tmp/shore-store");
let backend = classify_backend(Err(std::env::VarError::NotPresent), dir.clone()).unwrap();
assert_eq!(backend_dir(&backend), dir.as_path());
let backend = classify_backend(Ok("local".to_owned()), dir.clone()).unwrap();
assert_eq!(backend_dir(&backend), dir.as_path());
}
#[test]
fn classify_backend_rejects_memory_as_injection_only() {
let message = classify_backend(Ok("memory".to_owned()), PathBuf::from("/tmp/store"))
.expect_err("memory is not env-selectable")
.to_string();
assert!(
message.contains("SHORE_BACKEND"),
"names the env var: {message}"
);
assert!(
message.contains("injection"),
"explains it is injection-only: {message}"
);
}
#[test]
fn classify_backend_hard_errors_on_an_unknown_value() {
let message = classify_backend(Ok("ndjson".to_owned()), PathBuf::from("/tmp/store"))
.expect_err("an unknown backend value is rejected")
.to_string();
assert!(
message.contains("ndjson"),
"names the offending value: {message}"
);
assert!(
message.contains("local"),
"names the supported value: {message}"
);
}
#[test]
fn read_write_and_validation_resolve_the_same_local_backend() {
let repo = GitRepo::new();
let read = resolve_read_store(repo.path()).unwrap();
let write = resolve_write_store(repo.path()).unwrap();
let validation = resolve_write_validation_store(repo.path()).unwrap();
assert!(matches!(read.backend(), StoreBackend::Local(_)));
assert!(matches!(write.backend(), StoreBackend::Local(_)));
assert!(matches!(validation.backend(), StoreBackend::Local(_)));
assert_eq!(backend_dir(read.backend()), backend_dir(write.backend()));
assert_eq!(
backend_dir(read.backend()),
backend_dir(validation.backend())
);
assert_eq!(backend_dir(read.backend()), read.store_dir());
}
#[test]
fn select_backend_reads_the_environment_and_defaults_to_local() {
let dir = PathBuf::from("/tmp/shore-store");
let backend = select_backend(dir.clone()).unwrap();
assert_eq!(backend_dir(&backend), dir.as_path());
}
fn backend_dir(backend: &StoreBackend) -> &Path {
match backend {
StoreBackend::Local(dir) => dir.as_path(),
StoreBackend::Memory(_) => unreachable!("the selector never yields the memory backend"),
}
}
#[test]
fn prepare_write_landing_creates_dirs_on_the_common_dir_store() {
let repo = GitRepo::new();
let write = resolve_write_store(repo.path()).unwrap();
let storage = LocalStorage::new(write.store_dir());
prepare_write_landing(&write, &storage).unwrap();
assert!(write.store_dir().join("events").is_dir());
assert!(write.store_dir().join("artifacts/objects").is_dir());
let worktree_local = ShoreStorePaths::resolve(repo.path()).unwrap();
assert_ne!(write.store_dir(), worktree_local.store_dir());
}
fn record_review_initialized(store_dir: &Path, session: &str) -> ShoreEvent {
let event = review_initialized_event_for_session(session);
EventStore::open(store_dir)
.record_event_once(&event)
.unwrap();
event
}
fn review_initialized_event_for_session(session: &str) -> ShoreEvent {
ShoreEvent::new(
EventType::ReviewInitialized,
format!("review_initialized:{session}:work:default"),
EventTarget::for_journal(JournalId::new(session)),
Writer::shore_local("0.1.0"),
ReviewInitializedPayload {},
"2026-05-10T00:00:00Z",
)
.expect("event builds")
}
struct LinkedWorktreeFixture {
main: GitRepo,
_linked_parent: TempDir,
linked_path: PathBuf,
}
impl LinkedWorktreeFixture {
fn new() -> Self {
let main = GitRepo::new();
main.write("README.md", "base\n");
main.git(["add", "--all"]);
main.git(["commit", "-m", "base"]);
let linked_parent = TempDir::new().expect("create linked worktree parent");
let linked_path = linked_parent.path().join("linked");
main.git_os([
OsString::from("worktree"),
OsString::from("add"),
OsString::from("-b"),
OsString::from("linked"),
linked_path.as_os_str().to_owned(),
]);
Self {
main,
_linked_parent: linked_parent,
linked_path,
}
}
}
struct GitRepo {
root: TempDir,
}
impl GitRepo {
fn new() -> Self {
let root = TempDir::new().expect("create temp git repository directory");
let repo = Self { root };
repo.git(["init"]);
repo.git(["config", "user.name", "Shore Tests"]);
repo.git(["config", "user.email", "shore-tests@example.com"]);
repo.git(["config", "commit.gpgsign", "false"]);
repo
}
fn path(&self) -> &Path {
self.root.path()
}
fn write(&self, path: &str, contents: &str) {
let path = self.root.path().join(path);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent).unwrap();
}
fs::write(path, contents).unwrap();
}
fn git<I, S>(&self, args: I)
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
run_git(self.root.path(), args);
}
fn git_os<I>(&self, args: I)
where
I: IntoIterator<Item = OsString>,
{
run_git(self.root.path(), args);
}
}
fn run_git<I, S>(cwd: &Path, args: I)
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let args = args
.into_iter()
.map(|arg| arg.as_ref().to_owned())
.collect::<Vec<_>>();
let output = std::process::Command::new("git")
.args(&args)
.current_dir(cwd)
.output()
.unwrap_or_else(|error| panic!("run git {:?} in {}: {error}", args, cwd.display()));
assert!(
output.status.success(),
"git {:?} failed in {}\nstdout:\n{}\nstderr:\n{}",
args,
cwd.display(),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
fn assert_existing_paths_eq(actual: &Path, expected: &Path) {
fn normalize(path: &Path) -> PathBuf {
let mut ancestor = path.to_path_buf();
let mut tail: Vec<std::ffi::OsString> = Vec::new();
loop {
if ancestor.exists() {
let mut base = ancestor.canonicalize().expect("ancestor canonicalizes");
for part in tail.iter().rev() {
base.push(part);
}
return base;
}
match (ancestor.file_name(), ancestor.parent()) {
(Some(name), Some(parent)) => {
tail.push(name.to_owned());
ancestor = parent.to_path_buf();
}
_ => return path.to_path_buf(),
}
}
}
assert_eq!(normalize(actual), normalize(expected));
}
fn path_file_name(path: &Path) -> &str {
path.file_name()
.and_then(|name| name.to_str())
.expect("path has utf-8 file name")
}
}