use std::io::Write; #[derive(Debug, thiserror::Error)] pub enum GitError { #[error(transparent)] StdIoError(#[from] std::io::Error), #[error(transparent)] ParseIntError(#[from] std::num::ParseIntError), #[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", "--force", &self.path.path().to_string_lossy(), ]) .output(); match result { Err(e) => { println!("failed to run git: {:#?}", e); } Ok(result) => { if !result.status.success() { println!("failed to remove git worktree: {:#?}", result); } } } } } 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: {}", &String::from_utf8_lossy(&result.stdout)); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } Ok(Self { path }) } pub fn new_detached(branch: &str) -> Result { let path = tempfile::tempdir()?; let result = std::process::Command::new("git") .args([ "worktree", "add", "--detach", &path.path().to_string_lossy(), branch, ]) .output()?; if !result.status.success() { println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); 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: {}", &String::from_utf8_lossy(&result.stdout)); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); 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: {}", &String::from_utf8_lossy(&result.stdout)); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); 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: {}", &String::from_utf8_lossy(&result.stdout)); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); 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 worktree_is_dirty(dir: &str) -> Result { // `git status --porcelain` prints a terse list of files added or // modified (both staged and not), and new untracked files. So if // says *anything at all* it means the worktree is dirty. let result = std::process::Command::new("git") .args(["status", "--porcelain", "--untracked-files=no"]) .current_dir(dir) .output()?; return Ok(result.stdout.len() > 0); } pub fn add(file: &std::path::Path) -> Result<(), GitError> { let result = std::process::Command::new("git") .args(["add", &file.to_string_lossy()]) .current_dir( file.parent() .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))?, ) .output()?; if !result.status.success() { println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } return Ok(()); } pub fn restore_file(file: &std::path::Path) -> Result<(), GitError> { let result = std::process::Command::new("git") .args(["restore", &file.to_string_lossy()]) .current_dir( file.parent() .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))?, ) .output()?; if !result.status.success() { println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } return Ok(()); } pub fn commit(dir: &std::path::Path, msg: &str) -> Result<(), GitError> { let result = std::process::Command::new("git") .args(["commit", "-m", msg]) .current_dir(dir) .output()?; if !result.status.success() { println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } Ok(()) } pub fn git_commit_file(file: &std::path::Path) -> Result<(), GitError> { let mut git_dir = std::path::PathBuf::from(file); git_dir.pop(); let result = std::process::Command::new("git") .args([ "add", &file .file_name() .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? .to_string_lossy(), ]) .current_dir(&git_dir) .output()?; if !result.status.success() { println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } let result = std::process::Command::new("git") .args([ "commit", "-m", &format!( "update '{}' in issue {}", file.file_name() .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? .to_string_lossy(), git_dir .file_name() .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? .to_string_lossy() ), ]) .current_dir(&git_dir) .output()?; if !result.status.success() { println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } Ok(()) } pub fn git_fetch(dir: &std::path::Path, remote: &str) -> Result<(), GitError> { let result = std::process::Command::new("git") .args(["fetch", remote]) .current_dir(dir) .output()?; if !result.status.success() { println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } Ok(()) } pub fn sync(dir: &std::path::Path, remote: &str, branch: &str) -> Result<(), GitError> { // We do all the work in a directory that's (FIXME) hopefully a // worktree. If anything goes wrong we just fail out and ask the // human to fix it by hand :-/ // 1. `git fetch` // 2. `git merge REMOTE/BRANCH` // 3. `git push REMOTE BRANCH` git_fetch(dir, remote)?; // FIXME: Possible things to add: // * `git log -p` shows diff // * `git log --numstat` shows machine-readable diffstat // Show what we just fetched from the remote. let result = std::process::Command::new("git") .args([ "log", "--no-merges", "--pretty=format:%an: %s", &format!("{}/{}", remote, branch), &format!("^{}", branch), ]) .current_dir(dir) .output()?; if !result.status.success() { println!( "Sync failed! 'git log' error! Help, a human needs to fix the mess in {:?}", branch ); println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } if result.stdout.len() > 0 { println!("Changes fetched from remote {}:", remote); println!("{}", &String::from_utf8_lossy(&result.stdout)); println!(""); } // Show what we are about to push to the remote. let result = std::process::Command::new("git") .args([ "log", "--no-merges", "--pretty=format:%an: %s", &format!("{}", branch), &format!("^{}/{}", remote, branch), ]) .current_dir(dir) .output()?; if !result.status.success() { println!( "Sync failed! 'git log' error! Help, a human needs to fix the mess in {:?}", branch ); println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } if result.stdout.len() > 0 { println!("Changes to push to remote {}:", remote); println!("{}", &String::from_utf8_lossy(&result.stdout)); println!(""); } // Merge remote branch into local. let result = std::process::Command::new("git") .args(["merge", &format!("{}/{}", remote, branch)]) .current_dir(dir) .output()?; if !result.status.success() { println!( "Sync failed! Merge error! Help, a human needs to fix the mess in {:?}", branch ); println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } // Push merged branch to remote. let result = std::process::Command::new("git") .args(["push", remote, branch]) .current_dir(dir) .output()?; if !result.status.success() { println!( "Sync failed! Push error! Help, a human needs to fix the mess in {:?}", branch ); println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } Ok(()) } pub fn git_log_oldest_timestamp( path: &std::path::Path, ) -> Result, GitError> { let mut git_dir = std::path::PathBuf::from(path); git_dir.pop(); let result = std::process::Command::new("git") .args([ "log", "--pretty=format:%at", "--", &path .file_name() .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? .to_string_lossy(), ]) .current_dir(&git_dir) .output()?; if !result.status.success() { println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } let timestamp_str = std::str::from_utf8(&result.stdout).unwrap(); let timestamp_last = timestamp_str.split("\n").last().unwrap(); let timestamp_i64 = timestamp_last.parse::()?; let timestamp = chrono::DateTime::from_timestamp(timestamp_i64, 0) .unwrap() .with_timezone(&chrono::Local); Ok(timestamp) } pub fn git_log_oldest_author(path: &std::path::Path) -> Result { let mut git_dir = std::path::PathBuf::from(path); git_dir.pop(); let result = std::process::Command::new("git") .args([ "log", "--pretty=format:%an <%ae>", "--", &path .file_name() .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? .to_string_lossy(), ]) .current_dir(&git_dir) .output()?; if !result.status.success() { println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } let author_str = std::str::from_utf8(&result.stdout).unwrap(); let author_last = author_str.split("\n").last().unwrap(); Ok(String::from(author_last)) } pub fn git_log_oldest_author_timestamp( path: &std::path::Path, ) -> Result<(String, chrono::DateTime), GitError> { let mut git_dir = std::path::PathBuf::from(path); git_dir.pop(); let result = std::process::Command::new("git") .args([ "log", "--pretty=format:%at %an <%ae>", "--", &path .file_name() .ok_or(std::io::Error::from(std::io::ErrorKind::NotFound))? .to_string_lossy(), ]) .current_dir(&git_dir) .output()?; if !result.status.success() { println!("stdout: {}", &String::from_utf8_lossy(&result.stdout)); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); return Err(GitError::Oops); } let raw_output_str = String::from_utf8_lossy(&result.stdout); let Some(raw_output_last) = raw_output_str.split("\n").last() else { return Err(GitError::Oops); }; let Some(index) = raw_output_last.find(' ') else { return Err(GitError::Oops); }; let author_str = &raw_output_last[index + 1..]; let timestamp_str = &raw_output_last[0..index]; let timestamp_i64 = timestamp_str.parse::()?; let timestamp = chrono::DateTime::from_timestamp(timestamp_i64, 0) .unwrap() .with_timezone(&chrono::Local); Ok((String::from(author_str), timestamp)) } 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: {}", &String::from_utf8_lossy(&result.stdout)); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); 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: {}", &String::from_utf8_lossy(&result.stdout)); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); 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: {}", &String::from_utf8_lossy(&result.stdout)); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); 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: {}", &String::from_utf8_lossy(&result.stdout)); println!("stderr: {}", &String::from_utf8_lossy(&result.stderr)); 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!("{:032x}", 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!("{:032x}", rnd)); let r = git_branch_exists(&branch).unwrap(); assert_eq!(r, false); } }