use std::sync::Arc;
use git2::{Oid, Repository, Signature};
use crate::crdt::body_doc_manager::BodyDocManager;
use crate::crdt::materialize::materialize_workspace;
use crate::crdt::sanity::validate_workspace;
use crate::crdt::self_healing::{HealingAction, HealthTracker};
use crate::crdt::storage::CrdtStorage;
use crate::crdt::workspace_doc::WorkspaceCrdt;
use crate::error::DiaryxError;
pub struct CommitOptions {
pub message: Option<String>,
pub author_name: String,
pub author_email: String,
pub keep_updates: usize,
pub skip_validation: bool,
}
impl Default for CommitOptions {
fn default() -> Self {
Self {
message: None,
author_name: "Diaryx".to_string(),
author_email: "noreply@diaryx.app".to_string(),
keep_updates: 0,
skip_validation: false,
}
}
}
#[derive(Debug)]
pub struct CommitResult {
pub commit_id: Oid,
pub file_count: usize,
pub compacted: bool,
}
pub fn commit_workspace(
storage: &Arc<dyn CrdtStorage>,
workspace: &WorkspaceCrdt,
body_docs: &BodyDocManager,
repo: &Repository,
workspace_id: &str,
options: &CommitOptions,
health_tracker: &mut HealthTracker,
) -> Result<CommitResult, DiaryxError> {
let materialized = materialize_workspace(workspace, body_docs, workspace_id);
if materialized.files.is_empty() {
return Err(DiaryxError::Git("No files to commit".to_string()));
}
if !options.skip_validation {
let report = validate_workspace(workspace, body_docs, workspace_id);
if !report.is_ok() {
let action = health_tracker.record_failure();
let issue_summary: Vec<String> = report
.issues
.iter()
.take(5)
.map(|i| i.message.clone())
.collect();
match action {
HealingAction::SkipCommit => {
return Err(DiaryxError::Git(format!(
"Validation failed (attempt {}), skipping commit: {}",
health_tracker.consecutive_failures(),
issue_summary.join("; ")
)));
}
HealingAction::RebuildCrdt => {
return Err(DiaryxError::Git(format!(
"Validation failed {} times, CRDT rebuild recommended: {}",
health_tracker.consecutive_failures(),
issue_summary.join("; ")
)));
}
HealingAction::Proceed => {} }
} else {
health_tracker.record_success();
}
}
let tree_oid = build_tree(repo, &materialized.files)?;
let tree = repo
.find_tree(tree_oid)
.map_err(|e| DiaryxError::Git(e.to_string()))?;
let sig = Signature::now(&options.author_name, &options.author_email)
.map_err(|e| DiaryxError::Git(e.to_string()))?;
let message = options.message.clone().unwrap_or_else(|| {
let now = chrono::Utc::now();
format!(
"Workspace snapshot at {}",
now.format("%Y-%m-%d %H:%M:%S UTC")
)
});
let parent_commit = repo.head().ok().and_then(|head| head.peel_to_commit().ok());
let parents: Vec<&git2::Commit> = parent_commit.iter().collect();
let commit_oid = repo
.commit(Some("HEAD"), &sig, &sig, &message, &tree, &parents)
.map_err(|e| DiaryxError::Git(e.to_string()))?;
let file_count = materialized.files.len();
let compacted = compact_workspace(
storage,
workspace,
body_docs,
workspace_id,
options.keep_updates,
)?;
Ok(CommitResult {
commit_id: commit_oid,
file_count,
compacted,
})
}
pub fn compact_workspace(
storage: &Arc<dyn CrdtStorage>,
workspace: &WorkspaceCrdt,
body_docs: &BodyDocManager,
workspace_id: &str,
keep_updates: usize,
) -> Result<bool, DiaryxError> {
storage.compact(workspace.doc_name(), keep_updates)?;
let files = workspace.list_files();
for (key, meta) in &files {
if meta.deleted {
continue;
}
let path = if key.contains('/') || key.ends_with(".md") {
key.clone()
} else if let Some(p) = workspace.get_path(key) {
p.to_string_lossy().to_string()
} else {
continue;
};
let body_key = format!("body:{}/{}", workspace_id, path);
if body_docs.get(&body_key).is_some() {
storage.compact(&body_key, keep_updates)?;
}
}
Ok(true)
}
use crate::crdt::materialize::MaterializedFile;
fn build_tree(repo: &Repository, files: &[MaterializedFile]) -> Result<Oid, DiaryxError> {
let mut entries: Vec<(Vec<&str>, &[u8])> = Vec::new();
for file in files {
let components: Vec<&str> = file.path.split('/').collect();
entries.push((components, file.content.as_bytes()));
}
build_tree_recursive(repo, &entries, 0)
}
fn build_tree_recursive(
repo: &Repository,
entries: &[(Vec<&str>, &[u8])],
depth: usize,
) -> Result<Oid, DiaryxError> {
let mut builder = repo
.treebuilder(None)
.map_err(|e| DiaryxError::Git(e.to_string()))?;
let mut dirs: std::collections::HashMap<&str, Vec<(Vec<&str>, &[u8])>> =
std::collections::HashMap::new();
for (components, content) in entries {
if depth + 1 == components.len() {
let blob_oid = repo
.blob(content)
.map_err(|e| DiaryxError::Git(e.to_string()))?;
builder
.insert(components[depth], blob_oid, 0o100644)
.map_err(|e| DiaryxError::Git(e.to_string()))?;
} else if depth < components.len() {
dirs.entry(components[depth])
.or_default()
.push((components.clone(), *content));
}
}
for (dir_name, sub_entries) in &dirs {
let sub_tree_oid = build_tree_recursive(repo, sub_entries, depth + 1)?;
builder
.insert(dir_name, sub_tree_oid, 0o040000)
.map_err(|e| DiaryxError::Git(e.to_string()))?;
}
builder.write().map_err(|e| DiaryxError::Git(e.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::crdt::git::repo::{RepoKind, init_repo};
use crate::crdt::{FileMetadata, MemoryStorage};
fn setup() -> (
Arc<MemoryStorage>,
WorkspaceCrdt,
BodyDocManager,
tempfile::TempDir,
Repository,
) {
let storage = Arc::new(MemoryStorage::new());
let workspace = WorkspaceCrdt::new(storage.clone());
let body_docs = BodyDocManager::new(storage.clone());
let dir = tempfile::tempdir().unwrap();
let repo = init_repo(dir.path(), RepoKind::Standard).unwrap();
(storage, workspace, body_docs, dir, repo)
}
#[test]
fn test_commit_single_file() {
let (storage, workspace, body_docs, _dir, repo) = setup();
let meta = FileMetadata::with_filename("hello.md".to_string(), Some("Hello".to_string()));
let doc_id = workspace.create_file(meta).unwrap();
let path = workspace.get_path(&doc_id).unwrap();
let body_key = format!("body:test-ws/{}", path.to_string_lossy());
body_docs
.get_or_create(&body_key)
.set_body("Hello world")
.unwrap();
let mut tracker = HealthTracker::new();
let result = commit_workspace(
&(storage.clone() as Arc<dyn CrdtStorage>),
&workspace,
&body_docs,
&repo,
"test-ws",
&CommitOptions::default(),
&mut tracker,
)
.unwrap();
assert_eq!(result.file_count, 1);
assert!(result.compacted);
let commit = repo.find_commit(result.commit_id).unwrap();
let tree = commit.tree().unwrap();
assert!(tree.get_name("hello.md").is_some());
}
#[test]
fn test_commit_nested_dirs() {
let (storage, workspace, body_docs, _dir, repo) = setup();
let mut parent_meta =
FileMetadata::with_filename("daily".to_string(), Some("Daily".to_string()));
parent_meta.contents = Some(vec![]); let parent_id = workspace.create_file(parent_meta).unwrap();
let mut child_meta =
FileMetadata::with_filename("2024-01-01.md".to_string(), Some("Jan 1".to_string()));
child_meta.part_of = Some(parent_id.clone());
let child_id = workspace.create_file(child_meta).unwrap();
let child_path = workspace.get_path(&child_id).unwrap();
let body_key = format!("body:test-ws/{}", child_path.to_string_lossy());
body_docs
.get_or_create(&body_key)
.set_body("New year content")
.unwrap();
let mut tracker = HealthTracker::new();
let result = commit_workspace(
&(storage.clone() as Arc<dyn CrdtStorage>),
&workspace,
&body_docs,
&repo,
"test-ws",
&CommitOptions::default(),
&mut tracker,
)
.unwrap();
let commit = repo.find_commit(result.commit_id).unwrap();
let tree = commit.tree().unwrap();
let daily_entry = tree.get_name("daily").unwrap();
let daily_tree = repo.find_tree(daily_entry.id()).unwrap();
assert!(daily_tree.get_name("2024-01-01.md").is_some());
}
#[test]
fn test_commit_empty_workspace_fails() {
let (storage, workspace, body_docs, _dir, repo) = setup();
let mut tracker = HealthTracker::new();
let result = commit_workspace(
&(storage.clone() as Arc<dyn CrdtStorage>),
&workspace,
&body_docs,
&repo,
"test-ws",
&CommitOptions::default(),
&mut tracker,
);
assert!(result.is_err());
}
#[test]
fn test_commit_deleted_files_excluded() {
let (storage, workspace, body_docs, _dir, repo) = setup();
let meta = FileMetadata::with_filename("live.md".to_string(), Some("Live".to_string()));
let doc_id = workspace.create_file(meta).unwrap();
let path = workspace.get_path(&doc_id).unwrap();
let body_key = format!("body:test-ws/{}", path.to_string_lossy());
body_docs
.get_or_create(&body_key)
.set_body("Live content")
.unwrap();
let mut del_meta =
FileMetadata::with_filename("deleted.md".to_string(), Some("Deleted".to_string()));
del_meta.mark_deleted();
workspace.create_file(del_meta).unwrap();
let mut tracker = HealthTracker::new();
let result = commit_workspace(
&(storage.clone() as Arc<dyn CrdtStorage>),
&workspace,
&body_docs,
&repo,
"test-ws",
&CommitOptions::default(),
&mut tracker,
)
.unwrap();
assert_eq!(result.file_count, 1);
let commit = repo.find_commit(result.commit_id).unwrap();
let tree = commit.tree().unwrap();
assert!(tree.get_name("live.md").is_some());
assert!(tree.get_name("deleted.md").is_none());
}
#[test]
fn test_multi_commit() {
let (storage, workspace, body_docs, _dir, repo) = setup();
let meta = FileMetadata::with_filename("file.md".to_string(), Some("File".to_string()));
let doc_id = workspace.create_file(meta).unwrap();
let path = workspace.get_path(&doc_id).unwrap();
let body_key = format!("body:test-ws/{}", path.to_string_lossy());
body_docs
.get_or_create(&body_key)
.set_body("Version 1")
.unwrap();
let mut tracker = HealthTracker::new();
let options = CommitOptions {
message: Some("First commit".to_string()),
..CommitOptions::default()
};
let result1 = commit_workspace(
&(storage.clone() as Arc<dyn CrdtStorage>),
&workspace,
&body_docs,
&repo,
"test-ws",
&options,
&mut tracker,
)
.unwrap();
body_docs
.get_or_create(&body_key)
.set_body("Version 2")
.unwrap();
let options2 = CommitOptions {
message: Some("Second commit".to_string()),
..CommitOptions::default()
};
let result2 = commit_workspace(
&(storage.clone() as Arc<dyn CrdtStorage>),
&workspace,
&body_docs,
&repo,
"test-ws",
&options2,
&mut tracker,
)
.unwrap();
assert_ne!(result1.commit_id, result2.commit_id);
let mut revwalk = repo.revwalk().unwrap();
revwalk.push_head().unwrap();
let commits: Vec<_> = revwalk.collect::<Result<Vec<_>, _>>().unwrap();
assert_eq!(commits.len(), 2);
}
}