use std::collections::{BTreeMap, HashSet};
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{anyhow, bail, Context, Result};
use chrono::{DateTime, Utc};
use colored::Colorize;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::cli::OutputFormat;
use crate::config::Config;
use crate::storage::models::{Machine, Session, Tombstone};
use crate::storage::Database;
use crate::sync::gitref::{self, TreeEntry};
use crate::sync::keystore::{derive_store_key, generate_store_salt, store_id_from_salt, KeyStore};
use crate::sync::store::{
decrypt_session_record, decrypt_tombstones, encrypt_session_record, encrypt_tombstones,
SessionRecord,
};
use crate::sync::SyncError;
const SESSIONS_REF: &str = "refs/lore/sessions";
const GLOBAL_REMOTE: &str = "origin";
const MIN_PASSPHRASE_LEN: usize = 8;
const TOMBSTONES_PATH: &str = "meta/tombstones";
const MAX_SYNC_ATTEMPTS: usize = 5;
#[derive(clap::Args)]
#[command(after_help = "EXAMPLES:\n \
lore sync setup Create or join this repo's encrypted lore store\n \
lore sync Fetch, merge, and push reasoning history\n \
lore sync status Show sync state for this repo\n \
lore sync --remote upstream Sync against a non-default remote")]
pub struct Args {
#[command(subcommand)]
pub command: Option<SyncSubcommand>,
#[arg(long, global = true, default_value = "origin")]
pub remote: String,
#[arg(long, global = true)]
pub global: bool,
#[arg(long)]
pub quiet: bool,
}
#[derive(clap::Subcommand)]
pub enum SyncSubcommand {
#[command(
long_about = "Creates a new encrypted store for this repository, or joins an\n\
existing one already pushed to the remote. When joining, you are\n\
prompted for the shared passphrase and it is verified against an\n\
existing session. The derived key is stored locally so later syncs\n\
do not prompt again."
)]
Setup,
#[command(
long_about = "Reports whether the store is set up, how many local sessions are\n\
pending sync, the last sync time, and the local and remote ref state."
)]
Status {
#[arg(short, long, value_enum, default_value = "text")]
format: OutputFormat,
},
}
struct MachineIdentity {
id: String,
name: String,
}
#[derive(Serialize, Deserialize)]
struct SessionMeta {
id: Uuid,
tool: String,
started_at: DateTime<Utc>,
ended_at: Option<DateTime<Utc>>,
message_count: i32,
machine_id: Option<String>,
git_branch: Option<String>,
}
#[derive(Debug)]
struct SyncSummary {
pulled: usize,
pushed: usize,
}
#[derive(Serialize)]
struct StatusOutput {
set_up: bool,
keyed: bool,
unsynced_sessions: i32,
last_sync_at: Option<String>,
remote_store_exists: bool,
local_ref: Option<String>,
tracking_ref: Option<String>,
remote: String,
}
#[derive(Serialize)]
struct GlobalStatusOutput {
set_up: bool,
keyed: bool,
unsynced_sessions: i32,
last_sync_at: Option<String>,
remote_store_exists: bool,
local_ref: Option<String>,
tracking_ref: Option<String>,
remote: Option<String>,
store_path: String,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SyncStore {
PerRepo,
Global,
}
pub fn run(args: Args) -> Result<()> {
if args.global {
return match args.command {
Some(SyncSubcommand::Setup) => run_global_setup(),
Some(SyncSubcommand::Status { format }) => run_global_status(format),
None => run_global_sync(),
};
}
match args.command {
Some(SyncSubcommand::Setup) => run_setup(&args.remote),
Some(SyncSubcommand::Status { format }) => run_status(&args.remote, format),
None if args.quiet => run_sync_quiet(&args.remote),
None => run_sync(&args.remote),
}
}
fn run_setup(remote: &str) -> Result<()> {
let repo = current_repo()?;
let mut config = Config::load()?;
create_or_join_store(&repo, remote, &mut config)?;
println!("Run 'lore sync' to push your reasoning history.");
Ok(())
}
fn create_or_join_store(repo: &Path, remote: &str, config: &mut Config) -> Result<()> {
let machine = machine_identity(config)?;
let keystore = KeyStore::with_keychain(config.use_keychain);
gitref::fetch(repo, remote, SESSIONS_REF)
.with_context(|| format!("Failed to reach remote '{remote}'"))?;
match read_store_salt(repo, remote)? {
Some(salt) => {
println!("{}", "An existing lore store was found. Joining it.".bold());
println!("Enter the shared passphrase for this lore store.");
let passphrase = prompt_passphrase()?;
join_store(repo, remote, &keystore, &machine, &salt, &passphrase)?;
println!("{} Joined the lore store.", "Success!".green().bold());
}
None => {
println!("{}", "Setting up a new lore store.".bold());
println!(
"Your reasoning history is encrypted with a passphrase only you\n\
(and any teammates) know. It is never sent to the git host."
);
println!();
let passphrase = prompt_new_passphrase()?;
create_store(repo, remote, &keystore, &machine, &passphrase)?;
println!("{} Created the lore store.", "Success!".green().bold());
}
}
Ok(())
}
fn create_store(
repo: &Path,
remote: &str,
keystore: &KeyStore,
machine: &MachineIdentity,
passphrase: &str,
) -> Result<()> {
let salt = generate_store_salt();
let key = derive_store_key(passphrase, &salt)?;
establish_store(repo, remote, keystore, machine, &salt, &key)
}
fn join_store(
repo: &Path,
remote: &str,
keystore: &KeyStore,
machine: &MachineIdentity,
salt: &[u8],
passphrase: &str,
) -> Result<()> {
let key = derive_store_key(passphrase, salt)?;
if let Some(blob) = first_session_blob(repo, remote)? {
decrypt_session_record(&blob, &key).map_err(|_| {
anyhow!("Wrong passphrase: could not decrypt an existing session in the store")
})?;
}
establish_store(repo, remote, keystore, machine, salt, &key)
}
fn establish_store(
repo: &Path,
remote: &str,
keystore: &KeyStore,
machine: &MachineIdentity,
salt: &[u8],
key: &[u8],
) -> Result<()> {
let store_id = store_id_from_salt(salt);
keystore.store_key(&store_id, key)?;
let (base, parent) = store_base(repo, remote)?;
let mut machines = match &base {
Some(reference) => read_machines(repo, reference)?,
None => BTreeMap::new(),
};
machines.insert(machine.id.clone(), machine.name.clone());
let mut changes = BTreeMap::new();
changes.insert("meta/salt".to_string(), gitref::write_blob(repo, salt)?);
changes.insert(
"meta/machines.json".to_string(),
gitref::write_blob(repo, &serde_json::to_vec(&machines)?)?,
);
let tree = gitref::build_tree(repo, base.as_deref(), &changes)?;
let commit = gitref::commit_tree(repo, &tree, parent.as_deref(), "lore: set up store")?;
gitref::update_ref(repo, SESSIONS_REF, &commit)?;
gitref::push(repo, remote, SESSIONS_REF)
.with_context(|| format!("Failed to push the lore store to '{remote}'"))?;
if let Err(e) = gitref::add_lore_fetch_refspec(repo, remote) {
tracing::debug!("Could not add lore fetch refspec for '{remote}': {e}");
}
Ok(())
}
fn run_sync(remote: &str) -> Result<()> {
let repo = current_repo()?;
let mut config = Config::load()?;
let machine = machine_identity(&mut config)?;
let keystore = KeyStore::with_keychain(config.use_keychain);
let (key, salt) = load_store_credentials(&repo, remote, &keystore)?;
let mut db = Database::open_default()?;
let sessions = db.get_unsynced_sessions_for_repo(&repo)?;
let summary = perform_sync(&mut db, &repo, remote, &key, &salt, &machine, sessions)?;
println!(
"{} Pulled {}, pushed {}.",
"Sync complete.".green().bold(),
summary.pulled,
summary.pushed
);
Ok(())
}
fn run_sync_quiet(remote: &str) -> Result<()> {
let repo = current_repo()?;
sync_quiet_in_repo(&repo, remote)
}
fn sync_quiet_in_repo(repo: &Path, remote: &str) -> Result<()> {
let remote = match resolve_hook_remote(repo, remote)? {
Some(remote) => remote,
None => return Ok(()),
};
let salt = match read_store_salt(repo, &remote)? {
Some(salt) => salt,
None => return Ok(()),
};
let keystore = KeyStore::with_keychain(Config::load()?.use_keychain);
quiet_sync_with_keystore(repo, &remote, &salt, &keystore)
}
fn quiet_sync_with_keystore(
repo: &Path,
remote: &str,
salt: &[u8],
keystore: &KeyStore,
) -> Result<()> {
let store_id = store_id_from_salt(salt);
let key = match keystore.load_key(&store_id)? {
Some(key) => key,
None => return Ok(()),
};
let mut config = Config::load()?;
let machine = machine_identity(&mut config)?;
let mut db = Database::open_default()?;
let sessions = db.get_unsynced_sessions_for_repo(repo)?;
perform_sync(&mut db, repo, remote, &key, salt, &machine, sessions)?;
Ok(())
}
fn resolve_hook_remote(repo: &Path, remote: &str) -> Result<Option<String>> {
let remotes = configured_remotes(repo)?;
if remotes.iter().any(|r| r == remote) {
return Ok(Some(remote.to_string()));
}
if remotes.iter().any(|r| r == "origin") {
return Ok(Some("origin".to_string()));
}
Ok(None)
}
fn configured_remotes(repo: &Path) -> Result<Vec<String>> {
let output = Command::new("git")
.current_dir(repo)
.args(["remote"])
.output()
.context("Failed to run git remote")?;
if !output.status.success() {
return Ok(Vec::new());
}
Ok(String::from_utf8_lossy(&output.stdout)
.lines()
.map(|line| line.trim().to_string())
.filter(|line| !line.is_empty())
.collect())
}
fn perform_sync(
db: &mut Database,
repo: &Path,
remote: &str,
key: &[u8],
salt: &[u8],
machine: &MachineIdentity,
sessions: Vec<Session>,
) -> Result<SyncSummary> {
perform_sync_in_store(
SyncStore::PerRepo,
db,
repo,
remote,
key,
salt,
machine,
sessions,
)
}
#[allow(clippy::too_many_arguments)]
fn perform_sync_in_store(
store: SyncStore,
db: &mut Database,
repo: &Path,
remote: &str,
key: &[u8],
salt: &[u8],
machine: &MachineIdentity,
sessions: Vec<Session>,
) -> Result<SyncSummary> {
let mut pulled_total = 0;
for attempt in 0..MAX_SYNC_ATTEMPTS {
let is_last = attempt + 1 == MAX_SYNC_ATTEMPTS;
let old_local = gitref::resolve_ref(repo, SESSIONS_REF)?;
let fetched = gitref::fetch(repo, remote, SESSIONS_REF)
.with_context(|| format!("Failed to fetch the lore store from '{remote}'"))?;
let tracking_entries = if fetched.is_some() {
gitref::read_tracking_tree(repo, remote, SESSIONS_REF)?
} else {
Vec::new()
};
let remote_tombstones = read_remote_tombstones(repo, &tracking_entries, key)?;
db.add_tombstones(&remote_tombstones)?;
pulled_total += merge_remote_in_store(store, db, repo, &tracking_entries, key)?;
merge_machines(db, repo, &tracking_entries)?;
let tombstones = db.list_tombstones()?;
db.apply_tombstones(&tombstones)?;
let tracking_commit = match fetched {
Some(_) => {
gitref::resolve_ref(repo, &gitref::tracking_ref_name(remote, SESSIONS_REF)?)?
}
None => None,
};
let tree_base = tracking_commit.clone();
let commit_parent = tracking_commit.clone();
let mut changes = build_session_changes(db, repo, key, &sessions)?;
if let Some(local_commit) = &old_local {
let in_scope = match store {
SyncStore::PerRepo => db.get_session_ids_for_repo(repo)?,
SyncStore::Global => db.get_all_session_ids()?,
};
let carry_tracking: &[TreeEntry] = if tracking_commit.is_some() {
&tracking_entries
} else {
&[]
};
carry_forward_local_sessions(
repo,
local_commit,
carry_tracking,
&in_scope,
&mut changes,
)?;
}
add_meta_changes(db, repo, tree_base.as_deref(), salt, machine, &mut changes)?;
add_tombstone_changes(db, repo, &remote_tombstones, key, &mut changes)?;
let tree = gitref::build_tree(repo, tree_base.as_deref(), &changes)?;
let message = format!("lore: sync {} session(s)", sessions.len());
let commit = gitref::commit_tree(repo, &tree, commit_parent.as_deref(), &message)?;
match gitref::update_ref_checked(repo, SESSIONS_REF, &commit, old_local.as_deref()) {
Ok(()) => {}
Err(SyncError::RefCasMismatch(_)) if !is_last => continue,
Err(e) => return Err(e.into()),
}
match gitref::push(repo, remote, SESSIONS_REF) {
Ok(()) => {}
Err(e) if !is_last && is_non_fast_forward(&e) => continue,
Err(e) => {
return Err(anyhow::Error::from(e))
.with_context(|| format!("Failed to push the lore store to '{remote}'"));
}
}
let ids: Vec<Uuid> = sessions.iter().map(|s| s.id).collect();
match store {
SyncStore::PerRepo => db.mark_sessions_synced(&ids, Utc::now())?,
SyncStore::Global => db.mark_global_synced(&ids, Utc::now())?,
};
return Ok(SyncSummary {
pulled: pulled_total,
pushed: sessions.len(),
});
}
bail!("Sync did not converge after {MAX_SYNC_ATTEMPTS} attempts due to concurrent updates")
}
#[cfg(test)]
fn merge_remote(
db: &mut Database,
repo: &Path,
entries: &[TreeEntry],
key: &[u8],
) -> Result<usize> {
merge_remote_in_store(SyncStore::PerRepo, db, repo, entries, key)
}
fn merge_remote_in_store(
store: SyncStore,
db: &mut Database,
repo: &Path,
entries: &[TreeEntry],
key: &[u8],
) -> Result<usize> {
let mut pulled = 0;
let mut session_blobs = 0;
let mut decrypted = 0;
for entry in entries {
if !is_session_blob(&entry.path) {
continue;
}
session_blobs += 1;
let blob = gitref::read_blob(repo, &entry.sha)?;
let record = match decrypt_session_record(&blob, key) {
Ok(record) => record,
Err(e) => {
tracing::debug!("Skipping undecryptable session blob {}: {e}", entry.path);
continue;
}
};
decrypted += 1;
let now = Utc::now();
let imported = match store {
SyncStore::PerRepo => db.merge_remote_record(
&record.session,
&record.messages,
&record.links,
&record.tags,
&record.annotations,
record.summary.as_ref(),
now,
)?,
SyncStore::Global => db.merge_remote_record_global(
&record.session,
&record.messages,
&record.links,
&record.tags,
&record.annotations,
record.summary.as_ref(),
now,
)?,
};
if imported {
pulled += 1;
}
}
if session_blobs > 0 && decrypted == 0 {
bail!(
"Could not decrypt any of the {session_blobs} session(s) in the remote lore store. \
The sync key stored on this machine does not match this store's passphrase. \
Run 'lore sync setup' to re-enter the correct passphrase."
);
}
Ok(pulled)
}
fn merge_machines(db: &Database, repo: &Path, entries: &[TreeEntry]) -> Result<()> {
if let Some(bytes) = blob_at_path(repo, entries, "meta/machines.json")? {
let machines: BTreeMap<String, String> = serde_json::from_slice(&bytes).unwrap_or_default();
let now = Utc::now().to_rfc3339();
for (id, name) in machines {
db.upsert_machine(&Machine {
id,
name,
created_at: now.clone(),
})?;
}
}
Ok(())
}
fn build_session_changes(
db: &Database,
repo: &Path,
key: &[u8],
sessions: &[Session],
) -> Result<BTreeMap<String, String>> {
let mut changes = BTreeMap::new();
for session in sessions {
let record = assemble_record(db, session)?;
let blob = encrypt_session_record(&record, key)?;
let enc_sha = gitref::write_blob(repo, &blob)?;
let meta = SessionMeta {
id: session.id,
tool: session.tool.clone(),
started_at: session.started_at,
ended_at: session.ended_at,
message_count: session.message_count,
machine_id: session.machine_id.clone(),
git_branch: session.git_branch.clone(),
};
let meta_sha = gitref::write_blob(repo, &serde_json::to_vec(&meta)?)?;
changes.insert(format!("sessions/{}.enc", session.id), enc_sha);
changes.insert(format!("sessions/{}.meta.json", session.id), meta_sha);
}
Ok(changes)
}
fn add_meta_changes(
db: &Database,
repo: &Path,
base: Option<&str>,
salt: &[u8],
machine: &MachineIdentity,
changes: &mut BTreeMap<String, String>,
) -> Result<()> {
changes.insert("meta/salt".to_string(), gitref::write_blob(repo, salt)?);
let mut machines = match base {
Some(reference) => read_machines(repo, reference)?,
None => BTreeMap::new(),
};
for m in db.list_machines()? {
machines.insert(m.id, m.name);
}
machines.insert(machine.id.clone(), machine.name.clone());
changes.insert(
"meta/machines.json".to_string(),
gitref::write_blob(repo, &serde_json::to_vec(&machines)?)?,
);
Ok(())
}
fn read_remote_tombstones(
repo: &Path,
entries: &[TreeEntry],
key: &[u8],
) -> Result<Vec<Tombstone>> {
match blob_at_path(repo, entries, TOMBSTONES_PATH)? {
Some(bytes) => match decrypt_tombstones(&bytes, key) {
Ok(tombstones) => Ok(tombstones),
Err(e) => {
tracing::debug!("Could not decrypt remote tombstones: {e}");
Ok(Vec::new())
}
},
None => Ok(Vec::new()),
}
}
fn add_tombstone_changes(
db: &Database,
repo: &Path,
remote_tombstones: &[Tombstone],
key: &[u8],
changes: &mut BTreeMap<String, String>,
) -> Result<()> {
let local = db.list_tombstones()?;
if tombstone_keys_equal(&local, remote_tombstones) {
return Ok(());
}
let blob = encrypt_tombstones(&local, key)?;
let sha = gitref::write_blob(repo, &blob)?;
changes.insert(TOMBSTONES_PATH.to_string(), sha);
Ok(())
}
fn tombstone_keys_equal(a: &[Tombstone], b: &[Tombstone]) -> bool {
let keys_a: HashSet<(&str, &str)> = a
.iter()
.map(|t| (t.child_id.as_str(), t.kind.as_str()))
.collect();
let keys_b: HashSet<(&str, &str)> = b
.iter()
.map(|t| (t.child_id.as_str(), t.kind.as_str()))
.collect();
keys_a == keys_b
}
fn assemble_record(db: &Database, session: &Session) -> Result<SessionRecord> {
Ok(SessionRecord {
session: session.clone(),
messages: db.get_messages(&session.id)?,
links: db.get_links_by_session(&session.id)?,
tags: db.get_tags(&session.id)?,
annotations: db.get_annotations(&session.id)?,
summary: db.get_summary(&session.id)?,
})
}
fn run_status(remote: &str, format: OutputFormat) -> Result<()> {
let repo = current_repo()?;
let config = Config::load()?;
let keystore = KeyStore::with_keychain(config.use_keychain);
let db = Database::open_default()?;
let salt = read_store_salt(&repo, remote)?;
let keyed = match &salt {
Some(salt) => keystore.load_key(&store_id_from_salt(salt))?.is_some(),
None => false,
};
let set_up = salt.is_some();
let unsynced = db.unsynced_session_count_for_repo(&repo)?;
let last_sync = db.last_sync_time()?;
let remote_exists = gitref::remote_ref_exists(&repo, remote, SESSIONS_REF).unwrap_or(false);
let local_ref = gitref::resolve_ref(&repo, SESSIONS_REF)?;
let tracking_ref =
gitref::resolve_ref(&repo, &gitref::tracking_ref_name(remote, SESSIONS_REF)?)?;
match format {
OutputFormat::Json => {
let output = StatusOutput {
set_up,
keyed,
unsynced_sessions: unsynced,
last_sync_at: last_sync.map(|t| t.to_rfc3339()),
remote_store_exists: remote_exists,
local_ref: local_ref.clone(),
tracking_ref: tracking_ref.clone(),
remote: remote.to_string(),
};
println!("{}", serde_json::to_string_pretty(&output)?);
}
OutputFormat::Text | OutputFormat::Markdown => {
println!("{}", "Lore Sync".bold());
println!();
if set_up && keyed {
println!(" Store: {}", "set up".green());
} else if set_up {
println!(
" Store: {} (run 'lore sync setup')",
"no key on this machine".yellow()
);
} else {
println!(
" Store: {} (run 'lore sync setup')",
"not set up".yellow()
);
}
println!(" Remote: {remote}");
println!(
" Remote store: {}",
if remote_exists { "present" } else { "none" }
);
println!(" Pending sync: {unsynced}");
match last_sync {
Some(t) => println!(" Last sync: {}", t.to_rfc3339()),
None => println!(" Last sync: {}", "never".dimmed()),
}
println!(
" Local ref: {}",
local_ref.as_deref().unwrap_or("none")
);
println!(
" Tracking ref: {}",
tracking_ref.as_deref().unwrap_or("none")
);
}
}
Ok(())
}
fn run_global_setup() -> Result<()> {
let mut config = Config::load()?;
let remote_url = match config.sync_global_remote.clone() {
Some(url) => url,
None => {
let url = prompt_global_remote_url()?;
config.sync_global_remote = Some(url.clone());
config.save()?;
url
}
};
let repo = global_store_path()?;
ensure_global_repo(&repo, &remote_url)?;
create_or_join_store(&repo, GLOBAL_REMOTE, &mut config)?;
println!("Run 'lore sync --global' to push your reasoning history.");
Ok(())
}
fn run_global_sync() -> Result<()> {
let mut config = Config::load()?;
let remote_url = config.sync_global_remote.clone().ok_or_else(|| {
anyhow!("The global store is not set up. Run 'lore sync --global setup' first.")
})?;
let machine = machine_identity(&mut config)?;
let keystore = KeyStore::with_keychain(config.use_keychain);
let repo = global_store_path()?;
ensure_global_repo(&repo, &remote_url)?;
let (key, salt) = load_store_credentials(&repo, GLOBAL_REMOTE, &keystore)?;
let mut db = Database::open_default()?;
let sessions = db.get_unsynced_global_sessions()?;
let summary = perform_sync_in_store(
SyncStore::Global,
&mut db,
&repo,
GLOBAL_REMOTE,
&key,
&salt,
&machine,
sessions,
)?;
println!(
"{} Pulled {}, pushed {}.",
"Global sync complete.".green().bold(),
summary.pulled,
summary.pushed
);
Ok(())
}
fn run_global_status(format: OutputFormat) -> Result<()> {
let config = Config::load()?;
let keystore = KeyStore::with_keychain(config.use_keychain);
let db = Database::open_default()?;
let remote_url = config.sync_global_remote.clone();
let repo = global_store_path()?;
let repo_ready = repo.join(".git").exists();
let salt = if repo_ready {
read_store_salt(&repo, GLOBAL_REMOTE)?
} else {
None
};
let keyed = match &salt {
Some(salt) => keystore.load_key(&store_id_from_salt(salt))?.is_some(),
None => false,
};
let set_up = salt.is_some();
let unsynced = db.unsynced_global_count()?;
let last_sync = db.last_global_sync_time()?;
let (remote_exists, local_ref, tracking_ref) = if repo_ready {
(
gitref::remote_ref_exists(&repo, GLOBAL_REMOTE, SESSIONS_REF).unwrap_or(false),
gitref::resolve_ref(&repo, SESSIONS_REF)?,
gitref::resolve_ref(
&repo,
&gitref::tracking_ref_name(GLOBAL_REMOTE, SESSIONS_REF)?,
)?,
)
} else {
(false, None, None)
};
match format {
OutputFormat::Json => {
let output = GlobalStatusOutput {
set_up,
keyed,
unsynced_sessions: unsynced,
last_sync_at: last_sync.map(|t| t.to_rfc3339()),
remote_store_exists: remote_exists,
local_ref: local_ref.clone(),
tracking_ref: tracking_ref.clone(),
remote: remote_url.clone(),
store_path: repo.display().to_string(),
};
println!("{}", serde_json::to_string_pretty(&output)?);
}
OutputFormat::Text | OutputFormat::Markdown => {
println!("{}", "Lore Global Sync".bold());
println!();
if set_up && keyed {
println!(" Store: {}", "set up".green());
} else if set_up {
println!(
" Store: {} (run 'lore sync --global setup')",
"no key on this machine".yellow()
);
} else {
println!(
" Store: {} (run 'lore sync --global setup')",
"not set up".yellow()
);
}
match &remote_url {
Some(url) => println!(" Remote: {url}"),
None => println!(" Remote: {}", "not configured".yellow()),
}
println!(" Store path: {}", repo.display());
println!(
" Remote store: {}",
if remote_exists { "present" } else { "none" }
);
println!(" Pending sync: {unsynced}");
match last_sync {
Some(t) => println!(" Last sync: {}", t.to_rfc3339()),
None => println!(" Last sync: {}", "never".dimmed()),
}
println!(
" Local ref: {}",
local_ref.as_deref().unwrap_or("none")
);
println!(
" Tracking ref: {}",
tracking_ref.as_deref().unwrap_or("none")
);
}
}
Ok(())
}
fn global_store_path() -> Result<PathBuf> {
let home = dirs::home_dir().ok_or_else(|| anyhow!("Could not find home directory"))?;
Ok(home.join(".lore").join("sync"))
}
fn ensure_global_repo(repo: &Path, remote_url: &str) -> Result<()> {
std::fs::create_dir_all(repo).with_context(|| {
format!(
"Failed to create the global store directory: {}",
repo.display()
)
})?;
if !repo.join(".git").exists() {
run_git_checked(repo, &["init", "-q"], "initialize the global store repo")?;
}
run_git_checked(
repo,
&["config", "user.name", "lore"],
"configure the global store committer name",
)?;
run_git_checked(
repo,
&["config", "user.email", "lore@localhost"],
"configure the global store committer email",
)?;
run_git_checked(
repo,
&["config", "commit.gpgsign", "false"],
"disable commit signing for the global store",
)?;
configure_origin_remote(repo, remote_url)?;
Ok(())
}
fn configure_origin_remote(repo: &Path, remote_url: &str) -> Result<()> {
let has_origin = Command::new("git")
.current_dir(repo)
.args(["remote", "get-url", GLOBAL_REMOTE])
.output()
.context("Failed to run git remote get-url")?
.status
.success();
let args: [&str; 4] = if has_origin {
["remote", "set-url", GLOBAL_REMOTE, remote_url]
} else {
["remote", "add", GLOBAL_REMOTE, remote_url]
};
run_git_checked(repo, &args, "configure the global store remote")?;
Ok(())
}
fn run_git_checked(repo: &Path, args: &[&str], action: &str) -> Result<()> {
let output = Command::new("git")
.current_dir(repo)
.args(args)
.output()
.with_context(|| format!("Failed to run git to {action}"))?;
if !output.status.success() {
bail!(
"Failed to {action}: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(())
}
fn prompt_global_remote_url() -> Result<String> {
println!("{}", "Set up your global personal store.".bold());
println!(
"Enter the git remote URL of a private repository only you can access.\n\
It holds your cross-tool, cross-repo reasoning history for personal\n\
multi-machine backup and search."
);
print!("Remote URL: ");
io::stdout().flush()?;
let mut url = String::new();
io::stdin().read_line(&mut url)?;
let url = url.trim().to_string();
if url.is_empty() {
bail!("A remote URL is required to set up the global store.");
}
Ok(url)
}
fn load_store_credentials(
repo: &Path,
remote: &str,
keystore: &KeyStore,
) -> Result<(Vec<u8>, Vec<u8>)> {
let salt = read_store_salt(repo, remote)?.ok_or_else(|| {
anyhow!("This repo's lore store is not set up. Run 'lore sync setup' first.")
})?;
let store_id = store_id_from_salt(&salt);
let key = keystore.load_key(&store_id)?.ok_or_else(|| {
anyhow!("No sync key for this repo on this machine. Run 'lore sync setup' first.")
})?;
Ok((key, salt))
}
fn read_store_salt(repo: &Path, remote: &str) -> Result<Option<Vec<u8>>> {
if gitref::ref_exists(repo, SESSIONS_REF)? {
if let Some(bytes) = read_ref_path(repo, SESSIONS_REF, "meta/salt")? {
return Ok(Some(bytes));
}
}
let tracking = gitref::tracking_ref_name(remote, SESSIONS_REF)?;
if gitref::ref_exists(repo, &tracking)? {
if let Some(bytes) = read_ref_path(repo, &tracking, "meta/salt")? {
return Ok(Some(bytes));
}
}
Ok(None)
}
fn first_session_blob(repo: &Path, remote: &str) -> Result<Option<Vec<u8>>> {
let tracking = gitref::tracking_ref_name(remote, SESSIONS_REF)?;
for reference in [tracking.as_str(), SESSIONS_REF] {
if !gitref::ref_exists(repo, reference)? {
continue;
}
let entries = gitref::read_tree(repo, reference)?;
if let Some(sha) = entries
.iter()
.find(|e| is_session_blob(&e.path))
.map(|e| e.sha.clone())
{
return Ok(Some(gitref::read_blob(repo, &sha)?));
}
}
Ok(None)
}
fn store_base(repo: &Path, remote: &str) -> Result<(Option<String>, Option<String>)> {
let tracking = gitref::tracking_ref_name(remote, SESSIONS_REF)?;
if gitref::ref_exists(repo, &tracking)? {
return Ok((
Some(tracking.clone()),
gitref::resolve_ref(repo, &tracking)?,
));
}
if let Some(commit) = gitref::resolve_ref(repo, SESSIONS_REF)? {
return Ok((Some(SESSIONS_REF.to_string()), Some(commit)));
}
Ok((None, None))
}
fn read_machines(repo: &Path, reference: &str) -> Result<BTreeMap<String, String>> {
match read_ref_path(repo, reference, "meta/machines.json")? {
Some(bytes) => Ok(serde_json::from_slice(&bytes).unwrap_or_default()),
None => Ok(BTreeMap::new()),
}
}
fn read_ref_path(repo: &Path, reference: &str, path: &str) -> Result<Option<Vec<u8>>> {
let entries = gitref::read_tree(repo, reference)?;
blob_at_path(repo, &entries, path)
}
fn blob_at_path(repo: &Path, entries: &[TreeEntry], path: &str) -> Result<Option<Vec<u8>>> {
match entries.iter().find(|e| e.path == path) {
Some(entry) => Ok(Some(gitref::read_blob(repo, &entry.sha)?)),
None => Ok(None),
}
}
fn is_session_blob(path: &str) -> bool {
path.starts_with("sessions/") && path.ends_with(".enc")
}
fn is_session_path(path: &str) -> bool {
path.starts_with("sessions/") && (path.ends_with(".enc") || path.ends_with(".meta.json"))
}
fn session_uuid_from_path(path: &str) -> Option<Uuid> {
let name = path.strip_prefix("sessions/")?;
let stem = name
.strip_suffix(".meta.json")
.or_else(|| name.strip_suffix(".enc"))?;
Uuid::parse_str(stem).ok()
}
fn carry_forward_local_sessions(
repo: &Path,
local_commit: &str,
tracking_entries: &[TreeEntry],
in_scope: &HashSet<Uuid>,
changes: &mut BTreeMap<String, String>,
) -> Result<()> {
let tracking_paths: HashSet<&str> = tracking_entries.iter().map(|e| e.path.as_str()).collect();
for entry in gitref::read_tree(repo, local_commit)? {
if !is_session_path(&entry.path) || tracking_paths.contains(entry.path.as_str()) {
continue;
}
match session_uuid_from_path(&entry.path) {
Some(id) if in_scope.contains(&id) => {}
_ => continue,
}
changes.entry(entry.path.clone()).or_insert(entry.sha);
}
Ok(())
}
fn is_non_fast_forward(err: &SyncError) -> bool {
let message = err.to_string().to_lowercase();
message.contains("fast-forward")
|| message.contains("non-fast")
|| message.contains("rejected")
|| message.contains("fetch first")
|| message.contains("stale info")
}
fn current_repo() -> Result<PathBuf> {
let output = Command::new("git")
.args(["rev-parse", "--show-toplevel"])
.output()
.context("Failed to run git")?;
if !output.status.success() {
bail!("Not inside a git repository. Run this command from within a repo.");
}
let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
if path.is_empty() {
bail!("Not inside a git repository. Run this command from within a repo.");
}
Ok(PathBuf::from(path))
}
fn machine_identity(config: &mut Config) -> Result<MachineIdentity> {
let id = config.get_or_create_machine_id()?;
let name = config.get_machine_name();
Ok(MachineIdentity { id, name })
}
fn prompt_new_passphrase() -> Result<String> {
loop {
print!("Enter passphrase: ");
io::stdout().flush()?;
let passphrase = rpassword::read_password()?;
if passphrase.len() < MIN_PASSPHRASE_LEN {
println!(
"{}",
format!("Passphrase must be at least {MIN_PASSPHRASE_LEN} characters.").red()
);
continue;
}
print!("Confirm passphrase: ");
io::stdout().flush()?;
let confirm = rpassword::read_password()?;
if passphrase != confirm {
println!("{}", "Passphrases do not match.".red());
continue;
}
return Ok(passphrase);
}
}
fn prompt_passphrase() -> Result<String> {
print!("Passphrase: ");
io::stdout().flush()?;
let passphrase = rpassword::read_password()?;
Ok(passphrase)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::storage::models::{
Annotation, LinkCreator, LinkType, Message, MessageContent, MessageRole, SessionLink,
Summary, Tag,
};
use std::process::Command;
use tempfile::TempDir;
fn git(repo: &Path, args: &[&str]) {
let output = Command::new("git")
.current_dir(repo)
.args(args)
.output()
.expect("failed to spawn git");
assert!(
output.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
}
fn git_out(repo: &Path, args: &[&str]) -> String {
let output = Command::new("git")
.current_dir(repo)
.args(args)
.output()
.expect("failed to spawn git");
assert!(
output.status.success(),
"git {args:?} failed: {}",
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout).trim().to_string()
}
fn init_repo(repo: &Path) {
git(repo, &["init", "-q"]);
git(repo, &["config", "user.name", "Lore Test"]);
git(repo, &["config", "user.email", "test@example.com"]);
git(repo, &["config", "commit.gpgsign", "false"]);
git(repo, &["config", "tag.gpgsign", "false"]);
}
fn init_bare_remote() -> (TempDir, String) {
let dir = tempfile::tempdir().unwrap();
git(dir.path(), &["init", "--bare", "-q"]);
let url = dir.path().to_str().unwrap().to_string();
(dir, url)
}
fn open_db() -> (Database, TempDir) {
let dir = tempfile::tempdir().unwrap();
let db = Database::open(&dir.path().join("lore.db")).unwrap();
(db, dir)
}
fn test_keystore() -> (KeyStore, TempDir) {
let dir = tempfile::tempdir().unwrap();
let store = KeyStore::with_base_dir(dir.path().to_path_buf(), false);
(store, dir)
}
fn machine(id: &str, name: &str) -> MachineIdentity {
MachineIdentity {
id: id.to_string(),
name: name.to_string(),
}
}
fn repo_dir(repo: &Path) -> String {
repo.canonicalize().unwrap().to_string_lossy().to_string()
}
fn scoped_unsynced(db: &Database, repo: &Path) -> Vec<Session> {
db.get_unsynced_sessions_for_repo(repo).unwrap()
}
fn seed_full_session(db: &mut Database, machine_id: &str, working_directory: &str) -> Uuid {
let id = Uuid::new_v4();
let session = Session {
id,
tool: "claude-code".to_string(),
tool_version: Some("1.0.0".to_string()),
started_at: Utc::now(),
ended_at: Some(Utc::now()),
model: Some("claude-opus".to_string()),
working_directory: working_directory.to_string(),
git_branch: Some("main".to_string()),
source_path: None,
message_count: 1,
machine_id: Some(machine_id.to_string()),
};
let message = Message {
id: Uuid::new_v4(),
session_id: id,
parent_id: None,
index: 0,
timestamp: Utc::now(),
role: MessageRole::User,
content: MessageContent::Text("fix the bug".to_string()),
model: None,
git_branch: Some("main".to_string()),
cwd: Some(working_directory.to_string()),
};
db.import_session_with_messages(&session, &[message], None)
.unwrap();
db.insert_link(&SessionLink {
id: Uuid::new_v4(),
session_id: id,
link_type: LinkType::Commit,
commit_sha: Some("deadbeef".to_string()),
branch: Some("main".to_string()),
remote: Some("origin".to_string()),
created_at: Utc::now(),
created_by: LinkCreator::User,
confidence: Some(0.9),
})
.unwrap();
db.insert_tag(&Tag {
id: Uuid::new_v4(),
session_id: id,
label: "feature".to_string(),
created_at: Utc::now(),
})
.unwrap();
db.insert_annotation(&Annotation {
id: Uuid::new_v4(),
session_id: id,
content: "an important note".to_string(),
created_at: Utc::now(),
})
.unwrap();
db.insert_summary(&Summary {
id: Uuid::new_v4(),
session_id: id,
content: "fixed the parser".to_string(),
generated_at: Utc::now(),
})
.unwrap();
id
}
fn session_blob_sha(repo: &Path) -> String {
let entries = gitref::read_tree(repo, SESSIONS_REF).unwrap();
entries
.iter()
.find(|e| is_session_blob(&e.path))
.expect("a session blob should exist")
.sha
.clone()
}
fn session_blob_sha_path(repo: &Path) -> String {
let entries = gitref::read_tree(repo, SESSIONS_REF).unwrap();
entries
.iter()
.find(|e| is_session_blob(&e.path))
.expect("a session blob should exist")
.path
.clone()
}
#[test]
fn test_create_store_writes_salt_and_pushes() {
let (_remote_dir, remote_url) = init_bare_remote();
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
init_repo(repo);
git(repo, &["remote", "add", "origin", &remote_url]);
let (keystore, _kd) = test_keystore();
let m = machine("machine-a", "Machine A");
create_store(repo, "origin", &keystore, &m, "correct horse battery").unwrap();
assert!(gitref::ref_exists(repo, SESSIONS_REF).unwrap());
let salt = read_store_salt(repo, "origin")
.unwrap()
.expect("salt written");
let store_id = store_id_from_salt(&salt);
assert!(keystore.load_key(&store_id).unwrap().is_some());
assert!(gitref::remote_ref_exists(repo, "origin", SESSIONS_REF).unwrap());
}
#[test]
fn test_sync_round_trip_between_machines() {
let (_remote_dir, remote_url) = init_bare_remote();
let passphrase = "shared team passphrase";
let dir_a = tempfile::tempdir().unwrap();
let repo_a = dir_a.path();
init_repo(repo_a);
git(repo_a, &["remote", "add", "origin", &remote_url]);
let (keystore_a, _ka) = test_keystore();
let ma = machine("machine-a", "Machine A");
create_store(repo_a, "origin", &keystore_a, &ma, passphrase).unwrap();
let (mut db_a, _da) = open_db();
let session_id = seed_full_session(&mut db_a, "machine-a", &repo_dir(repo_a));
let (key_a, salt_a) = load_store_credentials(repo_a, "origin", &keystore_a).unwrap();
let sessions_a = scoped_unsynced(&db_a, repo_a);
let summary_a = perform_sync(
&mut db_a, repo_a, "origin", &key_a, &salt_a, &ma, sessions_a,
)
.unwrap();
assert_eq!(summary_a.pushed, 1);
let dir_b = tempfile::tempdir().unwrap();
let repo_b = dir_b.path();
init_repo(repo_b);
git(repo_b, &["remote", "add", "origin", &remote_url]);
let (keystore_b, _kb) = test_keystore();
let mb = machine("machine-b", "Machine B");
gitref::fetch(repo_b, "origin", SESSIONS_REF).unwrap();
let salt_b = read_store_salt(repo_b, "origin").unwrap().unwrap();
join_store(repo_b, "origin", &keystore_b, &mb, &salt_b, passphrase).unwrap();
let (mut db_b, _db) = open_db();
let (key_b, salt_b2) = load_store_credentials(repo_b, "origin", &keystore_b).unwrap();
assert_eq!(key_a, key_b);
let sessions_b = scoped_unsynced(&db_b, repo_b);
let summary_b = perform_sync(
&mut db_b, repo_b, "origin", &key_b, &salt_b2, &mb, sessions_b,
)
.unwrap();
assert_eq!(summary_b.pulled, 1);
let pulled = db_b
.get_session(&session_id)
.unwrap()
.expect("session pulled");
assert_eq!(pulled.tool, "claude-code");
assert_eq!(db_b.get_messages(&session_id).unwrap().len(), 1);
let links = db_b.get_links_by_session(&session_id).unwrap();
assert_eq!(links.len(), 1);
assert_eq!(links[0].commit_sha, Some("deadbeef".to_string()));
assert_eq!(db_b.get_tags(&session_id).unwrap()[0].label, "feature");
assert_eq!(db_b.get_annotations(&session_id).unwrap().len(), 1);
assert_eq!(
db_b.get_summary(&session_id).unwrap().unwrap().content,
"fixed the parser"
);
}
#[test]
fn test_incremental_sync_preserves_unchanged_blob() {
let (_remote_dir, remote_url) = init_bare_remote();
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
init_repo(repo);
git(repo, &["remote", "add", "origin", &remote_url]);
let (keystore, _kd) = test_keystore();
let m = machine("machine-a", "Machine A");
create_store(repo, "origin", &keystore, &m, "passphrase one two").unwrap();
let (mut db, _dd) = open_db();
seed_full_session(&mut db, "machine-a", &repo_dir(repo));
let (key, salt) = load_store_credentials(repo, "origin", &keystore).unwrap();
let sessions = scoped_unsynced(&db, repo);
perform_sync(&mut db, repo, "origin", &key, &salt, &m, sessions).unwrap();
let sha_first = session_blob_sha(repo);
let sessions = scoped_unsynced(&db, repo);
let summary = perform_sync(&mut db, repo, "origin", &key, &salt, &m, sessions).unwrap();
assert_eq!(summary.pushed, 0);
let sha_second = session_blob_sha(repo);
assert_eq!(
sha_first, sha_second,
"unchanged session blob must be reused"
);
}
#[test]
fn test_sync_errors_when_not_set_up() {
let (_remote_dir, remote_url) = init_bare_remote();
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
init_repo(repo);
git(repo, &["remote", "add", "origin", &remote_url]);
let (keystore, _kd) = test_keystore();
let err = load_store_credentials(repo, "origin", &keystore).unwrap_err();
assert!(
err.to_string().contains("lore sync setup"),
"error should point to setup: {err}"
);
}
#[test]
fn test_join_with_wrong_passphrase_fails() {
let (_remote_dir, remote_url) = init_bare_remote();
let dir_a = tempfile::tempdir().unwrap();
let repo_a = dir_a.path();
init_repo(repo_a);
git(repo_a, &["remote", "add", "origin", &remote_url]);
let (keystore_a, _ka) = test_keystore();
let ma = machine("machine-a", "Machine A");
create_store(repo_a, "origin", &keystore_a, &ma, "the real passphrase").unwrap();
let (mut db_a, _da) = open_db();
seed_full_session(&mut db_a, "machine-a", &repo_dir(repo_a));
let (key_a, salt_a) = load_store_credentials(repo_a, "origin", &keystore_a).unwrap();
let sessions_a = scoped_unsynced(&db_a, repo_a);
perform_sync(
&mut db_a, repo_a, "origin", &key_a, &salt_a, &ma, sessions_a,
)
.unwrap();
let dir_b = tempfile::tempdir().unwrap();
let repo_b = dir_b.path();
init_repo(repo_b);
git(repo_b, &["remote", "add", "origin", &remote_url]);
let (keystore_b, _kb) = test_keystore();
let mb = machine("machine-b", "Machine B");
gitref::fetch(repo_b, "origin", SESSIONS_REF).unwrap();
let salt_b = read_store_salt(repo_b, "origin").unwrap().unwrap();
let err = join_store(
repo_b,
"origin",
&keystore_b,
&mb,
&salt_b,
"the WRONG passphrase",
)
.unwrap_err();
assert!(
err.to_string().to_lowercase().contains("passphrase"),
"wrong passphrase should be reported: {err}"
);
}
#[test]
fn test_merge_skips_older_remote_session() {
let (_remote_dir, remote_url) = init_bare_remote();
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
init_repo(repo);
git(repo, &["remote", "add", "origin", &remote_url]);
let (keystore, _kd) = test_keystore();
let m = machine("machine-a", "Machine A");
create_store(repo, "origin", &keystore, &m, "passphrase abcdefgh").unwrap();
let (key, _salt) = load_store_credentials(repo, "origin", &keystore).unwrap();
let (mut db, _dd) = open_db();
let id = Uuid::new_v4();
let mut session = Session {
id,
tool: "claude-code".to_string(),
tool_version: None,
started_at: Utc::now(),
ended_at: Some(Utc::now()),
model: None,
working_directory: "/proj".to_string(),
git_branch: None,
source_path: None,
message_count: 2,
machine_id: Some("machine-a".to_string()),
};
db.import_session_with_messages(&session, &[], Some(Utc::now()))
.unwrap();
session.message_count = 1;
let record = SessionRecord {
session: session.clone(),
messages: vec![],
links: vec![],
tags: vec![],
annotations: vec![],
summary: None,
};
let blob = encrypt_session_record(&record, &key).unwrap();
let sha = gitref::write_blob(repo, &blob).unwrap();
let entries = vec![TreeEntry {
mode: "100644".to_string(),
sha,
path: format!("sessions/{id}.enc"),
}];
let pulled = merge_remote(&mut db, repo, &entries, &key).unwrap();
assert_eq!(pulled, 0, "older remote session must be skipped");
assert_eq!(db.get_session(&id).unwrap().unwrap().message_count, 2);
}
#[test]
fn test_is_session_blob() {
assert!(is_session_blob("sessions/abc.enc"));
assert!(!is_session_blob("sessions/abc.meta.json"));
assert!(!is_session_blob("meta/salt"));
assert!(!is_session_blob("abc.enc"));
}
#[test]
fn test_is_non_fast_forward_detects_rejection() {
let rejected = SyncError::Git(
"git push origin failed: ! [rejected] refs/lore/sessions (fetch first)".to_string(),
);
assert!(is_non_fast_forward(&rejected));
let unrelated = SyncError::Git("git push origin failed: permission denied".to_string());
assert!(!is_non_fast_forward(&unrelated));
}
#[test]
fn test_is_session_path() {
assert!(is_session_path("sessions/abc.enc"));
assert!(is_session_path("sessions/abc.meta.json"));
assert!(!is_session_path("meta/salt"));
assert!(!is_session_path("abc.enc"));
}
#[test]
fn test_added_link_reopens_session_and_syncs() {
let (_remote_dir, remote_url) = init_bare_remote();
let passphrase = "shared team passphrase";
let dir_a = tempfile::tempdir().unwrap();
let repo_a = dir_a.path();
init_repo(repo_a);
git(repo_a, &["remote", "add", "origin", &remote_url]);
let (keystore_a, _ka) = test_keystore();
let ma = machine("machine-a", "Machine A");
create_store(repo_a, "origin", &keystore_a, &ma, passphrase).unwrap();
let (mut db_a, _da) = open_db();
let session_id = seed_full_session(&mut db_a, "machine-a", &repo_dir(repo_a));
let (key_a, salt_a) = load_store_credentials(repo_a, "origin", &keystore_a).unwrap();
let sessions_a = scoped_unsynced(&db_a, repo_a);
let first = perform_sync(
&mut db_a, repo_a, "origin", &key_a, &salt_a, &ma, sessions_a,
)
.unwrap();
assert_eq!(first.pushed, 1);
db_a.insert_link(&SessionLink {
id: Uuid::new_v4(),
session_id,
link_type: LinkType::Commit,
commit_sha: Some("feedface".to_string()),
branch: Some("main".to_string()),
remote: Some("origin".to_string()),
created_at: Utc::now(),
created_by: LinkCreator::Auto,
confidence: Some(0.8),
})
.unwrap();
let sessions_a = scoped_unsynced(&db_a, repo_a);
let second = perform_sync(
&mut db_a, repo_a, "origin", &key_a, &salt_a, &ma, sessions_a,
)
.unwrap();
assert_eq!(
second.pushed, 1,
"adding a link must re-export the parent session"
);
let dir_b = tempfile::tempdir().unwrap();
let repo_b = dir_b.path();
init_repo(repo_b);
git(repo_b, &["remote", "add", "origin", &remote_url]);
let (keystore_b, _kb) = test_keystore();
let mb = machine("machine-b", "Machine B");
gitref::fetch(repo_b, "origin", SESSIONS_REF).unwrap();
let salt_b = read_store_salt(repo_b, "origin").unwrap().unwrap();
join_store(repo_b, "origin", &keystore_b, &mb, &salt_b, passphrase).unwrap();
let (mut db_b, _db) = open_db();
let (key_b, salt_b2) = load_store_credentials(repo_b, "origin", &keystore_b).unwrap();
let sessions_b = scoped_unsynced(&db_b, repo_b);
perform_sync(
&mut db_b, repo_b, "origin", &key_b, &salt_b2, &mb, sessions_b,
)
.unwrap();
let links = db_b.get_links_by_session(&session_id).unwrap();
assert_eq!(links.len(), 2, "both links must reach the teammate");
}
#[test]
fn test_wrong_key_errors_before_push() {
let (_remote_dir, remote_url) = init_bare_remote();
let dir_a = tempfile::tempdir().unwrap();
let repo_a = dir_a.path();
init_repo(repo_a);
git(repo_a, &["remote", "add", "origin", &remote_url]);
let (keystore_a, _ka) = test_keystore();
let ma = machine("machine-a", "Machine A");
create_store(repo_a, "origin", &keystore_a, &ma, "the real passphrase").unwrap();
let (mut db_a, _da) = open_db();
seed_full_session(&mut db_a, "machine-a", &repo_dir(repo_a));
let (key_a, salt_a) = load_store_credentials(repo_a, "origin", &keystore_a).unwrap();
let sessions_a = scoped_unsynced(&db_a, repo_a);
perform_sync(
&mut db_a, repo_a, "origin", &key_a, &salt_a, &ma, sessions_a,
)
.unwrap();
let dir_b = tempfile::tempdir().unwrap();
let repo_b = dir_b.path();
init_repo(repo_b);
git(repo_b, &["remote", "add", "origin", &remote_url]);
let (keystore_b, _kb) = test_keystore();
let mb = machine("machine-b", "Machine B");
gitref::fetch(repo_b, "origin", SESSIONS_REF).unwrap();
let salt_b = read_store_salt(repo_b, "origin").unwrap().unwrap();
let wrong_key = derive_store_key("the WRONG passphrase", &salt_b).unwrap();
keystore_b
.store_key(&store_id_from_salt(&salt_b), &wrong_key)
.unwrap();
let (mut db_b, _db) = open_db();
let local_id = seed_full_session(&mut db_b, "machine-b", &repo_dir(repo_b));
let sessions_b = scoped_unsynced(&db_b, repo_b);
let err = perform_sync(
&mut db_b, repo_b, "origin", &wrong_key, &salt_b, &mb, sessions_b,
)
.unwrap_err();
let msg = err.to_string().to_lowercase();
assert!(
msg.contains("passphrase") || msg.contains("decrypt"),
"wrong key must be reported clearly: {err}"
);
let unsynced = db_b.get_unsynced_sessions().unwrap();
assert!(
unsynced.iter().any(|s| s.id == local_id),
"local session must not be marked synced after a wrong-key failure"
);
}
#[test]
fn test_carry_forward_local_sessions_readds_missing() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
init_repo(repo);
let id_a = Uuid::new_v4();
let id_b = Uuid::new_v4();
let a_enc = format!("sessions/{id_a}.enc");
let a_meta = format!("sessions/{id_a}.meta.json");
let b_enc = format!("sessions/{id_b}.enc");
let enc_sha = gitref::write_blob(repo, b"local-a-enc").unwrap();
let meta_sha = gitref::write_blob(repo, b"local-a-meta").unwrap();
let other_sha = gitref::write_blob(repo, b"remote-b-enc").unwrap();
let mut local = BTreeMap::new();
local.insert(a_enc.clone(), enc_sha.clone());
local.insert(a_meta.clone(), meta_sha.clone());
local.insert(b_enc.clone(), other_sha.clone());
let local_tree = gitref::build_tree(repo, None, &local).unwrap();
let local_commit = gitref::commit_tree(repo, &local_tree, None, "lore: local").unwrap();
let tracking = vec![TreeEntry {
mode: "100644".to_string(),
sha: other_sha,
path: b_enc.clone(),
}];
let mut changes = BTreeMap::new();
changes.insert(a_enc.clone(), "fresh-sha".to_string());
let in_scope: HashSet<Uuid> = [id_a, id_b].into_iter().collect();
carry_forward_local_sessions(repo, &local_commit, &tracking, &in_scope, &mut changes)
.unwrap();
assert_eq!(changes.get(&a_meta), Some(&meta_sha));
assert_eq!(changes.get(&a_enc), Some(&"fresh-sha".to_string()));
assert!(!changes.contains_key(&b_enc));
}
#[test]
fn test_carry_forward_skips_out_of_scope_sessions() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
init_repo(repo);
let id_in = Uuid::new_v4();
let id_out = Uuid::new_v4();
let in_enc = format!("sessions/{id_in}.enc");
let in_meta = format!("sessions/{id_in}.meta.json");
let out_enc = format!("sessions/{id_out}.enc");
let out_meta = format!("sessions/{id_out}.meta.json");
let in_enc_sha = gitref::write_blob(repo, b"in-enc").unwrap();
let in_meta_sha = gitref::write_blob(repo, b"in-meta").unwrap();
let out_enc_sha = gitref::write_blob(repo, b"out-enc").unwrap();
let out_meta_sha = gitref::write_blob(repo, b"out-meta").unwrap();
let mut local = BTreeMap::new();
local.insert(in_enc.clone(), in_enc_sha);
local.insert(in_meta.clone(), in_meta_sha);
local.insert(out_enc.clone(), out_enc_sha);
local.insert(out_meta.clone(), out_meta_sha);
let local_tree = gitref::build_tree(repo, None, &local).unwrap();
let local_commit = gitref::commit_tree(repo, &local_tree, None, "lore: local").unwrap();
let tracking: Vec<TreeEntry> = Vec::new();
let in_scope: HashSet<Uuid> = [id_in].into_iter().collect();
let mut changes = BTreeMap::new();
carry_forward_local_sessions(repo, &local_commit, &tracking, &in_scope, &mut changes)
.unwrap();
assert!(changes.contains_key(&in_enc), "in-scope .enc carried");
assert!(changes.contains_key(&in_meta), "in-scope .meta carried");
assert!(
!changes.contains_key(&out_enc),
"out-of-scope .enc must not be carried forward"
);
assert!(
!changes.contains_key(&out_meta),
"out-of-scope .meta must not be carried forward"
);
}
#[test]
fn test_sync_quiet_noops_when_not_set_up() {
let (_remote_dir, remote_url) = init_bare_remote();
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
init_repo(repo);
git(repo, &["remote", "add", "origin", &remote_url]);
sync_quiet_in_repo(repo, "origin").expect("quiet sync must no-op when not set up");
}
#[test]
fn test_sync_quiet_noops_without_remote() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
init_repo(repo);
sync_quiet_in_repo(repo, "origin").expect("quiet sync must no-op without a remote");
}
#[test]
fn test_quiet_sync_no_key_returns_ok_without_mutating() {
let (_remote_dir, remote_url) = init_bare_remote();
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
init_repo(repo);
git(repo, &["remote", "add", "origin", &remote_url]);
let (setup_keystore, _sk) = test_keystore();
let m = machine("machine-a", "Machine A");
create_store(repo, "origin", &setup_keystore, &m, "passphrase abcdefgh").unwrap();
let salt = read_store_salt(repo, "origin")
.unwrap()
.expect("salt present");
let (empty_keystore, _ek) = test_keystore();
assert!(
empty_keystore
.load_key(&store_id_from_salt(&salt))
.unwrap()
.is_none(),
"the isolated key store must have no key for this store"
);
quiet_sync_with_keystore(repo, "origin", &salt, &empty_keystore)
.expect("quiet sync must no-op when no key is stored on this machine");
}
#[test]
fn test_resolve_hook_remote_prefers_named_remote() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
init_repo(repo);
git(
repo,
&["remote", "add", "origin", "https://example.com/a.git"],
);
git(
repo,
&["remote", "add", "upstream", "https://example.com/b.git"],
);
assert_eq!(
resolve_hook_remote(repo, "upstream").unwrap(),
Some("upstream".to_string())
);
}
#[test]
fn test_resolve_hook_remote_falls_back_to_origin_for_url() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
init_repo(repo);
git(
repo,
&["remote", "add", "origin", "https://example.com/a.git"],
);
assert_eq!(
resolve_hook_remote(repo, "https://example.com/other.git").unwrap(),
Some("origin".to_string())
);
}
#[test]
fn test_resolve_hook_remote_none_when_unknown_and_no_origin() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
init_repo(repo);
git(
repo,
&["remote", "add", "upstream", "https://example.com/b.git"],
);
assert_eq!(
resolve_hook_remote(repo, "https://example.com/x.git").unwrap(),
None
);
}
#[test]
fn test_local_only_session_survives_remote_rewind() {
let (remote_dir, remote_url) = init_bare_remote();
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
init_repo(repo);
git(repo, &["remote", "add", "origin", &remote_url]);
let (keystore, _kd) = test_keystore();
let m = machine("machine-a", "Machine A");
create_store(repo, "origin", &keystore, &m, "passphrase abcdefgh").unwrap();
let setup_commit = git_out(remote_dir.path(), &["rev-parse", SESSIONS_REF]);
let (mut db, _dd) = open_db();
seed_full_session(&mut db, "machine-a", &repo_dir(repo));
let (key, salt) = load_store_credentials(repo, "origin", &keystore).unwrap();
let sessions = scoped_unsynced(&db, repo);
perform_sync(&mut db, repo, "origin", &key, &salt, &m, sessions).unwrap();
let session_enc = session_blob_sha_path(repo);
git(
remote_dir.path(),
&["update-ref", SESSIONS_REF, setup_commit.trim()],
);
let sessions = scoped_unsynced(&db, repo);
perform_sync(&mut db, repo, "origin", &key, &salt, &m, sessions).unwrap();
let entries = gitref::read_tree(repo, SESSIONS_REF).unwrap();
assert!(
entries.iter().any(|e| e.path == session_enc),
"local-only session must survive a remote rewind"
);
}
#[test]
fn test_sync_only_pushes_in_scope_sessions() {
let (_remote_dir, remote_url) = init_bare_remote();
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
init_repo(repo);
git(repo, &["remote", "add", "origin", &remote_url]);
let (keystore, _kd) = test_keystore();
let m = machine("machine-a", "Machine A");
create_store(repo, "origin", &keystore, &m, "passphrase abcdefgh").unwrap();
let (mut db, _dd) = open_db();
let repo_sub = format!("{}/crate/src", repo_dir(repo));
let in_scope = seed_full_session(&mut db, "machine-a", &repo_sub);
let out_of_scope = seed_full_session(&mut db, "machine-a", "/somewhere/else/project");
let (key, salt) = load_store_credentials(repo, "origin", &keystore).unwrap();
let sessions = scoped_unsynced(&db, repo);
let summary = perform_sync(&mut db, repo, "origin", &key, &salt, &m, sessions).unwrap();
assert_eq!(
summary.pushed, 1,
"only the in-scope session must be pushed"
);
let entries = gitref::read_tree(repo, SESSIONS_REF).unwrap();
let session_blobs = entries.iter().filter(|e| is_session_blob(&e.path)).count();
assert_eq!(session_blobs, 1, "store must hold exactly one session blob");
assert!(
entries
.iter()
.any(|e| e.path == format!("sessions/{in_scope}.enc")),
"the in-scope session must be stored"
);
assert!(
!entries
.iter()
.any(|e| e.path == format!("sessions/{out_of_scope}.enc")),
"the out-of-scope session must not reach this repo's store"
);
let unsynced = db.get_unsynced_sessions().unwrap();
let unsynced_ids: HashSet<Uuid> = unsynced.iter().map(|s| s.id).collect();
assert!(
unsynced_ids.contains(&out_of_scope),
"the out-of-scope session must remain unsynced"
);
assert!(
!unsynced_ids.contains(&in_scope),
"the in-scope session must be marked synced after the push"
);
}
#[test]
fn test_sync_does_not_carry_forward_out_of_scope_local_session() {
let (_remote_dir, remote_url) = init_bare_remote();
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
init_repo(repo);
git(repo, &["remote", "add", "origin", &remote_url]);
let (keystore, _kd) = test_keystore();
let m = machine("machine-a", "Machine A");
create_store(repo, "origin", &keystore, &m, "passphrase abcdefgh").unwrap();
let (mut db, _dd) = open_db();
let in_scope = seed_full_session(&mut db, "machine-a", &repo_dir(repo));
let out_of_scope = seed_full_session(&mut db, "machine-a", "/somewhere/else/project");
let base = gitref::resolve_ref(repo, SESSIONS_REF).unwrap();
let leaked_enc = gitref::write_blob(repo, b"leaked-enc").unwrap();
let leaked_meta = gitref::write_blob(repo, b"leaked-meta").unwrap();
let mut inject = BTreeMap::new();
inject.insert(format!("sessions/{out_of_scope}.enc"), leaked_enc);
inject.insert(format!("sessions/{out_of_scope}.meta.json"), leaked_meta);
let tree = gitref::build_tree(repo, base.as_deref(), &inject).unwrap();
let commit = gitref::commit_tree(repo, &tree, base.as_deref(), "inject leaked").unwrap();
gitref::update_ref_checked(repo, SESSIONS_REF, &commit, base.as_deref()).unwrap();
let (key, salt) = load_store_credentials(repo, "origin", &keystore).unwrap();
let sessions = scoped_unsynced(&db, repo);
perform_sync(&mut db, repo, "origin", &key, &salt, &m, sessions).unwrap();
let entries = gitref::read_tree(repo, SESSIONS_REF).unwrap();
assert!(
entries
.iter()
.any(|e| e.path == format!("sessions/{in_scope}.enc")),
"the in-scope session must be stored"
);
assert!(
!entries
.iter()
.any(|e| e.path == format!("sessions/{out_of_scope}.enc")),
"out-of-scope local-only session must not be carried forward or pushed"
);
assert!(
!entries
.iter()
.any(|e| e.path == format!("sessions/{out_of_scope}.meta.json")),
"out-of-scope local-only metadata must not be carried forward or pushed"
);
}
#[test]
fn test_sync_no_remote_does_not_inherit_out_of_scope_local_artifacts() {
let (remote_dir, remote_url) = init_bare_remote();
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
init_repo(repo);
git(repo, &["remote", "add", "origin", &remote_url]);
let (keystore, _kd) = test_keystore();
let m = machine("machine-a", "Machine A");
create_store(repo, "origin", &keystore, &m, "passphrase abcdefgh").unwrap();
let (mut db, _dd) = open_db();
let in_scope = seed_full_session(&mut db, "machine-a", &repo_dir(repo));
let out_of_scope = seed_full_session(&mut db, "machine-a", "/somewhere/else/project");
let base = gitref::resolve_ref(repo, SESSIONS_REF).unwrap();
let leaked_enc = gitref::write_blob(repo, b"leaked-enc").unwrap();
let leaked_meta = gitref::write_blob(repo, b"leaked-meta").unwrap();
let mut inject = BTreeMap::new();
inject.insert(format!("sessions/{out_of_scope}.enc"), leaked_enc.clone());
inject.insert(format!("sessions/{out_of_scope}.meta.json"), leaked_meta);
let tree = gitref::build_tree(repo, base.as_deref(), &inject).unwrap();
let commit = gitref::commit_tree(repo, &tree, base.as_deref(), "inject leaked").unwrap();
gitref::update_ref_checked(repo, SESSIONS_REF, &commit, base.as_deref()).unwrap();
git(remote_dir.path(), &["update-ref", "-d", SESSIONS_REF]);
assert!(!gitref::remote_ref_exists(repo, "origin", SESSIONS_REF).unwrap());
let (key, salt) = load_store_credentials(repo, "origin", &keystore).unwrap();
let sessions = scoped_unsynced(&db, repo);
let summary = perform_sync(&mut db, repo, "origin", &key, &salt, &m, sessions).unwrap();
assert_eq!(summary.pushed, 1, "only the in-scope session is pushed");
assert!(gitref::remote_ref_exists(repo, "origin", SESSIONS_REF).unwrap());
gitref::fetch(repo, "origin", SESSIONS_REF).unwrap();
let tracking = gitref::tracking_ref_name("origin", SESSIONS_REF).unwrap();
for reference in [SESSIONS_REF, tracking.as_str()] {
let entries = gitref::read_tree(repo, reference).unwrap();
assert!(
entries
.iter()
.any(|e| e.path == format!("sessions/{in_scope}.enc")),
"the in-scope session must be present in {reference}"
);
assert!(
!entries
.iter()
.any(|e| e.path == format!("sessions/{out_of_scope}.enc")),
"out-of-scope .enc must not survive the no-remote sync in {reference}"
);
assert!(
!entries
.iter()
.any(|e| e.path == format!("sessions/{out_of_scope}.meta.json")),
"out-of-scope .meta must not survive the no-remote sync in {reference}"
);
assert!(
entries.iter().any(|e| e.path == "meta/salt"),
"meta/salt must be present in {reference}"
);
assert!(
entries.iter().any(|e| e.path == "meta/machines.json"),
"meta/machines.json must be present in {reference}"
);
}
for (label, dir) in [("local", repo), ("remote", remote_dir.path())] {
assert_eq!(
git_out(dir, &["rev-list", "--count", SESSIONS_REF]),
"1",
"the no-remote sync commit must be an orphan on the {label} ref"
);
}
for (label, dir) in [("local", repo), ("remote", remote_dir.path())] {
let objects = git_out(dir, &["rev-list", "--objects", SESSIONS_REF]);
assert!(
!objects.contains(&leaked_enc),
"out-of-scope blob object must not be reachable from the {label} ref"
);
assert!(
!objects.contains(&format!("sessions/{out_of_scope}.enc")),
"out-of-scope .enc path must not be reachable from the {label} ref"
);
}
}
fn init_global_store(remote_url: &str) -> (TempDir, PathBuf) {
let dir = tempfile::tempdir().unwrap();
let store = dir.path().to_path_buf();
ensure_global_repo(&store, remote_url).unwrap();
(dir, store)
}
#[test]
fn test_global_create_store_writes_salt_and_pushes() {
let (_remote_dir, remote_url) = init_bare_remote();
let (_store_dir, store) = init_global_store(&remote_url);
let (keystore, _kd) = test_keystore();
let m = machine("machine-a", "Machine A");
create_store(
&store,
GLOBAL_REMOTE,
&keystore,
&m,
"correct horse battery",
)
.unwrap();
assert!(gitref::ref_exists(&store, SESSIONS_REF).unwrap());
let salt = read_store_salt(&store, GLOBAL_REMOTE)
.unwrap()
.expect("salt written");
assert!(keystore
.load_key(&store_id_from_salt(&salt))
.unwrap()
.is_some());
assert!(gitref::remote_ref_exists(&store, GLOBAL_REMOTE, SESSIONS_REF).unwrap());
}
#[test]
fn test_ensure_global_repo_is_idempotent_and_updates_remote() {
let (_remote_dir, remote_url) = init_bare_remote();
let dir = tempfile::tempdir().unwrap();
let store = dir.path().join("sync");
ensure_global_repo(&store, &remote_url).unwrap();
assert!(store.join(".git").exists());
assert_eq!(
git_out(&store, &["remote", "get-url", "origin"]),
remote_url
);
assert_eq!(git_out(&store, &["config", "user.name"]), "lore");
let (_remote_dir2, remote_url2) = init_bare_remote();
ensure_global_repo(&store, &remote_url2).unwrap();
assert_eq!(
git_out(&store, &["remote", "get-url", "origin"]),
remote_url2
);
}
#[test]
fn test_global_sync_round_trip_between_machines() {
let (_remote_dir, remote_url) = init_bare_remote();
let passphrase = "shared global passphrase";
let (_store_a, store_a) = init_global_store(&remote_url);
let (keystore_a, _ka) = test_keystore();
let ma = machine("machine-a", "Machine A");
create_store(&store_a, GLOBAL_REMOTE, &keystore_a, &ma, passphrase).unwrap();
let (mut db_a, _da) = open_db();
let id_one = seed_full_session(&mut db_a, "machine-a", "/projects/repo-one");
let id_two = seed_full_session(&mut db_a, "machine-a", "/elsewhere/repo-two");
let (key_a, salt_a) = load_store_credentials(&store_a, GLOBAL_REMOTE, &keystore_a).unwrap();
let sessions_a = db_a.get_unsynced_global_sessions().unwrap();
assert_eq!(sessions_a.len(), 2);
let summary_a = perform_sync_in_store(
SyncStore::Global,
&mut db_a,
&store_a,
GLOBAL_REMOTE,
&key_a,
&salt_a,
&ma,
sessions_a,
)
.unwrap();
assert_eq!(summary_a.pushed, 2, "global sync pushes all sessions");
assert!(db_a.get_unsynced_global_sessions().unwrap().is_empty());
let (_store_b, store_b) = init_global_store(&remote_url);
let (keystore_b, _kb) = test_keystore();
let mb = machine("machine-b", "Machine B");
gitref::fetch(&store_b, GLOBAL_REMOTE, SESSIONS_REF).unwrap();
let salt_b = read_store_salt(&store_b, GLOBAL_REMOTE).unwrap().unwrap();
join_store(
&store_b,
GLOBAL_REMOTE,
&keystore_b,
&mb,
&salt_b,
passphrase,
)
.unwrap();
let (mut db_b, _db) = open_db();
let (key_b, salt_b2) =
load_store_credentials(&store_b, GLOBAL_REMOTE, &keystore_b).unwrap();
let sessions_b = db_b.get_unsynced_global_sessions().unwrap();
let summary_b = perform_sync_in_store(
SyncStore::Global,
&mut db_b,
&store_b,
GLOBAL_REMOTE,
&key_b,
&salt_b2,
&mb,
sessions_b,
)
.unwrap();
assert_eq!(summary_b.pulled, 2, "both sessions must be pulled");
for id in [id_one, id_two] {
assert!(
db_b.get_session(&id).unwrap().is_some(),
"session {id} must be pulled"
);
assert_eq!(db_b.get_messages(&id).unwrap().len(), 1);
assert_eq!(db_b.get_links_by_session(&id).unwrap().len(), 1);
}
}
#[test]
fn test_global_sync_pushes_all_sessions_regardless_of_directory() {
let (_remote_dir, remote_url) = init_bare_remote();
let (_store_dir, store) = init_global_store(&remote_url);
let (keystore, _kd) = test_keystore();
let m = machine("machine-a", "Machine A");
create_store(&store, GLOBAL_REMOTE, &keystore, &m, "passphrase abcdefgh").unwrap();
let (mut db, _dd) = open_db();
let id_a = seed_full_session(&mut db, "machine-a", "/somewhere/project-a");
let id_b = seed_full_session(&mut db, "machine-a", "/totally/other/project-b");
assert!(
db.get_unsynced_sessions_for_repo(&store)
.unwrap()
.is_empty(),
"no session lives inside the store repo, so per-repo scope is empty"
);
let (key, salt) = load_store_credentials(&store, GLOBAL_REMOTE, &keystore).unwrap();
let sessions = db.get_unsynced_global_sessions().unwrap();
let summary = perform_sync_in_store(
SyncStore::Global,
&mut db,
&store,
GLOBAL_REMOTE,
&key,
&salt,
&m,
sessions,
)
.unwrap();
assert_eq!(summary.pushed, 2, "global sync pushes both sessions");
let entries = gitref::read_tree(&store, SESSIONS_REF).unwrap();
for id in [id_a, id_b] {
assert!(
entries
.iter()
.any(|e| e.path == format!("sessions/{id}.enc")),
"session {id} must be stored in the global store"
);
}
}
#[test]
fn test_per_repo_sync_does_not_mark_global_track() {
let (_remote_dir, remote_url) = init_bare_remote();
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
init_repo(repo);
git(repo, &["remote", "add", "origin", &remote_url]);
let (keystore, _kd) = test_keystore();
let m = machine("machine-a", "Machine A");
create_store(repo, "origin", &keystore, &m, "passphrase abcdefgh").unwrap();
let (mut db, _dd) = open_db();
let id = seed_full_session(&mut db, "machine-a", &repo_dir(repo));
let (key, salt) = load_store_credentials(repo, "origin", &keystore).unwrap();
let sessions = scoped_unsynced(&db, repo);
perform_sync(&mut db, repo, "origin", &key, &salt, &m, sessions).unwrap();
assert!(
!db.get_unsynced_sessions()
.unwrap()
.iter()
.any(|s| s.id == id),
"per-repo sync must mark the per-repo track"
);
assert!(
db.get_unsynced_global_sessions()
.unwrap()
.iter()
.any(|s| s.id == id),
"per-repo sync must NOT mark the global track"
);
}
#[test]
fn test_global_sync_does_not_mark_per_repo_track() {
let (_remote_dir, remote_url) = init_bare_remote();
let (_store_dir, store) = init_global_store(&remote_url);
let (keystore, _kd) = test_keystore();
let m = machine("machine-a", "Machine A");
create_store(&store, GLOBAL_REMOTE, &keystore, &m, "passphrase abcdefgh").unwrap();
let (mut db, _dd) = open_db();
let id = seed_full_session(&mut db, "machine-a", "/some/project");
let (key, salt) = load_store_credentials(&store, GLOBAL_REMOTE, &keystore).unwrap();
let sessions = db.get_unsynced_global_sessions().unwrap();
perform_sync_in_store(
SyncStore::Global,
&mut db,
&store,
GLOBAL_REMOTE,
&key,
&salt,
&m,
sessions,
)
.unwrap();
assert!(
!db.get_unsynced_global_sessions()
.unwrap()
.iter()
.any(|s| s.id == id),
"global sync must mark the global track"
);
assert!(
db.get_unsynced_sessions()
.unwrap()
.iter()
.any(|s| s.id == id),
"global sync must NOT mark the per-repo track"
);
}
fn setup_repo_with_store(
remote_url: &str,
passphrase: &str,
) -> (TempDir, PathBuf, KeyStore, TempDir, MachineIdentity) {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path().to_path_buf();
init_repo(&repo);
git(&repo, &["remote", "add", "origin", remote_url]);
let (keystore, kd) = test_keystore();
let m = machine("machine-a", "Machine A");
create_store(&repo, "origin", &keystore, &m, passphrase).unwrap();
(dir, repo, keystore, kd, m)
}
#[test]
fn test_link_deletion_propagates_via_tombstone() {
let (_remote_dir, remote_url) = init_bare_remote();
let passphrase = "shared team passphrase";
let (_da, repo_a, keystore_a, _ka, ma) = setup_repo_with_store(&remote_url, passphrase);
let (mut db_a, _dba) = open_db();
let session_id = seed_full_session(&mut db_a, "machine-a", &repo_dir(&repo_a));
let (key_a, salt_a) = load_store_credentials(&repo_a, "origin", &keystore_a).unwrap();
let sessions_a = scoped_unsynced(&db_a, &repo_a);
perform_sync(
&mut db_a, &repo_a, "origin", &key_a, &salt_a, &ma, sessions_a,
)
.unwrap();
let dir_b = tempfile::tempdir().unwrap();
let repo_b = dir_b.path();
init_repo(repo_b);
git(repo_b, &["remote", "add", "origin", &remote_url]);
let (keystore_b, _kb) = test_keystore();
let mb = machine("machine-b", "Machine B");
gitref::fetch(repo_b, "origin", SESSIONS_REF).unwrap();
let salt_b = read_store_salt(repo_b, "origin").unwrap().unwrap();
join_store(repo_b, "origin", &keystore_b, &mb, &salt_b, passphrase).unwrap();
let (mut db_b, _dbb) = open_db();
let (key_b, salt_b2) = load_store_credentials(repo_b, "origin", &keystore_b).unwrap();
let sessions_b = scoped_unsynced(&db_b, repo_b);
perform_sync(
&mut db_b, repo_b, "origin", &key_b, &salt_b2, &mb, sessions_b,
)
.unwrap();
assert_eq!(
db_b.get_links_by_session(&session_id).unwrap().len(),
1,
"machine B must have pulled the link"
);
assert!(db_a
.delete_link_by_session_and_commit(&session_id, "deadbeef")
.unwrap());
let sessions_a = scoped_unsynced(&db_a, &repo_a);
perform_sync(
&mut db_a, &repo_a, "origin", &key_a, &salt_a, &ma, sessions_a,
)
.unwrap();
let sessions_b = scoped_unsynced(&db_b, repo_b);
perform_sync(
&mut db_b, repo_b, "origin", &key_b, &salt_b2, &mb, sessions_b,
)
.unwrap();
assert!(
db_b.get_links_by_session(&session_id).unwrap().is_empty(),
"the deleted link must be removed on machine B"
);
let sessions_b = scoped_unsynced(&db_b, repo_b);
perform_sync(
&mut db_b, repo_b, "origin", &key_b, &salt_b2, &mb, sessions_b,
)
.unwrap();
assert!(
db_b.get_links_by_session(&session_id).unwrap().is_empty(),
"the deleted link must stay removed after another sync"
);
}
#[test]
fn test_concurrent_add_survives_remote_deletion() {
let (_remote_dir, remote_url) = init_bare_remote();
let passphrase = "shared global passphrase";
let (_store_a, store_a) = init_global_store(&remote_url);
let (keystore_a, _ka) = test_keystore();
let ma = machine("machine-a", "Machine A");
create_store(&store_a, GLOBAL_REMOTE, &keystore_a, &ma, passphrase).unwrap();
let (mut db_a, _dba) = open_db();
let session_id = seed_full_session(&mut db_a, "machine-a", "/projects/repo-one");
let (key_a, salt_a) = load_store_credentials(&store_a, GLOBAL_REMOTE, &keystore_a).unwrap();
let run_a = |db: &mut Database| {
let sessions = db.get_unsynced_global_sessions().unwrap();
perform_sync_in_store(
SyncStore::Global,
db,
&store_a,
GLOBAL_REMOTE,
&key_a,
&salt_a,
&ma,
sessions,
)
.unwrap();
};
run_a(&mut db_a);
let (_store_b, store_b) = init_global_store(&remote_url);
let (keystore_b, _kb) = test_keystore();
let mb = machine("machine-b", "Machine B");
gitref::fetch(&store_b, GLOBAL_REMOTE, SESSIONS_REF).unwrap();
let salt_b = read_store_salt(&store_b, GLOBAL_REMOTE).unwrap().unwrap();
join_store(
&store_b,
GLOBAL_REMOTE,
&keystore_b,
&mb,
&salt_b,
passphrase,
)
.unwrap();
let (mut db_b, _dbb) = open_db();
let (key_b, salt_b2) =
load_store_credentials(&store_b, GLOBAL_REMOTE, &keystore_b).unwrap();
let run_b = |db: &mut Database| {
let sessions = db.get_unsynced_global_sessions().unwrap();
perform_sync_in_store(
SyncStore::Global,
db,
&store_b,
GLOBAL_REMOTE,
&key_b,
&salt_b2,
&mb,
sessions,
)
.unwrap();
};
run_b(&mut db_b);
assert!(db_a
.delete_link_by_session_and_commit(&session_id, "deadbeef")
.unwrap());
run_a(&mut db_a);
let y_id = Uuid::new_v4();
db_b.insert_link(&SessionLink {
id: y_id,
session_id,
link_type: LinkType::Commit,
commit_sha: Some("feedface".to_string()),
branch: Some("main".to_string()),
remote: Some("origin".to_string()),
created_at: Utc::now(),
created_by: LinkCreator::User,
confidence: Some(0.9),
})
.unwrap();
run_b(&mut db_b);
let b_links = db_b.get_links_by_session(&session_id).unwrap();
assert_eq!(b_links.len(), 1, "B keeps only its own new link");
assert_eq!(b_links[0].id, y_id);
run_a(&mut db_a);
let a_links = db_a.get_links_by_session(&session_id).unwrap();
assert_eq!(a_links.len(), 1, "A gets the concurrently added link");
assert_eq!(a_links[0].id, y_id, "X stays deleted, Y propagates to A");
}
#[test]
fn test_tombstone_suppresses_stale_remote_blob() {
let (_remote_dir, remote_url) = init_bare_remote();
let (_da, repo, keystore, _kd, _m) =
setup_repo_with_store(&remote_url, "passphrase abcdefgh");
let (key, _salt) = load_store_credentials(&repo, "origin", &keystore).unwrap();
let (mut db, _dbd) = open_db();
let session_id = seed_full_session(&mut db, "machine-a", &repo_dir(&repo));
let link = db.get_links_by_session(&session_id).unwrap()[0].clone();
assert!(db
.delete_link_by_session_and_commit(&session_id, "deadbeef")
.unwrap());
let session = db.get_session(&session_id).unwrap().unwrap();
let record = SessionRecord {
session,
messages: vec![],
links: vec![link],
tags: vec![],
annotations: vec![],
summary: None,
};
let blob = encrypt_session_record(&record, &key).unwrap();
let sha = gitref::write_blob(&repo, &blob).unwrap();
let entries = vec![TreeEntry {
mode: "100644".to_string(),
sha,
path: format!("sessions/{session_id}.enc"),
}];
merge_remote(&mut db, &repo, &entries, &key).unwrap();
assert!(
db.get_links_by_session(&session_id).unwrap().is_empty(),
"a stale remote blob must not resurrect a tombstoned link"
);
}
#[test]
fn test_old_remote_tombstone_suppresses_stale_blob_through_sync() {
let (_remote_dir, remote_url) = init_bare_remote();
let passphrase = "shared team passphrase";
let (_da, repo_a, keystore_a, _ka, ma) = setup_repo_with_store(&remote_url, passphrase);
let (mut db_a, _dba) = open_db();
let session_id = seed_full_session(&mut db_a, "machine-a", &repo_dir(&repo_a));
let link = db_a.get_links_by_session(&session_id).unwrap()[0].clone();
let (key_a, salt_a) = load_store_credentials(&repo_a, "origin", &keystore_a).unwrap();
let sessions_a = scoped_unsynced(&db_a, &repo_a);
perform_sync(
&mut db_a, &repo_a, "origin", &key_a, &salt_a, &ma, sessions_a,
)
.unwrap();
db_a.add_tombstones(&[Tombstone {
child_id: link.id.to_string(),
kind: "link".to_string(),
session_id: Some(session_id.to_string()),
deleted_at: Utc::now() - chrono::Duration::days(120),
}])
.unwrap();
let sessions_a = scoped_unsynced(&db_a, &repo_a);
perform_sync(
&mut db_a, &repo_a, "origin", &key_a, &salt_a, &ma, sessions_a,
)
.unwrap();
let dir_b = tempfile::tempdir().unwrap();
let repo_b = dir_b.path();
init_repo(repo_b);
git(repo_b, &["remote", "add", "origin", &remote_url]);
let (keystore_b, _kb) = test_keystore();
let mb = machine("machine-b", "Machine B");
gitref::fetch(repo_b, "origin", SESSIONS_REF).unwrap();
let salt_b = read_store_salt(repo_b, "origin").unwrap().unwrap();
join_store(repo_b, "origin", &keystore_b, &mb, &salt_b, passphrase).unwrap();
let (mut db_b, _dbb) = open_db();
let (key_b, salt_b2) = load_store_credentials(repo_b, "origin", &keystore_b).unwrap();
let sessions_b = scoped_unsynced(&db_b, repo_b);
perform_sync(
&mut db_b, repo_b, "origin", &key_b, &salt_b2, &mb, sessions_b,
)
.unwrap();
assert!(
db_b.get_links_by_session(&session_id).unwrap().is_empty(),
"an old remote tombstone must still suppress a stale session blob"
);
}
#[test]
fn test_annotation_deletion_propagates_via_tombstone_global() {
let (_remote_dir, remote_url) = init_bare_remote();
let passphrase = "shared global passphrase";
let (_store_a, store_a) = init_global_store(&remote_url);
let (keystore_a, _ka) = test_keystore();
let ma = machine("machine-a", "Machine A");
create_store(&store_a, GLOBAL_REMOTE, &keystore_a, &ma, passphrase).unwrap();
let (mut db_a, _dba) = open_db();
let session_id = seed_full_session(&mut db_a, "machine-a", "/projects/repo-one");
let annotation_id = db_a.get_annotations(&session_id).unwrap()[0].id;
let (key_a, salt_a) = load_store_credentials(&store_a, GLOBAL_REMOTE, &keystore_a).unwrap();
let run_a = |db: &mut Database| {
let sessions = db.get_unsynced_global_sessions().unwrap();
perform_sync_in_store(
SyncStore::Global,
db,
&store_a,
GLOBAL_REMOTE,
&key_a,
&salt_a,
&ma,
sessions,
)
.unwrap();
};
run_a(&mut db_a);
let (_store_b, store_b) = init_global_store(&remote_url);
let (keystore_b, _kb) = test_keystore();
let mb = machine("machine-b", "Machine B");
gitref::fetch(&store_b, GLOBAL_REMOTE, SESSIONS_REF).unwrap();
let salt_b = read_store_salt(&store_b, GLOBAL_REMOTE).unwrap().unwrap();
join_store(
&store_b,
GLOBAL_REMOTE,
&keystore_b,
&mb,
&salt_b,
passphrase,
)
.unwrap();
let (mut db_b, _dbb) = open_db();
let (key_b, salt_b2) =
load_store_credentials(&store_b, GLOBAL_REMOTE, &keystore_b).unwrap();
let run_b = |db: &mut Database| {
let sessions = db.get_unsynced_global_sessions().unwrap();
perform_sync_in_store(
SyncStore::Global,
db,
&store_b,
GLOBAL_REMOTE,
&key_b,
&salt_b2,
&mb,
sessions,
)
.unwrap();
};
run_b(&mut db_b);
assert_eq!(
db_b.get_annotations(&session_id).unwrap().len(),
1,
"machine B must have pulled the annotation"
);
assert!(db_a.delete_annotation(&annotation_id).unwrap());
run_a(&mut db_a);
run_b(&mut db_b);
assert!(
db_b.get_annotations(&session_id).unwrap().is_empty(),
"the deleted annotation must be removed on machine B via the global store"
);
run_b(&mut db_b);
assert!(
db_b.get_annotations(&session_id).unwrap().is_empty(),
"the deleted annotation must stay removed"
);
}
#[test]
fn test_tombstone_blob_is_stable_across_unchanged_syncs() {
let (_remote_dir, remote_url) = init_bare_remote();
let (_da, repo, keystore, _kd, m) =
setup_repo_with_store(&remote_url, "passphrase abcdefgh");
let (key, salt) = load_store_credentials(&repo, "origin", &keystore).unwrap();
let (mut db, _dbd) = open_db();
let session_id = seed_full_session(&mut db, "machine-a", &repo_dir(&repo));
let sessions = scoped_unsynced(&db, &repo);
perform_sync(&mut db, &repo, "origin", &key, &salt, &m, sessions).unwrap();
assert!(db
.delete_link_by_session_and_commit(&session_id, "deadbeef")
.unwrap());
let sessions = scoped_unsynced(&db, &repo);
perform_sync(&mut db, &repo, "origin", &key, &salt, &m, sessions).unwrap();
let first = tombstone_blob_sha(&repo);
let sessions = scoped_unsynced(&db, &repo);
perform_sync(&mut db, &repo, "origin", &key, &salt, &m, sessions).unwrap();
let second = tombstone_blob_sha(&repo);
assert_eq!(first, second, "unchanged tombstone blob must be reused");
}
fn tombstone_blob_sha(repo: &Path) -> String {
let entries = gitref::read_tree(repo, SESSIONS_REF).unwrap();
entries
.iter()
.find(|e| e.path == TOMBSTONES_PATH)
.expect("a tombstone blob should exist")
.sha
.clone()
}
}