use std::path::Path;
use kernel::event::record::{CheckpointCommitted, Commit, FileRestored};
use kernel::{Address, GitOid, Payload, TimeMs};
use serde_json::{Map, Value};
use crate::alias::WriteTarget;
use crate::bundle::landing::{Bits, land};
use crate::error::StorageError;
use crate::real_fs::RealFs;
use super::provenance::Provenance;
use super::scan::CommitPlan;
pub(super) fn subject_of(scopes: &[String]) -> String {
if scopes.is_empty() {
return "checkpoint: .".to_owned();
}
format!("checkpoint: {}", scopes.join(" "))
}
pub(super) fn checkpoint_ref(of: &Provenance, oid: git2::Oid) -> String {
format!("refs/sprawling/runs/{}/{oid}", of.run())
}
pub(super) fn committed(
oid: git2::Oid,
of: &Provenance,
scopes: &[String],
files: Vec<String>,
) -> Result<Payload, StorageError> {
let spelled = oid.to_string();
let oid = GitOid::parse(&spelled).ok_or_else(|| StorageError::Checkpoint {
op: "record a checkpoint commit",
detail: format!("git named this commit {spelled}, which is not 40 hex digits"),
})?;
let record = CheckpointCommitted::Committed(Commit {
oid,
by: of.attribution(),
scope: scopes.to_vec(),
files,
});
Payload::of(&record).map_err(|source| StorageError::Draft { source })
}
pub struct Checkpoint {
pub(crate) repo: git2::Repository,
pub(crate) last: Option<git2::Oid>,
}
pub(crate) fn git_err(op: &'static str) -> impl FnOnce(git2::Error) -> StorageError {
move |err| StorageError::Checkpoint {
op,
detail: err.message().to_owned(),
}
}
impl Checkpoint {
pub fn open(city_root: &Path) -> Result<Checkpoint, StorageError> {
let repo = match git2::Repository::open(city_root) {
Ok(repo) => repo,
Err(_) => git2::Repository::init(city_root).map_err(git_err("init repository"))?,
};
repo.config()
.and_then(|mut config| config.set_bool("core.autocrlf", false))
.map_err(git_err("pin the repository's line endings"))?;
Ok(Checkpoint { repo, last: None })
}
pub fn ensure_base(
&mut self,
scopes: &[String],
t: TimeMs,
of: &Provenance,
) -> Result<Option<Payload>, StorageError> {
if self.repo.head().is_ok() {
return Ok(None);
}
let files = self.stage_scopes(scopes)?;
self.scan_staged()?;
let oid = self.commit(&CommitPlan {
t,
of,
subject: &subject_of(scopes),
onto_head: true,
})?;
committed(oid, of, scopes, files).map(Some)
}
pub fn wave_pre(
&mut self,
scopes: &[String],
t: TimeMs,
of: &Provenance,
) -> Result<Payload, StorageError> {
let files = self.stage_scopes(scopes)?;
self.scan_staged()?;
let oid = self.commit(&CommitPlan {
t,
of,
subject: &subject_of(scopes),
onto_head: false,
})?;
self.repo
.reference(
&checkpoint_ref(of, oid),
oid,
true,
"sprawling: a wave checkpoint",
)
.map_err(git_err("file a wave checkpoint"))?;
committed(oid, of, scopes, files)
}
pub fn wave_post(&mut self, pre_oid: &str) -> Result<Vec<Payload>, StorageError> {
let oid = git2::Oid::from_str(pre_oid).map_err(git_err("parse checkpoint oid"))?;
let commit = self
.repo
.find_commit(oid)
.map_err(git_err("find checkpoint commit"))?;
let tree = commit.tree().map_err(git_err("read checkpoint tree"))?;
let workdir = self
.repo
.workdir()
.ok_or_else(|| StorageError::Checkpoint {
op: "sweep the working tree",
detail: "the city repository is bare".to_owned(),
})?
.to_path_buf();
let mut deleted: Vec<String> = Vec::new();
let mut fault: Option<StorageError> = None;
let walked = tree.walk(git2::TreeWalkMode::PreOrder, |dir, entry| {
if entry.kind() != Some(git2::ObjectType::Blob) {
return git2::TreeWalkResult::Ok;
}
let path = format!("{dir}{}", entry.name().unwrap_or_default());
match std::fs::symlink_metadata(workdir.join(&path)) {
Ok(_) => {}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => deleted.push(path),
Err(err) => {
fault = Some(StorageError::Checkpoint {
op: "sweep the working tree",
detail: format!("{path}: {err}"),
});
return git2::TreeWalkResult::Abort;
}
}
git2::TreeWalkResult::Ok
});
walked.map_err(git_err("walk the checkpoint tree"))?;
if let Some(err) = fault {
return Err(err);
}
deleted.sort();
let mut payloads = Vec::new();
for path in deleted {
let mut map = Map::new();
map.insert(
"paths".to_owned(),
Value::Array(vec![Value::String(format!("file:{path}"))]),
);
let mut restoration = Map::new();
restoration.insert(
"tracked".to_owned(),
Value::String(format!("file:{path}@{pre_oid}")),
);
map.insert("restoration".to_owned(), Value::Object(restoration));
payloads.push(Payload::new(map).map_err(|source| StorageError::Draft { source })?);
}
Ok(payloads)
}
pub fn restore(&self, address: &Address, oid: &GitOid) -> Result<(), StorageError> {
let refused = |detail: String| StorageError::Checkpoint {
op: "restore a discarded file",
detail,
};
if address.is_reserved() {
return Err(refused(format!(
"{address} is protected metadata; the city writes it through its own gates, not a restore"
)));
}
let commit = git2::Oid::from_str(&oid.to_string())
.and_then(|oid| self.repo.find_commit(oid))
.map_err(git_err("find the restoration commit"))?;
let blob = commit
.tree()
.and_then(|tree| tree.get_path(Path::new(address.as_str())))
.and_then(|entry| entry.to_object(&self.repo))
.map_err(git_err("find the discarded file in its commit"))?
.into_blob()
.map_err(|_| refused(format!("{address}@{oid} is not a file")))?;
let workdir = self
.repo
.workdir()
.ok_or_else(|| refused("the city repository is bare".to_owned()))?;
let target = WriteTarget::within(
"restore a discarded file",
workdir,
&workdir.join(address.as_str()),
)?;
let target = target.as_path();
if let Some(parent) = target.parent() {
std::fs::create_dir_all(parent)
.map_err(|err| refused(format!("{}: {err}", parent.display())))?;
}
match std::fs::read(target) {
Ok(current) if current == blob.content() => return Ok(()),
Ok(_) => {
return Err(refused(format!(
"{address}: a file already sits there; move it aside and restore again"
)));
}
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(err) => return Err(refused(format!("{address}: {err}"))),
}
std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.open(target)
.and_then(|mut file| std::io::Write::write_all(&mut file, blob.content()))
.map_err(|err| refused(format!("{address}: {err}")))
}
pub fn take_back(
&self,
address: &Address,
point: &GitOid,
) -> Result<FileRestored, StorageError> {
let refused = |detail: String| StorageError::Checkpoint {
op: "take a file back from a checkpoint",
detail,
};
if address.is_reserved() {
return Err(refused(format!(
"{address} is protected metadata; the city writes it through its own gates"
)));
}
let tree = git2::Oid::from_str(&point.to_string())
.and_then(|oid| self.repo.find_commit(oid))
.and_then(|commit| commit.tree())
.map_err(git_err("find the checkpoint to take a file back from"))?;
let workdir = self
.repo
.workdir()
.ok_or_else(|| refused("the city repository is bare".to_owned()))?;
let target = WriteTarget::within(
"take a file back from a checkpoint",
workdir,
&workdir.join(address.as_str()),
)?;
let restored = FileRestored {
name: String::new(),
path: address.as_str().to_owned(),
point: *point,
};
let entry = match tree.get_path(Path::new(address.as_str())) {
Ok(entry) => entry,
Err(err) if err.code() == git2::ErrorCode::NotFound => {
return match std::fs::remove_file(target.as_path()) {
Ok(()) => Ok(restored),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(restored),
Err(err) => Err(refused(format!("{address}: {err}"))),
};
}
Err(err) => return Err(git_err("find the file in the checkpoint")(err)),
};
let blob = entry
.to_object(&self.repo)
.map_err(git_err("read the file in the checkpoint"))?
.into_blob()
.map_err(|_| refused(format!("{address}@{point} is not a file")))?;
if let Some(parent) = target.as_path().parent() {
std::fs::create_dir_all(parent)
.map_err(|err| refused(format!("{}: {err}", parent.display())))?;
}
land(&mut RealFs::new(), target, blob.content(), Bits::OfReplaced)?;
Ok(restored)
}
}
#[cfg(test)]
#[allow(
clippy::unwrap_used,
clippy::expect_used,
clippy::panic,
clippy::indexing_slicing,
reason = "test code"
)]
mod tests;
#[cfg(test)]
#[allow(clippy::unwrap_used, reason = "test code")]
mod restore_tests;