pub mod fsck;
pub mod index;
pub mod lock;
pub mod objects;
pub mod refs;
pub mod retention;
pub mod tombstone;
use crate::decode::decode_object;
use crate::encode::encode_object;
use crate::error::ObjectError;
use crate::gid::Gid;
use crate::hash::gid_from_envelope;
use crate::limits::Limits;
use crate::value::Object;
use std::fmt;
use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
pub const META_DIR: &str = ".gemel";
pub const REF_HEAD: &str = "refs/head";
pub const REF_STATE_HEAD: &str = "refs/state/head";
pub const REF_CONFIG: &str = "refs/config";
pub const REF_NAMES: &str = "refs/names";
pub const REF_TRAJECTORIES: &str = "refs/trajectories";
pub const REF_MAPPINGS: &str = "refs/mappings";
pub const REF_CASES: &str = "refs/cases";
pub const REF_RELEASES: &str = "refs/releases";
pub const REF_CHECKPOINTS: &str = "refs/checkpoints";
pub const REF_RECONCILIATIONS: &str = "refs/reconciliations";
#[derive(Debug)]
pub enum Error {
Io(std::io::Error),
Object(ObjectError),
NotARepository(PathBuf),
RepoAlreadyExists(PathBuf),
RefNameInvalid(String),
RefCorrupt {
name: String,
detail: String,
},
ObjectNotFound(Gid),
ObjectPruned {
id: Gid,
tombstone: tombstone::Tombstone,
},
ObjectCorrupt {
id: Gid,
detail: String,
},
HashCollision {
id: Gid,
},
Unresolved(String),
NoPendingChange,
PendingChangeAlreadyExists,
NoHead,
Invalid(String),
Index(String),
Lock(String),
Limit {
kind: &'static str,
limit: u64,
found: u64,
},
Path(String),
Unsupported(String),
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::Io(e) => write!(f, "io error: {e}"),
Error::Object(e) => write!(f, "object error: {e}"),
Error::NotARepository(p) => write!(f, "not a gemel repository: {}", p.display()),
Error::RepoAlreadyExists(p) => write!(f, "repository already exists: {}", p.display()),
Error::RefNameInvalid(name) => write!(f, "invalid ref name: {name:?}"),
Error::RefCorrupt { name, detail } => write!(f, "corrupt ref {name:?}: {detail}"),
Error::ObjectNotFound(id) => write!(f, "object not found: {id}"),
Error::ObjectPruned { id, .. } => write!(f, "object pruned by retention policy: {id}"),
Error::ObjectCorrupt { id, detail } => write!(f, "corrupt object {id}: {detail}"),
Error::HashCollision { id } => {
write!(f, "hash collision: object {id} exists with different bytes")
}
Error::Unresolved(name) => write!(f, "unresolved name or identity: {name:?}"),
Error::NoPendingChange => write!(f, "no change in progress (run `gemel change begin`)"),
Error::PendingChangeAlreadyExists => {
write!(
f,
"a change is already in progress (run `gemel change finish`)"
)
}
Error::NoHead => write!(f, "repository has no head change"),
Error::Invalid(msg) => write!(f, "invalid: {msg}"),
Error::Index(msg) => write!(f, "index error: {msg}"),
Error::Lock(msg) => write!(f, "lock error: {msg}"),
Error::Limit { kind, limit, found } => {
write!(f, "limit exceeded: {kind} (limit {limit}, found {found})")
}
Error::Path(msg) => write!(f, "path error: {msg}"),
Error::Unsupported(msg) => write!(f, "unsupported: {msg}"),
}
}
}
impl std::error::Error for Error {}
impl From<std::io::Error> for Error {
fn from(e: std::io::Error) -> Self {
Error::Io(e)
}
}
impl From<ObjectError> for Error {
fn from(e: ObjectError) -> Self {
Error::Object(e)
}
}
#[derive(Debug, Clone)]
pub struct Repo {
root: PathBuf,
meta: PathBuf,
}
#[derive(Debug, Clone, Default)]
pub struct InitOptions {
pub author_name: Option<String>,
pub author_email: Option<String>,
}
impl Repo {
pub fn init(root: &Path, opts: &InitOptions) -> Result<Repo, Error> {
let meta = root.join(META_DIR);
if meta.join("meta.json").exists() {
return Err(Error::RepoAlreadyExists(root.to_path_buf()));
}
if !meta.exists() {
std::fs::create_dir_all(&meta)?;
}
std::fs::create_dir_all(meta.join("objects"))?;
std::fs::create_dir_all(meta.join("refs").join("names"))?;
std::fs::create_dir_all(meta.join("refs").join("trajectories"))?;
std::fs::create_dir_all(meta.join("refs").join("cases"))?;
std::fs::create_dir_all(meta.join("refs").join("releases"))?;
std::fs::create_dir_all(meta.join("journal"))?;
std::fs::create_dir_all(meta.join("index"))?;
std::fs::create_dir_all(meta.join("worktrees").join("default"))?;
std::fs::File::create(meta.join("lock"))?;
let repo = Repo {
root: root.to_path_buf(),
meta: meta.clone(),
};
let producer = match &opts.author_name {
Some(name) => {
crate::defaults::human_producer_object(name, opts.author_email.as_deref())
}
None => crate::defaults::automation_producer_object("gemel"),
};
let producer_id = repo.insert_object(&producer)?;
let config = crate::defaults::default_config_object();
let config_id = repo.insert_object(&config)?;
let meta_json = serde_json::json!({
"schema": "gemel.meta.v1",
"default_producer": producer_id.to_string(),
"counters": {
"intent": 0,
"trajectory": 0,
"change": 0,
"state": 0,
"checkpoint": 0,
"reconciliation": 0,
},
});
repo.write_meta(&meta_json)?;
let txn = refs::RefTransaction {
ops: vec![refs::RefOp::set(REF_CONFIG, config_id)],
};
repo.write_refs(&txn)?;
Ok(repo)
}
pub fn open(root: &Path) -> Result<Repo, Error> {
let meta = root.join(META_DIR);
if !meta.is_dir() || !meta.join("meta.json").is_file() {
return Err(Error::NotARepository(root.to_path_buf()));
}
let repo = Repo {
root: root.to_path_buf(),
meta,
};
repo.recover_opportunistic();
Ok(repo)
}
pub fn find(start: &Path) -> Result<Repo, Error> {
let mut dir = Some(start);
while let Some(d) = dir {
if d.join(META_DIR).is_dir() {
return Repo::open(d);
}
dir = d.parent();
}
Err(Error::NotARepository(start.to_path_buf()))
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn meta_dir(&self) -> &Path {
&self.meta
}
pub fn limits(&self) -> Limits {
Limits::default()
}
pub fn resolve(&self, name_or_id: &str) -> Result<Gid, Error> {
if let Ok(gid) = name_or_id.parse::<Gid>() {
return Ok(gid);
}
for ns in [
REF_NAMES,
REF_TRAJECTORIES,
REF_CASES,
REF_RELEASES,
REF_RECONCILIATIONS,
REF_CHECKPOINTS,
REF_MAPPINGS,
] {
if let Some(gid) = self.read_ref(&format!("{ns}/{name_or_id}"))? {
return Ok(gid);
}
}
Err(Error::Unresolved(name_or_id.to_string()))
}
pub fn read_meta(&self) -> Result<serde_json::Value, Error> {
let path = self.meta.join("meta.json");
match std::fs::read_to_string(&path) {
Ok(text) => Ok(serde_json::from_str(&text)
.map_err(|e| Error::Invalid(format!("meta.json: {e}")))?),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Err(Error::Invalid(
"repository metadata missing; run fsck".into(),
)),
Err(e) => Err(e.into()),
}
}
pub fn write_meta(&self, value: &serde_json::Value) -> Result<(), Error> {
let path = self.meta.join("meta.json");
let mut bytes =
serde_json::to_vec_pretty(value).map_err(|e| Error::Invalid(e.to_string()))?;
bytes.push(b'\n');
objects::write_atomic(&path, &bytes)?;
Ok(())
}
pub fn with_write_lock<T>(&self, f: impl FnOnce() -> Result<T, Error>) -> Result<T, Error> {
lock::with_write_lock(&self.meta.join("lock"), f)
}
pub(crate) fn try_with_write_lock<T>(
&self,
f: impl FnOnce() -> Result<T, Error>,
) -> Option<Result<T, Error>> {
lock::try_with_write_lock(&self.meta.join("lock"), f)
}
fn recover_opportunistic(&self) {
let _ = self.try_with_write_lock(|| {
if refs::recover_unlocked(&self.meta)? {
index::rebuild(self)?;
}
Ok::<(), Error>(())
});
}
pub fn insert_object(&self, obj: &Object) -> Result<Gid, Error> {
let bytes = encode_object(obj, &self.limits())?;
self.insert_bytes(&bytes)
}
pub fn insert_bytes(&self, bytes: &[u8]) -> Result<Gid, Error> {
let decoded = decode_object(bytes, &self.limits())?;
let id = gid_from_envelope(bytes).expect("validated envelope has a family");
if decoded.family != id.family() {
return Err(Error::Invalid("envelope family mismatch".into()));
}
objects::insert(&self.meta, id, bytes, &self.limits())?;
self.index_note_insert(&decoded, id, bytes.len() as u64)?;
Ok(id)
}
pub fn read_object(&self, id: &Gid) -> Result<ReadOutcome, Error> {
match objects::read(&self.meta, id, &self.limits()) {
Ok(bytes) => {
let obj = decode_object(&bytes, &self.limits())?;
if obj.family != id.family() {
return Err(Error::ObjectCorrupt {
id: *id,
detail: "decoded family differs from identity".into(),
});
}
Ok(ReadOutcome::Object(obj))
}
Err(objects::ObjectError::NotFound) => match tombstone::read(&self.meta, id)? {
Some(t) => Ok(ReadOutcome::Pruned(t)),
None => Err(Error::ObjectNotFound(*id)),
},
Err(e) => Err(e.into()),
}
}
pub fn read_bytes(&self, id: &Gid) -> Result<Vec<u8>, Error> {
objects::read(&self.meta, id, &self.limits()).map_err(|e| match e {
objects::ObjectError::NotFound => Error::ObjectNotFound(*id),
other => other.into(),
})
}
pub fn load(&self, id: &Gid) -> Result<Object, Error> {
match self.read_object(id)? {
ReadOutcome::Object(obj) => Ok(obj),
ReadOutcome::Pruned(t) => Err(Error::ObjectPruned {
id: *id,
tombstone: t,
}),
}
}
pub fn has_object(&self, id: &Gid) -> Result<bool, Error> {
objects::exists(&self.meta, id).map_err(Into::into)
}
pub fn read_ref(&self, name: &str) -> Result<Option<Gid>, Error> {
refs::read(&self.meta, name)
}
pub fn write_refs(&self, txn: &refs::RefTransaction) -> Result<(), Error> {
self.with_write_lock(|| {
refs::apply_unlocked(&self.meta, txn)?;
self.index_note_refs(txn)?;
Ok(())
})
}
pub fn apply_refs_unlocked(&self, txn: &refs::RefTransaction) -> Result<(), Error> {
refs::apply_unlocked(&self.meta, txn)?;
self.index_note_refs(txn)?;
Ok(())
}
pub fn all_refs(&self) -> Result<Vec<(String, Gid)>, Error> {
refs::all(&self.meta)
}
pub fn name_of(&self, gid: &Gid) -> Result<Option<String>, Error> {
let rank = |name: &str| -> u8 {
if name.starts_with("refs/names/") {
0
} else if name.starts_with("refs/trajectories/") {
1
} else if name.starts_with("refs/cases/") {
2
} else if name.starts_with("refs/releases/") {
3
} else {
4
}
};
let mut best: Option<(u8, String)> = None;
for (name, g) in refs::all(&self.meta)? {
if g == *gid {
let r = rank(&name);
let short = name.rsplit('/').next().unwrap_or(&name).to_string();
if best.as_ref().map(|(br, _)| r < *br).unwrap_or(true) {
best = Some((r, short));
}
}
}
Ok(best.map(|(_, n)| n))
}
fn index_note_insert(&self, obj: &Object, id: Gid, size: u64) -> Result<(), Error> {
if let Err(e) = index::note_insert(self, obj, id, size) {
index::mark_stale(self);
let _ = e;
}
Ok(())
}
fn index_note_refs(&self, txn: &refs::RefTransaction) -> Result<(), Error> {
if let Err(e) = index::note_refs(self, txn) {
index::mark_stale(self);
let _ = e;
}
Ok(())
}
pub fn rebuild_index(&self) -> Result<(), Error> {
self.with_write_lock(|| index::rebuild(self))
}
pub fn scan_canonical(&self) -> Vec<(Gid, Object)> {
index::scan_canonical(self)
}
pub fn index_is_fresh(&self) -> bool {
index::is_fresh(self)
}
pub fn fsck(&self, opts: &fsck::FsckOptions) -> Result<fsck::FsckReport, Error> {
fsck::run(self, opts)
}
}
#[derive(Debug, Clone)]
pub enum ReadOutcome {
Object(Object),
Pruned(tombstone::Tombstone),
}
pub fn now_ms() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis() as i64)
.unwrap_or(0)
}
#[cfg(test)]
pub(crate) mod testing {
use super::*;
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
pub fn temp_root(tag: &str) -> PathBuf {
let n = COUNTER.fetch_add(1, Ordering::SeqCst);
let dir = std::env::temp_dir().join(format!("gemel-test-{tag}-{}-{n}", std::process::id()));
std::fs::remove_dir_all(&dir).ok();
std::fs::create_dir_all(&dir).unwrap();
dir
}
pub fn fresh_repo(tag: &str) -> (Repo, PathBuf) {
let root = temp_root(tag);
let repo = Repo::init(&root, &InitOptions::default()).expect("init");
(repo, root)
}
}