use std::io::Write;
use std::path::PathBuf;
use crate::lockfile::{LockFile, LockWait};
use crate::objects::ObjectId;
use crate::repo::Repository;
use super::error::HistoryError;
use super::types::{HeadEffect, PlannedRefUpdate, RewritePlan};
const FORMAT_VERSION: u32 = 1;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct HistoryLastRecord {
pub operation: String,
pub head_ref: Option<String>,
pub head_old_commit: Option<ObjectId>,
pub head_new_commit: Option<ObjectId>,
pub head_old_tree: Option<ObjectId>,
pub head_new_tree: Option<ObjectId>,
pub ref_updates: Vec<PlannedRefUpdate>,
}
impl HistoryLastRecord {
#[must_use]
pub fn from_applied_plan(operation: &str, plan: &RewritePlan) -> Self {
Self {
operation: operation.into(),
head_ref: plan.head.head_ref.clone(),
head_old_commit: plan.head.old_commit,
head_new_commit: plan
.head_change()
.map(|(_, new)| new)
.or(plan.head.old_commit),
head_old_tree: plan.head.old_tree,
head_new_tree: plan.head.new_tree,
ref_updates: plan.ref_updates.clone(),
}
}
#[must_use]
pub fn inverse_ref_updates(&self) -> Vec<PlannedRefUpdate> {
self.ref_updates
.iter()
.map(|u| PlannedRefUpdate {
name: u.name.clone(),
old: u.new,
new: u.old,
})
.collect()
}
#[must_use]
pub fn undo_plan(&self) -> RewritePlan {
RewritePlan {
commits: Vec::new(),
ref_updates: self.inverse_ref_updates(),
head: HeadEffect {
commit_changes: self.head_old_commit != self.head_new_commit,
old_commit: self.head_new_commit.or(self.head_old_commit),
head_ref: self.head_ref.clone(),
old_tree: self.head_new_tree.or(self.head_old_tree),
new_tree: self.head_old_tree,
},
}
}
}
fn history_last_path(repo: &Repository) -> PathBuf {
repo.git_dir().join("grit/history-last")
}
fn history_last_lock_path(repo: &Repository) -> PathBuf {
repo.git_dir().join("grit/history-last.lock")
}
pub fn write_history_last(
repo: &Repository,
record: &HistoryLastRecord,
) -> Result<(), HistoryError> {
let grit_dir = repo.git_dir().join("grit");
std::fs::create_dir_all(&grit_dir).map_err(|e| HistoryError::UndoRecordWrite {
message: e.to_string(),
})?;
let body = serialize_record(record);
let lock_path = history_last_lock_path(repo);
let target = history_last_path(repo);
let wait = LockWait::Immediately;
let mut lock =
LockFile::acquire(&lock_path, wait).map_err(|e| HistoryError::UndoRecordWrite {
message: e.to_string(),
})?;
lock.write_all(body.as_bytes())
.map_err(|e| HistoryError::UndoRecordWrite {
message: e.to_string(),
})?;
lock.commit_to(&target)
.map_err(|e| HistoryError::UndoRecordWrite {
message: e.to_string(),
})?;
Ok(())
}
pub fn read_history_last(repo: &Repository) -> Result<Option<HistoryLastRecord>, HistoryError> {
let path = history_last_path(repo);
if !path.is_file() {
return Ok(None);
}
let bytes = std::fs::read(&path).map_err(|e| HistoryError::UndoRecordRead {
message: e.to_string(),
})?;
parse_record(&bytes).map(Some)
}
fn serialize_record(record: &HistoryLastRecord) -> String {
let mut out = format!("version {FORMAT_VERSION}\n");
out.push_str(&format!("operation {}\n", record.operation));
match &record.head_ref {
Some(name) => out.push_str(&format!("head symbolic {name}\n")),
None => out.push_str("head detached\n"),
}
if let (Some(old), Some(new)) = (record.head_old_commit, record.head_new_commit) {
out.push_str(&format!("head-commit {} {}\n", old.to_hex(), new.to_hex()));
}
if let (Some(old), Some(new)) = (record.head_old_tree, record.head_new_tree) {
out.push_str(&format!("head-tree {} {}\n", old.to_hex(), new.to_hex()));
}
for u in &record.ref_updates {
out.push_str(&format!(
"ref {} {} {}\n",
u.name,
u.old.to_hex(),
u.new.to_hex()
));
}
out
}
fn parse_record(bytes: &[u8]) -> Result<HistoryLastRecord, HistoryError> {
let text = std::str::from_utf8(bytes).map_err(|e| HistoryError::UndoRecordRead {
message: e.to_string(),
})?;
let mut version: Option<u32> = None;
let mut operation: Option<String> = None;
let mut head_ref: Option<Option<String>> = None;
let mut head_old_commit: Option<ObjectId> = None;
let mut head_new_commit: Option<ObjectId> = None;
let mut head_old_tree: Option<ObjectId> = None;
let mut head_new_tree: Option<ObjectId> = None;
let mut ref_updates = Vec::new();
for (line_no, line) in text.lines().enumerate() {
let line = line.trim();
if line.is_empty() {
continue;
}
let mut parts = line.split_whitespace();
let keyword = parts.next().unwrap_or_default();
match keyword {
"version" => {
let v = parts
.next()
.ok_or_else(|| parse_err(line_no, "missing version number"))?
.parse::<u32>()
.map_err(|e| parse_err(line_no, &e.to_string()))?;
version = Some(v);
}
"operation" => {
let op = parts
.next()
.ok_or_else(|| parse_err(line_no, "missing operation name"))?;
operation = Some(op.into());
}
"head" => {
let mode = parts
.next()
.ok_or_else(|| parse_err(line_no, "missing head mode"))?;
head_ref = Some(match mode {
"symbolic" => {
let name = parts
.next()
.ok_or_else(|| parse_err(line_no, "missing symbolic ref"))?;
Some(name.into())
}
"detached" => None,
other => return Err(parse_err(line_no, &format!("unknown head mode {other}"))),
});
}
"head-commit" => {
let old = parse_oid(parts.next(), line_no, "head-commit old")?;
let new = parse_oid(parts.next(), line_no, "head-commit new")?;
head_old_commit = Some(old);
head_new_commit = Some(new);
}
"head-tree" => {
let old = parse_oid(parts.next(), line_no, "head-tree old")?;
let new = parse_oid(parts.next(), line_no, "head-tree new")?;
head_old_tree = Some(old);
head_new_tree = Some(new);
}
"ref" => {
let name = parts
.next()
.ok_or_else(|| parse_err(line_no, "missing ref name"))?
.to_owned();
let old = parse_oid(parts.next(), line_no, "ref old")?;
let new = parse_oid(parts.next(), line_no, "ref new")?;
ref_updates.push(PlannedRefUpdate { name, old, new });
}
other => return Err(parse_err(line_no, &format!("unknown keyword {other}"))),
}
}
if version != Some(FORMAT_VERSION) {
return Err(HistoryError::UndoRecordRead {
message: format!("unsupported or missing version (expected {FORMAT_VERSION})"),
});
}
let operation = operation.ok_or_else(|| HistoryError::UndoRecordRead {
message: "missing operation line".into(),
})?;
let head_ref = match head_ref {
Some(v) => v,
None => {
return Err(HistoryError::UndoRecordRead {
message: "missing head line".into(),
})
}
};
Ok(HistoryLastRecord {
operation,
head_ref,
head_old_commit,
head_new_commit,
head_old_tree,
head_new_tree,
ref_updates,
})
}
fn parse_oid(token: Option<&str>, line_no: usize, label: &str) -> Result<ObjectId, HistoryError> {
let hex = token.ok_or_else(|| parse_err(line_no, &format!("missing {label}")))?;
ObjectId::from_hex(hex).map_err(|e| parse_err(line_no, &e.to_string()))
}
fn parse_err(line_no: usize, detail: &str) -> HistoryError {
HistoryError::UndoRecordRead {
message: format!("line {}: {detail}", line_no + 1),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn oid(byte: u8) -> ObjectId {
let mut bytes = [0u8; 20];
bytes[19] = byte;
ObjectId::from_bytes(&bytes).expect("oid")
}
#[test]
fn record_roundtrip_text_format() {
let record = HistoryLastRecord {
operation: "drop".into(),
head_ref: Some("refs/heads/main".into()),
head_old_commit: Some(oid(1)),
head_new_commit: Some(oid(2)),
head_old_tree: Some(oid(3)),
head_new_tree: Some(oid(4)),
ref_updates: vec![PlannedRefUpdate {
name: "refs/heads/main".into(),
old: oid(1),
new: oid(2),
}],
};
let parsed = parse_record(serialize_record(&record).as_bytes()).expect("parse");
assert_eq!(parsed, record);
}
}