use std::fs;
use std::io::{self, Read};
use std::path::{Path, PathBuf};
use crate::refs::{Backend, Expected, RefEdit, RefName, Reflog, ReflogEntry, ReflogRecord, Target};
use crate::{ObjectFormat, ObjectId, Repository, Signature};
#[derive(Clone, Copy, Debug)]
#[non_exhaustive]
pub struct LogLimits {
pub max_bytes: usize,
pub max_entries: usize,
}
impl Default for LogLimits {
fn default() -> Self {
Self {
max_bytes: 16 * 1024 * 1024,
max_entries: 100_000,
}
}
}
#[derive(Clone, Debug)]
pub struct History {
directory: PathBuf,
format: ObjectFormat,
tip: Option<ObjectId>,
bytes: Option<Vec<u8>>,
records: Vec<ReflogRecord>,
limits: LogLimits,
}
impl LogLimits {
#[must_use]
pub fn with_max_bytes(mut self, max_bytes: usize) -> Self {
self.max_bytes = max_bytes;
self
}
}
impl History {
pub fn read(repo: &Repository, limits: LogLimits) -> Result<Self, LogError> {
if repo.reference_backend() != Backend::Files {
return Err(LogError::Backend);
}
let name = stash_name();
let tip = match repo.references()?.read(&name)? {
Some(Target::Direct(id)) => Some(id),
None => None,
Some(Target::Symbolic(_)) => return Err(LogError::Inconsistent),
};
let bytes = read_log(repo.common_dir(), limits)?;
let records =
ReflogRecord::parse(repo.object_format(), bytes.as_deref().unwrap_or_default())?;
if records.len() > limits.max_entries {
return Err(LogError::Limit);
}
if records.last().map(|r| r.entry().new) != tip || (tip.is_none() && bytes.is_some()) {
return Err(LogError::Inconsistent);
}
Ok(Self {
directory: repo.common_dir().to_path_buf(),
format: repo.object_format(),
tip,
bytes,
records,
limits,
})
}
pub fn entries(&self) -> impl DoubleEndedIterator<Item = &ReflogEntry> + ExactSizeIterator {
self.records.iter().rev().map(ReflogRecord::entry)
}
pub fn push(
&self,
id: ObjectId,
committer: Signature,
message: Vec<u8>,
) -> Result<LogEdit, LogError> {
id.require_format(self.format)
.map_err(crate::refs::ReferenceError::from)?;
let entry = ReflogEntry {
old: self.tip.unwrap_or_else(|| ObjectId::null(self.format)),
new: id,
committer,
message,
};
let mut after = self.bytes.clone().unwrap_or_default();
after.extend(entry.encode()?);
if after.len() > self.limits.max_bytes || self.records.len() >= self.limits.max_entries {
return Err(LogError::Limit);
}
Ok(LogEdit {
before: self.clone(),
after: Some(after),
tip: Some(id),
})
}
pub fn drop(&self, position: usize) -> Result<LogEdit, LogError> {
let selected = self
.records
.len()
.checked_sub(position.saturating_add(1))
.ok_or(LogError::Position)?;
let mut after = Vec::new();
let mut previous = ObjectId::null(self.format);
let mut tip = None;
for (index, record) in self.records.iter().enumerate() {
if index == selected {
continue;
}
if index > selected {
after.extend_from_slice(previous.to_string().as_bytes());
after.extend_from_slice(
&record.as_bytes()[ObjectId::null(self.format).to_string().len()..],
);
} else {
after.extend_from_slice(record.as_bytes());
}
previous = record.entry().new;
tip = Some(previous);
}
Ok(LogEdit {
before: self.clone(),
after: (!after.is_empty()).then_some(after),
tip,
})
}
}
#[derive(Clone, Debug)]
pub struct LogEdit {
before: History,
after: Option<Vec<u8>>,
tip: Option<ObjectId>,
}
impl LogEdit {
pub fn publish(&self, repo: &Repository) -> Result<(), LogError> {
self.publish_with(repo, || Ok(()))
}
fn publish_with(
&self,
repo: &Repository,
after_ref: impl FnOnce() -> Result<(), crate::refs::ReferenceError>,
) -> Result<(), LogError> {
if repo.common_dir() != self.before.directory
|| repo.object_format() != self.before.format
|| repo.reference_backend() != Backend::Files
{
return Err(LogError::Changed);
}
let path = repo.common_dir().join("logs/refs/stash");
let lock = crate::refs::FileLock::acquire(path.clone())?;
if read_log(repo.common_dir(), self.before.limits)? != self.before.bytes {
return Err(LogError::Changed);
}
let name = stash_name();
let expected = self
.before
.tip
.map_or(Expected::Absent, |id| Expected::Value(Target::Direct(id)));
let edit = RefEdit::new(
name.clone(),
self.tip.map(Target::Direct),
expected,
Reflog::Preserve,
);
if repo.references()?.read(&name)? != self.before.tip.map(Target::Direct) {
return Err(LogError::Changed);
}
lock.check_owned()?;
repo.references()?.transaction(&[edit])?;
let publish = || -> Result<(), crate::refs::ReferenceError> {
after_ref()?;
lock.check_owned()?;
if let Some(bytes) = &self.after {
lock.publish_retaining_lock(bytes)
} else {
fs::remove_file(&path).map_err(|source| crate::refs::ReferenceError::Io {
path: path.clone(),
source,
})
}
};
publish().map_err(|source| LogError::Partial {
tip: self.tip,
source: Box::new(source),
})
}
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum LogError {
#[error("stash reference history")]
Reference(#[from] crate::refs::ReferenceError),
#[error("publishing stash tip")]
Transaction(#[from] crate::refs::TransactionError),
#[error("stash history limit exceeded")]
Limit,
#[error("stash history changed")]
Changed,
#[error("stash tip and reflog are inconsistent")]
Inconsistent,
#[error("stash position is absent")]
Position,
#[error("stash history editing requires files reference storage")]
Backend,
#[error("stash tip published but log publication failed")]
Partial {
tip: Option<ObjectId>,
#[source]
source: Box<crate::refs::ReferenceError>,
},
}
fn stash_name() -> RefName {
RefName::new("refs/stash").expect("fixed valid stash reference")
}
fn read_log(directory: &Path, limits: LogLimits) -> Result<Option<Vec<u8>>, LogError> {
let mut path = directory.to_path_buf();
for part in ["logs", "refs", "stash"] {
path.push(part);
match fs::symlink_metadata(&path) {
Ok(metadata) if metadata.file_type().is_symlink() => {
return Err(LogError::Reference(crate::refs::ReferenceError::Io {
path,
source: io::Error::new(io::ErrorKind::InvalidInput, "stash log symlink"),
}));
}
Ok(metadata) if part == "stash" && !metadata.is_file() => {
return Err(LogError::Inconsistent);
}
Ok(_) => (),
Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(None),
Err(source) => {
return Err(LogError::Reference(crate::refs::ReferenceError::Io {
path,
source,
}));
}
}
}
let file = fs::File::open(&path).map_err(|source| crate::refs::ReferenceError::Io {
path: path.clone(),
source,
})?;
if !file
.metadata()
.map_err(|source| crate::refs::ReferenceError::Io {
path: path.clone(),
source,
})?
.is_file()
{
return Err(LogError::Inconsistent);
}
let mut bytes = Vec::new();
file.take(limits.max_bytes.saturating_add(1) as u64)
.read_to_end(&mut bytes)
.map_err(|source| crate::refs::ReferenceError::Io { path, source })?;
if bytes.len() > limits.max_bytes {
return Err(LogError::Limit);
}
Ok(Some(bytes))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{Commit, InitKind, Tree};
#[test]
fn log_failure_after_tip_publication_retains_original_history_and_receipt() {
let directory = tempfile::tempdir().unwrap();
let repo = Repository::init(
ObjectFormat::Sha1,
directory.path().join("repo"),
InitKind::Bare,
)
.unwrap();
let writer = Signature {
name: b"Writer".to_vec(),
email: b"w@example.com".to_vec(),
seconds: 1700000000,
offset_minutes: 0,
};
let loose = repo.loose_objects();
let tree = loose
.write_tree(&Tree::new(ObjectFormat::Sha1, vec![]).unwrap())
.unwrap();
let first = loose
.write_commit(
&Commit::builder(tree, writer.clone(), b"first")
.build()
.unwrap(),
)
.unwrap();
let second = loose
.write_commit(
&Commit::builder(tree, writer.clone(), b"second")
.build()
.unwrap(),
)
.unwrap();
History::read(&repo, LogLimits::default())
.unwrap()
.push(first, writer.clone(), b"first".to_vec())
.unwrap()
.publish(&repo)
.unwrap();
let observed = History::read(&repo, LogLimits::default()).unwrap();
let plan = observed.push(second, writer, b"second".to_vec()).unwrap();
let result = plan.publish_with(&repo, || {
Err(crate::refs::ReferenceError::Io {
path: repo.common_dir().join("logs/refs/stash"),
source: io::Error::other("injected log failure"),
})
});
assert!(matches!(result, Err(LogError::Partial { tip: Some(id), .. }) if id == second));
assert_eq!(
repo.references().unwrap().read(&stash_name()).unwrap(),
Some(Target::Direct(second))
);
assert_eq!(
read_log(repo.common_dir(), LogLimits::default()).unwrap(),
observed.bytes
);
assert!(matches!(
History::read(&repo, LogLimits::default()),
Err(LogError::Inconsistent)
));
repo.objects().unwrap().read_commit(first).unwrap();
repo.objects().unwrap().read_commit(second).unwrap();
}
}