/* * git-cat-autosave: * - select an autosave * - create a tree that merges autosave and workdir * - show the diff of tree with workdir * * git-merge-autosave: * - error is workdir is dirty * - check default merge strategy * - merge autosave using default strategy * - allow a different merge strategy to be specified * - allow --no-commit * - display available autosaves if there is more than one * * git undo-restore-autosave: * - check for differences between workdir and restored save * - error if there are unsaved changes * - apply `refs/autosave/undo` to tree * - allow force * * git repair-autosave */ use git2::{ FetchOptions, Oid, PushOptions, Reference, RemoteCallbacks, Repository, Signature, Time, Tree, }; use uuid::Uuid; pub mod authenticate; pub mod config; pub mod utils; pub use config::Config; use utils::TreeError; pub const AUTOSAVE_ID_CONFIG_KEY: &str = "autosave.id"; pub const AUTOSAVE_REF_NAMESPACE: &str = "refs/autosave/"; pub const AUTOSAVE_ENTRIES_NAMESPACE: &str = "refs/autosave/autosaves/"; pub const AUTOSAVE_REFSPEC: &str = "+refs/autosave/autosaves/*:refs/autosave/autosaves/*"; pub const UNDO_RESTORE_REF: &str = "refs/autosave/undo"; pub const RESTORED_AUTOSAVE_REF: &str = "refs/autosave/restored"; #[derive(Debug)] pub struct Autosave { pub repo_id: String, pub branch_name: String, pub commit_id: Oid, pub author: Option, pub email: Option, pub host_name: Option, pub time: Time, } impl TryFrom> for Autosave { type Error = git2::Error; fn try_from(value: Reference<'_>) -> Result { Self::try_from(&value) } } impl TryFrom<&Reference<'_>> for Autosave { type Error = git2::Error; fn try_from(reference: &Reference<'_>) -> Result { let reference_name = reference.name().ok_or(git2::Error::new( git2::ErrorCode::Invalid, git2::ErrorClass::Reference, "Reference is not valid UTF-8", ))?; let reference_name = reference_name .strip_prefix(AUTOSAVE_ENTRIES_NAMESPACE) .unwrap_or(reference_name); let Some((id, branch)) = reference_name.split_once('/') else { return Err(git2::Error::new( git2::ErrorCode::Invalid, git2::ErrorClass::Reference, "Reference does not follow format of refs/autosave/autosaves/{UUID}/{BRANCH}", )); }; let commit = reference.peel_to_commit()?; let message = commit.message(); let host_name = message.and_then(|m| m.strip_prefix("Autosave: ")); let author = commit.author(); let author_name = author.name(); let author_email = author.email(); let time = commit.author().when(); Ok(Autosave { repo_id: id.to_string(), branch_name: branch.to_string(), commit_id: commit.id(), author: author_name.map(|s| s.to_string()), email: author_email.map(|s| s.to_string()), host_name: host_name.map(|s| s.to_string()), time, }) } } pub fn repository_id(repository: &Repository) -> Result { repository .config()? .get_entry(AUTOSAVE_ID_CONFIG_KEY)? .value() .map(|s| s.to_string()) .ok_or(git2::Error::new( git2::ErrorCode::Invalid, git2::ErrorClass::Config, "Repository ID is not valid UTF-8", )) } pub fn init(repository: &Repository, config: Option<&mut Config>) -> Result { let id = Uuid::new_v4(); let workdir = repository.workdir(); repository .config()? .set_str(AUTOSAVE_ID_CONFIG_KEY, &id.to_string())?; if let Some(config) = config && let Some(workdir) = workdir { config.repositories_mut().insert(workdir.into()); } Ok(id) } pub fn commit_autosave(repository: &Repository) -> Result { let head = repository.head()?; let uid = repository_id(repository)?; let branch = utils::current_branch(repository)?; let refname = &format!("{AUTOSAVE_ENTRIES_NAMESPACE}{uid}/{branch}"); let signature = repository.signature()?; let tree = repository.find_tree(utils::workdir_to_tree(repository)?)?; let parent = head.peel_to_commit()?; let hostname = hostname::get(); let hostname = hostname .map(|hostname| hostname.to_string_lossy().into_owned()) .map(|hostname| format!("Autosave: {hostname}")) .unwrap_or_else(|_| "Autosave".to_string()); let commit = repository.commit(None, &signature, &signature, &hostname, &tree, &[&parent])?; repository.reference(refname, commit, true, "Autosaved {branch}")?; Ok(commit) } pub fn push_autosaves( repository: &Repository, callbacks: RemoteCallbacks<'_>, ) -> Result<(), git2::Error> { let id = repository_id(repository)?; let remote = repository.branch_upstream_remote(&utils::current_branch_ref(repository)?)?; let mut remote = repository.find_remote(&String::from_utf8_lossy(&remote))?; let refs = repository .references()? .filter_map(|reference| reference.ok()) .filter(|reference| { reference .name_bytes() .starts_with(format!("{AUTOSAVE_ENTRIES_NAMESPACE}{id}").as_bytes()) }) .filter_map(|reference| reference.name().map(|n| n.to_string())) .map(|reference| format!("+{reference}:{reference}")) .collect::>(); remote.push(&refs, Some(PushOptions::new().remote_callbacks(callbacks)))?; Ok(()) } pub fn fetch_autosaves( repository: &Repository, callbacks: RemoteCallbacks<'_>, ) -> Result<(), git2::Error> { let remote = repository.branch_upstream_remote(&utils::current_branch_ref(repository)?)?; let mut remote = repository.find_remote(&String::from_utf8_lossy(&remote))?; remote.fetch( &[AUTOSAVE_REFSPEC], Some(FetchOptions::new().remote_callbacks(callbacks)), Some("Updated autosaves"), )?; Ok(()) } pub fn autosaves(repository: &Repository) -> Result, git2::Error> { Ok(repository .references()? .filter_map(|reference| reference.ok()) .filter(|reference| { reference .name_bytes() .starts_with(AUTOSAVE_ENTRIES_NAMESPACE.as_bytes()) }) .filter_map(|reference| reference.try_into().ok()) .collect()) } pub fn save_undo_tree(repository: &Repository, workdir: &Tree<'_>) -> Result<(), TreeError> { let signature = repository.signature()?; let commit = repository.commit( None, &signature, &signature, "Work done before autosave was restored", workdir, &[&repository.head()?.peel_to_commit()?], )?; repository.reference( UNDO_RESTORE_REF, commit, true, "Save work before restoring autosave", )?; Ok(()) } pub fn saved_restored_autosave( repository: &Repository, autosave: &Autosave, ) -> Result<(), git2::Error> { let author = if let Some(author) = &autosave.author && let Some(email) = &autosave.email { Signature::new(author, email, &autosave.time)? } else { repository.signature()? }; let committer = repository.signature()?; let message = format!( "{}:{}/{}", autosave.host_name.clone().unwrap_or_default(), &autosave.repo_id, &autosave.branch_name ); let tree = repository.find_commit(autosave.commit_id)?.tree()?; let parent = repository .find_reference(RESTORED_AUTOSAVE_REF) .ok() .and_then(|reference| reference.peel_to_commit().ok()); let parents = parent.iter().collect::>(); repository.commit( Some(RESTORED_AUTOSAVE_REF), &author, &committer, &message, &tree, &parents, )?; Ok(()) }