use crate::config::Config;
use crate::gitx::{Repo, EMPTY_TREE_OID};
use crate::records::store::LoadedRecord;
use anyhow::Result;
use std::collections::HashSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum Layer {
Team,
Branch,
Working,
}
impl Layer {
pub fn as_str(self) -> &'static str {
match self {
Layer::Team => "team",
Layer::Branch => "branch",
Layer::Working => "working",
}
}
}
#[allow(dead_code)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
#[serde(rename_all = "kebab-case")]
pub enum SyncState {
Current,
Behind,
Ahead,
Diverged,
OfflineUnknown,
Unconfigured,
}
impl SyncState {
pub fn as_str(self) -> &'static str {
match self {
SyncState::Current => "current",
SyncState::Behind => "behind",
SyncState::Ahead => "ahead",
SyncState::Diverged => "diverged",
SyncState::OfflineUnknown => "offline-unknown",
SyncState::Unconfigured => "unconfigured",
}
}
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct Revisions {
pub baseline_ref: String,
pub team_memory_revision: String,
pub local_head: Option<String>,
pub branch_memory_revision: String,
pub working_memory_revision: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub last_fetch_at: Option<String>,
pub sync_state: SyncState,
pub shared_memory_exact: bool,
pub memlay_version: String,
pub record_format_version: u32,
}
#[derive(Debug, Default)]
pub struct LayerMap {
team_paths: HashSet<String>,
branch_paths: HashSet<String>,
head_paths: HashSet<String>,
}
impl LayerMap {
pub fn baseline_only_count(&self) -> usize {
self.team_paths
.iter()
.filter(|p| !self.head_paths.contains(*p))
.count()
}
pub fn branch_only_count(&self) -> usize {
self.branch_paths.len()
}
}
impl LayerMap {
pub fn layer_of(&self, rel_path: &str) -> Layer {
if self.team_paths.contains(rel_path) {
Layer::Team
} else if self.branch_paths.contains(rel_path) {
Layer::Branch
} else {
Layer::Working
}
}
}
fn tree_paths(repo: &Repo, at_ref: &str) -> Vec<String> {
repo.run(&[
"ls-tree",
"-r",
"--name-only",
at_ref,
"--",
".memlay/records",
])
.map(|out| out.lines().map(|l| l.to_string()).collect())
.unwrap_or_default()
}
pub fn layer_map(repo: &Repo, cfg: &Config) -> LayerMap {
let mut map = LayerMap::default();
let baseline = &cfg.team.baseline_ref;
if repo.ref_exists(baseline) {
map.team_paths = tree_paths(repo, baseline).into_iter().collect();
}
if repo.head_oid().is_some() {
map.head_paths = tree_paths(repo, "HEAD").into_iter().collect();
map.branch_paths = map
.head_paths
.iter()
.filter(|p| !map.team_paths.contains(*p))
.cloned()
.collect();
}
map
}
pub fn working_memory_revision(
repo_root: &std::path::Path,
records: &[LoadedRecord],
layers: &LayerMap,
) -> String {
let mut entries: Vec<(String, String)> = records
.iter()
.filter(|r| layers.layer_of(&r.rel_path) == Layer::Working)
.map(|r| {
let abs = repo_root.join(r.rel_path.replace('/', std::path::MAIN_SEPARATOR_STR));
let content_hash = std::fs::read(&abs)
.map(|b| blake3::hash(&b).to_hex().to_string())
.unwrap_or_default();
(r.rel_path.clone(), content_hash)
})
.collect();
if entries.is_empty() {
return "empty".to_string();
}
entries.sort();
let mut hasher = blake3::Hasher::new();
for (path, hash) in entries {
hasher.update(path.as_bytes());
hasher.update(b"\0");
hasher.update(hash.as_bytes());
hasher.update(b"\n");
}
hasher.finalize().to_hex().to_string()[..16].to_string()
}
pub fn last_fetch_at(repo: &Repo) -> Option<String> {
let state = repo.state_dir().join("state.json");
if let Ok(text) = std::fs::read_to_string(&state) {
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&text) {
if let Some(t) = v.get("last_fetch_at").and_then(|t| t.as_str()) {
return Some(t.to_string());
}
}
}
let fetch_head = repo.git_dir.join("FETCH_HEAD");
let meta = std::fs::metadata(fetch_head).ok()?;
let modified: chrono::DateTime<chrono::Utc> = meta.modified().ok()?.into();
Some(modified.to_rfc3339_opts(chrono::SecondsFormat::Secs, true))
}
pub fn compute_revisions(
repo: &Repo,
cfg: &Config,
records: &[LoadedRecord],
layers: &LayerMap,
) -> Result<Revisions> {
let baseline = cfg.team.baseline_ref.clone();
let baseline_resolves = repo.ref_exists(&baseline);
let team_memory_revision = if baseline_resolves {
repo.records_tree_oid(&baseline)?
} else {
EMPTY_TREE_OID.to_string()
};
let branch_memory_revision = if repo.head_oid().is_some() {
repo.records_tree_oid("HEAD")
.unwrap_or_else(|_| EMPTY_TREE_OID.to_string())
} else {
EMPTY_TREE_OID.to_string()
};
let last_fetch = last_fetch_at(repo);
let fetch_fresh = last_fetch
.as_deref()
.and_then(|f| chrono::DateTime::parse_from_rfc3339(f).ok())
.map(|t| {
let age = chrono::Utc::now().signed_duration_since(t.with_timezone(&chrono::Utc));
age.num_seconds() >= 0 && (age.num_seconds() as u64) <= cfg.team.max_fetch_age_seconds
})
.unwrap_or(false);
let behind = layers.baseline_only_count() > 0;
let ahead = layers.branch_only_count() > 0;
let sync_state = if !baseline_resolves {
SyncState::Unconfigured
} else if behind && ahead {
SyncState::Diverged
} else if behind {
SyncState::Behind
} else if ahead {
SyncState::Ahead
} else if fetch_fresh {
SyncState::Current
} else {
SyncState::OfflineUnknown
};
Ok(Revisions {
baseline_ref: baseline,
team_memory_revision,
local_head: repo.head_oid(),
branch_memory_revision,
working_memory_revision: working_memory_revision(&repo.root, records, layers),
last_fetch_at: last_fetch,
sync_state,
shared_memory_exact: sync_state == SyncState::Current,
memlay_version: env!("CARGO_PKG_VERSION").to_string(),
record_format_version: crate::records::RECORD_FORMAT_VERSION,
})
}