summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorMica White <botahamec@outlook.com>2026-04-03 20:41:13 -0400
committerMica White <botahamec@outlook.com>2026-04-03 20:41:13 -0400
commit02c306bf2cba2ecab1bcd33fb9a6b5de995163ee (patch)
tree2ca16caa124ac2d43cc7da23591b9ec17c03f754 /src
parent03d6d4846ffcd29e589fdecaddbc749217761bfe (diff)
Try a single-binary approach
Diffstat (limited to 'src')
-rw-r--r--src/bin/git-autosave.rs26
-rw-r--r--src/main.rs498
2 files changed, 498 insertions, 26 deletions
diff --git a/src/bin/git-autosave.rs b/src/bin/git-autosave.rs
deleted file mode 100644
index fa0d9e6..0000000
--- a/src/bin/git-autosave.rs
+++ /dev/null
@@ -1,26 +0,0 @@
-use auth_git2::GitAuthenticator;
-use git_autosave::{Config, authenticate::Inquirer, commit_autosave, push_autosaves};
-use git2::{RemoteCallbacks, Repository};
-
-fn main() -> 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(())
-}
diff --git a/src/main.rs b/src/main.rs
new file mode 100644
index 0000000..c71db37
--- /dev/null
+++ b/src/main.rs
@@ -0,0 +1,498 @@
+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<Cache: FileIdCache + 'static> {
+ config: &'static Mutex<Config>,
+ repo_watcher: &'static mut Debouncer<RecommendedWatcher, Cache>,
+}
+
+struct RepoWatcher {
+ config: &'static Mutex<Config>,
+ push_queue: Sender<PathBuf>,
+}
+
+fn push_queue(
+ consumer: Receiver<PathBuf>,
+ config: &'static Mutex<Config>,
+ 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<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 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<Config> = 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<Autosave, git2::Error> = (&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::<Vec<_>>();
+
+ 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);
+}