use crate::repo_read_layout::dot_git_target;
use crate::service_land_start::{IndexWitness, decode, encode};
use crate::service_protocol::Cleanup;
use std::collections::HashMap;
use std::fs::{self, File};
use std::io::Write;
use std::path::{Path, PathBuf};
pub const OPEN_EXTENSION: &str = "intent";
pub const DONE_EXTENSION: &str = "done";
const TEMPORARY_EXTENSION: &str = "tmp";
pub const CLAIM_EXTENSION: &str = "claim";
pub const TEMPORARY_GRACE: std::time::Duration = std::time::Duration::from_secs(10 * 60);
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Intent {
pub seq: u64,
pub repository: PathBuf,
pub common_dir: PathBuf,
pub reference: String,
pub old: String,
pub new: String,
pub ids: Vec<String>,
pub commits: Vec<String>,
pub paths: Vec<String>,
pub derived: bool,
pub cleanup: Option<Cleanup>,
pub index: PathBuf,
pub git_dir: PathBuf,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Recovery {
Drop,
Finish,
}
pub fn recovery_for(intent: &Intent, current: &str, new_is_ancestor_of_current: bool) -> Recovery {
if current == intent.new || (current != intent.old && new_is_ancestor_of_current) {
return Recovery::Finish;
}
return Recovery::Drop;
}
pub fn intent_text(intent: &Intent) -> String {
let cleanup: String = intent.cleanup.as_ref().map_or_else(String::new, |found| {
return format!("{}\t{}\t{}", found.git_dir.display(), found.scratch.display(), u8::from(found.reftable));
});
return format!(
"seq={}\nrepository={}\ncommon={}\nref={}\nold={}\nnew={}\nids={}\ncommits={}\npaths={}\nderived={}\ncleanup={}\nindex={}\ngitdir={}\n",
intent.seq,
intent.repository.display(),
intent.common_dir.display(),
intent.reference,
intent.old,
intent.new,
intent.ids.join("\t"),
intent.commits.join("\t"),
intent.paths.join("\t"),
u8::from(intent.derived),
cleanup,
intent.index.display(),
intent.git_dir.display(),
);
}
fn git_dir_of_worktree(repository: &Path) -> PathBuf {
return match dot_git_target(repository) {
Some(Some((git_dir, _))) => git_dir,
Some(None) | None => repository.join(".git"),
};
}
pub fn parse_intent(text: &str) -> Option<Intent> {
let value = |key: &str| -> Option<&str> { return text.lines().find_map(|line| { return line.strip_prefix(key)?.strip_prefix('='); }); };
let list = |key: &str| -> Option<Vec<String>> {
return Some(value(key)?.split('\t').filter(|item| { return !item.is_empty(); }).map(str::to_string).collect());
};
let index: PathBuf = value("index").map_or_else(|| { return PathBuf::from(value("repository").unwrap_or("")).join(".git").join("index"); }, PathBuf::from);
let repository: PathBuf = PathBuf::from(value("repository")?);
let worktree_git_dir: PathBuf = value("gitdir").map_or_else(|| { return git_dir_of_worktree(repository.as_path()); }, PathBuf::from);
return Some(Intent {
seq: value("seq")?.parse().ok()?,
repository,
common_dir: PathBuf::from(value("common")?),
reference: value("ref")?.to_string(),
old: value("old")?.to_string(),
new: value("new")?.to_string(),
ids: list("ids")?,
commits: list("commits")?,
paths: list("paths")?,
derived: value("derived") == Some("1"),
index,
git_dir: worktree_git_dir,
cleanup: value("cleanup").and_then(|entry| {
let mut parts = entry.split('\t');
let (git_dir, scratch, reftable) = (parts.next()?, parts.next()?, parts.next()?);
return Some(Cleanup { git_dir: PathBuf::from(git_dir), scratch: PathBuf::from(scratch), reftable: reftable == "1" });
}),
});
}
pub fn batch_path(dir: &Path, seq: u64, extension: &str) -> PathBuf {
return dir.join(format!("{seq}.{extension}"));
}
#[derive(Debug)]
pub struct BatchClaim {
seq: u64,
path: PathBuf,
}
impl BatchClaim {
pub fn seq(&self) -> u64 {
return self.seq;
}
}
impl Drop for BatchClaim {
fn drop(&mut self) {
let _removed = fs::remove_file(&self.path);
}
}
fn number_has_record(dir: &Path, seq: u64) -> bool {
return batch_path(dir, seq, OPEN_EXTENSION).exists() || batch_path(dir, seq, DONE_EXTENSION).exists();
}
pub fn claim_batch(dir: &Path, first: u64) -> std::io::Result<BatchClaim> {
let mut seq: u64 = first;
loop {
let path: PathBuf = batch_path(dir, seq, CLAIM_EXTENSION);
match File::options().write(true).create_new(true).open(&path) {
Ok(_created) => {
let claim: BatchClaim = BatchClaim { seq, path };
if !number_has_record(dir, seq) {
return Ok(claim);
}
drop(claim);
}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {}
Err(error) => return Err(error),
}
seq = seq.checked_add(1).ok_or_else(|| { return std::io::Error::other("the journal holds every batch number"); })?;
}
}
pub fn write_intent(dir: &Path, intent: &Intent) -> std::io::Result<()> {
let temporary: PathBuf = batch_path(dir, intent.seq, TEMPORARY_EXTENSION);
let mut file: File = File::create(&temporary)?;
file.write_all(intent_text(intent).as_bytes())?;
crate::service_durable::flush_file(&file, "intent-file")?;
fs::rename(&temporary, batch_path(dir, intent.seq, OPEN_EXTENSION))?;
return crate::service_durable::flush_directory(dir, "intent-dir");
}
pub const INDEXED_EXTENSION: &str = "indexed";
pub fn mark_indexed(dir: &Path, seq: u64, new: &str) -> std::io::Result<()> {
let temporary: PathBuf = batch_path(dir, seq, TEMPORARY_EXTENSION);
let mut file: File = File::create(&temporary)?;
file.write_all(format!("{new}\n").as_bytes())?;
crate::service_durable::flush_file(&file, "indexed-file")?;
fs::rename(&temporary, batch_path(dir, seq, INDEXED_EXTENSION))?;
crate::service_fault::note("indexed-recorded");
return crate::service_durable::flush_directory(dir, "indexed-dir");
}
pub const START_EXTENSION: &str = "start";
pub fn write_start(dir: &Path, seq: u64, witness: &IndexWitness) -> std::io::Result<()> {
let temporary: PathBuf = batch_path(dir, seq, TEMPORARY_EXTENSION);
let mut file: File = File::create(&temporary)?;
file.write_all(format!("{}\n", encode(witness)).as_bytes())?;
return fs::rename(&temporary, batch_path(dir, seq, START_EXTENSION));
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum Started {
Absent,
Recorded(IndexWitness),
Unreadable,
}
pub fn read_start(dir: &Path, seq: u64) -> Started {
return match fs::read_to_string(batch_path(dir, seq, START_EXTENSION)) {
Ok(text) => decode(text.trim_end_matches('\n')).map_or(Started::Absent, Started::Recorded),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Started::Absent,
Err(error) if error.kind() == std::io::ErrorKind::InvalidData => Started::Absent,
Err(_) => Started::Unreadable,
};
}
pub fn remove_start(dir: &Path, seq: u64) {
let _removed = fs::remove_file(batch_path(dir, seq, START_EXTENSION));
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum Indexed {
Absent,
Installed,
Unreadable,
}
pub fn indexed_state(dir: &Path, seq: u64, new: &str) -> Indexed {
return match fs::read_to_string(batch_path(dir, seq, INDEXED_EXTENSION)) {
Ok(text) if text.trim_end() == new => Indexed::Installed,
Ok(_) => Indexed::Unreadable,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Indexed::Absent,
Err(_) => Indexed::Unreadable,
};
}
pub fn mark_done(dir: &Path, seq: u64) -> std::io::Result<()> {
fs::rename(batch_path(dir, seq, OPEN_EXTENSION), batch_path(dir, seq, DONE_EXTENSION))?;
crate::service_durable::flush_directory(dir, "journal-dir")?;
crate::service_fault::note("remove-indexed");
let _record_removed = fs::remove_file(batch_path(dir, seq, INDEXED_EXTENSION));
remove_start(dir, seq);
return Ok(());
}
pub fn list_intents(dir: &Path, extension: &str) -> Vec<(PathBuf, Option<Intent>)> {
let Ok(entries) = fs::read_dir(dir) else {
return Vec::new();
};
let mut found: Vec<(PathBuf, Option<Intent>)> = Vec::new();
for entry in entries.flatten() {
let path: PathBuf = entry.path();
match path.extension().and_then(|text| { return text.to_str(); }) {
Some(TEMPORARY_EXTENSION | CLAIM_EXTENSION) => {
let abandoned: bool = entry
.metadata()
.and_then(|meta| { return meta.modified(); })
.is_ok_and(|modified| { return modified.elapsed().is_ok_and(|age| { return age > TEMPORARY_GRACE; }); });
if abandoned {
let _removed = fs::remove_file(&path);
}
}
Some(found_extension) if found_extension == extension => {
let parsed: Option<Intent> = fs::read_to_string(&path).ok().and_then(|text| { return parse_intent(text.as_str()); });
found.push((path, parsed));
}
_ => {}
}
}
found.sort_by_key(|(path, intent)| {
return intent.as_ref().map_or_else(|| { return path.to_string_lossy().len() as u64; }, |parsed| { return parsed.seq; });
});
return found;
}
pub const DONE_RETAINED: usize = 1024;
pub fn prune_done(dir: &Path, retained: usize) -> usize {
let Ok(entries) = fs::read_dir(dir) else {
return 0;
};
let mut finished: Vec<(u64, PathBuf)> = entries
.flatten()
.filter_map(|entry| {
let path: PathBuf = entry.path();
if path.extension()? != DONE_EXTENSION || !entry.file_type().ok()?.is_file() {
return None;
}
let seq: u64 = path.file_stem()?.to_str()?.parse().ok()?;
return Some((seq, path));
})
.collect();
finished.sort_by_key(|(seq, _)| { return std::cmp::Reverse(*seq); });
return finished.into_iter().skip(retained).filter(|(_, path)| { return fs::remove_file(path).is_ok(); }).count();
}
pub fn landed_commits(dir: &Path) -> HashMap<String, String> {
let mut landed: HashMap<String, String> = HashMap::new();
for (_, intent) in list_intents(dir, DONE_EXTENSION) {
if let Some(parsed) = intent {
landed.extend(parsed.ids.into_iter().zip(parsed.commits));
}
}
return landed;
}
#[cfg(test)]
#[path = "service_journal_tests.rs"]
mod tests;