summaryrefslogtreecommitdiff
path: root/src/lib.rs
blob: 1cf0a98e22385e95b33ee841826330b288dd1a29 (plain)
/*
 * git-cat-autosave:
 * - select an autosave
 * - create a tree that merges autosave and workdir
 * - show the diff of tree with workdir
 *
 * 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 git2::{
	FetchOptions, Oid, PushOptions, Reference, RemoteCallbacks, Repository, Signature, Time, Tree,
};
use uuid::Uuid;

pub mod authenticate;
pub mod config;
pub mod utils;

pub use config::Config;

use utils::TreeError;

pub const AUTOSAVE_ID_CONFIG_KEY: &str = "autosave.id";
pub const AUTOSAVE_REF_NAMESPACE: &str = "refs/autosave/";
pub const AUTOSAVE_ENTRIES_NAMESPACE: &str = "refs/autosave/autosaves/";
pub const AUTOSAVE_REFSPEC: &str = "+refs/autosave/autosaves/*:refs/autosave/autosaves/*";
pub const UNDO_RESTORE_REF: &str = "refs/autosave/undo";
pub const RESTORED_AUTOSAVE_REF: &str = "refs/autosave/restored";

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(AUTOSAVE_ENTRIES_NAMESPACE)
			.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 repository_id(repository: &Repository) -> Result<String, git2::Error> {
	repository
		.config()?
		.get_entry(AUTOSAVE_ID_CONFIG_KEY)?
		.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_CONFIG_KEY, &id.to_string())?;

	if let Some(config) = config
		&& let Some(workdir) = workdir
	{
		config.repositories_mut().insert(workdir.into());
	}

	Ok(id)
}

pub fn commit_autosave(repository: &Repository) -> Result<Oid, TreeError> {
	let head = repository.head()?;
	let uid = repository_id(repository)?;
	let branch = utils::current_branch(repository)?;
	let refname = &format!("{AUTOSAVE_ENTRIES_NAMESPACE}{uid}/{branch}");
	let signature = repository.signature()?;
	let tree = repository.find_tree(utils::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(&utils::current_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!("{AUTOSAVE_ENTRIES_NAMESPACE}{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(&utils::current_branch_ref(repository)?)?;
	let mut remote = repository.find_remote(&String::from_utf8_lossy(&remote))?;
	remote.fetch(
		&[AUTOSAVE_REFSPEC],
		Some(FetchOptions::new().remote_callbacks(callbacks)),
		Some("Updated autosaves"),
	)?;

	Ok(())
}

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(AUTOSAVE_ENTRIES_NAMESPACE.as_bytes())
		})
		.filter_map(|reference| reference.try_into().ok())
		.collect())
}

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(
		UNDO_RESTORE_REF,
		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(RESTORED_AUTOSAVE_REF)
		.ok()
		.and_then(|reference| reference.peel_to_commit().ok());
	let parents = parent.iter().collect::<Vec<_>>();

	repository.commit(
		Some(RESTORED_AUTOSAVE_REF),
		&author,
		&committer,
		&message,
		&tree,
		&parents,
	)?;

	Ok(())
}