summaryrefslogtreecommitdiff
path: root/src/lib.rs
blob: 5a6b7df4492ee280351d27adb157faf40b39232b (plain)
/*
 * 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::collections::{HashMap, HashSet};
use std::fs::Metadata;
use std::path::{Path, PathBuf};

use confy::ConfyError;
use git2::{
	Commit, FetchOptions, MergeOptions, Oid, PushOptions, Reference, RemoteCallbacks, Repository,
	Signature, Time, Tree,
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use uuid::Uuid;

pub mod authenticate;

#[derive(Default, Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct Config {
	repositories: HashSet<PathBuf>,
	passwords: HashMap<String, (Option<String>, String)>,
	passphrases: HashMap<PathBuf, String>,
}

impl Config {
	pub fn repositories(&self) -> &HashSet<PathBuf> {
		&self.repositories
	}

	pub fn username_for_url(&self, url: &str) -> Option<&String> {
		self.passwords.get(url)?.0.as_ref()
	}

	pub fn password_for_url(&self, url: &str) -> Option<&String> {
		Some(&self.passwords.get(url)?.1)
	}

	pub fn passphrase_for_key(&self, key: &Path) -> Option<&String> {
		self.passphrases.get(key)
	}
}

#[derive(Debug, Error)]
pub enum TreeError {
	#[error(transparent)]
	Io(#[from] std::io::Error),
	#[error(transparent)]
	Git(#[from] git2::Error),
}

pub fn load_config() -> Result<Config, ConfyError> {
	confy::load("git-autosave", "git-autosaved")
}

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();
	repository
		.config()?
		.set_str("autosave.id", &id.to_string())?;

	if let Some(config) = config
		&& let Some(workdir) = workdir
	{
		config.repositories.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(metadata: &Metadata) -> i32 {
	if metadata.is_dir() {
		0o040000
	} else if metadata.is_symlink() {
		0o120000
	} else if metadata.permissions().readonly() {
		0o100644
	} else {
		0o100755
	}
}

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.metadata()?);
			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(&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!("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(&current_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(())
}