summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorMica White <botahamec@outlook.com>2026-03-29 15:48:31 -0400
committerMica White <botahamec@outlook.com>2026-03-29 15:48:31 -0400
commit9a3fa573f2f471218247476d3b3ad5fde08df29d (patch)
tree4be264e3d6137d69043cf111a945cd10a8f7897c /src
parent604d3be6dc04892cdef71935d1117855c1cc7bb4 (diff)
Autosave daemon
Diffstat (limited to 'src')
-rw-r--r--src/authenticate.rs93
-rw-r--r--src/bin/git-autosave-daemon.rs206
-rw-r--r--src/bin/git-autosave.rs100
-rw-r--r--src/lib.rs16
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));
diff --git a/src/lib.rs b/src/lib.rs
index 2882039..caa69b8 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -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()
}