use std::collections::BTreeSet;
use std::future::Future;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, MutexGuard};
use eyre::{Result, bail};
use super::checkpoint::{Draft, Outcome, Store};
use super::journal::JournalEntry;
use super::store::{
self, Changes, Checkpoint, DescriptionSource, Operation, OperationKind, OperationMarker,
OperationStatus, Pending, Summary, TreeInfo, Trigger,
};
use super::tracked::TrackedSet;
use crate::config::Settings;
use crate::dirs;
use crate::env;
use crate::lock_file::LockFile;
pub(crate) const ENV_VAR: &str = "__MISE_HISTORY_OPERATION";
struct Writer {
store: Store,
tracked: TrackedSet,
_lock: fslock::LockFile,
before: Option<(u64, String)>,
starting_head: Option<String>,
pending: Pending,
promote: Vec<PathBuf>,
}
type Shared = Arc<Mutex<Writer>>;
static CURRENT: Mutex<Option<Shared>> = Mutex::new(None);
static INITIALIZING: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
fn lock_unpoisoned<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
mutex
.lock()
.unwrap_or_else(|poisoned| poisoned.into_inner())
}
pub(crate) fn record(entry: JournalEntry) -> Result<Option<u32>> {
let shared = lock_unpoisoned(&CURRENT).clone();
match shared {
Some(shared) => lock_unpoisoned(&shared).record(entry).map(Some),
None => Ok(None),
}
}
pub(crate) fn is_active() -> bool {
lock_unpoisoned(&CURRENT).is_some()
}
pub(crate) fn requires_recovery_preimage() -> bool {
lock_unpoisoned(&CURRENT)
.as_ref()
.is_some_and(|writer| lock_unpoisoned(writer).kind() != OperationKind::Bootstrap)
}
#[must_use = "finish the scope so the operation is recorded"]
pub(crate) struct OperationScope(Option<Shared>);
impl OperationScope {
pub(crate) async fn begin(command: &str, dry_run: bool) -> Result<Self> {
Self::begin_kind(OperationKind::Bootstrap, command, dry_run).await
}
pub(crate) async fn begin_kind(
kind: OperationKind,
command: &str,
dry_run: bool,
) -> Result<Self> {
Self::begin_with_wait(kind, command, dry_run, OPERATION_LOCK_WAIT).await
}
pub(crate) async fn begin_automatic_apply() -> Result<Self> {
Self::begin_with_wait(
OperationKind::Apply,
"dotfiles pull",
false,
std::time::Duration::ZERO,
)
.await
}
async fn begin_with_wait(
kind: OperationKind,
command: &str,
dry_run: bool,
wait: std::time::Duration,
) -> Result<Self> {
if dry_run {
return Ok(Self(None));
}
if !Settings::get().history.enabled
&& matches!(kind, OperationKind::Capture | OperationKind::Bootstrap)
{
debug!("history: disabled by settings");
return Ok(Self(None));
}
let _initializing = INITIALIZING.lock().await;
if std::env::var_os(ENV_VAR).is_some() {
debug!("history: attached to the parent mise operation");
return Ok(Self(None));
}
if lock_unpoisoned(&CURRENT).is_some() {
debug!("history: an operation is already open");
return Ok(Self(None));
}
let tracked = TrackedSet::effective().await?;
let command = command.to_owned();
let writer = tokio::task::spawn_blocking(move || {
Writer::begin(&dirs::STATE, kind, &command, tracked, wait)
})
.await??;
let uuid = writer.pending.checkpoint.uuid.clone();
debug!("history: recording operation {uuid}");
let shared = Arc::new(Mutex::new(writer));
*lock_unpoisoned(&CURRENT) = Some(shared.clone());
env::set_var(ENV_VAR, uuid);
Ok(Self(Some(shared)))
}
pub(crate) async fn refresh_tracked(&self) {
if self.0.is_none() {
return;
}
let tracked = match crate::config::Config::reset().await {
Ok(config) => TrackedSet::from_config(&config),
Err(err) => Err(err),
};
match tracked {
Ok(tracked) => {
if let Some(shared) = &self.0 {
let mut writer = lock_unpoisoned(shared);
writer.tracked = tracked;
}
}
Err(err) => warn!("history: keeping the tracked set from before the run: {err:#}"),
}
}
pub(crate) async fn wrap<T, F>(command: &str, dry_run: bool, f: F) -> Result<T>
where
F: Future<Output = Result<T>>,
{
let scope = Self::begin(command, dry_run).await?;
let result = f.await;
scope.refresh_tracked().await;
let summary = Summary { message: None };
scope.finish(
result.as_ref().err().map(|err| format!("{err:#}")),
Some(summary),
);
result
}
pub(crate) fn with_operation(&self, f: impl FnOnce(&mut Operation)) {
if let Some(shared) = &self.0 {
let mut writer = lock_unpoisoned(shared);
f(writer.operation_mut());
if let Err(err) = writer.write_pending() {
warn!("history: could not persist the operation record: {err:#}");
}
}
}
pub(crate) fn prepare_capture(&self, label: Option<&str>) {
if let Some(shared) = &self.0 {
let mut writer = lock_unpoisoned(shared);
writer.promote = writer
.tracked
.entries
.iter()
.map(|entry| entry.path.clone())
.collect();
writer.pending.checkpoint.labels = label.into_iter().map(str::to_owned).collect();
writer.operation_mut().message = label.map(str::to_owned);
if let Err(err) = writer.write_pending() {
warn!("history: could not persist the capture label: {err:#}");
}
}
}
pub(crate) fn before(&self) -> Option<(u64, String)> {
self.0
.as_ref()
.and_then(|shared| lock_unpoisoned(shared).before.clone())
}
pub(crate) fn validate_starting_head(&self, expected: Option<&str>) -> Result<()> {
let Some(shared) = &self.0 else {
eyre::bail!("incoming application requires an active recovery operation");
};
let writer = lock_unpoisoned(shared);
if writer.starting_head.as_deref() != expected {
eyre::bail!(
"local saved history changed since planning; nothing was applied; run pull again"
);
}
Ok(())
}
pub(crate) fn recapture_before(&self, paths: &[PathBuf]) -> Result<()> {
let Some(shared) = &self.0 else {
return Ok(());
};
let mut writer = lock_unpoisoned(shared);
let _store_lock = writer.store.lock()?;
let previous = writer.before.take();
let id = writer.store.reserve_id()?;
writer.capture_before(id, paths);
match (writer.before.is_some(), previous) {
(true, Some(_)) => {}
(false, Some(previous)) => {
writer.operation_mut().before = Some(previous.1.clone());
writer.before = Some(previous);
}
(_, None) => {}
}
Ok(())
}
pub(crate) fn promote(&self, paths: &[PathBuf]) {
if let Some(shared) = &self.0 {
lock_unpoisoned(shared)
.promote
.extend(paths.iter().cloned());
}
}
pub(crate) fn finish(self, error: Option<String>, summary: Option<Summary>) {
self.finish_with_writes(error, summary, true);
}
pub(crate) fn finish_incomplete(self, error: Option<String>, summary: Option<Summary>) {
self.finish_with_writes(error, summary, false);
}
fn finish_with_writes(
mut self,
error: Option<String>,
summary: Option<Summary>,
writes_finished: bool,
) {
let Some(shared) = self.0.take() else {
return;
};
Self::clear_current();
if let Err(err) = lock_unpoisoned(&shared).finish(error, summary, writes_finished) {
warn!("history: could not finish the operation record: {err:#}");
}
}
fn clear_current() {
*lock_unpoisoned(&CURRENT) = None;
env::remove_var(ENV_VAR);
}
}
impl Drop for OperationScope {
fn drop(&mut self) {
let Some(shared) = self.0.take() else {
return;
};
Self::clear_current();
if std::thread::panicking() {
return;
}
if let Err(err) = lock_unpoisoned(&shared).abandon() {
warn!("history: could not mark the operation failed: {err:#}");
}
}
}
impl Writer {
fn begin(
state_dir: &Path,
kind: OperationKind,
command: &str,
tracked: TrackedSet,
wait: std::time::Duration,
) -> Result<Self> {
let store = Store::open_in(state_dir)?;
let lock = take_operation_lock_with_wait(&store, &tracked, wait)?;
let argv: Vec<String> = env::ARGS.read().unwrap().iter().skip(1).cloned().collect();
let command = if argv.is_empty() {
command.to_string()
} else {
shell_words::join(&argv)
};
let uuid = store::new_uuid();
let _store_lock = store.lock()?;
let starting_head = store
.repo()
.map(|repo| repo.ref_oid(super::shadow::HistoryRepo::HISTORY_REF))
.transpose()?
.flatten();
let before_id = store.reserve_id()?;
let outcome_id = store.reserve_id()?;
store::write_marker_in(
state_dir,
&OperationMarker {
uuid: uuid.clone(),
kind,
started_at: store::now_rfc3339(),
command: command.clone(),
},
)?;
let operation = Operation {
id: uuid.clone(),
kind,
status: OperationStatus::Pending,
command,
argv,
cwd: dirs::CWD.clone().unwrap_or_default(),
user: std::env::var("USER")
.or_else(|_| std::env::var("USERNAME"))
.ok(),
finished_at: None,
error: None,
before: None,
to: None,
undoes: None,
applied: None,
affected: vec![],
sources: vec![],
directories: vec![],
directory_modes: Default::default(),
message: None,
journal: vec![],
};
let pending = Pending {
id: outcome_id,
recovery: store::RecoveryState::Pending,
checkpoint: Checkpoint {
schema_version: store::SCHEMA_VERSION,
uuid,
machine: store.machine().clone(),
created_at: store::now_rfc3339(),
mise_version: crate::cli::version::VERSION_PLAIN.clone(),
trigger: outcome_trigger(kind),
description: String::new(),
description_source: DescriptionSource::Computed,
summary: String::new(),
task: None,
labels: vec![],
tree: TreeInfo {
snapshot: None,
available: store.unavailable().is_none(),
reason: store.unavailable().map(str::to_string),
roots: vec![],
coverage: Default::default(),
modes: Default::default(),
},
changes: Changes::default(),
operation: Some(operation),
},
};
store::write_pending_in(state_dir, &pending)?;
let mut writer = Self {
store,
tracked,
_lock: lock,
before: None,
starting_head,
pending,
promote: vec![],
};
if (kind != OperationKind::Apply || writer.starting_head.is_some())
&& records_file_history(&writer.store, &writer.tracked, Some(kind))?
{
writer.capture_before(before_id, &[]);
}
Ok(writer)
}
fn capture_before(&mut self, id: u64, paths: &[PathBuf]) {
let mut draft = Draft::new(before_trigger(self.kind()));
draft.protective = true;
draft.explicit_paths = paths.to_vec();
if self.kind() == OperationKind::Capture {
draft.explicit_paths = self
.tracked
.entries
.iter()
.map(|entry| entry.path.clone())
.collect();
}
match self.store.attempt_locked(&self.tracked, draft, Some(id)) {
Ok(Outcome::Created(entry)) => {
self.operation_mut().before = Some(entry.checkpoint.uuid.clone());
self.before = Some((entry.id, entry.checkpoint.uuid));
}
Ok(Outcome::Unchanged) => {}
Ok(Outcome::Unavailable(reason)) => {
warn!("history: no content snapshot before this run: {reason}");
self.pending.checkpoint.tree.available = false;
self.pending.checkpoint.tree.reason = Some(reason);
}
Err(err) => {
warn!("history: snapshot before this run failed: {err:#}");
self.pending.checkpoint.tree.available = false;
self.pending.checkpoint.tree.reason = Some(format!("{err:#}"));
}
}
if let Err(err) = self.write_pending() {
warn!("history: could not persist the operation record: {err:#}");
}
}
fn kind(&self) -> OperationKind {
self.operation().kind
}
fn operation(&self) -> &Operation {
self.pending
.checkpoint
.operation
.as_ref()
.expect("an operation record always has an operation")
}
fn operation_mut(&mut self) -> &mut Operation {
self.pending
.checkpoint
.operation
.as_mut()
.expect("an operation record always has an operation")
}
fn record(&mut self, entry: JournalEntry) -> Result<u32> {
let seq = self.operation().journal.len() as u32;
self.operation_mut().journal.push(entry);
self.write_pending()
.map_err(|err| eyre::eyre!("could not persist the operation journal: {err:#}"))?;
if self.kind() == OperationKind::Bootstrap
&& let Some((_, before)) = &self.before
&& let JournalEntry::PathChanged { path, prior, .. } =
&self.operation().journal[seq as usize]
{
match self.store.protect_manual_preimage(
before,
&self.tracked,
path,
prior,
&self.operation().journal[..seq as usize],
) {
Ok(Some(entry)) => {
self.operation_mut().before = Some(entry.checkpoint.uuid.clone());
self.before = Some((entry.id, entry.checkpoint.uuid));
}
Ok(None) => {}
Err(err) => {
warn!(
"history: cannot preserve the operation's manual-file preimage: {err:#}; historical undo will be unavailable"
);
self.before = None;
self.operation_mut().before = None;
}
}
self.write_pending()?;
}
Ok(seq)
}
fn write_pending(&self) -> Result<()> {
store::write_pending_in(self.store.state_dir(), &self.pending)
}
fn finish(
&mut self,
error: Option<String>,
summary: Option<Summary>,
writes_finished: bool,
) -> Result<()> {
self.pending.recovery = if writes_finished {
store::RecoveryState::UnfinishedWrites
} else {
store::RecoveryState::Pending
};
let status = if error.is_some() {
OperationStatus::Failed
} else {
OperationStatus::Completed
};
{
let operation = self.operation_mut();
operation.status = status;
operation.finished_at = Some(store::now_rfc3339());
operation.error = error;
if let Some(summary) = summary {
operation.message = summary.message;
}
}
self.write_outcome()
}
fn abandon(&mut self) -> Result<()> {
{
let operation = self.operation_mut();
operation.status = OperationStatus::Failed;
operation.finished_at = Some(store::now_rfc3339());
operation.error = Some("the command exited before finishing".into());
}
self.write_outcome()
}
fn write_outcome(&mut self) -> Result<()> {
let state_dir = self.store.state_dir().to_path_buf();
let _store_lock = self.store.lock()?;
self.write_pending()?;
recover_pending(&state_dir, &mut self.pending)?;
let checkpoint = &self.pending.checkpoint;
let operation = self.operation().clone();
let mut draft = Draft::new(checkpoint.trigger);
draft.uuid = Some(checkpoint.uuid.clone());
draft.labels = checkpoint.labels.clone();
draft.operation = Some(operation.clone());
draft.explicit_paths = self.promote.clone();
draft
.explicit_paths
.extend(operation.journal.iter().filter_map(|entry| {
let JournalEntry::Committed { seq, .. } = entry else {
return None;
};
match operation.journal.get(*seq as usize) {
Some(JournalEntry::PathChanged { path, .. }) => Some(path.clone()),
_ => None,
}
}));
if records_file_history(&self.store, &self.tracked, Some(self.kind()))?
&& let Outcome::Unavailable(reason) =
self.store
.attempt_locked(&self.tracked, draft, Some(self.pending.id))?
{
warn!("history: operation completed without a Git history commit: {reason}");
}
let uuid = self.pending.checkpoint.uuid.clone();
store::remove_pending_in(&state_dir, &uuid);
super::recovery::discard(&state_dir, &operation.journal)?;
store::remove_marker_in(&state_dir);
Ok(())
}
}
fn records_file_history(
store: &Store,
tracked: &TrackedSet,
kind: Option<OperationKind>,
) -> Result<bool> {
if !Settings::get().history.enabled {
return Ok(false);
}
Ok(kind == Some(OperationKind::Capture)
|| !tracked.entries.is_empty()
|| !tracked.manifest.enrollment.is_empty()
|| store
.repo()
.map(|repo| repo.ref_oid(super::shadow::HistoryRepo::HISTORY_REF))
.transpose()?
.flatten()
.is_some())
}
pub(crate) fn take_operation_lock(store: &Store, tracked: &TrackedSet) -> Result<fslock::LockFile> {
take_operation_lock_with_wait(store, tracked, OPERATION_LOCK_WAIT)
}
pub(crate) fn try_operation_lock(
store: &Store,
tracked: &TrackedSet,
) -> Result<Option<fslock::LockFile>> {
let Some(lock) = LockFile::new(&store::operation_lock_in(store.state_dir())).try_lock()? else {
return Ok(None);
};
recover_stale(store, tracked)?;
Ok(Some(lock))
}
fn take_operation_lock_with_wait(
store: &Store,
tracked: &TrackedSet,
wait: std::time::Duration,
) -> Result<fslock::LockFile> {
let lock = acquire_operation_lock(store, wait)?;
recover_stale(store, tracked)?;
Ok(lock)
}
pub(crate) fn recovery_lock(store: &Store) -> Result<fslock::LockFile> {
acquire_operation_lock(store, std::time::Duration::ZERO)
}
fn acquire_operation_lock(store: &Store, wait: std::time::Duration) -> Result<fslock::LockFile> {
let state_dir = store.state_dir();
let path = store::operation_lock_in(state_dir);
let deadline = std::time::Instant::now() + wait;
let mut announced = false;
let lock = loop {
if let Some(lock) = LockFile::new(&path).try_lock()? {
break lock;
}
let marker = store::read_marker_in(state_dir)?;
if std::time::Instant::now() >= deadline {
match marker {
Some(marker) => bail!(
"another history operation is running: {} since {} ({})",
marker.kind.as_str(),
marker.started_at,
marker.command
),
None => bail!("another history operation is running"),
}
}
if !announced {
announced = true;
info!(
"history: waiting for another history operation to finish{}",
marker
.map(|marker| format!(": {} ({})", marker.kind.as_str(), marker.command))
.unwrap_or_default()
);
}
std::thread::sleep(OPERATION_LOCK_POLL);
};
Ok(lock)
}
const OPERATION_LOCK_WAIT: std::time::Duration = std::time::Duration::from_secs(30);
const OPERATION_LOCK_POLL: std::time::Duration = std::time::Duration::from_millis(200);
pub(crate) fn recover_stale(store: &Store, tracked: &TrackedSet) -> Result<()> {
recover_records(store, tracked, store::list_pending_in(store.state_dir())?)
}
pub(crate) fn recover_operation(
store: &Store,
tracked: &TrackedSet,
uuid: &str,
keep_current: bool,
) -> Result<()> {
let mut pending = store::list_pending_in(store.state_dir())?
.into_iter()
.filter(|(_, record)| record.checkpoint.uuid == uuid)
.collect::<Vec<_>>();
if pending.is_empty() {
bail!("no pending operation {uuid}");
}
if keep_current {
for (_, record) in &mut pending {
record.recovery = store::RecoveryState::Finished;
if let Some(operation) = &mut record.checkpoint.operation {
operation.status = OperationStatus::Failed;
operation.finished_at = Some(store::now_rfc3339());
operation.error =
Some("current live files explicitly accepted during recovery".into());
}
store::write_pending_in(store.state_dir(), record)?;
}
}
recover_records(store, tracked, pending)
}
fn recover_records(
store: &Store,
tracked: &TrackedSet,
pending: Vec<(PathBuf, Pending)>,
) -> Result<()> {
let state_dir = store.state_dir();
if pending.is_empty() {
store::remove_marker_in(state_dir);
return Ok(());
}
let index = store.rebuild_index()?;
let _store_lock = store.lock()?;
let mut recorded_operations = BTreeSet::new();
for entry in &index.entries {
if let Some(operation) = store::read_meta_cache_in(state_dir, &entry.uuid)?
.and_then(|checkpoint| checkpoint.operation)
{
recorded_operations.insert(operation.id);
}
}
for (path, mut record) in pending {
let interrupted = record
.checkpoint
.operation
.as_ref()
.is_some_and(|operation| operation.status == OperationStatus::Pending);
if record
.checkpoint
.operation
.as_ref()
.is_some_and(|op| recorded_operations.contains(&op.id))
{
warn!(
"history: dropping a stale pending record for checkpoint {}, which was recorded",
record.checkpoint.uuid
);
if let Some(operation) = &record.checkpoint.operation {
super::recovery::discard_pending(
state_dir,
&operation.journal,
&record.checkpoint.uuid,
)?;
}
std::fs::remove_file(&path)?;
continue;
}
recover_pending(state_dir, &mut record)?;
warn!(
"history: recovering interrupted operation {}",
record.checkpoint.uuid
);
if let Some(operation) = record.checkpoint.operation.as_mut()
&& interrupted
{
operation.status = OperationStatus::Failed;
operation.finished_at = Some(store::now_rfc3339());
operation.error = Some("the command exited before finishing; files observed during recovery may include later edits".into());
}
let mut draft = Draft::new(record.checkpoint.trigger);
draft.uuid = Some(record.checkpoint.uuid.clone());
draft.labels = record.checkpoint.labels.clone();
if record
.checkpoint
.operation
.as_ref()
.is_some_and(|op| op.kind == OperationKind::Capture)
{
draft.explicit_paths = tracked
.entries
.iter()
.map(|entry| entry.path.clone())
.collect();
}
draft.operation = record.checkpoint.operation.clone();
let captured = if records_file_history(
store,
tracked,
record.checkpoint.operation.as_ref().map(|op| op.kind),
)? {
store.attempt_locked(tracked, draft, Some(record.id))
} else {
Ok(Outcome::Unchanged)
};
match captured {
Ok(_) => {
if let Some(operation) = &record.checkpoint.operation {
super::recovery::discard_pending(
state_dir,
&operation.journal,
&record.checkpoint.uuid,
)?;
}
std::fs::remove_file(&path)?;
}
Err(err) => warn!(
"history: could not close operation {}; keeping {} for the next run: {err:#}",
record.checkpoint.uuid,
crate::file::display_path(&path)
),
}
}
if store::list_pending_in(state_dir)?.is_empty() {
store::remove_marker_in(state_dir);
}
Ok(())
}
fn recover_pending(state_dir: &Path, pending: &mut Pending) -> Result<()> {
if let Some(operation) = &pending.checkpoint.operation {
match pending.recovery {
store::RecoveryState::Pending => {
super::recovery::recover(state_dir, &operation.journal)?
}
store::RecoveryState::UnfinishedWrites => {
super::recovery::recover_unfinished(state_dir, &operation.journal)?
}
store::RecoveryState::Finished => return Ok(()),
}
}
pending.recovery = store::RecoveryState::Finished;
store::write_pending_in(state_dir, pending)
}
fn before_trigger(kind: OperationKind) -> Trigger {
match kind {
OperationKind::Capture => Trigger::CaptureBefore,
OperationKind::Bootstrap => Trigger::BootstrapBefore,
OperationKind::Rollback => Trigger::RollbackBefore,
OperationKind::Undo => Trigger::UndoBefore,
OperationKind::Apply => Trigger::ApplyBefore,
}
}
fn outcome_trigger(kind: OperationKind) -> Trigger {
match kind {
OperationKind::Capture => Trigger::Capture,
OperationKind::Bootstrap => Trigger::Bootstrap,
OperationKind::Rollback => Trigger::Rollback,
OperationKind::Undo => Trigger::Undo,
OperationKind::Apply => Trigger::Apply,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::system::history::shadow::HistoryRepo;
#[test]
fn accepting_current_files_preserves_edits_and_other_pending_operations() -> Result<()> {
use super::super::journal::{Capture, PathSnapshot, PathState};
let temp = tempfile::tempdir()?;
let live = temp.path().join("untracked");
std::fs::write(&live, vec![b'x'; 70_000])?;
let writer = Writer::begin(
temp.path(),
OperationKind::Bootstrap,
"bootstrap",
TrackedSet::default(),
std::time::Duration::ZERO,
)?;
let mut pending = writer.pending.clone();
let prior = PathSnapshot::capture_with(temp.path(), &live, Capture::Full);
std::fs::write(&live, "operation contents")?;
pending.checkpoint.operation.as_mut().unwrap().journal = vec![
JournalEntry::PathChanged {
part: "files".into(),
item: "untracked".into(),
path: live.clone(),
prior,
},
JournalEntry::Committed {
seq: 0,
after: PathState::observe(&live),
},
];
store::write_pending_in(temp.path(), &pending)?;
drop(writer);
std::fs::write(&live, "later user edit")?;
let store = Store::open_in(temp.path())?;
assert!(recover_stale(&store, &TrackedSet::default()).is_err());
assert_eq!(std::fs::read_to_string(&live)?, "later user edit");
assert_eq!(store::list_pending_in(temp.path())?.len(), 1);
let mut other = pending.clone();
other.checkpoint.uuid = uuid::Uuid::new_v4().to_string();
other.checkpoint.operation.as_mut().unwrap().id = other.checkpoint.uuid.clone();
other.checkpoint.operation.as_mut().unwrap().journal.clear();
store::write_pending_in(temp.path(), &other)?;
recover_operation(
&store,
&TrackedSet::default(),
&pending.checkpoint.uuid,
true,
)?;
assert_eq!(std::fs::read_to_string(&live)?, "later user edit");
let remaining = store::list_pending_in(temp.path())?;
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].1.checkpoint.uuid, other.checkpoint.uuid);
assert_eq!(
std::fs::read_dir(super::super::journal::blobs_dir_in(temp.path()))?.count(),
0
);
assert!(
store
.repo()
.unwrap()
.ref_oid(HistoryRepo::HISTORY_REF)?
.is_none()
);
Ok(())
}
#[test]
fn committed_outcome_is_not_repeated_after_pending_cleanup_crashes() -> Result<()> {
let temp = tempfile::tempdir()?;
let mut writer = Writer::begin(
temp.path(),
OperationKind::Capture,
"capture",
TrackedSet::default(),
std::time::Duration::ZERO,
)?;
let pending = writer.pending.clone();
writer.finish(None, None, true)?;
let head = writer
.store
.repo()
.unwrap()
.ref_oid(HistoryRepo::HISTORY_REF)?;
let before = writer.store.list()?;
store::write_pending_in(temp.path(), &pending)?;
std::fs::remove_file(store::index_dir_in(temp.path()).join("checkpoints.json"))?;
drop(writer);
let store = Store::open_in(temp.path())?;
recover_stale(&store, &TrackedSet::default())?;
assert!(store::list_pending_in(temp.path())?.is_empty());
assert_eq!(
store.repo().unwrap().ref_oid(HistoryRepo::HISTORY_REF)?,
head
);
assert_eq!(store.list()?.len(), before.len());
Ok(())
}
#[test]
fn unenrolled_bootstrap_keeps_recovery_private_without_creating_history() -> Result<()> {
let temp = tempfile::tempdir()?;
let mut writer = Writer::begin(
temp.path(),
OperationKind::Bootstrap,
"bootstrap",
TrackedSet::default(),
std::time::Duration::ZERO,
)?;
assert!(
writer
.store
.repo()
.unwrap()
.ref_oid(super::super::shadow::HistoryRepo::HISTORY_REF)?
.is_none()
);
assert_eq!(store::list_pending_in(temp.path())?.len(), 1);
writer.finish(None, None, true)?;
assert!(
writer
.store
.repo()
.unwrap()
.ref_oid(super::super::shadow::HistoryRepo::HISTORY_REF)?
.is_none()
);
assert!(store::list_pending_in(temp.path())?.is_empty());
Ok(())
}
#[test]
fn application_checks_head_from_before_its_own_protective_commit() -> Result<()> {
let temp = tempfile::tempdir()?;
let store = Store::open_in(temp.path())?;
let repo = store.repo().unwrap();
let tree =
super::super::manifest::Manifest::default().write(repo, &repo.empty_object("tree")?)?;
let planned = repo.commit_tree(&tree, vec![], "planned")?;
repo.update_history_head(&planned, None)?;
let saved = repo.commit_tree(&tree, vec![&planned], "concurrent save")?;
repo.update_history_head(&saved, Some(&planned))?;
let writer = Writer::begin(
temp.path(),
OperationKind::Apply,
"pull",
TrackedSet::default(),
std::time::Duration::ZERO,
)?;
let scope = OperationScope(Some(Arc::new(Mutex::new(writer))));
assert!(scope.validate_starting_head(Some(&planned)).is_err());
assert!(scope.validate_starting_head(Some(&saved)).is_ok());
assert_ne!(
repo.ref_oid(super::super::shadow::HistoryRepo::HISTORY_REF)?
.as_deref(),
Some(saved.as_str())
);
Ok(())
}
#[test]
fn automatic_operation_lock_does_not_wait() {
let temp = tempfile::tempdir().unwrap();
let store = Store::open_in(temp.path()).unwrap();
let held = LockFile::new(&store::operation_lock_in(temp.path()))
.try_lock()
.unwrap()
.unwrap();
let started = std::time::Instant::now();
let result = take_operation_lock_with_wait(
&store,
&TrackedSet::default(),
std::time::Duration::ZERO,
);
assert!(result.is_err());
assert!(started.elapsed() < std::time::Duration::from_secs(2));
drop(held);
assert!(
take_operation_lock_with_wait(
&store,
&TrackedSet::default(),
std::time::Duration::ZERO,
)
.is_ok()
);
}
}