From e8910b906a7f05eb2a22c6752994161eb181aa63 Mon Sep 17 00:00:00 2001 From: Sebastian Kuzminsky Date: Fri, 4 Jul 2025 13:07:10 -0600 Subject: [PATCH] add git support This mostly provides an abstraction for "ephemeral worktrees", which is a branch checked out in a worktree, to be read and maybe modified, and the worktree is deleted/pruned when we're done with it. There are also some helper functions for doing git things, the most important one creates an orphaned branch. The intent is to keep all the issues in a git branch. When we want to do anything with issues (list them, add new issues, modify an issue, etc) we check the issues branch out into an ephemeral worktree, modify the branch, and delete the worktree. --- Cargo.toml | 2 + src/git.rs | 200 +++++++++++++++++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + 3 files changed, 203 insertions(+) create mode 100644 src/git.rs diff --git a/Cargo.toml b/Cargo.toml index 2230bb4..8c9d262 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,8 @@ version = "0.1.0" edition = "2024" [dependencies] +rand = "0.9.1" serde = { version = "1.0.217", features = ["derive"] } +tempfile = "3.20.0" thiserror = "2.0.11" toml = "0.8.19" diff --git a/src/git.rs b/src/git.rs new file mode 100644 index 0000000..2b03b5a --- /dev/null +++ b/src/git.rs @@ -0,0 +1,200 @@ +use std::io::Write; + +#[derive(Debug, thiserror::Error)] +pub enum GitError { + #[error(transparent)] + StdIoError(#[from] std::io::Error), + #[error("Oops, something went wrong")] + Oops, +} + +#[derive(Debug)] +/// `Worktree` is a struct that manages a temporary directory containing +/// a checkout of a specific branch. The worktree is removed and pruned +/// when the `Worktree` struct is dropped. +pub struct Worktree { + path: tempfile::TempDir, +} + +impl Drop for Worktree { + fn drop(&mut self) { + let _result = std::process::Command::new("git") + .args(["worktree", "remove", &self.path.path().to_string_lossy()]) + .output(); + } +} + +impl Worktree { + pub fn new(branch: &str) -> Result { + let path = tempfile::tempdir()?; + let result = std::process::Command::new("git") + .args(["worktree", "add", &path.path().to_string_lossy(), branch]) + .output()?; + if !result.status.success() { + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + return Err(GitError::Oops); + } + Ok(Self { path }) + } + + pub fn path(&self) -> &std::path::Path { + self.path.as_ref() + } +} + +pub fn checkout_branch_in_worktree( + branch: &str, + worktree_dir: &std::path::Path, +) -> Result<(), GitError> { + let result = std::process::Command::new("git") + .args(["worktree", "add", &worktree_dir.to_string_lossy(), branch]) + .output()?; + if !result.status.success() { + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + return Err(GitError::Oops); + } + Ok(()) +} + +pub fn git_worktree_prune() -> Result<(), GitError> { + let result = std::process::Command::new("git") + .args(["worktree", "prune"]) + .output()?; + if !result.status.success() { + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + return Err(GitError::Oops); + } + Ok(()) +} + +pub fn git_remove_branch(branch: &str) -> Result<(), GitError> { + let result = std::process::Command::new("git") + .args(["branch", "-D", branch]) + .output()?; + if !result.status.success() { + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + return Err(GitError::Oops); + } + Ok(()) +} + +pub fn git_branch_exists(branch: &str) -> Result { + let result = std::process::Command::new("git") + .args(["show-ref", "--quiet", branch]) + .output()?; + return Ok(result.status.success()); +} + +pub fn create_orphan_branch(branch: &str) -> Result<(), GitError> { + { + let tmp_worktree = tempfile::tempdir().unwrap(); + create_orphan_branch_at_path(branch, tmp_worktree.path())?; + } + // The temp dir is now removed / cleaned up. + + let result = std::process::Command::new("git") + .args(["worktree", "prune"]) + .output()?; + if !result.status.success() { + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + return Err(GitError::Oops); + } + + Ok(()) +} + +fn create_orphan_branch_at_path( + branch: &str, + worktree_path: &std::path::Path, +) -> Result<(), GitError> { + let worktree_dir = worktree_path.to_string_lossy(); + let result = std::process::Command::new("git") + .args(["worktree", "add", "--orphan", "-b", branch, &worktree_dir]) + .output()?; + if !result.status.success() { + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + return Err(GitError::Oops); + } + + let mut readme_filename = std::path::PathBuf::from(worktree_path); + readme_filename.push("README.md"); + let mut readme = std::fs::File::create(readme_filename)?; + write!( + readme, + "This branch is used by entomologist to track issues." + )?; + + let result = std::process::Command::new("git") + .args(["add", "README.md"]) + .current_dir(worktree_path) + .output()?; + if !result.status.success() { + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + return Err(GitError::Oops); + } + + let result = std::process::Command::new("git") + .args(["commit", "-m", "create entomologist issue branch"]) + .current_dir(worktree_path) + .output()?; + if !result.status.success() { + println!("stdout: {}", std::str::from_utf8(&result.stdout).unwrap()); + println!("stderr: {}", std::str::from_utf8(&result.stderr).unwrap()); + return Err(GitError::Oops); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_worktree() { + let mut p = std::path::PathBuf::new(); + { + let worktree = Worktree::new("origin/main").unwrap(); + + p.push(worktree.path()); + assert!(p.exists()); + + let mut p2 = p.clone(); + p2.push("README.md"); + assert!(p2.exists()); + } + // The temporary worktree directory is removed when the Temp variable is dropped. + assert!(!p.exists()); + } + + #[test] + fn test_create_orphan_branch() { + let rnd: u128 = rand::random(); + let mut branch = std::string::String::from("entomologist-test-branch-"); + branch.push_str(&format!("{:0x}", rnd)); + create_orphan_branch(&branch).unwrap(); + git_remove_branch(&branch).unwrap(); + } + + #[test] + fn test_branch_exists_0() { + let r = git_branch_exists("main").unwrap(); + assert_eq!(r, true); + } + + #[test] + fn test_branch_exists_1() { + let rnd: u128 = rand::random(); + let mut branch = std::string::String::from("entomologist-missing-branch-"); + branch.push_str(&format!("{:0x}", rnd)); + let r = git_branch_exists(&branch).unwrap(); + assert_eq!(r, false); + } +} diff --git a/src/lib.rs b/src/lib.rs index 713acd1..cca1c90 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,2 +1,3 @@ +pub mod git; pub mod issue; pub mod issues;