/*
* git-restore-autosave:
* - fetch upstream's autosaves
* - filter to autosaves from the local user and branch
* - display available autosaves if there is more than one
* - check for conflicts between workdir and autosave
* - save workdir to `refs/autosave/undo`
* - copy autosave to `refs/autosave/restored` with new commit
* - apply tree to workdir
* - allow `git restore-autosave --force`
* - allow a device to be specified by name or UUID
*
* git-merge-autosave:
* - error is workdir is dirty
* - 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:
* - check for differences between workdir and restored save
* - error if there are unsaved changes
* - apply `refs/autosave/undo` to tree
* - allow force
*
* git repair-autosave
*/
use std::path::Path;
use git2::{
Commit, FetchOptions, MergeOptions, Oid, PushOptions, Reference, RemoteCallbacks, Repository,
Signature, Time, Tree,
};
use is_executable::is_executable;
use thiserror::Error;
use uuid::Uuid;
pub mod authenticate;
pub mod config;
pub use config::Config;
#[derive(Debug, Error)]
pub enum TreeError {
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Git(#[from] git2::Error),
}
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();
repository
.config()?
.set_str("autosave.id", &id.to_string())?;
if let Some(config) = config
&& let Some(workdir) = workdir
{
config.repositories_mut().insert(workdir.into());
}
Ok(id)
}
pub fn current_branch_ref(repository: &Repository) -> Result<String, git2::Error> {
let head = repository.head()?;
let ref_name = match head.kind() {
Some(git2::ReferenceType::Symbolic) => head.symbolic_target(),
_ => head.name(),
}
.ok_or(git2::Error::new(
git2::ErrorCode::Invalid,
git2::ErrorClass::Reference,
"The current branch's name is not valid UTF-8",
))?
.to_string();
Ok(ref_name)
}
pub fn current_branch(repository: &Repository) -> Result<String, git2::Error> {
let ref_name = current_branch_ref(repository)?;
let branch_name = ref_name
.strip_prefix("refs/heads/")
.unwrap_or(&ref_name)
.to_string();
Ok(branch_name)
}
pub fn filemode_for_dir_entry(path: &Path) -> std::io::Result<i32> {
let metadata = path.metadata()?;
Ok(if metadata.is_dir() {
0o040000
} else if metadata.is_symlink() {
0o120000
} else if is_executable(path) {
0o100755
} else {
0o100644
})
}
pub fn path_to_tree(repository: &Repository, path: &Path) -> Result<Oid, TreeError> {
let workdir = repository.workdir().expect("a non-bare repo");
if path.is_dir() {
let mut tree = repository.treebuilder(None)?;
for entry in path.read_dir()? {
let entry = entry?;
let full_path = entry.path();
let relative_path = full_path.strip_prefix(workdir).unwrap_or(path);
if repository.is_path_ignored(relative_path)? {
continue;
}
let filemode = filemode_for_dir_entry(&entry.path())?;
let oid = path_to_tree(repository, &entry.path())?;
let filename = entry.file_name();
tree.insert(filename, oid, filemode)?;
}
Ok(tree.write()?)
} else {
Ok(repository.blob_path(path)?)
}
}
pub fn workdir_to_tree(repository: &Repository) -> Result<Oid, TreeError> {
let Some(workdir) = repository.workdir() else {
return Err(TreeError::Git(git2::Error::new(
git2::ErrorCode::BareRepo,
git2::ErrorClass::Repository,
"git-autosave only supports worktrees",
)));
};
path_to_tree(repository, workdir)
}
pub fn commit_autosave(repository: &Repository) -> Result<Oid, TreeError> {
let config = repository.config()?;
let head = repository.head()?;
let uid = config.get_entry("autosave.id")?;
let uid = String::from_utf8_lossy(uid.value_bytes());
let branch = current_branch(repository)?;
let refname = &format!("refs/autosave/autosaves/{uid}/{branch}");
let signature = repository.signature()?;
let tree = repository.find_tree(workdir_to_tree(repository)?)?;
let parent = head.peel_to_commit()?;
let hostname = hostname::get();
let hostname = hostname
.map(|hostname| hostname.to_string_lossy().into_owned())
.map(|hostname| format!("Autosave: {hostname}"))
.unwrap_or_else(|_| "Autosave".to_string());
let commit = repository.commit(None, &signature, &signature, &hostname, &tree, &[&parent])?;
repository.reference(refname, commit, true, "Autosaved {branch}")?;
Ok(commit)
}
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
.references()?
.filter_map(|reference| reference.ok())
.filter(|reference| {
reference
.name_bytes()
.starts_with(format!("refs/autosave/autosaves/{id}").as_bytes())
})
.filter_map(|reference| reference.name().map(|n| n.to_string()))
.map(|reference| format!("+{reference}:{reference}"))
.collect::<Vec<_>>();
remote.push(&refs, Some(PushOptions::new().remote_callbacks(callbacks)))?;
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_to(repository)?)
}
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(())
}
|