summaryrefslogtreecommitdiff
path: root/src/main.rs
blob: c71db37b089d1869cef8533696a935c11d51e499 (plain)
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);
}