use std::collections::HashSet; use std::fmt::Display; use std::path::{Path, PathBuf}; use std::sync::mpsc::{Receiver, Sender}; use std::time::Duration; use auth_git2::GitAuthenticator; use chrono::{Local, Utc}; use git_autosave::{Autosave, Config, authenticate::Inquirer, commit_autosave, push_autosaves}; use git2::build::CheckoutBuilder; use git2::{RemoteCallbacks, Repository, StatusOptions}; use happylock::{Mutex, ThreadKey}; use notify::{EventKind, RecommendedWatcher, RecursiveMode}; use notify_debouncer_full::{DebounceEventHandler, DebounceEventResult, Debouncer, FileIdCache}; const THREE_MONTHS: i64 = 60 * 60 * 24 * 30 * 3; struct AutosaveOption { text: String, value: Autosave, } impl Display for AutosaveOption { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { self.text.fmt(f) } } struct ConfigWatcher { config: &'static Mutex, repo_watcher: &'static mut Debouncer, } struct RepoWatcher { config: &'static Mutex, push_queue: Sender, } fn push_queue( consumer: Receiver, config: &'static Mutex, mut key: ThreadKey, ) -> ! { let mut queue = HashSet::new(); loop { while ping::new("8.8.8.8".parse().unwrap()) .socket_type(ping::SocketType::DGRAM) .timeout(Duration::from_secs(60)) .ttl(128) .send() .is_err() { let mut added_item = false; while let Ok(workdir) = consumer.try_recv() { added_item = true; queue.insert(workdir); } if added_item { log::warn!( "There is no Internet connection, so pushes to the repository have been queued" ); } std::thread::yield_now(); } while let Ok(workdir) = consumer.try_recv() { queue.insert(workdir); } config.scoped_lock(&mut key, |config| { for workdir in &queue { log::info!("Pushing {}...", workdir.to_string_lossy()); let Ok(repository) = Repository::open(workdir) else { log::error!("Failed to open repository: {}", workdir.to_string_lossy()); continue; }; let Ok(gitconfig) = repository.config() else { log::error!("Failed to load gitconfig for repository: {:?}", workdir); return; }; let auth = GitAuthenticator::new().set_prompter(Inquirer(&*config)); let mut callbacks = RemoteCallbacks::new(); callbacks.credentials(auth.credentials(&gitconfig)); if let Err(e) = push_autosaves(&repository, callbacks) { log::error!("Failed to push autosaves: {e}"); } log::info!("Successfully pushed {}", workdir.to_string_lossy()); } }); queue.clear(); } } impl DebounceEventHandler for ConfigWatcher { fn handle_event(&mut self, events: DebounceEventResult) { let events = match events { Ok(events) => events, Err(errors) => { for error in errors { log::error!("Failed to load event: {error}"); } return; } }; if events .iter() .all(|event| matches!(event.kind, EventKind::Access(_))) { return; } log::info!("The config was updated. Reloading..."); let Some(key) = ThreadKey::get() else { log::error!("Failed to acquire thread key when reloading config. This is a bug!"); return; }; let config = match Config::load() { Ok(config) => config, Err(e) => { log::error!("Failed to reload autosave config: {e}"); return; } }; self.config.scoped_lock(key, |old_config| { let paths_to_unwatch = old_config.repositories().difference(config.repositories()); let paths_to_watch = config.repositories().difference(old_config.repositories()); for path in paths_to_unwatch { if let Err(e) = self.repo_watcher.unwatch(path) { log::error!("Error when removing path from being watched: {e}"); } log::info!("Removed {path:?} from list of repos to watch"); } for path in paths_to_watch { if let Err(e) = self.repo_watcher.watch(path, RecursiveMode::Recursive) { log::error!("Error when adding path to repositories to watch: {e}"); } log::info!("Added {path:?} from list of repos to watch"); } *old_config = config; }); log::info!("Successfully reloaded autosave config"); } } impl DebounceEventHandler for RepoWatcher { fn handle_event(&mut self, events: DebounceEventResult) { let Some(mut key) = ThreadKey::get() else { log::error!("Failed to acquire thread key when autosaving repository. This is a bug!"); return; }; let events = match events { Ok(events) => events, Err(errors) => { for error in errors { log::error!("Failed to get update events: {error}"); } return; } }; let mut workdirs_to_autosave = HashSet::new(); let mut repositories_to_autosave = Vec::new(); for (event, path) in events .iter() .filter(|event| !matches!(event.kind, EventKind::Access(_))) .flat_map(|event| event.paths.iter().map(move |path| (event, path))) { if path .components() .any(|component| component.as_os_str() == ".git") { // Prevent infinite loop from commits triggering autosaves continue; } let Ok(repository) = Repository::discover(path) else { log::warn!("Skipping non-repository: {:?}", &path); continue; }; let Some(workdir) = repository.workdir() else { log::warn!("Skipping bare repository: {:?}", &path); continue; }; if workdirs_to_autosave.contains(workdir) { continue; } match repository.is_path_ignored(path) { Ok(true) => { log::trace!("Skipping event for ignored path: {:?}", path); continue; } Ok(false) => {} Err(e) => { log::error!("Failed to determine if path is ignore: {e}"); } } if let Ok(status) = repository.statuses(Some( StatusOptions::new() .include_untracked(true) .include_ignored(false), )) && status.is_empty() { continue; } log::info!("Event: {:?}", event); log::info!("Updated path: {:?}", &path); workdirs_to_autosave.insert(workdir.to_path_buf()); repositories_to_autosave.push(repository); } self.config.scoped_lock(&mut key, |config| { for repository in repositories_to_autosave { let workdir = repository .workdir() .map(|path| path.to_string_lossy()) .unwrap_or_default(); log::info!("Autosaving {:?}...", workdir); let Ok(gitconfig) = repository.config() else { log::error!("Failed to load gitconfig for repository: {:?}", workdir); return; }; let auth = GitAuthenticator::new().set_prompter(Inquirer(&*config)); let mut callbacks = RemoteCallbacks::new(); callbacks.credentials(auth.credentials(&gitconfig)); if let Err(e) = commit_autosave(&repository) { log::error!("Failed to commit autosave: {e}"); } if let Err(e) = self .push_queue .send(repository.workdir().unwrap().to_path_buf()) { log::error!("Failed to add repository to push queue: {e}"); } log::info!("Successfully autosaved {:?}", workdir); } }); } } fn daemon() -> Result<(), anyhow::Error> { let mut key = ThreadKey::get().expect("Could not get ThreadKey on startup. This is a bug!"); colog::init(); log::info!("Loading autosave config..."); let config: &'static Mutex = Box::leak(Box::new(Mutex::new(Config::load()?))); log::info!("Loaded autosave config"); log::info!("Starting push queue..."); let (sender, receiver) = std::sync::mpsc::channel(); std::thread::spawn(move || { let key = ThreadKey::get().unwrap(); push_queue(receiver, config, key); }); config.scoped_lock(&mut key, |config| { for repository in config.repositories() { if let Err(e) = sender.send(repository.clone()) { log::error!("Failed to queue {}: {e}", repository.to_string_lossy()); } } }); log::info!("Started push queue"); log::info!("Starting repository watcher..."); let repo_watcher = Box::leak(Box::new(notify_debouncer_full::new_debouncer( Duration::from_secs(5), None, RepoWatcher { config, push_queue: sender, }, )?)); config.scoped_lock(key, |config| { log::info!("Adding repositories to watch..."); for repository in config.repositories() { if let Err(e) = repo_watcher.watch(repository, RecursiveMode::Recursive) { log::error!("Failed to watch {repository:?}: {e}"); } log::info!("Added {repository:?}"); } }); log::info!("Started repository watcher"); log::info!("Starting configuration watcher..."); notify_debouncer_full::new_debouncer( Duration::from_secs(5), None, ConfigWatcher { config, repo_watcher, }, )? .watch( &confy::get_configuration_file_path("git-autosave", "git-autosaved")?, RecursiveMode::NonRecursive, )?; log::info!("Started configuration watcher"); log::info!("Initializing complete. Parking..."); loop { std::thread::yield_now(); } } fn init() -> Result<(), anyhow::Error> { let repository = Repository::discover(".")?; let mut config = Config::load()?; let id = git_autosave::init(&repository, Some(&mut config))?; config.save()?; println!("Initialized autosave for repository: {id}"); Ok(()) } fn autosave() -> Result<(), anyhow::Error> { let repository = Repository::discover(".")?; let gitconfig = repository.config()?; let config: &'static mut Config = Box::leak(Box::new(Config::load()?)); if std::env::args().any(|arg| arg == "--init") { let id = git_autosave::init(&repository, Some(config))?; config.save()?; println!("Initialized autosave for repository: {id}"); } let auth = GitAuthenticator::new().set_prompter(Inquirer(config)); let mut callbacks = RemoteCallbacks::new(); callbacks.credentials(auth.credentials(&gitconfig)); let commit = commit_autosave(&repository)?; println!("Commited autosave: {commit}"); push_autosaves(&repository, callbacks)?; println!("Successfully pushed autosave to remote"); Ok(()) } fn clean_autosaves() -> Result<(), anyhow::Error> { let repository = Repository::discover(".")?; for reference in repository.references()? { let Ok(mut reference) = reference else { continue; }; let Ok(autosave): Result = (&reference).try_into() else { continue; }; if Utc::now().timestamp() - autosave.time.seconds() > THREE_MONTHS { reference.delete()?; } } Ok(()) } fn restore_autosave() -> Result<(), anyhow::Error> { let all_users = std::env::args().any(|arg| arg == "--all-users"); let all_branches = std::env::args().any(|arg| arg == "--all-branches"); let all_devices = std::env::args().any(|arg| arg == "--all-devices"); let anytime = std::env::args().any(|arg| arg == "--anytime"); let force = std::env::args().any(|arg| arg == "--force"); let repository = Repository::discover(".")?; let repo_id = git_autosave::repository_id(&repository)?; let signature = repository.signature()?; let branch = git_autosave::utils::current_branch(&repository)?; let earliest_time = repository.head()?.peel_to_commit()?.time(); let gitconfig = repository.config()?; let config: &'static _ = Box::leak(Box::new(Config::load()?)); let auth = GitAuthenticator::new().set_prompter(Inquirer(config)); let mut callbacks = RemoteCallbacks::new(); callbacks.credentials(auth.credentials(&gitconfig)); git_autosave::fetch_autosaves(&repository, callbacks)?; let mut autosaves = git_autosave::autosaves(&repository)? .into_iter() .filter(|autosave| { all_users || signature .name() .zip(autosave.author.clone()) .is_some_and(|(a, b)| a == b) || signature .email() .zip(autosave.email.clone()) .is_some_and(|(a, b)| a == b) }) .filter(|autosave| all_branches || autosave.branch_name == branch) .filter(|autosave| anytime || autosave.time > earliest_time) .filter(|autosave| all_devices || autosave.repo_id.as_bytes() != repo_id.as_bytes()) .collect::>(); if autosaves.is_empty() { eprintln!("ERROR: There are no available autosaves for the given filters."); if !all_users || !all_branches || !anytime { eprintln!( "hint: Use --all-users, --all-branches, --all-devices, or --anytime to include more options." ); } std::process::exit(1); } let autosave = if autosaves.len() > 1 { let autosaves = autosaves .into_iter() .map(|autosave| { let device = autosave.host_name.as_ref().unwrap_or(&autosave.repo_id); let time = chrono::DateTime::from_timestamp(autosave.time.seconds(), 0) .map(|time| time.with_timezone(&Local)) .map(|time| time.to_rfc2822()) .unwrap_or(autosave.time.seconds().to_string()); let branch = if all_branches { format!(" on {}", &autosave.branch_name) } else { String::new() }; let author = if let Some(author) = autosave.author.as_ref().or(autosave.email.as_ref()) && all_users { format!(" by {author}") } else { String::new() }; AutosaveOption { text: format!("{device} ({time}{branch}{author})"), value: autosave, } }) .collect(); inquire::Select::new("Select an autosave:", autosaves) .prompt()? .value } else { autosaves .pop() .expect("There should be an autosave to select but there isn't. This is a bug!") }; let autosaved_commit = repository.find_commit(autosave.commit_id)?; let workdir = repository.find_tree(git_autosave::utils::workdir_to_tree(&repository)?)?; let new_tree = git_autosave::utils::merge_commit_with_tree(&repository, &autosaved_commit, &workdir)?; git_autosave::save_undo_tree(&repository, &workdir)?; git_autosave::saved_restored_autosave(&repository, &autosave)?; let mut options = CheckoutBuilder::new(); if force { options.force(); } repository.checkout_tree( &repository.find_object(new_tree, Some(git2::ObjectType::Tree))?, Some(&mut options), )?; Ok(()) } fn subcommand(command: &Path, name: &str, f: fn() -> Result<(), anyhow::Error>) { let Some(command) = command.components().next_back() else { eprintln!("Invalid binary name: {}", command.to_string_lossy()); std::process::exit(1); }; if command.as_os_str().to_string_lossy() != name { return; } match f() { Ok(()) => std::process::exit(0), Err(e) => { eprintln!("{e}"); std::process::exit(1); } } } fn main() { let Some(command) = std::env::args_os().next() else { eprintln!("No command provided"); std::process::exit(1); }; let path = PathBuf::from(command); subcommand(&path, "git-init-autosave", init); subcommand(&path, "git-autosave", autosave); subcommand(&path, "git-clean-autosaves", clean_autosaves); subcommand(&path, "git-restore-autosave", restore_autosave); subcommand(&path, "git-autosave-daemon", daemon); eprintln!("Unrecognized command: {}", path.to_string_lossy()); std::process::exit(1); }