diff options
| author | Mica White <botahamec@outlook.com> | 2026-03-29 15:48:31 -0400 |
|---|---|---|
| committer | Mica White <botahamec@outlook.com> | 2026-03-29 15:48:31 -0400 |
| commit | 9a3fa573f2f471218247476d3b3ad5fde08df29d (patch) | |
| tree | 4be264e3d6137d69043cf111a945cd10a8f7897c /src | |
| parent | 604d3be6dc04892cdef71935d1117855c1cc7bb4 (diff) | |
Autosave daemon
Diffstat (limited to 'src')
| -rw-r--r-- | src/authenticate.rs | 93 | ||||
| -rw-r--r-- | src/bin/git-autosave-daemon.rs | 206 | ||||
| -rw-r--r-- | src/bin/git-autosave.rs | 100 | ||||
| -rw-r--r-- | src/lib.rs | 16 |
4 files changed, 310 insertions, 105 deletions
diff --git a/src/authenticate.rs b/src/authenticate.rs new file mode 100644 index 0000000..fab33a2 --- /dev/null +++ b/src/authenticate.rs @@ -0,0 +1,93 @@ +use std::path::Path; + +use auth_git2::Prompter; +use inquire::{InquireError, PasswordDisplayMode}; + +use crate::Config; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Inquirer<'a>(pub &'a Config); + +fn config_value(git_config: &git2::Config, name: &str) -> Option<String> { + git_config + .get_entry(name) + .ok() + .and_then(|entry| entry.value().map(|entry| entry.to_string())) +} + +fn prompt_secret(message: &str) -> Result<String, InquireError> { + inquire::Password::new(message) + .without_confirmation() + .with_display_mode(PasswordDisplayMode::Masked) + .prompt() +} + +impl Prompter for Inquirer<'_> { + fn prompt_username_password( + &mut self, + url: &str, + git_config: &git2::Config, + ) -> Option<(String, String)> { + let username = self + .0 + .username_for_url(url) + .cloned() + .or_else(|| config_value(git_config, "autosave.username")); + let password = self + .0 + .password_for_url(url) + .cloned() + .or_else(|| config_value(git_config, "autosave.password")); + if let Some(username) = username + && let Some(password) = password + { + return Some((username, password)); + } + + println!("Authenticating to {url}"); + let username = inquire::prompt_text("Username:").ok()?; + let password = prompt_secret("Password:").ok()?; + + Some((username, password)) + } + + fn prompt_password( + &mut self, + username: &str, + url: &str, + git_config: &git2::Config, + ) -> Option<String> { + let password = self + .0 + .password_for_url(url) + .cloned() + .or_else(|| config_value(git_config, "autosave.password")); + if let Some(password) = password { + return Some(password); + } + + println!("Authenticating to {url}"); + prompt_secret(&format!("Password for {username}:")).ok() + } + + fn prompt_ssh_key_passphrase( + &mut self, + private_key_path: &Path, + git_config: &git2::Config, + ) -> Option<String> { + let password = self + .0 + .passphrase_for_key(private_key_path) + .cloned() + .or_else(|| config_value(git_config, "autosave.password")); + if let Some(password) = password { + return Some(password); + } + + prompt_secret(&format!( + "Passphrase for {}:", + private_key_path.to_string_lossy() + )) + .ok() + } +} diff --git a/src/bin/git-autosave-daemon.rs b/src/bin/git-autosave-daemon.rs new file mode 100644 index 0000000..05ead05 --- /dev/null +++ b/src/bin/git-autosave-daemon.rs @@ -0,0 +1,206 @@ +use std::{collections::HashSet, time::Duration}; + +use auth_git2::GitAuthenticator; +use git_autosave::{Config, authenticate::Inquirer, commit_autosave, push_autosaves}; +use git2::{RemoteCallbacks, Repository}; +use happylock::{Mutex, ThreadKey}; +use notify::{EventKind, INotifyWatcher, RecursiveMode}; +use notify_debouncer_full::{ + DebounceEventHandler, DebounceEventResult, DebouncedEvent, Debouncer, FileIdCache, +}; + +struct ConfigWatcher<Cache: FileIdCache + 'static> { + config: &'static Mutex<Config>, + repo_watcher: &'static mut Debouncer<INotifyWatcher, Cache>, +} + +struct Watcher(&'static Mutex<Config>); + +fn is_event_useful(events: &[DebouncedEvent]) -> bool { + events.iter().all(|event| { + event.kind == EventKind::Any + || event.kind == EventKind::Other + || matches!(event.kind, EventKind::Access(_)) + }) +} + +impl<Cache: FileIdCache + Send + 'static> DebounceEventHandler for ConfigWatcher<Cache> { + 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 !is_event_useful(&events) { + 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 git_autosave::load_config() { + 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 Watcher { + 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; + } + }; + if !is_event_useful(&events) { + return; + } + + let mut workdirs_to_autosave = HashSet::new(); + let mut repositories_to_autosave = Vec::new(); + for path in events.iter().flat_map(|event| &event.paths) { + 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; + }; + 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}"); + } + } + let Some(workdir) = repository.workdir() else { + log::warn!("Skipping bare repository: {:?}", &path); + continue; + }; + if workdirs_to_autosave.contains(workdir) { + continue; + } + + log::info!("Updated path: {:?}", &path); + workdirs_to_autosave.insert(workdir.to_path_buf()); + repositories_to_autosave.push(repository); + } + self.0.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) = push_autosaves(&repository, callbacks) { + log::error!("Failed to push autosaves: {e}"); + } + + log::info!("Successfully autosaved {:?}", workdir); + } + }); + } +} + +fn main() -> Result<(), anyhow::Error> { + let 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<Config> = + Box::leak(Box::new(Mutex::new(git_autosave::load_config()?))); + log::info!("Loaded autosave config"); + + log::info!("Starting repository watcher..."); + let repo_watcher = Box::leak(Box::new(notify_debouncer_full::new_debouncer( + Duration::from_secs(1), + None, + Watcher(config), + )?)); + 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(1), + 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(); + } +} diff --git a/src/bin/git-autosave.rs b/src/bin/git-autosave.rs index 3df94b6..64ffd67 100644 --- a/src/bin/git-autosave.rs +++ b/src/bin/git-autosave.rs @@ -1,105 +1,15 @@ -use std::path::Path; - -use auth_git2::{GitAuthenticator, Prompter}; -use git_autosave::{Config, commit_autosave, push_autosaves}; +use auth_git2::GitAuthenticator; +use git_autosave::{Config, authenticate::Inquirer, commit_autosave, push_autosaves}; use git2::{RemoteCallbacks, Repository}; -use inquire::{InquireError, PasswordDisplayMode}; - -#[derive(Default, Debug, Clone, PartialEq, Eq)] -struct Inquirer(Config); - -fn config_value(git_config: &git2::Config, name: &str) -> Option<String> { - git_config - .get_entry(name) - .ok() - .and_then(|entry| entry.value().map(|entry| entry.to_string())) -} - -fn prompt_secret(message: &str) -> Result<String, InquireError> { - inquire::Password::new(message) - .without_confirmation() - .with_display_mode(PasswordDisplayMode::Masked) - .prompt() -} - -impl Prompter for Inquirer { - fn prompt_username_password( - &mut self, - url: &str, - git_config: &git2::Config, - ) -> Option<(String, String)> { - let username = self - .0 - .username_for_url(url) - .cloned() - .or_else(|| config_value(git_config, "autosave.username")); - let password = self - .0 - .password_for_url(url) - .cloned() - .or_else(|| config_value(git_config, "autosave.password")); - if let Some(username) = username - && let Some(password) = password - { - return Some((username, password)); - } - - println!("Authenticating to {url}"); - let username = inquire::prompt_text("Username:").ok()?; - let password = prompt_secret("Password:").ok()?; - - Some((username, password)) - } - - fn prompt_password( - &mut self, - username: &str, - url: &str, - git_config: &git2::Config, - ) -> Option<String> { - let password = self - .0 - .password_for_url(url) - .cloned() - .or_else(|| config_value(git_config, "autosave.password")); - if let Some(password) = password { - return Some(password); - } - - println!("Authenticating to {url}"); - prompt_secret(&format!("Password for {username}:")).ok() - } - - fn prompt_ssh_key_passphrase( - &mut self, - private_key_path: &Path, - git_config: &git2::Config, - ) -> Option<String> { - let password = self - .0 - .passphrase_for_key(private_key_path) - .cloned() - .or_else(|| config_value(git_config, "autosave.password")); - if let Some(password) = password { - return Some(password); - } - - prompt_secret(&format!( - "Passphrase for {}:", - private_key_path.to_string_lossy() - )) - .ok() - } -} fn main() -> Result<(), anyhow::Error> { let repository = Repository::discover(".")?; let gitconfig = repository.config()?; - let mut config = git_autosave::load_config()?; + let config: &'static mut Config = Box::leak(Box::new(git_autosave::load_config()?)); if std::env::args().any(|arg| arg == "--init") { - git_autosave::init(&repository, Some(&mut config))?; - git_autosave::save_config(&config)?; + git_autosave::init(&repository, Some(config))?; + git_autosave::save_config(config)?; } let auth = GitAuthenticator::new().set_prompter(Inquirer(config)); @@ -1,14 +1,4 @@ /* - * 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 @@ -49,6 +39,8 @@ use serde::{Deserialize, Serialize}; use thiserror::Error; use uuid::Uuid; +pub mod authenticate; + #[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct Config { repositories: HashSet<PathBuf>, @@ -57,6 +49,10 @@ pub struct Config { } impl Config { + pub fn repositories(&self) -> &HashSet<PathBuf> { + &self.repositories + } + pub fn username_for_url(&self, url: &str) -> Option<&String> { self.passwords.get(url)?.0.as_ref() } |
