use std::path::{Path, PathBuf};
use serde::Serialize;
use crate::canonical_hash::sha256_bytes_hex;
use crate::error::{Result, ShoreError};
use crate::session::event::EventType;
use crate::session::store::bundle::{
ExportFidelityStatus, import_store_bundle_with_verification, preview_import_store_bundle,
verify_source_subset_of_target,
};
use crate::session::store::resolution::clone_local_store_dir;
use crate::session::store::sensitivity::scan_worktree_sensitivity;
use crate::session::store::store_config::{
StoreMode, clear_family_binding_for_repo, resolve_family_binding, resolve_store_mode,
set_family_binding_for_repo,
};
use crate::session::store::store_init::ShoreStorePaths;
use crate::session::store::user_level::{
deregister_clone, ensure_family_store_scaffold, flag_unsupported_filesystem,
read_family_manifest, register_clone, user_level_store_dir, validate_family_slug,
};
use crate::session::{EventStore, EventVerificationPolicy, TrustSet};
const SENSITIVITY_BLOCK: &str = "block";
#[derive(Clone, Debug)]
pub struct StoreLinkOptions {
repo: PathBuf,
slug: Option<String>,
include_ephemeral: bool,
include_sensitive: bool,
retire_source: bool,
trust_set: TrustSet,
}
impl StoreLinkOptions {
pub fn new(repo: impl AsRef<Path>, slug: Option<String>) -> Self {
Self {
repo: repo.as_ref().to_path_buf(),
slug,
include_ephemeral: false,
include_sensitive: false,
retire_source: false,
trust_set: TrustSet::default(),
}
}
pub fn with_include_ephemeral(mut self, include_ephemeral: bool) -> Self {
self.include_ephemeral = include_ephemeral;
self
}
pub fn with_include_sensitive(mut self, include_sensitive: bool) -> Self {
self.include_sensitive = include_sensitive;
self
}
pub fn with_retire_source(mut self, retire_source: bool) -> Self {
self.retire_source = retire_source;
self
}
pub fn with_trust_set(mut self, trust_set: TrustSet) -> Self {
self.trust_set = trust_set;
self
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StoreLinkResult {
pub family_ref: String,
pub clone_ref: String,
pub created_family: bool,
pub folded_events_created: usize,
pub folded_events_existing: usize,
pub folded_artifacts_created: usize,
pub folded_removal_event_count: usize,
pub folded_absent_artifact_count: usize,
pub source_retired: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub filesystem_warning: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub history_overlap_warning: Option<String>,
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StoreLinkPreview {
pub family_ref: String,
pub clone_ref: String,
pub would_create_family: bool,
pub source_present: bool,
pub export_fidelity: String,
pub folded_events_to_create: usize,
pub folded_events_existing: usize,
pub folded_artifacts_to_create: usize,
pub folded_artifacts_existing: usize,
pub folded_removal_event_count: usize,
pub folded_absent_artifact_count: usize,
#[serde(skip_serializing_if = "Option::is_none")]
pub filesystem_warning: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub history_overlap_warning: Option<String>,
}
struct LinkPlan {
slug: String,
family_dir: PathBuf,
worktree_root: PathBuf,
clone_ref: String,
root_oids: Vec<String>,
would_create_family: bool,
filesystem_warning: Option<String>,
history_overlap_warning: Option<String>,
}
fn plan_link(options: &StoreLinkOptions) -> Result<LinkPlan> {
let paths = ShoreStorePaths::resolve(&options.repo)?;
let worktree_root = paths.worktree_root().to_path_buf();
let slug = match &options.slug {
Some(slug) => {
validate_family_slug(slug)?;
slug.clone()
}
None => return Err(no_slug_error(&worktree_root)),
};
if !options.include_ephemeral && resolve_store_mode(&worktree_root)? == StoreMode::Ephemeral {
return Err(ShoreError::Message(
"refusing to link an ephemeral worktree into a family store; re-run with the \
include-ephemeral override to link it anyway"
.to_owned(),
));
}
if !options.include_sensitive {
let scan = scan_worktree_sensitivity(&worktree_root)?;
if scan.policy_outcome == SENSITIVITY_BLOCK {
return Err(ShoreError::Message(
"refusing to link a worktree flagged sensitive into a family store; run \
`shore store status --show-paths` to see which files matched, then add \
known-safe paths to .shore/sensitivity.json excludeGlobs for a targeted \
exclude, or re-run with the include-sensitive override to link it anyway"
.to_owned(),
));
}
}
let family_dir = user_level_store_dir(&slug)?;
let existing_manifest = read_family_manifest(&family_dir)?;
if let Some(manifest) = &existing_manifest
&& manifest.family_id != slug
{
return Err(ShoreError::Message(format!(
"family store {} is stamped for family `{}`, not `{}`; refusing to link",
family_dir.display(),
manifest.family_id,
slug
)));
}
let filesystem_warning = flag_unsupported_filesystem(&family_dir);
let root_oids = root_commit_oids(&worktree_root)?;
let history_overlap_warning = history_overlap_warning_for(&family_dir, &slug, &root_oids)?;
let clone_ref = mint_clone_ref(&crate::git::git_common_dir(&worktree_root)?);
Ok(LinkPlan {
would_create_family: existing_manifest.is_none(),
slug,
family_dir,
worktree_root,
clone_ref,
root_oids,
filesystem_warning,
history_overlap_warning,
})
}
pub fn link_store_to_family(options: StoreLinkOptions) -> Result<StoreLinkResult> {
let plan = plan_link(&options)?;
let created_family =
ensure_family_store_scaffold(&plan.family_dir, &plan.slug, &plan.root_oids)?;
let source = clone_local_store_dir(&plan.worktree_root)?;
let fold = fold_source_forward(
&source,
&plan.family_dir,
&options.trust_set,
options.retire_source,
)?;
register_clone(
&plan.family_dir,
&plan.slug,
&plan.clone_ref,
&plan.worktree_root,
)?;
set_family_binding_for_repo(&options.repo, &plan.slug, &plan.clone_ref)?;
Ok(StoreLinkResult {
family_ref: plan.slug,
clone_ref: plan.clone_ref,
created_family,
folded_events_created: fold.events_created,
folded_events_existing: fold.events_existing,
folded_artifacts_created: fold.artifacts_created,
folded_removal_event_count: fold.removal_event_count,
folded_absent_artifact_count: fold.absent_artifact_count,
source_retired: fold.source_retired,
filesystem_warning: plan.filesystem_warning,
history_overlap_warning: plan.history_overlap_warning,
})
}
pub fn preview_link_to_family(options: StoreLinkOptions) -> Result<StoreLinkPreview> {
let plan = plan_link(&options)?;
let source = clone_local_store_dir(&plan.worktree_root)?;
let fold = preview_fold(&source, &plan.family_dir)?;
Ok(StoreLinkPreview {
family_ref: plan.slug,
clone_ref: plan.clone_ref,
would_create_family: plan.would_create_family,
source_present: fold.source_present,
export_fidelity: fold.export_fidelity,
folded_events_to_create: fold.events_to_create,
folded_events_existing: fold.events_existing,
folded_artifacts_to_create: fold.artifacts_to_create,
folded_artifacts_existing: fold.artifacts_existing,
folded_removal_event_count: fold.removal_event_count,
folded_absent_artifact_count: fold.absent_artifact_count,
filesystem_warning: plan.filesystem_warning,
history_overlap_warning: plan.history_overlap_warning,
})
}
#[derive(Clone, Debug)]
pub struct StoreUnlinkOptions {
repo: PathBuf,
}
impl StoreUnlinkOptions {
pub fn new(repo: impl AsRef<Path>) -> Self {
Self {
repo: repo.as_ref().to_path_buf(),
}
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct StoreUnlinkResult {
#[serde(skip_serializing_if = "Option::is_none")]
pub previous_family_ref: Option<String>,
pub deregistered: bool,
}
pub fn unlink_store_from_family(options: StoreUnlinkOptions) -> Result<StoreUnlinkResult> {
let worktree_root = ShoreStorePaths::resolve(&options.repo)?
.worktree_root()
.to_path_buf();
let Some(binding) = resolve_family_binding(&worktree_root)? else {
return Ok(StoreUnlinkResult {
previous_family_ref: None,
deregistered: false,
});
};
let family_dir = user_level_store_dir(&binding.family_ref)?;
let deregistered = deregister_clone(&family_dir, &binding.clone_ref)?;
clear_family_binding_for_repo(&options.repo)?;
Ok(StoreUnlinkResult {
previous_family_ref: Some(binding.family_ref),
deregistered,
})
}
fn root_commit_oids(worktree_root: &Path) -> Result<Vec<String>> {
let output = std::process::Command::new("git")
.args(["rev-list", "--max-parents=0", "HEAD"])
.current_dir(worktree_root)
.output()
.map_err(|error| ShoreError::Message(format!("run git rev-list: {error}")))?;
if !output.status.success() {
return Ok(Vec::new());
}
Ok(String::from_utf8_lossy(&output.stdout)
.lines()
.map(|line| line.trim().to_owned())
.filter(|line| !line.is_empty())
.collect())
}
fn history_overlap_warning_for(
family_dir: &Path,
slug: &str,
clone_roots: &[String],
) -> Result<Option<String>> {
let Some(manifest) = read_family_manifest(family_dir)? else {
return Ok(None); };
if manifest.root_commit_oids.is_empty() || clone_roots.is_empty() {
return Ok(None);
}
let overlaps = clone_roots
.iter()
.any(|oid| manifest.root_commit_oids.contains(oid));
Ok((!overlaps).then(|| {
format!(
"this clone shares no git history with family `{slug}` (no common root \
commit); if this is a different project, unlink and choose another slug"
)
}))
}
fn mint_clone_ref(common_dir: &Path) -> String {
let digest = sha256_bytes_hex(common_dir.to_string_lossy().as_bytes());
digest[..16].to_owned()
}
fn no_slug_error(worktree_root: &Path) -> ShoreError {
match suggest_family_slug(worktree_root) {
Some(suggestion) => ShoreError::Message(format!(
"no family slug given; re-run as `shore store link <slug>` (suggested: `{suggestion}`)"
)),
None => ShoreError::Message(
"no family slug given and none could be suggested from the worktree name; \
re-run as `shore store link <slug>`"
.to_owned(),
),
}
}
fn suggest_family_slug(worktree_root: &Path) -> Option<String> {
let base = worktree_root
.file_name()?
.to_string_lossy()
.to_ascii_lowercase();
let slug: String = base
.chars()
.map(|ch| if ch.is_ascii_alphanumeric() { ch } else { '-' })
.collect();
let slug = slug.trim_matches('-').to_owned();
(!slug.is_empty()).then_some(slug)
}
struct FoldOutcome {
events_created: usize,
events_existing: usize,
artifacts_created: usize,
removal_event_count: usize,
absent_artifact_count: usize,
source_retired: bool,
}
impl FoldOutcome {
fn empty() -> Self {
Self {
events_created: 0,
events_existing: 0,
artifacts_created: 0,
removal_event_count: 0,
absent_artifact_count: 0,
source_retired: false,
}
}
}
fn count_unsigned_artifact_removals(source: &Path) -> Result<usize> {
Ok(EventStore::open(source)
.list_events()?
.iter()
.filter(|event| event.event_type == EventType::ArtifactRemoved && event.signature.is_none())
.count())
}
fn fold_source_forward(
source: &Path,
family_dir: &Path,
trust_set: &TrustSet,
retire_source: bool,
) -> Result<FoldOutcome> {
if !source.join("events").exists() {
return Ok(FoldOutcome::empty());
}
let removal_event_count = count_unsigned_artifact_removals(source)?;
let imported = import_store_bundle_with_verification(
source,
family_dir,
EventVerificationPolicy::advisory(),
trust_set.clone(),
)?;
let mut outcome = FoldOutcome {
events_created: imported.events_created,
events_existing: imported.events_existing,
artifacts_created: imported.artifacts_created,
removal_event_count,
absent_artifact_count: imported.absent_artifact_count,
source_retired: false,
};
if retire_source {
verify_source_subset_of_target(source, family_dir)?;
std::fs::remove_dir_all(source).map_err(|error| {
ShoreError::Message(format!(
"remove retired source store {}: {error}",
source.display()
))
})?;
outcome.source_retired = true;
}
Ok(outcome)
}
struct FoldPreview {
source_present: bool,
export_fidelity: String,
events_to_create: usize,
events_existing: usize,
artifacts_to_create: usize,
artifacts_existing: usize,
removal_event_count: usize,
absent_artifact_count: usize,
}
fn preview_fold(source: &Path, family_dir: &Path) -> Result<FoldPreview> {
if !source.join("events").exists() {
return Ok(FoldPreview {
source_present: false,
export_fidelity: fidelity_label(ExportFidelityStatus::Full),
events_to_create: 0,
events_existing: 0,
artifacts_to_create: 0,
artifacts_existing: 0,
removal_event_count: 0,
absent_artifact_count: 0,
});
}
let removal_event_count = count_unsigned_artifact_removals(source)?;
let import = preview_import_store_bundle(source, family_dir)?;
Ok(FoldPreview {
source_present: true,
export_fidelity: fidelity_label(import.fidelity_status),
events_to_create: import.events_created,
events_existing: import.events_existing,
artifacts_to_create: import.artifacts_created,
artifacts_existing: import.artifacts_existing,
removal_event_count,
absent_artifact_count: import.absent_artifact_count,
})
}
fn fidelity_label(status: ExportFidelityStatus) -> String {
match status {
ExportFidelityStatus::Full => "full",
ExportFidelityStatus::Incomplete => "incomplete",
}
.to_owned()
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::ffi::OsStr;
use std::path::{Path, PathBuf};
use std::process::Command;
use super::*;
use crate::session::store::store_config::{
StoreMode, resolve_family_binding, write_store_config,
};
use crate::session::store::user_level::{user_level_store_dir, write_family_manifest};
#[test]
fn fresh_link_writes_binding_registry_and_scaffold() {
let repo = git_repo();
let home = repo.path().join("home");
std::fs::create_dir_all(&home).unwrap();
let (result, family_dir) = with_shore_home(&home, || {
let result =
link_store_to_family(StoreLinkOptions::new(repo.path(), Some("fam".to_owned())));
let family_dir = user_level_store_dir("fam").unwrap();
(result, family_dir)
});
let result = result.unwrap();
assert!(result.created_family, "a fresh family is created");
assert_eq!(result.family_ref, "fam");
assert_eq!(result.clone_ref.len(), 16, "clone_ref is 16 hex chars");
assert!(family_dir.join("family.json").is_file());
assert!(family_dir.join("events").is_dir());
let registry =
crate::session::store::user_level::read_family_registry(&family_dir).unwrap();
assert_eq!(registry.entries.len(), 1);
assert_eq!(registry.entries[0].clone_ref, result.clone_ref);
let binding = resolve_family_binding(repo.path()).unwrap().expect("bound");
assert_eq!(binding.family_ref, "fam");
assert_eq!(binding.clone_ref, result.clone_ref);
}
#[test]
fn clone_ref_keys_on_the_common_dir() {
let a = Path::new("/repo/.git");
let b = Path::new("/other/.git");
assert_eq!(mint_clone_ref(a), mint_clone_ref(a));
assert_ne!(mint_clone_ref(a), mint_clone_ref(b));
assert_eq!(mint_clone_ref(a).len(), 16);
}
#[test]
fn re_linking_a_sibling_worktree_dedups_to_one_registry_member() {
let main = git_repo();
let wt_parent = tempfile::tempdir().unwrap();
let wt = wt_parent.path().join("sibling");
run_git(main.path(), ["branch", "sib"]);
run_git(
main.path(),
[
OsStr::new("worktree"),
OsStr::new("add"),
wt.as_os_str(),
OsStr::new("sib"),
],
);
let home = main.path().join("home");
std::fs::create_dir_all(&home).unwrap();
let (main_ref, sib_ref, entries) = with_shore_home(&home, || {
let main_ref =
link_store_to_family(StoreLinkOptions::new(main.path(), Some("fam".to_owned())))
.unwrap()
.clone_ref;
let sib_ref = link_store_to_family(StoreLinkOptions::new(&wt, Some("fam".to_owned())))
.unwrap()
.clone_ref;
let family_dir = user_level_store_dir("fam").unwrap();
let entries = crate::session::store::user_level::read_family_registry(&family_dir)
.unwrap()
.entries
.len();
(main_ref, sib_ref, entries)
});
assert_eq!(main_ref, sib_ref, "one physical clone → one clone_ref");
assert_eq!(entries, 1, "the sibling re-link dedups, not duplicates");
}
#[test]
fn ephemeral_worktree_refuses_without_override_and_links_with_it() {
let repo = git_repo();
write_store_config(repo.path(), StoreMode::Ephemeral).unwrap();
let home = repo.path().join("home");
std::fs::create_dir_all(&home).unwrap();
let refused = with_shore_home(&home, || {
link_store_to_family(StoreLinkOptions::new(repo.path(), Some("fam".to_owned())))
})
.expect_err("an ephemeral worktree refuses without the override");
assert!(refused.to_string().contains("ephemeral"));
let (linked, resolved, family_dir) = with_shore_home(&home, || {
let linked = link_store_to_family(
StoreLinkOptions::new(repo.path(), Some("fam".to_owned()))
.with_include_ephemeral(true),
)
.unwrap();
let resolved = crate::session::store::resolution::resolve_store(repo.path())
.unwrap()
.store_dir()
.to_path_buf();
(linked, resolved, user_level_store_dir("fam").unwrap())
});
assert!(linked.created_family);
assert_eq!(resolved, family_dir);
}
#[test]
fn linking_a_local_ephemeral_override_promotes_to_the_family_store() {
let repo = git_repo();
std::fs::create_dir_all(repo.path().join(".shore")).unwrap();
std::fs::write(
repo.path().join(".shore/store.local.json"),
r#"{"schema":"shore.store-config","version":1,"mode":"ephemeral"}"#,
)
.unwrap();
let home = repo.path().join("home");
std::fs::create_dir_all(&home).unwrap();
let (resolved, family_dir) = with_shore_home(&home, || {
link_store_to_family(
StoreLinkOptions::new(repo.path(), Some("fam".to_owned()))
.with_include_ephemeral(true),
)
.expect("link succeeds against a local ephemeral override");
let resolved = crate::session::store::resolution::resolve_store(repo.path())
.unwrap()
.store_dir()
.to_path_buf();
(resolved, user_level_store_dir("fam").unwrap())
});
assert_eq!(
resolved, family_dir,
"the clone resolves the family store, not .shore/data"
);
}
#[test]
fn sensitivity_block_refuses_without_override_and_links_with_it() {
let repo = git_repo();
std::fs::create_dir_all(repo.path().join("keys")).unwrap();
std::fs::write(
repo.path().join("keys/dev.pem"),
"-----BEGIN PRIVATE KEY-----\nredacted\n",
)
.unwrap();
let home = repo.path().join("home");
std::fs::create_dir_all(&home).unwrap();
let refused = with_shore_home(&home, || {
link_store_to_family(StoreLinkOptions::new(repo.path(), Some("fam".to_owned())))
})
.expect_err("a sensitive worktree refuses without the override");
let message = refused.to_string();
assert!(
message.contains("sensitivity.json"),
"names the exclude fix: {message}"
);
assert!(
message.contains("store status --show-paths"),
"points at the local-only command that lists the matched files: {message}"
);
let linked = with_shore_home(&home, || {
link_store_to_family(
StoreLinkOptions::new(repo.path(), Some("fam".to_owned()))
.with_include_sensitive(true),
)
})
.unwrap();
assert!(linked.created_family);
}
#[test]
fn a_family_stamp_mismatch_refuses_with_no_override() {
let repo = git_repo();
let home = repo.path().join("home");
std::fs::create_dir_all(&home).unwrap();
let error = with_shore_home(&home, || {
let family_dir = user_level_store_dir("fam").unwrap();
std::fs::create_dir_all(&family_dir).unwrap();
write_family_manifest(&family_dir, "other", &[]).unwrap();
link_store_to_family(StoreLinkOptions::new(repo.path(), Some("fam".to_owned())))
})
.expect_err("a family-stamp mismatch refuses");
assert!(
error.to_string().contains("other"),
"names the stamped family"
);
}
#[test]
fn a_missing_slug_errors_with_a_suggestion() {
let repo = git_repo();
let home = repo.path().join("home");
std::fs::create_dir_all(&home).unwrap();
let error = with_shore_home(&home, || {
link_store_to_family(StoreLinkOptions::new(repo.path(), None))
})
.expect_err("no slug is an actionable error");
let message = error.to_string();
assert!(
message.contains("shore store link"),
"names the command: {message}"
);
assert!(
message.contains("suggested"),
"carries a suggestion: {message}"
);
}
#[test]
fn joining_an_unrelated_family_yields_a_history_overlap_warning() {
let founder = git_repo();
let joiner = git_repo();
let home = founder.path().join("home");
std::fs::create_dir_all(&home).unwrap();
let founded = with_shore_home(&home, || {
link_store_to_family(StoreLinkOptions::new(
founder.path(),
Some("fam".to_owned()),
))
})
.unwrap();
assert!(
founded.history_overlap_warning.is_none(),
"the founder never warns"
);
let joined = with_shore_home(&home, || {
link_store_to_family(StoreLinkOptions::new(joiner.path(), Some("fam".to_owned())))
})
.unwrap();
assert!(
joined.history_overlap_warning.is_some(),
"an unrelated clone joining an existing family warns"
);
assert!(!joined.created_family, "it joined; it did not found");
}
#[test]
fn a_true_clone_joins_its_family_without_a_history_warning() {
let founder = git_repo();
let clone_parent = tempfile::tempdir().unwrap();
let clone_dir = clone_parent.path().join("clone-b");
let status = Command::new("git")
.args([
OsStr::new("clone"),
founder.path().as_os_str(),
clone_dir.as_os_str(),
])
.status()
.unwrap();
assert!(status.success());
let home = founder.path().join("home");
std::fs::create_dir_all(&home).unwrap();
with_shore_home(&home, || {
link_store_to_family(StoreLinkOptions::new(
founder.path(),
Some("fam".to_owned()),
))
})
.unwrap();
let joined = with_shore_home(&home, || {
link_store_to_family(StoreLinkOptions::new(&clone_dir, Some("fam".to_owned())))
})
.unwrap();
assert!(
joined.history_overlap_warning.is_none(),
"a real clone shares the root commit — no warning"
);
}
#[test]
fn a_sync_managed_store_root_yields_a_filesystem_warning() {
let repo = git_repo();
let home = repo.path().join("Dropbox");
std::fs::create_dir_all(&home).unwrap();
let result = with_shore_home(&home, || {
link_store_to_family(StoreLinkOptions::new(repo.path(), Some("fam".to_owned())))
})
.unwrap();
assert!(
result.filesystem_warning.is_some(),
"sync-managed root warns"
);
assert!(result.created_family, "the warning does not block the link");
}
#[test]
fn link_folds_existing_clone_history() {
let repo = modified_git_repo();
crate::session::capture_worktree_review(crate::session::CaptureOptions::new(repo.path()))
.unwrap();
let home = repo.path().join("home");
std::fs::create_dir_all(&home).unwrap();
let (result, family_dir) = with_shore_home(&home, || {
let result =
link_store_to_family(StoreLinkOptions::new(repo.path(), Some("fam".to_owned())));
let family_dir = user_level_store_dir("fam").unwrap();
(result, family_dir)
});
let result = result.unwrap();
assert!(
result.folded_events_created >= 1,
"the capture history folds forward"
);
let family_events = crate::session::EventStore::open(&family_dir)
.list_events()
.unwrap();
assert!(
!family_events.is_empty(),
"the family store now lists the folded events"
);
}
#[test]
fn an_unsigned_artifact_removed_in_the_source_is_counted() {
let repo = modified_git_repo();
let source = crate::session::store::resolution::clone_local_store_dir(repo.path()).unwrap();
crate::session::EventStore::open(&source)
.record_event_once(&removal_event_for("sha256:deadbeef"))
.unwrap();
let home = repo.path().join("home");
std::fs::create_dir_all(&home).unwrap();
let result = with_shore_home(&home, || {
link_store_to_family(StoreLinkOptions::new(repo.path(), Some("fam".to_owned())))
})
.unwrap();
assert_eq!(
result.folded_removal_event_count, 1,
"the unsigned removal is disclosed"
);
}
#[test]
fn retire_source_deletes_the_clone_store_after_a_verified_fold() {
let repo = modified_git_repo();
crate::session::capture_worktree_review(crate::session::CaptureOptions::new(repo.path()))
.unwrap();
let source = crate::session::store::resolution::clone_local_store_dir(repo.path()).unwrap();
assert!(
source.join("events").is_dir(),
"the clone-local store is populated"
);
let home = repo.path().join("home");
std::fs::create_dir_all(&home).unwrap();
let result = with_shore_home(&home, || {
link_store_to_family(
StoreLinkOptions::new(repo.path(), Some("fam".to_owned())).with_retire_source(true),
)
})
.unwrap();
assert!(result.source_retired);
assert!(
!source.exists(),
"the clone-local store is retired only after verification"
);
}
#[test]
fn an_empty_source_folds_as_a_clean_no_op() {
let repo = git_repo(); let home = repo.path().join("home");
std::fs::create_dir_all(&home).unwrap();
let result = with_shore_home(&home, || {
link_store_to_family(StoreLinkOptions::new(repo.path(), Some("fam".to_owned())))
})
.unwrap();
assert_eq!(result.folded_events_created, 0);
assert_eq!(result.folded_removal_event_count, 0);
assert!(
result.created_family,
"the family is still created with no history to fold"
);
}
#[test]
fn a_linked_clone_unlinks_and_leaves_the_family_store_intact() {
let repo = git_repo();
let home = repo.path().join("home");
std::fs::create_dir_all(&home).unwrap();
let family_dir = with_shore_home(&home, || {
link_store_to_family(StoreLinkOptions::new(repo.path(), Some("fam".to_owned())))
.unwrap();
user_level_store_dir("fam").unwrap()
});
let result = with_shore_home(&home, || {
unlink_store_from_family(StoreUnlinkOptions::new(repo.path()))
})
.unwrap();
assert_eq!(result.previous_family_ref.as_deref(), Some("fam"));
assert!(result.deregistered);
assert!(resolve_family_binding(repo.path()).unwrap().is_none());
let registry =
crate::session::store::user_level::read_family_registry(&family_dir).unwrap();
assert!(registry.entries.is_empty());
assert!(
family_dir.join("family.json").is_file(),
"detach moves no data"
);
assert!(family_dir.join("events").is_dir());
}
#[test]
fn unlink_when_not_linked_is_a_no_op() {
let repo = git_repo();
let home = repo.path().join("home");
std::fs::create_dir_all(&home).unwrap();
let result = with_shore_home(&home, || {
unlink_store_from_family(StoreUnlinkOptions::new(repo.path()))
})
.unwrap();
assert!(result.previous_family_ref.is_none());
assert!(!result.deregistered);
}
#[test]
fn unlink_with_a_forgotten_family_dir_still_clears_the_binding() {
let repo = git_repo();
let home = repo.path().join("home");
std::fs::create_dir_all(&home).unwrap();
let family_dir = with_shore_home(&home, || {
link_store_to_family(StoreLinkOptions::new(repo.path(), Some("fam".to_owned())))
.unwrap();
user_level_store_dir("fam").unwrap()
});
std::fs::remove_dir_all(&family_dir).unwrap();
let result = with_shore_home(&home, || {
unlink_store_from_family(StoreUnlinkOptions::new(repo.path()))
})
.unwrap();
assert_eq!(result.previous_family_ref.as_deref(), Some("fam"));
assert!(
!result.deregistered,
"nothing to deregister from a forgotten family"
);
assert!(
resolve_family_binding(repo.path()).unwrap().is_none(),
"the binding is cleared despite the dangling family dir"
);
}
#[test]
fn dry_run_on_a_clean_path_returns_a_preview_and_writes_nothing() {
let repo = modified_git_repo();
crate::session::capture_worktree_review(crate::session::CaptureOptions::new(repo.path()))
.unwrap();
let home = repo.path().join("home");
std::fs::create_dir_all(&home).unwrap();
let before = tree_fingerprint(&home);
let preview = with_shore_home(&home, || {
preview_link_to_family(StoreLinkOptions::new(repo.path(), Some("fam".to_owned())))
})
.unwrap();
assert!(preview.would_create_family);
assert!(preview.source_present);
assert_eq!(preview.export_fidelity, "full");
assert!(preview.folded_events_to_create >= 1);
assert_eq!(preview.folded_events_existing, 0);
assert_eq!(preview.folded_removal_event_count, 0);
assert_eq!(tree_fingerprint(&home), before);
assert!(!repo.path().join(".shore/store.local.json").exists());
let binding = with_shore_home(&home, || resolve_family_binding(repo.path())).unwrap();
assert!(binding.is_none());
}
#[test]
fn dry_run_reports_existing_counts_when_the_family_already_holds_the_history() {
let repo = modified_git_repo();
crate::session::capture_worktree_review(crate::session::CaptureOptions::new(repo.path()))
.unwrap();
let home = repo.path().join("home");
std::fs::create_dir_all(&home).unwrap();
with_shore_home(&home, || {
link_store_to_family(StoreLinkOptions::new(repo.path(), Some("fam".to_owned())))
.unwrap()
});
let after_link = tree_fingerprint(&home);
let preview = with_shore_home(&home, || {
preview_link_to_family(StoreLinkOptions::new(repo.path(), Some("fam".to_owned())))
})
.unwrap();
assert!(!preview.would_create_family);
assert_eq!(preview.folded_events_to_create, 0);
assert!(preview.folded_events_existing >= 1);
assert_eq!(tree_fingerprint(&home), after_link);
}
#[test]
fn dry_run_blocks_on_an_ephemeral_worktree_without_the_override() {
let repo = git_repo();
write_store_config(repo.path(), StoreMode::Ephemeral).unwrap();
let home = repo.path().join("home");
std::fs::create_dir_all(&home).unwrap();
let before = tree_fingerprint(&home);
let error = with_shore_home(&home, || {
preview_link_to_family(StoreLinkOptions::new(repo.path(), Some("fam".to_owned())))
})
.expect_err("an ephemeral worktree refuses without the override");
assert!(error.to_string().contains("ephemeral"));
assert_eq!(tree_fingerprint(&home), before);
assert!(!repo.path().join(".shore/store.local.json").exists());
}
#[test]
fn dry_run_previews_success_with_the_include_ephemeral_override() {
let repo = git_repo();
write_store_config(repo.path(), StoreMode::Ephemeral).unwrap();
let home = repo.path().join("home");
std::fs::create_dir_all(&home).unwrap();
let before = tree_fingerprint(&home);
let preview = with_shore_home(&home, || {
preview_link_to_family(
StoreLinkOptions::new(repo.path(), Some("fam".to_owned()))
.with_include_ephemeral(true),
)
})
.unwrap();
assert!(preview.would_create_family);
assert_eq!(tree_fingerprint(&home), before);
}
#[test]
fn dry_run_blocks_on_a_sensitive_worktree_without_the_override() {
let repo = git_repo();
std::fs::create_dir_all(repo.path().join("keys")).unwrap();
std::fs::write(
repo.path().join("keys/dev.pem"),
"-----BEGIN PRIVATE KEY-----\nredacted\n",
)
.unwrap();
let home = repo.path().join("home");
std::fs::create_dir_all(&home).unwrap();
let before = tree_fingerprint(&home);
let error = with_shore_home(&home, || {
preview_link_to_family(StoreLinkOptions::new(repo.path(), Some("fam".to_owned())))
})
.expect_err("a sensitive worktree refuses without the override");
assert!(error.to_string().contains("sensitivity.json"));
assert_eq!(tree_fingerprint(&home), before);
}
#[test]
fn dry_run_previews_success_with_the_include_sensitive_override() {
let repo = git_repo();
std::fs::create_dir_all(repo.path().join("keys")).unwrap();
std::fs::write(
repo.path().join("keys/dev.pem"),
"-----BEGIN PRIVATE KEY-----\nredacted\n",
)
.unwrap();
let home = repo.path().join("home");
std::fs::create_dir_all(&home).unwrap();
let before = tree_fingerprint(&home);
let preview = with_shore_home(&home, || {
preview_link_to_family(
StoreLinkOptions::new(repo.path(), Some("fam".to_owned()))
.with_include_sensitive(true),
)
})
.unwrap();
assert!(preview.would_create_family);
assert_eq!(tree_fingerprint(&home), before);
}
#[test]
fn dry_run_blocks_on_a_family_stamp_mismatch() {
let repo = git_repo();
let home = repo.path().join("home");
std::fs::create_dir_all(&home).unwrap();
let error = with_shore_home(&home, || {
let family_dir = user_level_store_dir("fam").unwrap();
std::fs::create_dir_all(&family_dir).unwrap();
write_family_manifest(&family_dir, "other", &[]).unwrap();
let before = tree_fingerprint(&home);
let error =
preview_link_to_family(StoreLinkOptions::new(repo.path(), Some("fam".to_owned())))
.expect_err("a family-stamp mismatch refuses");
assert_eq!(tree_fingerprint(&home), before);
error
});
assert!(error.to_string().contains("other"));
}
#[test]
fn dry_run_tolerates_an_unaccounted_missing_artifact_and_discloses_it() {
let repo = modified_git_repo();
crate::session::capture_worktree_review(crate::session::CaptureOptions::new(repo.path()))
.unwrap();
let source = crate::session::store::resolution::clone_local_store_dir(repo.path()).unwrap();
std::fs::remove_dir_all(source.join("artifacts/objects")).unwrap();
let home = repo.path().join("home");
std::fs::create_dir_all(&home).unwrap();
let before = tree_fingerprint(&home);
let preview = with_shore_home(&home, || {
preview_link_to_family(StoreLinkOptions::new(repo.path(), Some("fam".to_owned())))
})
.unwrap();
assert_eq!(preview.export_fidelity, "incomplete");
assert!(preview.folded_absent_artifact_count >= 1);
assert_eq!(tree_fingerprint(&home), before);
}
#[test]
fn dry_run_previews_a_store_with_a_removed_artifact_as_linkable() {
let repo = modified_git_repo();
let capture = crate::session::capture_worktree_review(crate::session::CaptureOptions::new(
repo.path(),
))
.unwrap();
let source = crate::session::store::resolution::clone_local_store_dir(repo.path()).unwrap();
crate::session::EventStore::open(&source)
.record_event_once(&removal_event_for(&capture.object_artifact_content_hash))
.unwrap();
std::fs::remove_dir_all(source.join("artifacts/objects")).unwrap();
let home = repo.path().join("home");
std::fs::create_dir_all(&home).unwrap();
let preview = with_shore_home(&home, || {
preview_link_to_family(StoreLinkOptions::new(repo.path(), Some("fam".to_owned())))
})
.unwrap();
assert_eq!(preview.export_fidelity, "full");
assert!(preview.folded_removal_event_count >= 1);
assert_eq!(preview.folded_absent_artifact_count, 0);
}
#[test]
fn dry_run_previews_the_filesystem_advisory_warning() {
let repo = git_repo();
let home = repo.path().join("Dropbox");
std::fs::create_dir_all(&home).unwrap();
let preview = with_shore_home(&home, || {
preview_link_to_family(StoreLinkOptions::new(repo.path(), Some("fam".to_owned())))
})
.unwrap();
assert!(preview.filesystem_warning.is_some());
assert!(preview.would_create_family);
}
#[test]
fn dry_run_previews_the_history_overlap_advisory_warning() {
let founder = git_repo();
let joiner = git_repo(); let home = founder.path().join("home");
std::fs::create_dir_all(&home).unwrap();
with_shore_home(&home, || {
link_store_to_family(StoreLinkOptions::new(
founder.path(),
Some("fam".to_owned()),
))
.unwrap()
});
let preview = with_shore_home(&home, || {
preview_link_to_family(StoreLinkOptions::new(joiner.path(), Some("fam".to_owned())))
})
.unwrap();
assert!(preview.history_overlap_warning.is_some());
assert!(!preview.would_create_family);
}
fn tree_fingerprint(root: &Path) -> BTreeMap<PathBuf, Vec<u8>> {
fn walk(dir: &Path, base: &Path, acc: &mut BTreeMap<PathBuf, Vec<u8>>) {
let Ok(entries) = std::fs::read_dir(dir) else {
return;
};
for entry in entries.flatten() {
let path = entry.path();
if path.is_dir() {
walk(&path, base, acc);
} else if let Ok(bytes) = std::fs::read(&path) {
let rel = path.strip_prefix(base).unwrap_or(&path).to_path_buf();
acc.insert(rel, bytes);
}
}
}
let mut acc = BTreeMap::new();
walk(root, root, &mut acc);
acc
}
fn modified_git_repo() -> tempfile::TempDir {
let repo = git_repo();
std::fs::write(repo.path().join("README.md"), "changed\n").unwrap();
repo
}
fn removal_event_for(content_hash: &str) -> crate::session::event::ShoreEvent {
use crate::model::JournalId;
use crate::session::event::{
ArtifactRemovedPayload, EventTarget, EventType, ShoreEvent, Writer,
};
ShoreEvent::new(
EventType::ArtifactRemoved,
ArtifactRemovedPayload::idempotency_key(content_hash),
EventTarget::for_journal(JournalId::new("journal:test")),
Writer::shore_local("0.1.0"),
ArtifactRemovedPayload {
content_hash: content_hash.to_owned(),
},
"2026-06-19T00:00:00Z",
)
.expect("removal event builds")
}
fn with_shore_home<T>(home: &Path, f: impl FnOnce() -> T) -> T {
unsafe {
std::env::set_var("SHORE_HOME", home);
}
let out = f();
unsafe {
std::env::remove_var("SHORE_HOME");
}
out
}
fn git_repo() -> tempfile::TempDir {
let repo = tempfile::tempdir().expect("temp git repo");
run_git(repo.path(), ["init"]);
run_git(repo.path(), ["config", "user.name", "Shore Tests"]);
run_git(
repo.path(),
["config", "user.email", "shore-tests@example.com"],
);
run_git(repo.path(), ["config", "commit.gpgsign", "false"]);
std::fs::write(
repo.path().join("README.md"),
format!("base {}\n", repo.path().display()),
)
.unwrap();
run_git(repo.path(), ["add", "--all"]);
run_git(repo.path(), ["commit", "-m", "base"]);
repo
}
fn run_git<I, S>(cwd: &Path, args: I)
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let output = Command::new("git")
.args(args)
.current_dir(cwd)
.output()
.unwrap();
assert!(output.status.success(), "git failed: {output:?}");
}
}