use quilt_rs::lineage::CommitState;
use quilt_uri::Namespace;
use crate::cli::Error;
use crate::cli::model::Commands;
use crate::cli::output::Render;
use crate::cli::output::Std;
#[derive(Debug)]
pub struct Input {
pub namespace: Namespace,
}
#[derive(Debug)]
pub struct Output {
pub commit: CommitState,
}
impl std::fmt::Display for Output {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "Undid the last commit; now at \"{}\"", self.commit.hash)
}
}
impl Render for Output {
fn to_json(&self) -> serde_json::Value {
serde_json::json!({ "hash": &self.commit.hash })
}
}
pub async fn command(m: impl Commands, args: Input) -> Std {
Std::from_result(m.undo_commit(args).await)
}
pub async fn model(
local_domain: &quilt_rs::LocalDomain,
Input { namespace }: Input,
) -> Result<Output, Error> {
let package = local_domain
.get_installed_package(&namespace)
.await?
.ok_or_else(|| Error::NamespaceNotFound(namespace))?;
let commit = package.undo_commit().await?;
Ok(Output { commit })
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use tempfile::TempDir;
use crate::cli::commit;
use crate::cli::create;
use crate::cli::model::Model;
use crate::cli::model::create_model_in_temp_dir;
use crate::cli::output::Render;
use quilt_rs::flow::UserMeta;
use quilt_rs::io::storage::ByteStream;
use quilt_rs::io::storage::LocalStorage;
use quilt_rs::io::storage::Storage;
async fn package_with(
revisions: &[&str],
) -> Result<(Model, Namespace, PathBuf, TempDir), Error> {
let (cli_model, temp_dir) = create_model_in_temp_dir().await?;
let namespace: Namespace = ("demo", "local").into();
let created = cli_model
.create(create::Input {
namespace: namespace.clone(),
source: None,
message: Some("initial".to_string()),
})
.await?;
let package_home = created.installed_package.package_home().await?;
let storage = LocalStorage::new();
for message in revisions {
storage
.write_byte_stream(
package_home.join("value.txt"),
ByteStream::from(message.as_bytes().to_vec()),
)
.await?;
cli_model
.commit(commit::Input {
message: (*message).to_string(),
namespace: namespace.clone(),
user_meta: UserMeta::Keep,
workflow: quilt_rs::io::remote::WorkflowIntent::BucketDefault,
host_config: None,
})
.await?;
}
Ok((cli_model, namespace, package_home, temp_dir))
}
async fn changes(cli_model: &Model, namespace: &Namespace) -> Result<Vec<String>, Error> {
let package = cli_model
.get_local_domain()
.get_installed_package(namespace)
.await?
.expect("installed");
let status = package.status(None).await?;
Ok(status
.changes
.keys()
.map(|path| path.display().to_string())
.collect())
}
async fn tracked(cli_model: &Model, namespace: &Namespace) -> Result<Vec<String>, Error> {
let lineage = cli_model.get_local_domain().get_lineage().await?;
let mut keys: Vec<String> = lineage
.packages
.get(namespace)
.expect("installed")
.paths
.keys()
.map(|path| path.display().to_string())
.collect();
keys.sort();
Ok(keys)
}
async fn undo(cli_model: &Model, namespace: &Namespace) -> Result<Output, Error> {
super::model(
cli_model.get_local_domain(),
Input {
namespace: namespace.clone(),
},
)
.await
}
#[tokio::test]
async fn undoes_the_newest_commit() -> Result<(), Error> {
let (cli_model, _temp_dir) = create_model_in_temp_dir().await?;
let namespace: Namespace = ("demo", "local").into();
let created = cli_model
.create(create::Input {
namespace: namespace.clone(),
source: None,
message: Some("initial".to_string()),
})
.await?;
let package_home = created.installed_package.package_home().await?;
let storage = LocalStorage::new();
storage
.write_byte_stream(
package_home.join("value.txt"),
ByteStream::from_static(b"first"),
)
.await?;
cli_model
.commit(commit::Input {
message: "first".to_string(),
namespace: namespace.clone(),
user_meta: UserMeta::Keep,
workflow: quilt_rs::io::remote::WorkflowIntent::BucketDefault,
host_config: None,
})
.await?;
storage
.write_byte_stream(
package_home.join("value.txt"),
ByteStream::from_static(b"second"),
)
.await?;
cli_model
.commit(commit::Input {
message: "second".to_string(),
namespace: namespace.clone(),
user_meta: UserMeta::Keep,
workflow: quilt_rs::io::remote::WorkflowIntent::BucketDefault,
host_config: None,
})
.await?;
let output = super::model(
cli_model.get_local_domain(),
Input {
namespace: namespace.clone(),
},
)
.await?;
assert_eq!(output.commit.prev_hashes.len(), 1);
assert_eq!(
tokio::fs::read(package_home.join("value.txt")).await?,
b"first"
);
Ok(())
}
#[tokio::test]
async fn refuses_at_the_initial_commit() -> Result<(), Error> {
let (cli_model, namespace, _home, _temp) = package_with(&[]).await?;
let err = undo(&cli_model, &namespace).await.unwrap_err();
assert!(
err.to_string().contains("first revision"),
"should name the floor, got: {err}"
);
Ok(())
}
#[tokio::test]
async fn refuses_a_dirty_tree_and_writes_nothing() -> Result<(), Error> {
let (cli_model, namespace, home, _temp) = package_with(&["first", "second"]).await?;
LocalStorage::new()
.write_byte_stream(
home.join("value.txt"),
ByteStream::from_static(b"uncommitted"),
)
.await?;
let err = undo(&cli_model, &namespace).await.unwrap_err();
assert!(
err.to_string().contains("value.txt"),
"should name the dirty path, got: {err}"
);
assert_eq!(
tokio::fs::read(home.join("value.txt")).await?,
b"uncommitted"
);
Ok(())
}
#[tokio::test]
async fn dirty_refusal_names_no_discard_verb() -> Result<(), Error> {
let (cli_model, namespace, home, _temp) = package_with(&["first", "second"]).await?;
LocalStorage::new()
.write_byte_stream(home.join("value.txt"), ByteStream::from_static(b"edited"))
.await?;
let msg = undo(&cli_model, &namespace).await.unwrap_err().to_string();
assert!(msg.contains("by hand"), "got: {msg}");
for absent in ["discard", "reset", "revert", "quilt clean"] {
assert!(!msg.contains(absent), "must not offer `{absent}`: {msg}");
}
Ok(())
}
#[tokio::test]
async fn restores_added_and_removed_paths() -> Result<(), Error> {
let (cli_model, namespace, home, _temp) = package_with(&["first"]).await?;
let storage = LocalStorage::new();
tokio::fs::remove_file(home.join("value.txt")).await?;
storage
.write_byte_stream(home.join("other.txt"), ByteStream::from_static(b"new"))
.await?;
cli_model
.commit(commit::Input {
message: "swap".to_string(),
namespace: namespace.clone(),
user_meta: UserMeta::Keep,
workflow: quilt_rs::io::remote::WorkflowIntent::BucketDefault,
host_config: None,
})
.await?;
undo(&cli_model, &namespace).await?;
assert_eq!(tokio::fs::read(home.join("value.txt")).await?, b"first");
assert!(
!home.join("other.txt").exists(),
"the added file should be gone again"
);
Ok(())
}
#[tokio::test]
async fn undone_revision_still_appears_in_the_log() -> Result<(), Error> {
let (cli_model, namespace, _home, _temp) = package_with(&["first", "second"]).await?;
let package = cli_model
.get_local_domain()
.get_installed_package(&namespace)
.await?
.unwrap();
let before = package.revisions().await?.len();
undo(&cli_model, &namespace).await?;
assert_eq!(package.revisions().await?.len(), before);
Ok(())
}
#[tokio::test]
async fn handles_a_file_becoming_a_directory() -> Result<(), Error> {
let (cli_model, namespace, home, _temp) = package_with(&["first"]).await?;
let storage = LocalStorage::new();
storage
.write_byte_stream(home.join("a"), ByteStream::from_static(b"a-as-file"))
.await?;
cli_model
.commit(commit::Input {
message: "a is a file".to_string(),
namespace: namespace.clone(),
user_meta: UserMeta::Keep,
workflow: quilt_rs::io::remote::WorkflowIntent::BucketDefault,
host_config: None,
})
.await?;
tokio::fs::remove_file(home.join("a")).await?;
storage
.write_byte_stream(home.join("a").join("b"), ByteStream::from_static(b"nested"))
.await?;
cli_model
.commit(commit::Input {
message: "a is a directory".to_string(),
namespace: namespace.clone(),
user_meta: UserMeta::Keep,
workflow: quilt_rs::io::remote::WorkflowIntent::BucketDefault,
host_config: None,
})
.await?;
undo(&cli_model, &namespace).await?;
assert_eq!(tokio::fs::read(home.join("a")).await?, b"a-as-file");
assert!(
!home.join("a").join("b").exists(),
"the nested path should be gone"
);
Ok(())
}
#[tokio::test]
async fn a_failed_undo_reports_the_mixed_tree_rather_than_resuming() -> Result<(), Error> {
let (cli_model, namespace, home, _temp) = package_with(&[]).await?;
let storage = LocalStorage::new();
for (name, body) in [("one.txt", "one-v1"), ("two.txt", "two-v1")] {
storage
.write_byte_stream(home.join(name), ByteStream::from(body.as_bytes().to_vec()))
.await?;
}
cli_model
.commit(commit::Input {
message: "v1".to_string(),
namespace: namespace.clone(),
user_meta: UserMeta::Keep,
workflow: quilt_rs::io::remote::WorkflowIntent::BucketDefault,
host_config: None,
})
.await?;
for (name, body) in [("one.txt", "one-v2"), ("two.txt", "two-v2")] {
storage
.write_byte_stream(home.join(name), ByteStream::from(body.as_bytes().to_vec()))
.await?;
}
cli_model
.commit(commit::Input {
message: "v2".to_string(),
namespace: namespace.clone(),
user_meta: UserMeta::Keep,
workflow: quilt_rs::io::remote::WorkflowIntent::BucketDefault,
host_config: None,
})
.await?;
let package = cli_model
.get_local_domain()
.get_installed_package(&namespace)
.await?
.unwrap();
let mut entries = tokio::fs::read_dir(package.paths.objects_dir()).await?;
let mut victim = None;
while let Some(entry) = entries.next_entry().await? {
if entry.file_type().await?.is_file()
&& tokio::fs::read(entry.path()).await? == b"two-v1"
{
victim = Some(entry.path());
}
}
let victim = victim.expect("the previous content of two.txt is in the object store");
let bytes = tokio::fs::read(&victim).await?;
tokio::fs::remove_file(&victim).await?;
assert!(undo(&cli_model, &namespace).await.is_err());
assert_eq!(
tokio::fs::read(home.join("one.txt")).await?,
b"one-v1",
"the first write landed before the failure"
);
tokio::fs::write(&victim, &bytes).await?;
let err = undo(&cli_model, &namespace).await.unwrap_err();
assert!(err.to_string().contains("one.txt"), "got: {err}");
assert_eq!(
tokio::fs::read(home.join("two.txt")).await?,
b"two-v2",
"nothing further was written"
);
Ok(())
}
#[tokio::test]
async fn refuses_an_edit_to_a_path_it_would_remove() -> Result<(), Error> {
let (cli_model, namespace, home, _temp) = package_with(&["first"]).await?;
let storage = LocalStorage::new();
storage
.write_byte_stream(
home.join("added.txt"),
ByteStream::from_static(b"committed"),
)
.await?;
cli_model
.commit(commit::Input {
message: "adds a file".to_string(),
namespace: namespace.clone(),
user_meta: UserMeta::Keep,
workflow: quilt_rs::io::remote::WorkflowIntent::BucketDefault,
host_config: None,
})
.await?;
storage
.write_byte_stream(
home.join("added.txt"),
ByteStream::from_static(b"then edited"),
)
.await?;
let err = undo(&cli_model, &namespace).await.unwrap_err();
assert!(err.to_string().contains("added.txt"), "got: {err}");
assert_eq!(
tokio::fs::read(home.join("added.txt")).await?,
b"then edited"
);
Ok(())
}
#[cfg(unix)]
#[tokio::test]
async fn refuses_when_a_path_it_would_remove_cannot_be_read() -> Result<(), Error> {
let (cli_model, namespace, home, _temp) = package_with(&["first"]).await?;
LocalStorage::new()
.write_byte_stream(
home.join("added.txt"),
ByteStream::from_static(b"committed"),
)
.await?;
cli_model
.commit(commit::Input {
message: "adds a file".to_string(),
namespace: namespace.clone(),
user_meta: UserMeta::Keep,
workflow: quilt_rs::io::remote::WorkflowIntent::BucketDefault,
host_config: None,
})
.await?;
tokio::fs::remove_file(home.join("added.txt")).await?;
std::os::unix::fs::symlink("added.txt", home.join("added.txt"))?;
let err = undo(&cli_model, &namespace).await.unwrap_err();
assert!(
!err.to_string().contains("uncommitted changes"),
"an unreadable path is not a known edit: {err}"
);
assert!(
home.join("added.txt").symlink_metadata().is_ok(),
"nothing should have been removed"
);
Ok(())
}
#[tokio::test]
async fn refuses_to_overwrite_an_untracked_file_at_a_restore_destination() -> Result<(), Error>
{
let (cli_model, namespace, home, _temp) = package_with(&[]).await?;
let storage = LocalStorage::new();
storage
.write_byte_stream(home.join("x"), ByteStream::from_static(b"committed-x"))
.await?;
cli_model
.commit(commit::Input {
message: "adds x".to_string(),
namespace: namespace.clone(),
user_meta: UserMeta::Keep,
workflow: quilt_rs::io::remote::WorkflowIntent::BucketDefault,
host_config: None,
})
.await?;
tokio::fs::remove_file(home.join("x")).await?;
cli_model
.commit(commit::Input {
message: "deletes x".to_string(),
namespace: namespace.clone(),
user_meta: UserMeta::Keep,
workflow: quilt_rs::io::remote::WorkflowIntent::BucketDefault,
host_config: None,
})
.await?;
storage
.write_byte_stream(
home.join("x"),
ByteStream::from_static(b"mine, never committed"),
)
.await?;
let err = undo(&cli_model, &namespace).await.unwrap_err();
assert!(err.to_string().contains('x'), "should name the path: {err}");
assert_eq!(
tokio::fs::read(home.join("x")).await?,
b"mine, never committed",
"the user file must survive"
);
Ok(())
}
#[tokio::test]
async fn the_tracked_path_map_matches_the_restored_revision() -> Result<(), Error> {
let (cli_model, namespace, home, _temp) = package_with(&["first"]).await?;
let storage = LocalStorage::new();
storage
.write_byte_stream(home.join("kept.txt"), ByteStream::from_static(b"kept-v1"))
.await?;
cli_model
.commit(commit::Input {
message: "v1".to_string(),
namespace: namespace.clone(),
user_meta: UserMeta::Keep,
workflow: quilt_rs::io::remote::WorkflowIntent::BucketDefault,
host_config: None,
})
.await?;
storage
.write_byte_stream(home.join("kept.txt"), ByteStream::from_static(b"kept-v2"))
.await?;
storage
.write_byte_stream(home.join("gained.txt"), ByteStream::from_static(b"gained"))
.await?;
tokio::fs::remove_file(home.join("value.txt")).await?;
cli_model
.commit(commit::Input {
message: "v2".to_string(),
namespace: namespace.clone(),
user_meta: UserMeta::Keep,
workflow: quilt_rs::io::remote::WorkflowIntent::BucketDefault,
host_config: None,
})
.await?;
assert_eq!(
tracked(&cli_model, &namespace).await?,
["gained.txt", "kept.txt"],
"v2 tracks the addition and the modification, not the removal"
);
undo(&cli_model, &namespace).await?;
assert_eq!(
tracked(&cli_model, &namespace).await?,
["kept.txt", "value.txt"]
);
assert!(changes(&cli_model, &namespace).await?.is_empty());
Ok(())
}
#[tokio::test]
async fn undoing_to_the_initial_commit_empties_the_tree() -> Result<(), Error> {
let (cli_model, namespace, home, _temp) = package_with(&["first"]).await?;
assert!(home.join("value.txt").exists());
undo(&cli_model, &namespace).await?;
let mut entries = tokio::fs::read_dir(&home).await?;
let mut left = Vec::new();
while let Some(entry) = entries.next_entry().await? {
left.push(entry.file_name().to_string_lossy().into_owned());
}
left.sort();
assert!(left.is_empty(), "files left behind: {left:?}");
assert!(
tracked(&cli_model, &namespace).await?.is_empty(),
"the tracked-path map should be empty too"
);
Ok(())
}
#[tokio::test]
async fn clearing_the_obstruction_lets_the_undo_through() -> Result<(), Error> {
let (cli_model, namespace, home, _temp) = package_with(&["first", "second"]).await?;
LocalStorage::new()
.write_byte_stream(home.join("value.txt"), ByteStream::from_static(b"edited"))
.await?;
assert!(undo(&cli_model, &namespace).await.is_err());
LocalStorage::new()
.write_byte_stream(home.join("value.txt"), ByteStream::from_static(b"second"))
.await?;
undo(&cli_model, &namespace).await?;
assert_eq!(tokio::fs::read(home.join("value.txt")).await?, b"first");
assert!(changes(&cli_model, &namespace).await?.is_empty());
Ok(())
}
#[test]
fn json_carries_the_restored_hash() {
let output = Output {
commit: CommitState {
timestamp: std::time::SystemTime::UNIX_EPOCH.into(),
hash: "abc123".to_string(),
prev_hashes: vec!["older".to_string()],
},
};
assert_eq!(output.to_json().to_string(), r#"{"hash":"abc123"}"#);
}
}