summaryrefslogtreecommitdiff
path: root/src/lib.rs
blob: 2882039e4537b107d3c011047411bbb41f1aa152 (plain)
/*
 * git-init-autosave:
 * - generate local repo UUID
 * - add repo to autosave configuration (optionally)
 *
 * git-autosave:
 * - convert workdir to tree
 * - note hostname
 * - commit workdir to `refs/autosave/{UUID}-{branch}`
 * - push autosave ref to branch upstream
 *
 * git-autosave-daemon:
 * - watch configuration directory
 * - watch configured repositories
 * - run autosave on change
 *
 * 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
 * - 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
 */

use std::collections::{HashMap, HashSet};
use std::fs::Metadata;
use std::path::{Path, PathBuf};

use confy::ConfyError;
use git2::{Oid, PushOptions, RemoteCallbacks, Repository};
use serde::{Deserialize, Serialize};
use thiserror::Error;
use uuid::Uuid;

#[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 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 init(repository: &Repository, config: Option<&mut Config>) -> Result<(), 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(())
}

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 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(b"refs/autosave/autosaves")
		})
		.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(())
}