use crate::cli::App;
use crate::errors::{err, ErrorCode};
use crate::index::Index;
use anyhow::Result;
use serde::{Deserialize, Serialize};
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct SyncState {
#[serde(skip_serializing_if = "Option::is_none")]
pub last_fetch_at: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub indexed_team_revision: Option<String>,
}
pub fn read_state(app: &App) -> SyncState {
let path = app.repo.state_dir().join("state.json");
std::fs::read_to_string(path)
.ok()
.and_then(|t| serde_json::from_str(&t).ok())
.unwrap_or_default()
}
pub fn write_state(app: &App, state: &SyncState) -> Result<()> {
let dir = app.repo.state_dir();
std::fs::create_dir_all(&dir)?;
std::fs::write(dir.join("state.json"), serde_json::to_string_pretty(state)?)?;
Ok(())
}
fn fetch_args(baseline_ref: &str, remote: &str) -> Option<(String, String)> {
let rest = baseline_ref.strip_prefix("refs/remotes/")?;
let (r, branch) = rest.split_once('/')?;
Some((
if r.is_empty() {
remote.to_string()
} else {
r.to_string()
},
branch.to_string(),
))
}
pub fn sync(app: &App, check_only: bool, all: bool, offline: bool) -> Result<()> {
let baseline = app.config.team.baseline_ref.clone();
let mut state = read_state(app);
if check_only {
return sync_check(app, &state);
}
let old_revision = state.indexed_team_revision.clone();
if !offline {
let fetched = if all {
app.repo.run(&["fetch", "--all", "--quiet"]).map(|_| true)
} else if let Some((remote, branch)) = fetch_args(&baseline, &app.config.team.remote) {
app.repo
.run(&["fetch", "--quiet", &remote, &branch])
.map(|_| true)
} else {
Ok(false)
};
match fetched {
Ok(true) => {
state.last_fetch_at =
Some(chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true));
}
Ok(false) => {}
Err(e) => {
if !app.json {
eprintln!("warning: fetch failed ({e}); continuing with last known baseline");
}
}
}
}
let new_revision = if app.repo.ref_exists(&baseline) {
app.repo.records_tree_oid(&baseline)?
} else {
return Err(err(
ErrorCode::TeamMemoryUnknown,
format!("baseline ref '{baseline}' does not resolve after fetch; check [team] config"),
));
};
let mut added: Vec<String> = Vec::new();
let mut removed: Vec<String> = Vec::new();
if let Some(old) = &old_revision {
if old != &new_revision {
if let Ok(out) =
app.repo
.run(&["diff", "--name-status", old.as_str(), new_revision.as_str()])
{
for line in out.lines() {
let mut parts = line.split('\t');
let status = parts.next().unwrap_or("");
let path = parts.next_back().unwrap_or("");
match status.chars().next() {
Some('A') => added.push(path.to_string()),
Some('D') => removed.push(path.to_string()),
_ => {}
}
}
}
}
}
state.indexed_team_revision = Some(new_revision.clone());
write_state(app, &state)?;
crate::index::update_all(&app.repo, &app.config)?;
if app.json {
println!(
"{}",
serde_json::json!({
"baseline_ref": baseline,
"previous_team_memory_revision": old_revision,
"team_memory_revision": new_revision,
"newly_shared_records": added,
"removed_records": removed,
"last_fetch_at": state.last_fetch_at,
})
);
} else {
match &old_revision {
Some(old) if *old == new_revision => {
println!(
"Team memory unchanged at {}",
&new_revision[..12.min(new_revision.len())]
);
}
Some(old) => {
println!(
"Team memory updated {} -> {}",
&old[..12.min(old.len())],
&new_revision[..12.min(new_revision.len())]
);
if !added.is_empty() {
println!(" {} newly shared record(s)", added.len());
}
if !removed.is_empty() {
println!(
" {} record(s) removed from baseline (archive or history rewrite)",
removed.len()
);
}
}
None => {
println!(
"Team baseline indexed at {}",
&new_revision[..12.min(new_revision.len())]
);
}
}
}
Ok(())
}
fn sync_check(app: &App, state: &SyncState) -> Result<()> {
let baseline = &app.config.team.baseline_ref;
if !app.repo.ref_exists(baseline) {
return Err(err(
ErrorCode::TeamMemoryUnknown,
format!("baseline ref '{baseline}' does not resolve"),
));
}
let current = app.repo.records_tree_oid(baseline)?;
let indexed = state.indexed_team_revision.as_deref();
if indexed != Some(current.as_str()) {
return Err(err(
ErrorCode::TeamMemoryBehind,
format!(
"indexed baseline {} does not match current baseline {}; run 'memlay sync'",
indexed.unwrap_or("(none)"),
current
),
));
}
if let Some(fetch_at) = &state.last_fetch_at {
if let Ok(t) = chrono::DateTime::parse_from_rfc3339(fetch_at) {
let age = chrono::Utc::now().signed_duration_since(t.with_timezone(&chrono::Utc));
if age.num_seconds() < 0
|| age.num_seconds() as u64 > app.config.team.max_fetch_age_seconds
{
return Err(err(
ErrorCode::TeamMemoryUnknown,
format!(
"last fetch at {fetch_at} exceeds max_fetch_age_seconds ({}); run 'memlay sync'",
app.config.team.max_fetch_age_seconds
),
));
}
}
} else {
return Err(err(
ErrorCode::TeamMemoryUnknown,
"no recorded fetch; run 'memlay sync'",
));
}
if !app.json {
println!("baseline current at {}", ¤t[..12.min(current.len())]);
}
Ok(())
}
pub fn index_update(app: &App) -> Result<Index> {
crate::index::update_all(&app.repo, &app.config)
}