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);
}
|