breb 0.5.0

the blog/reblog library and command-line tool
Documentation
use std::{
	ffi::OsStr,
	fs::{self, File},
	io,
	path::Path,
	process::Command,
};

use tempfile::TempDir;
use walkdir::WalkDir;

use crate::{Metadata, Mime, serve::Serve};

use super::{Mount, Url};

/// [`Serve`]s a git repository via the [dumb] protocol
///
/// this can handle more or less any kind of git repo, depending on how you construct it:
/// - [`Self::local`]: a git repo on the local filesystem (bare or otherwise)
/// - [`Self::remote`]: a url you can clone with your local `git` cli
///
/// by default, these use the system default [temporary directory][std::env::temp_dir].
/// you can provide a specific one with the `_in` variants.
/// either way, a new temporary directory is created inside.
///
/// **currently**,
/// this will always clone your repo into a temporary directory (cleaned up on `Drop`),
/// since [a few small tweaks] are needed to make the dumb protocol work.
/// **this is a bug!**
/// but since fixing it would require writing a fully lazy equivalent of `get update-server-info`,
/// for an issue unlikely to cause any serious problems in most use-cases,
/// i'm leaving it for now.
///
/// that said: i do intend to fix the bug eventually,
/// and it will *not* be a breaking change,
/// so don't depend on this behavior!
///
/// this can't use the more efficient 'smart' protocol,
/// because b/rb is designed primarily as a static site generator,
/// which the smart protocol is incompatible with.
/// (this is also why the bug is considered minor;
/// live updates aren't a very high priority!)
///
///   [dumb]: https://git-scm.com/book/en/v2/Git-Internals-Transfer-Protocols#_the_dumb_protocol
///   [a few small tweaks]: https://echowritescode.dev/post/the-git-dumb-protocol-is-neat
#[derive(Debug)]
pub struct GitRepo {
	/// where the serve is coming from and where it's going
	mount: Mount,
	/// the temporary directory we cloned into
	///
	/// this isn't actually used -- we just need it for its `Drop`.
	/// the path is kept in the `mount`, for consistency with other [Serve] impls.
	clone: TempDir,
}

impl GitRepo {
	/// serve a git repo available locally
	///
	/// this uses git's "local clone" optimizations; see `git-clone(1)` for more details.
	/// it saves a fair amount of space when available
	/// and usually runs much faster than cloning it as a remote url.
	/// it can also cause issues if you're fiddling with the repo while generating the blog, though,
	/// including fiddling with the repo while viewing *any part* of the live server.
	pub fn local(url: impl Into<Url>, src: &Path) -> io::Result<Self> {
		Self::clone(url, &["--no-local"], src, TempDir::new()?)
	}

	/// like [`Self::local`], but you choose the directory for the clone to be made in
	pub fn local_in(url: impl Into<Url>, tmp: &Path, src: &Path) -> io::Result<Self> {
		Self::clone(url, &["--no-local"], src, TempDir::new_in(tmp)?)
	}

	/// serve a git repo available remotely
	///
	/// this *forces off* git's "local clone" optimizations; see `git-clone(1)` for more details.
	/// it sometimes costs more storage space and clones slower,
	/// but can solve some issues
	///
	/// also, obviously, this supports cloning from any arbitrary url, not just local paths.
	pub fn remote(url: impl Into<Url>, src: impl Into<url::Url>) -> io::Result<Self> {
		Self::clone(url, &["--no-local"], src.into().as_str(), TempDir::new()?)
	}

	/// like [`Self::remote`], but you can choose the directory for the clone to be made in
	pub fn remote_in(
		url: impl Into<Url>,
		tmp: &Path,
		src: impl Into<url::Url>,
	) -> io::Result<Self> {
		Self::clone(url, &["--no-local"], src.into().as_str(), TempDir::new_in(tmp)?)
	}

	fn clone(
		url: impl Into<Url>,
		extra: &[&str],
		src: impl AsRef<OsStr>,
		dest: TempDir,
	) -> io::Result<Self> {
		let src = src.as_ref();
		Self::run(
			Command::new("git")
				.args(["clone", "--bare"])
				.args(extra)
				.arg(src)
				.arg(dest.path()),
		)?;
		Self::run(Command::new("git").arg("-C").arg(dest.path()).arg("update-server-info"))?;
		// just making sure the file exists, not changing it if it doesn't
		fs::create_dir_all(dest.path().join("hooks"))?;
		File::options()
			.create(true)
			.append(true)
			.truncate(false)
			.open(dest.path().join("hooks/post-receive"))?;
		Ok(Self {
			mount: Mount::new(url, dest.path()),
			clone: dest,
		})
	}

	fn run(cmd: &mut Command) -> io::Result<()> {
		let output = cmd.output()?;
		if !output.status.success() {
			let stderr = String::from_utf8_lossy(&output.stderr);
			return Err(io::Error::other(format!("`git` errored:\n{stderr}")));
		}
		Ok(())
	}
}

impl Serve for GitRepo {
	fn base(&self) -> &Url {
		&self.mount.url
	}

	fn scan(&self, _dir: &Path) -> io::Result<crate::Metadata> {
		let mut res = Metadata::default();
		for de in WalkDir::new(self.clone.path()).follow_links(true) {
			let de = de?;
			if !de.file_type().is_file() {
				continue;
			}
			let Ok(rel) = de.path().strip_prefix(self.clone.path()) else {
				continue;
			};
			let url = Url(self.mount.url.join(rel));
			res.files.insert(url, Mime::BYTES);
		}
		// if you don't already have a post-receive hook, we need to add one:
		res.files.entry(Url(self.mount.url.join("hooks/post-receive"))).or_insert(Mime::BYTES);
		Ok(res)
	}

	fn render(
		&self,
		_: &crate::Blog,
		url: &Url,
		into: &mut dyn io::prelude::Write,
	) -> io::Result<()> {
		let Ok(rel) = url.0.strip_prefix(&self.mount.url.0) else {
			return Err(io::Error::new(
				io::ErrorKind::InvalidInput,
				format!("url {:?} not under base {:?}", url, self.mount.url),
			));
		};
		let in_dir = self.clone.path().join(rel);
		let mut f = File::open(in_dir)?;
		io::copy(&mut f, into)?;
		Ok(())
	}
}