/* * git-restore-autosave: * - fetch upstream's autosaves * - filter to autosaves from the local user and branch * - display available autosaves if there is more than one * - check for conflicts between workdir and autosave * - save workdir to `refs/autosave/undo` * - copy autosave to `refs/autosave/restored` with new commit * - apply tree to workdir * - allow `git restore-autosave --force` * - allow a device to be specified by name or UUID * * 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 std::path::Path; use git2::{ Commit, FetchOptions, MergeOptions, Oid, PushOptions, Reference, RemoteCallbacks, Repository, Signature, Time, Tree, }; use is_executable::is_executable; use thiserror::Error; use uuid::Uuid; pub mod authenticate; pub mod config; pub use config::Config; #[derive(Debug, Error)] pub enum TreeError { #[error(transparent)] Io(#[from] std::io::Error), #[error(transparent)] Git(#[from] git2::Error), } pub fn repository_id(repository: &Repository) -> Result { repository .config()? .get_entry("autosave.id")? .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", &id.to_string())?; if let Some(config) = config && let Some(workdir) = workdir { config.repositories_mut().insert(workdir.into()); } Ok(id) } pub fn current_branch_ref(repository: &Repository) -> Result { let head = repository.head()?; let ref_name = match head.kind() { Some(git2::ReferenceType::Symbolic) => head.symbolic_target(), _ => head.name(), } .ok_or(git2::Error::new( git2::ErrorCode::Invalid, git2::ErrorClass::Reference, "The current branch's name is not valid UTF-8", ))? .to_string(); Ok(ref_name) } pub fn current_branch(repository: &Repository) -> Result { let ref_name = current_branch_ref(repository)?; let branch_name = ref_name .strip_prefix("refs/heads/") .unwrap_or(&ref_name) .to_string(); Ok(branch_name) } pub fn filemode_for_dir_entry(path: &Path) -> std::io::Result { let metadata = path.metadata()?; Ok(if metadata.is_dir() { 0o040000 } else if metadata.is_symlink() { 0o120000 } else if is_executable(path) { 0o100755 } else { 0o100644 }) } pub fn path_to_tree(repository: &Repository, path: &Path) -> Result { let workdir = repository.workdir().expect("a non-bare repo"); if path.is_dir() { let mut tree = repository.treebuilder(None)?; for entry in path.read_dir()? { let entry = entry?; let full_path = entry.path(); let relative_path = full_path.strip_prefix(workdir).unwrap_or(path); if repository.is_path_ignored(relative_path)? { continue; } let filemode = filemode_for_dir_entry(&entry.path())?; let oid = path_to_tree(repository, &entry.path())?; let filename = entry.file_name(); tree.insert(filename, oid, filemode)?; } Ok(tree.write()?) } else { Ok(repository.blob_path(path)?) } } pub fn workdir_to_tree(repository: &Repository) -> Result { let Some(workdir) = repository.workdir() else { return Err(TreeError::Git(git2::Error::new( git2::ErrorCode::BareRepo, git2::ErrorClass::Repository, "git-autosave only supports worktrees", ))); }; path_to_tree(repository, workdir) } pub fn commit_autosave(repository: &Repository) -> Result { let config = repository.config()?; let head = repository.head()?; let uid = config.get_entry("autosave.id")?; let uid = String::from_utf8_lossy(uid.value_bytes()); let branch = current_branch(repository)?; let refname = &format!("refs/autosave/autosaves/{uid}/{branch}"); let signature = repository.signature()?; let tree = repository.find_tree(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(¤t_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!("refs/autosave/autosaves/{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(¤t_branch_ref(repository)?)?; let mut remote = repository.find_remote(&String::from_utf8_lossy(&remote))?; remote.fetch( &["+refs/autosave/autosaves/*:refs/autosave/autosaves/*"], Some(FetchOptions::new().remote_callbacks(callbacks)), Some("Updated autosaves"), )?; Ok(()) } 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("refs/autosave/autosaves/") .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 autosaves(repository: &Repository) -> Result, git2::Error> { Ok(repository .references()? .filter_map(|reference| reference.ok()) .filter(|reference| { reference .name_bytes() .starts_with(b"refs/autosave/autosaves") }) .filter_map(|reference| reference.try_into().ok()) .collect()) } pub fn merge_commit_with_tree( repository: &Repository, commit: &Commit<'_>, workdir: &Tree<'_>, ) -> Result { Ok(repository .merge_trees( &repository .find_commit( repository .merge_base(repository.head()?.peel_to_commit()?.id(), commit.id())?, )? .tree()?, workdir, &commit.tree()?, Some(MergeOptions::new().find_renames(true).patience(true)), )? .write_tree_to(repository)?) } 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( "refs/autosave/undo", 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("refs/autosave/restored") .ok() .and_then(|reference| reference.peel_to_commit().ok()); let parents = parent.iter().collect::>(); repository.commit( Some("refs/autosave/restored"), &author, &committer, &message, &tree, &parents, )?; Ok(()) }