/* * git-init-autosave: * - generate local repo UUID * - add repo to autosave configuration (optionally) * * git-autosave: * - convert workdir to tree * - note hostname * - commit workdir to `refs/autosave/{UUID}-{branch}` * - push autosave ref to branch upstream * * git-autosave-daemon: * - watch configuration directory * - watch configured repositories * - run autosave on change * * 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 * - 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 */ use std::collections::HashSet; use std::fs::Metadata; use std::path::{Path, PathBuf}; use confy::ConfyError; use git2::{Oid, PushOptions, RemoteCallbacks, Repository}; use serde::{Deserialize, Serialize}; use thiserror::Error; use uuid::Uuid; #[derive(Default, Serialize, Deserialize)] pub struct Config { repositories: HashSet, } #[derive(Debug, Error)] pub enum TreeError { #[error(transparent)] Io(#[from] std::io::Error), #[error(transparent)] Git(#[from] git2::Error), } pub fn load_config() -> Result { confy::load("git-autosave", "git-autosaved") } pub fn save_config(config: &Config) -> Result<(), ConfyError> { confy::store("git-autosave", "git-autosaved", config) } pub fn init(repository: &Repository, config: Option<&mut Config>) -> Result<(), git2::Error> { 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.insert(workdir.into()); } Ok(()) } 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(metadata: &Metadata) -> i32 { if metadata.is_dir() { 0o040000 } else if metadata.is_symlink() { 0o120000 } else if metadata.permissions().readonly() { 0o100644 } else { 0o100755 } } 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.metadata()?); 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 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(b"refs/autosave/autosaves") }) .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(()) }