use std::collections::BTreeMap;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use uuid::Uuid;
use super::SyncError;
pub const ZERO_OID: &str = "0000000000000000000000000000000000000000";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TreeEntry {
pub mode: String,
pub sha: String,
pub path: String,
}
pub fn write_blob(repo: &Path, data: &[u8]) -> Result<String, SyncError> {
let out = run_git_stdin(repo, &["hash-object", "-w", "--stdin"], data)?;
Ok(stdout_to_string(out))
}
pub fn read_blob(repo: &Path, sha: &str) -> Result<Vec<u8>, SyncError> {
run_git(repo, &["cat-file", "blob", sha])
}
pub fn read_tree(repo: &Path, reference: &str) -> Result<Vec<TreeEntry>, SyncError> {
let out = run_git(repo, &["ls-tree", "-r", reference])?;
let text = String::from_utf8_lossy(&out);
let mut entries = Vec::new();
for line in text.lines() {
let Some((meta, path)) = line.split_once('\t') else {
continue;
};
let mut parts = meta.split_whitespace();
let mode = parts.next().unwrap_or("").to_string();
let _object_type = parts.next();
let sha = parts.next().unwrap_or("").to_string();
if mode.is_empty() || sha.is_empty() {
continue;
}
entries.push(TreeEntry {
mode,
sha,
path: path.to_string(),
});
}
Ok(entries)
}
pub fn build_tree(
repo: &Path,
base: Option<&str>,
changes: &BTreeMap<String, String>,
) -> Result<String, SyncError> {
let git_dir = absolute_git_dir(repo)?;
let index_path = git_dir.join(format!("lore-index-{}", Uuid::new_v4()));
let result = build_tree_with_index(repo, &index_path, base, changes);
let _ = std::fs::remove_file(&index_path);
result
}
fn build_tree_with_index(
repo: &Path,
index_path: &Path,
base: Option<&str>,
changes: &BTreeMap<String, String>,
) -> Result<String, SyncError> {
if let Some(base_ref) = base {
run_git_index(repo, index_path, &["read-tree", base_ref])?;
}
for (path, sha) in changes {
let cacheinfo = format!("100644,{sha},{path}");
run_git_index(
repo,
index_path,
&["update-index", "--add", "--cacheinfo", &cacheinfo],
)?;
}
let out = run_git_index(repo, index_path, &["write-tree"])?;
Ok(stdout_to_string(out))
}
pub fn commit_tree(
repo: &Path,
tree_sha: &str,
parent: Option<&str>,
message: &str,
) -> Result<String, SyncError> {
let mut args: Vec<&str> = vec!["commit-tree", tree_sha];
if let Some(parent_sha) = parent {
args.push("-p");
args.push(parent_sha);
}
args.push("-m");
args.push(message);
let out = run_git(repo, &args)?;
Ok(stdout_to_string(out))
}
pub fn update_ref(repo: &Path, ref_name: &str, commit_sha: &str) -> Result<(), SyncError> {
run_git(repo, &["update-ref", ref_name, commit_sha])?;
Ok(())
}
pub fn update_ref_checked(
repo: &Path,
ref_name: &str,
new_sha: &str,
old: Option<&str>,
) -> Result<(), SyncError> {
let old_value = old.unwrap_or(ZERO_OID);
let args = ["update-ref", ref_name, new_sha, old_value];
let output = Command::new("git")
.current_dir(repo)
.args(args)
.output()
.map_err(|e| SyncError::Git(format!("failed to spawn git: {e}")))?;
if output.status.success() {
return Ok(());
}
let stderr = String::from_utf8_lossy(&output.stderr);
let lowered = stderr.to_lowercase();
if lowered.contains("but expected")
|| lowered.contains("reference already exists")
|| lowered.contains("cannot lock ref")
|| lowered.contains("unable to resolve reference")
{
Err(SyncError::RefCasMismatch(format!(
"{ref_name} did not hold expected value {old_value}: {}",
stderr.trim()
)))
} else {
Err(git_error(&args, &output.stderr))
}
}
pub fn resolve_ref(repo: &Path, ref_name: &str) -> Result<Option<String>, SyncError> {
resolve_revision(repo, &format!("{ref_name}^{{commit}}"))
}
#[allow(dead_code)]
pub fn resolve_tree(repo: &Path, ref_name: &str) -> Result<Option<String>, SyncError> {
resolve_revision(repo, &format!("{ref_name}^{{tree}}"))
}
pub fn ref_exists(repo: &Path, ref_name: &str) -> Result<bool, SyncError> {
Ok(resolve_ref(repo, ref_name)?.is_some())
}
pub fn push(repo: &Path, remote: &str, ref_name: &str) -> Result<(), SyncError> {
let refspec = format!("{ref_name}:{ref_name}");
run_git(repo, &["push", remote, &refspec])?;
Ok(())
}
pub fn tracking_ref_name(remote: &str, ref_name: &str) -> Result<String, SyncError> {
let name = ref_name.strip_prefix("refs/lore/").ok_or_else(|| {
SyncError::Git(format!(
"lore ref name must start with refs/lore/: {ref_name}"
))
})?;
Ok(format!("refs/lore/remotes/{remote}/{name}"))
}
pub fn remote_ref_exists(repo: &Path, remote: &str, ref_name: &str) -> Result<bool, SyncError> {
let args = ["ls-remote", "--exit-code", remote, ref_name];
let output = Command::new("git")
.current_dir(repo)
.args(args)
.output()
.map_err(|e| SyncError::Git(format!("failed to spawn git: {e}")))?;
if output.status.success() {
Ok(true)
} else if output.status.code() == Some(2) {
Ok(false)
} else {
Err(git_error(&args, &output.stderr))
}
}
pub fn fetch(repo: &Path, remote: &str, ref_name: &str) -> Result<Option<String>, SyncError> {
if !remote_ref_exists(repo, remote, ref_name)? {
return Ok(None);
}
let tracking = tracking_ref_name(remote, ref_name)?;
let refspec = format!("+{ref_name}:{tracking}");
run_git(repo, &["fetch", remote, &refspec])?;
Ok(Some(tracking))
}
pub fn read_tracking_tree(
repo: &Path,
remote: &str,
ref_name: &str,
) -> Result<Vec<TreeEntry>, SyncError> {
let tracking = tracking_ref_name(remote, ref_name)?;
if ref_exists(repo, &tracking)? {
read_tree(repo, &tracking)
} else {
Ok(Vec::new())
}
}
pub fn add_lore_fetch_refspec(repo: &Path, remote: &str) -> Result<(), SyncError> {
let key = format!("remote.{remote}.fetch");
let desired = format!("+refs/lore/*:refs/lore/remotes/{remote}/*");
let old_form = "+refs/lore/*:refs/lore/*";
let output = Command::new("git")
.current_dir(repo)
.args(["config", "--get-all", &key])
.output()
.map_err(|e| SyncError::Git(format!("failed to spawn git: {e}")))?;
let existing: Vec<String> = if output.status.success() {
String::from_utf8_lossy(&output.stdout)
.lines()
.map(|line| line.trim().to_string())
.collect()
} else {
Vec::new()
};
let has_desired = existing.iter().any(|line| line == &desired);
let has_old_form = existing.iter().any(|line| line == old_form);
if has_old_form {
run_git(
repo,
&[
"config",
"--unset-all",
&key,
r"^\+refs/lore/\*:refs/lore/\*$",
],
)?;
}
if !has_desired {
run_git(repo, &["config", "--add", &key, &desired])?;
}
Ok(())
}
fn resolve_revision(repo: &Path, spec: &str) -> Result<Option<String>, SyncError> {
let output = Command::new("git")
.current_dir(repo)
.args(["rev-parse", "--verify", "--quiet", spec])
.output()
.map_err(|e| SyncError::Git(format!("failed to spawn git: {e}")))?;
if output.status.success() {
Ok(Some(stdout_to_string(output.stdout)))
} else if output.stderr.is_empty() {
Ok(None)
} else {
Err(git_error(&["rev-parse", "--verify", spec], &output.stderr))
}
}
fn absolute_git_dir(repo: &Path) -> Result<PathBuf, SyncError> {
let out = run_git(repo, &["rev-parse", "--absolute-git-dir"])?;
Ok(PathBuf::from(stdout_to_string(out)))
}
fn run_git(repo: &Path, args: &[&str]) -> Result<Vec<u8>, SyncError> {
let output = Command::new("git")
.current_dir(repo)
.args(args)
.output()
.map_err(|e| SyncError::Git(format!("failed to spawn git: {e}")))?;
if output.status.success() {
Ok(output.stdout)
} else {
Err(git_error(args, &output.stderr))
}
}
fn run_git_index(repo: &Path, index_path: &Path, args: &[&str]) -> Result<Vec<u8>, SyncError> {
let output = Command::new("git")
.current_dir(repo)
.env("GIT_INDEX_FILE", index_path)
.args(args)
.output()
.map_err(|e| SyncError::Git(format!("failed to spawn git: {e}")))?;
if output.status.success() {
Ok(output.stdout)
} else {
Err(git_error(args, &output.stderr))
}
}
fn run_git_stdin(repo: &Path, args: &[&str], input: &[u8]) -> Result<Vec<u8>, SyncError> {
let mut child = Command::new("git")
.current_dir(repo)
.args(args)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| SyncError::Git(format!("failed to spawn git: {e}")))?;
{
let mut stdin = child
.stdin
.take()
.ok_or_else(|| SyncError::Git("failed to open git stdin".to_string()))?;
stdin.write_all(input)?;
}
let output = child.wait_with_output()?;
if output.status.success() {
Ok(output.stdout)
} else {
Err(git_error(args, &output.stderr))
}
}
fn git_error(args: &[&str], stderr: &[u8]) -> SyncError {
let message = String::from_utf8_lossy(stderr);
SyncError::Git(format!("git {} failed: {}", args.join(" "), message.trim()))
}
fn stdout_to_string(out: Vec<u8>) -> String {
String::from_utf8_lossy(&out).trim().to_string()
}
#[cfg(test)]
mod tests {
use super::*;
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 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"]);
}
#[test]
fn test_blob_tree_commit_ref_round_trip() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
init_repo(repo);
let session_id = "11111111-1111-1111-1111-111111111111";
let enc_bytes = b"encrypted-session-bytes";
let salt_bytes = b"salt-bytes-not-secret";
let enc_sha = write_blob(repo, enc_bytes).unwrap();
let salt_sha = write_blob(repo, salt_bytes).unwrap();
let mut changes = BTreeMap::new();
changes.insert(format!("sessions/{session_id}.enc"), enc_sha.clone());
changes.insert("meta/salt".to_string(), salt_sha.clone());
let tree = build_tree(repo, None, &changes).unwrap();
let commit = commit_tree(repo, &tree, None, "lore: initial").unwrap();
update_ref(repo, "refs/lore/sessions", &commit).unwrap();
assert!(ref_exists(repo, "refs/lore/sessions").unwrap());
assert_eq!(
resolve_ref(repo, "refs/lore/sessions").unwrap(),
Some(commit.clone())
);
assert!(resolve_tree(repo, "refs/lore/sessions").unwrap().is_some());
let entries = read_tree(repo, "refs/lore/sessions").unwrap();
assert_eq!(entries.len(), 2);
let enc_entry = entries
.iter()
.find(|e| e.path == format!("sessions/{session_id}.enc"))
.expect("session blob present");
assert_eq!(enc_entry.sha, enc_sha);
assert_eq!(read_blob(repo, &enc_entry.sha).unwrap(), enc_bytes);
let salt_entry = entries
.iter()
.find(|e| e.path == "meta/salt")
.expect("salt blob present");
assert_eq!(read_blob(repo, &salt_entry.sha).unwrap(), salt_bytes);
}
#[test]
fn test_incremental_rebuild_preserves_unchanged_blob() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
init_repo(repo);
let first_id = "aaaaaaaa-0000-0000-0000-000000000001";
let first_sha = write_blob(repo, b"first-session").unwrap();
let mut changes = BTreeMap::new();
changes.insert(format!("sessions/{first_id}.enc"), first_sha.clone());
let tree1 = build_tree(repo, None, &changes).unwrap();
let commit1 = commit_tree(repo, &tree1, None, "lore: first").unwrap();
update_ref(repo, "refs/lore/sessions", &commit1).unwrap();
let second_id = "bbbbbbbb-0000-0000-0000-000000000002";
let second_sha = write_blob(repo, b"second-session").unwrap();
let mut changes2 = BTreeMap::new();
changes2.insert(format!("sessions/{second_id}.enc"), second_sha.clone());
let tree2 = build_tree(repo, Some("refs/lore/sessions"), &changes2).unwrap();
let commit2 = commit_tree(repo, &tree2, Some(&commit1), "lore: second").unwrap();
update_ref(repo, "refs/lore/sessions", &commit2).unwrap();
let entries = read_tree(repo, "refs/lore/sessions").unwrap();
assert_eq!(entries.len(), 2);
let first_entry = entries
.iter()
.find(|e| e.path == format!("sessions/{first_id}.enc"))
.expect("first session still present");
assert_eq!(first_entry.sha, first_sha);
let second_entry = entries
.iter()
.find(|e| e.path == format!("sessions/{second_id}.enc"))
.expect("second session present");
assert_eq!(second_entry.sha, second_sha);
}
#[test]
fn test_push_and_fetch_between_repos() {
let remote_dir = tempfile::tempdir().unwrap();
let remote = remote_dir.path();
git(remote, &["init", "--bare", "-q"]);
let remote_url = remote.to_str().unwrap();
let dst_dir = tempfile::tempdir().unwrap();
let dst = dst_dir.path();
init_repo(dst);
git(dst, &["remote", "add", "origin", remote_url]);
let local_blob = write_blob(dst, b"local-only-reasoning").unwrap();
let mut local_changes = BTreeMap::new();
local_changes.insert("sessions/local.enc".to_string(), local_blob);
let local_tree = build_tree(dst, None, &local_changes).unwrap();
let local_commit = commit_tree(dst, &local_tree, None, "lore: local").unwrap();
update_ref(dst, "refs/lore/sessions", &local_commit).unwrap();
assert_eq!(fetch(dst, "origin", "refs/lore/sessions").unwrap(), None);
let src_dir = tempfile::tempdir().unwrap();
let src = src_dir.path();
init_repo(src);
git(src, &["remote", "add", "origin", remote_url]);
let blob = write_blob(src, b"reasoning-history").unwrap();
let mut changes = BTreeMap::new();
changes.insert("sessions/x.enc".to_string(), blob.clone());
let tree = build_tree(src, None, &changes).unwrap();
let commit = commit_tree(src, &tree, None, "lore: push").unwrap();
update_ref(src, "refs/lore/sessions", &commit).unwrap();
push(src, "origin", "refs/lore/sessions").unwrap();
let tracking = fetch(dst, "origin", "refs/lore/sessions").unwrap();
assert_eq!(
tracking.as_deref(),
Some("refs/lore/remotes/origin/sessions")
);
assert_eq!(
resolve_ref(dst, "refs/lore/sessions").unwrap(),
Some(local_commit)
);
let entries = read_tracking_tree(dst, "origin", "refs/lore/sessions").unwrap();
let entry = entries
.iter()
.find(|e| e.path == "sessions/x.enc")
.expect("session transferred into tracking ref");
assert_eq!(read_blob(dst, &entry.sha).unwrap(), b"reasoning-history");
let tracking2 = fetch(dst, "origin", "refs/lore/sessions").unwrap();
assert_eq!(
tracking2.as_deref(),
Some("refs/lore/remotes/origin/sessions")
);
}
#[test]
fn test_tracking_ref_name_requires_lore_prefix() {
assert_eq!(
tracking_ref_name("origin", "refs/lore/sessions").unwrap(),
"refs/lore/remotes/origin/sessions"
);
assert!(tracking_ref_name("origin", "refs/heads/main").is_err());
}
#[test]
fn test_read_tracking_tree_empty_when_not_fetched() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
init_repo(repo);
let entries = read_tracking_tree(repo, "origin", "refs/lore/sessions").unwrap();
assert!(entries.is_empty());
}
#[test]
fn test_update_ref_checked_create_and_cas() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
init_repo(repo);
let blob = write_blob(repo, b"v1").unwrap();
let mut changes = BTreeMap::new();
changes.insert("sessions/a.enc".to_string(), blob);
let tree = build_tree(repo, None, &changes).unwrap();
let commit1 = commit_tree(repo, &tree, None, "lore: v1").unwrap();
let commit2 = commit_tree(repo, &tree, Some(&commit1), "lore: v2").unwrap();
update_ref_checked(repo, "refs/lore/sessions", &commit1, None).unwrap();
assert_eq!(
resolve_ref(repo, "refs/lore/sessions").unwrap(),
Some(commit1.clone())
);
let err = update_ref_checked(repo, "refs/lore/sessions", &commit2, None).unwrap_err();
assert!(matches!(err, SyncError::RefCasMismatch(_)));
update_ref_checked(repo, "refs/lore/sessions", &commit2, Some(&commit1)).unwrap();
assert_eq!(
resolve_ref(repo, "refs/lore/sessions").unwrap(),
Some(commit2.clone())
);
let err =
update_ref_checked(repo, "refs/lore/sessions", &commit1, Some(&commit1)).unwrap_err();
assert!(matches!(err, SyncError::RefCasMismatch(_)));
assert_eq!(
resolve_ref(repo, "refs/lore/sessions").unwrap(),
Some(commit2)
);
}
#[test]
fn test_lore_ref_not_in_branches_or_working_tree() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
init_repo(repo);
std::fs::write(repo.join("README.md"), "hello").unwrap();
git(repo, &["add", "README.md"]);
git(repo, &["commit", "-q", "-m", "init"]);
let blob = write_blob(repo, b"reasoning").unwrap();
let salt = write_blob(repo, b"salt").unwrap();
let mut changes = BTreeMap::new();
changes.insert("sessions/y.enc".to_string(), blob);
changes.insert("meta/salt".to_string(), salt);
let tree = build_tree(repo, None, &changes).unwrap();
let commit = commit_tree(repo, &tree, None, "lore: hidden").unwrap();
update_ref(repo, "refs/lore/sessions", &commit).unwrap();
let branches = run_git(repo, &["branch", "--format=%(refname)"]).unwrap();
let branch_text = String::from_utf8_lossy(&branches);
assert!(
!branch_text.contains("refs/lore"),
"lore ref leaked into branches: {branch_text}"
);
assert!(!repo.join("sessions").exists());
assert!(!repo.join("meta").exists());
}
#[test]
fn test_resolve_ref_missing_returns_none() {
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
init_repo(repo);
assert_eq!(resolve_ref(repo, "refs/lore/sessions").unwrap(), None);
assert!(!ref_exists(repo, "refs/lore/sessions").unwrap());
}
#[test]
fn test_add_lore_fetch_refspec_idempotent() {
let remote_dir = tempfile::tempdir().unwrap();
git(remote_dir.path(), &["init", "--bare", "-q"]);
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
init_repo(repo);
git(
repo,
&[
"remote",
"add",
"origin",
remote_dir.path().to_str().unwrap(),
],
);
add_lore_fetch_refspec(repo, "origin").unwrap();
add_lore_fetch_refspec(repo, "origin").unwrap();
let out = run_git(repo, &["config", "--get-all", "remote.origin.fetch"]).unwrap();
let text = String::from_utf8_lossy(&out);
let tracking = text
.lines()
.filter(|l| l.trim() == "+refs/lore/*:refs/lore/remotes/origin/*")
.count();
assert_eq!(
tracking, 1,
"tracking refspec should appear exactly once: {text}"
);
assert!(
!text.lines().any(|l| l.trim() == "+refs/lore/*:refs/lore/*"),
"old-form refspec must not be configured: {text}"
);
}
#[test]
fn test_add_lore_fetch_refspec_migrates_old_form() {
let remote_dir = tempfile::tempdir().unwrap();
git(remote_dir.path(), &["init", "--bare", "-q"]);
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
init_repo(repo);
git(
repo,
&[
"remote",
"add",
"origin",
remote_dir.path().to_str().unwrap(),
],
);
git(
repo,
&[
"config",
"--add",
"remote.origin.fetch",
"+refs/lore/*:refs/lore/*",
],
);
add_lore_fetch_refspec(repo, "origin").unwrap();
let out = run_git(repo, &["config", "--get-all", "remote.origin.fetch"]).unwrap();
let text = String::from_utf8_lossy(&out);
assert!(
!text.lines().any(|l| l.trim() == "+refs/lore/*:refs/lore/*"),
"old-form refspec should be removed: {text}"
);
let tracking = text
.lines()
.filter(|l| l.trim() == "+refs/lore/*:refs/lore/remotes/origin/*")
.count();
assert_eq!(
tracking, 1,
"tracking refspec should appear exactly once after migration: {text}"
);
add_lore_fetch_refspec(repo, "origin").unwrap();
let out = run_git(repo, &["config", "--get-all", "remote.origin.fetch"]).unwrap();
let text = String::from_utf8_lossy(&out);
let tracking = text
.lines()
.filter(|l| l.trim() == "+refs/lore/*:refs/lore/remotes/origin/*")
.count();
assert_eq!(tracking, 1, "migration must stay idempotent: {text}");
}
#[test]
fn test_remote_ref_exists_reflects_remote_state() {
let remote_dir = tempfile::tempdir().unwrap();
let remote = remote_dir.path();
git(remote, &["init", "--bare", "-q"]);
let remote_url = remote.to_str().unwrap();
let dir = tempfile::tempdir().unwrap();
let repo = dir.path();
init_repo(repo);
git(repo, &["remote", "add", "origin", remote_url]);
assert!(!remote_ref_exists(repo, "origin", "refs/lore/sessions").unwrap());
let blob = write_blob(repo, b"reasoning").unwrap();
let mut changes = BTreeMap::new();
changes.insert("sessions/x.enc".to_string(), blob);
let tree = build_tree(repo, None, &changes).unwrap();
let commit = commit_tree(repo, &tree, None, "lore: push").unwrap();
update_ref(repo, "refs/lore/sessions", &commit).unwrap();
push(repo, "origin", "refs/lore/sessions").unwrap();
assert!(remote_ref_exists(repo, "origin", "refs/lore/sessions").unwrap());
}
#[test]
fn test_run_git_error_on_non_repo() {
let dir = tempfile::tempdir().unwrap();
let result = read_tree(dir.path(), "refs/lore/sessions");
assert!(result.is_err());
}
}