diff options
| -rw-r--r-- | Cargo.lock | 121 | ||||
| -rw-r--r-- | Cargo.toml | 1 | ||||
| -rw-r--r-- | src/bin/git-autosave-daemon.rs | 4 | ||||
| -rw-r--r-- | src/bin/git-restore-autosave.rs | 128 | ||||
| -rw-r--r-- | src/lib.rs | 184 |
5 files changed, 434 insertions, 4 deletions
@@ -12,6 +12,15 @@ dependencies = [ ] [[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] name = "anstream" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -79,6 +88,12 @@ dependencies = [ ] [[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] name = "bitflags" version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -115,6 +130,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" [[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] name = "colog" version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -163,6 +191,12 @@ dependencies = [ ] [[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] name = "crossterm" version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -398,6 +432,7 @@ version = "0.1.0" dependencies = [ "anyhow", "auth-git2", + "chrono", "colog", "confy", "git2", @@ -480,6 +515,30 @@ dependencies = [ ] [[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] name = "icu_collections" version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -874,6 +933,15 @@ dependencies = [ ] [[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] name = "once_cell" version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1527,12 +1595,65 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" [[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" [[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] name = "windows-sys" version = "0.59.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -13,6 +13,7 @@ notify = "8" notify-debouncer-full = "0.7" confy = "2" happylock = "0.5" +chrono = "0.4" log = "0.4" colog = "1" hostname = "0.4" diff --git a/src/bin/git-autosave-daemon.rs b/src/bin/git-autosave-daemon.rs index 05ead05..a0066a5 100644 --- a/src/bin/git-autosave-daemon.rs +++ b/src/bin/git-autosave-daemon.rs @@ -170,7 +170,7 @@ fn main() -> Result<(), anyhow::Error> { log::info!("Starting repository watcher..."); let repo_watcher = Box::leak(Box::new(notify_debouncer_full::new_debouncer( - Duration::from_secs(1), + Duration::from_secs(5), None, Watcher(config), )?)); @@ -186,7 +186,7 @@ fn main() -> Result<(), anyhow::Error> { log::info!("Started repository watcher"); log::info!("Starting configuration watcher..."); notify_debouncer_full::new_debouncer( - Duration::from_secs(1), + Duration::from_secs(5), None, ConfigWatcher { config, diff --git a/src/bin/git-restore-autosave.rs b/src/bin/git-restore-autosave.rs new file mode 100644 index 0000000..51cc14c --- /dev/null +++ b/src/bin/git-restore-autosave.rs @@ -0,0 +1,128 @@ +// git restore-autosave +// - --user and --all-users cannot both be present +// - --branch and --all-branches cannot both be present +// - if --user or -u is not present, the selected user is the repository signature +// - if --branch or -b is not present, the selected branch is the checked out branch +// - if --device or -d is present (UUID or hostname), filter to autosaves from that device +// - filter to autosaves from the current user (name or email) if --all-users is not present +// - filter to autosaves on the current branch +// - filter to autosaves after the head commit +// - if there is more than one option, enter pick mode +// - if there are no options, tell the user to use --all-users or --all-branches or --anytime + +use std::fmt::Display; + +use auth_git2::GitAuthenticator; +use chrono::Local; +use git_autosave::{Autosave, authenticate::Inquirer}; +use git2::{RemoteCallbacks, Repository, build::CheckoutBuilder}; + +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) + } +} + +fn main() -> 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 anytime = std::env::args().any(|arg| arg == "--anytime"); + let force = std::env::args().any(|arg| arg == "--force"); + + let repository = Repository::discover(".")?; + let signature = repository.signature()?; + let branch = git_autosave::current_branch(&repository)?; + let earliest_time = repository.head()?.peel_to_commit()?.time(); + + let gitconfig = repository.config()?; + let config: &'static _ = Box::leak(Box::new(git_autosave::load_config()?)); + 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) + .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, 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::workdir_to_tree(&repository)?)?; + let new_tree = git_autosave::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(()) +} @@ -15,6 +15,7 @@ * - check default merge strategy * - merge autosave using default strategy * - allow a different merge strategy to be specified + * - allow --no-commit * - display available autosaves if there is more than one * * git undo-restore-autosave: @@ -31,7 +32,10 @@ use std::fs::Metadata; use std::path::{Path, PathBuf}; use confy::ConfyError; -use git2::{Oid, PushOptions, RemoteCallbacks, Repository}; +use git2::{ + Commit, FetchOptions, MergeOptions, Oid, PushOptions, Reference, RemoteCallbacks, Repository, + Signature, Time, Tree, +}; use serde::{Deserialize, Serialize}; use thiserror::Error; use uuid::Uuid; @@ -79,6 +83,19 @@ pub fn save_config(config: &Config) -> Result<(), ConfyError> { confy::store("git-autosave", "git-autosaved", config) } +pub fn repository_id(repository: &Repository) -> Result<String, git2::Error> { + repository + .config()? + .get_entry("autosave.id")? + .value() + .map(|s| s.to_string()) + .ok_or(git2::Error::new( + git2::ErrorCode::Invalid, + git2::ErrorClass::Config, + "Repository ID is not valid UTF-8", + )) +} + pub fn init(repository: &Repository, config: Option<&mut Config>) -> Result<Uuid, git2::Error> { let id = Uuid::new_v4(); let workdir = repository.workdir(); @@ -192,6 +209,7 @@ pub fn push_autosaves( repository: &Repository, callbacks: RemoteCallbacks<'_>, ) -> Result<(), git2::Error> { + let id = repository_id(repository)?; let remote = repository.branch_upstream_remote(¤t_branch_ref(repository)?)?; let mut remote = repository.find_remote(&String::from_utf8_lossy(&remote))?; let refs = repository @@ -200,7 +218,7 @@ pub fn push_autosaves( .filter(|reference| { reference .name_bytes() - .starts_with(b"refs/autosave/autosaves") + .starts_with(format!("refs/autosave/autosaves/{id}").as_bytes()) }) .filter_map(|reference| reference.name().map(|n| n.to_string())) .map(|reference| format!("+{reference}:{reference}")) @@ -209,3 +227,165 @@ pub fn push_autosaves( Ok(()) } + +pub fn fetch_autosaves( + repository: &Repository, + callbacks: RemoteCallbacks<'_>, +) -> Result<(), git2::Error> { + let remote = repository.branch_upstream_remote(¤t_branch_ref(repository)?)?; + let mut remote = repository.find_remote(&String::from_utf8_lossy(&remote))?; + remote.fetch( + &["+refs/autosave/autosaves/*:refs/autosave/autosaves/*"], + Some(FetchOptions::new().remote_callbacks(callbacks)), + Some("Updated autosaves"), + )?; + + Ok(()) +} + +pub struct Autosave { + pub repo_id: String, + pub branch_name: String, + pub commit_id: Oid, + pub author: Option<String>, + pub email: Option<String>, + pub host_name: Option<String>, + pub time: Time, +} + +impl TryFrom<Reference<'_>> for Autosave { + type Error = git2::Error; + + fn try_from(value: Reference<'_>) -> Result<Self, Self::Error> { + Self::try_from(&value) + } +} + +impl TryFrom<&Reference<'_>> for Autosave { + type Error = git2::Error; + + fn try_from(reference: &Reference<'_>) -> Result<Self, Self::Error> { + let reference_name = reference.name().ok_or(git2::Error::new( + git2::ErrorCode::Invalid, + git2::ErrorClass::Reference, + "Reference is not valid UTF-8", + ))?; + let reference_name = reference_name + .strip_prefix("refs/autosave/autosaves/") + .unwrap_or(reference_name); + let Some((id, branch)) = reference_name.split_once('/') else { + return Err(git2::Error::new( + git2::ErrorCode::Invalid, + git2::ErrorClass::Reference, + "Reference does not follow format of refs/autosave/autosaves/{UUID}/{BRANCH}", + )); + }; + let commit = reference.peel_to_commit()?; + let message = commit.message(); + let host_name = message.and_then(|m| m.strip_prefix("Autosave: ")); + let author = commit.author(); + let author_name = author.name(); + let author_email = author.email(); + let time = commit.author().when(); + + Ok(Autosave { + repo_id: id.to_string(), + branch_name: branch.to_string(), + commit_id: commit.id(), + author: author_name.map(|s| s.to_string()), + email: author_email.map(|s| s.to_string()), + host_name: host_name.map(|s| s.to_string()), + time, + }) + } +} + +pub fn autosaves(repository: &Repository) -> Result<Vec<Autosave>, git2::Error> { + Ok(repository + .references()? + .filter_map(|reference| reference.ok()) + .filter(|reference| { + reference + .name_bytes() + .starts_with(b"refs/autosave/autosaves") + }) + .filter_map(|reference| reference.try_into().ok()) + .collect()) +} + +pub fn merge_commit_with_tree( + repository: &Repository, + commit: &Commit<'_>, + workdir: &Tree<'_>, +) -> Result<Oid, TreeError> { + Ok(repository + .merge_trees( + &repository + .find_commit( + repository + .merge_base(repository.head()?.peel_to_commit()?.id(), commit.id())?, + )? + .tree()?, + workdir, + &commit.tree()?, + Some(MergeOptions::new().find_renames(true).patience(true)), + )? + .write_tree()?) +} + +pub fn save_undo_tree(repository: &Repository, workdir: &Tree<'_>) -> Result<(), TreeError> { + let signature = repository.signature()?; + let commit = repository.commit( + None, + &signature, + &signature, + "Work done before autosave was restored", + workdir, + &[&repository.head()?.peel_to_commit()?], + )?; + repository.reference( + "refs/autosave/undo", + commit, + true, + "Save work before restoring autosave", + )?; + + Ok(()) +} + +pub fn saved_restored_autosave( + repository: &Repository, + autosave: &Autosave, +) -> Result<(), git2::Error> { + let author = if let Some(author) = &autosave.author + && let Some(email) = &autosave.email + { + Signature::new(author, email, &autosave.time)? + } else { + repository.signature()? + }; + let committer = repository.signature()?; + let message = format!( + "{}:{}/{}", + autosave.host_name.clone().unwrap_or_default(), + &autosave.repo_id, + &autosave.branch_name + ); + let tree = repository.find_commit(autosave.commit_id)?.tree()?; + let parent = repository + .find_reference("refs/autosave/restored") + .ok() + .and_then(|reference| reference.peel_to_commit().ok()); + let parents = parent.iter().collect::<Vec<_>>(); + + repository.commit( + Some("refs/autosave/restored"), + &author, + &committer, + &message, + &tree, + &parents, + )?; + + Ok(()) +} |
