panrelease 0.19.0

Utility to release software
Documentation
use crate::changelog::{BlameLine, CommitInfo};
use crate::project::config::GitConfig;
use crate::runner::CmdRunner;
use crate::system::FileSystem;
use anyhow::anyhow;
use std::path::{Path, PathBuf};

pub struct GitRepo {
    config: GitConfig,
    path: PathBuf,
}

impl GitRepo {
    pub fn open<F: FileSystem>(config: GitConfig, path: &Path) -> anyhow::Result<Self> {
        Ok(Self {
            config,
            path: Self::find_git_root::<F>(path)?.to_path_buf(),
        })
    }

    pub fn find_git_root<F: FileSystem>(path: &Path) -> anyhow::Result<&Path> {
        let mut current = path;
        loop {
            if F::is_a_dir(&current.join(".git")) {
                break Ok(current);
            } else {
                current = current.parent().ok_or_else(|| anyhow!(
                    "Could not find a git repository: no .git directory found \
                     searching up from {:?}",
                    path
                ))?;
            }
        }
    }

    pub fn current_branch(&self) -> anyhow::Result<Option<String>> {
        let mut runner = CmdRunner::build(
            "git",
            &[String::from("rev-parse"), String::from("--abbrev-ref"), String::from("HEAD")],
            &self.path,
        )?;
        let out = runner.output().and_then(|b| Ok(String::from_utf8(b)?))?;
        let branch = out.trim().to_string();
        if branch == "HEAD" {
            Ok(None)
        } else {
            Ok(Some(branch))
        }
    }

    pub fn merge_base(&self, mainline: &str) -> anyhow::Result<String> {
        let mut runner = CmdRunner::build(
            "git",
            &[String::from("merge-base"), String::from("HEAD"), String::from(mainline)],
            &self.path,
        )?;
        let out = runner.output().and_then(|b| Ok(String::from_utf8(b)?))?;
        Ok(out.trim().to_string())
    }

    pub fn latest_version_tag(&self, commit: &str) -> anyhow::Result<Option<String>> {
        let mut runner = CmdRunner::build(
            "git",
            &[String::from("describe"), String::from("--tags"), String::from("--abbrev=0"), String::from(commit)],
            &self.path,
        )?;
        match runner.output() {
            Ok(b) => {
                let tag = String::from_utf8(b)?.trim().to_string();
                if tag.is_empty() {
                    Ok(None)
                } else {
                    Ok(Some(tag))
                }
            }
            Err(_) => Ok(None),
        }
    }

    pub fn dirty_files(&self) -> anyhow::Result<Vec<String>> {
        let mut runner = CmdRunner::build(
            "git",
            &[String::from("status"), String::from("--porcelain=v1")],
            &self.path,
        )?;
        let out = runner.output().and_then(|b| Ok(String::from_utf8(b)?))?;
        Ok(out
            .split('\n')
            .map(|line| line.trim())
            .filter(|line| !line.is_empty() && !line.starts_with("??"))
            .map(String::from)
            .collect())
    }

    pub fn commits_since_tag(&self, tag: &str) -> anyhow::Result<Vec<CommitInfo>> {
        const SEP: &str = "---PANRELEASE_COMMIT_SEP---";
        let format_str = format!("%H%n%at%n%B{SEP}");
        let range = format!("{tag}..HEAD");
        let mut runner = CmdRunner::build(
            "git",
            &[
                String::from("log"),
                range,
                format!("--format={format_str}"),
            ],
            &self.path,
        )?;
        let out = runner.output().and_then(|b| Ok(String::from_utf8(b)?))?;

        let mut commits = Vec::new();
        for block in out.split(SEP) {
            let block = block.trim();
            if block.is_empty() {
                continue;
            }
            let mut lines = block.lines();
            let hash = match lines.next() {
                Some(h) => h.trim().to_string(),
                None => continue,
            };
            let timestamp: i64 = match lines.next() {
                Some(ts) => ts.trim().parse().unwrap_or(0),
                None => continue,
            };
            let message: String = lines.collect::<Vec<_>>().join("\n");
            commits.push(CommitInfo {
                hash,
                message,
                timestamp,
            });
        }

        Ok(commits)
    }

    pub fn blame_file(&self, path: &Path) -> anyhow::Result<Vec<BlameLine>> {
        let mut runner = CmdRunner::build(
            "git",
            &[
                String::from("blame"),
                String::from("--porcelain"),
                path.to_string_lossy().to_string(),
            ],
            &self.path,
        )?;
        let out = match runner.output() {
            Ok(b) => String::from_utf8(b)?,
            Err(_) => return Ok(Vec::new()),
        };

        let mut result = Vec::new();
        let mut current_hash = String::new();

        for line in out.lines() {
            if line.starts_with('\t') {
                result.push(BlameLine {
                    commit_hash: current_hash.clone(),
                    line_content: line[1..].to_string(),
                });
            } else if let Some(hash) = line.split_whitespace().next() {
                if hash.len() >= 40 && hash.chars().all(|c| c.is_ascii_hexdigit()) {
                    current_hash = hash.to_string();
                }
            }
        }

        Ok(result)
    }

    pub fn update_and_commit(&self, version: semver::Version) -> anyhow::Result<()> {
        CmdRunner::build(
            "git",
            &[String::from("add"), String::from("-u")],
            &self.path,
        )?
        .run()?;

        let descr = version.to_string();
        let commit_args = if self.config.force_sign {
            vec! [String::from("commit"), String::from("-S"), String::from("-m"), descr]
        } else {
            vec! [String::from("commit"), String::from("-m"), descr]
        };

        CmdRunner::build(
            "git",
            &commit_args,
            &self.path,
        )?
        .run()?;

        let tag_descr = self
            .config
            .tag_template
            .replace("{{version}}", &version.to_string());

        if self.config.force_sign {
            CmdRunner::build(
                "git",
                &[
                    String::from("tag"),
                    String::from("-a"),
                    tag_descr.clone(),
                    String::from("-m"),
                    tag_descr,
                    String::from("-s"),
                ],
                &self.path,
            )?
            .run()?;
        } else {
            CmdRunner::build("git", &[String::from("tag"), tag_descr], &self.path)?.run()?;
        }

        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::system::NativeSystem;

    #[test]
    fn find_git_root_not_found_mentions_start_path() {
        // /tmp/no_such_panrelease_repo is virtually guaranteed to have no .git above it
        let start = std::path::Path::new("/tmp/no_such_panrelease_repo_xyz");
        let result = GitRepo::find_git_root::<NativeSystem>(start);
        let err = result.unwrap_err();
        let msg = err.to_string();
        assert!(
            msg.contains("no_such_panrelease_repo_xyz"),
            "error should mention the start path: {msg}"
        );
        assert!(
            msg.contains(".git"),
            "error should mention .git: {msg}"
        );
    }

    #[test]
    fn find_git_root_finds_panrelease_own_repo() {
        // panrelease's own repo has a .git directory
        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
        let result = GitRepo::find_git_root::<NativeSystem>(path);
        assert!(result.is_ok(), "should find panrelease's own git root: {:?}", result);
    }

    #[test]
    fn dirty_files_does_not_error_on_own_repo() {
        // We only assert it doesn't error — the actual set of dirty files
        // depends on the repo state at test time.
        let config = crate::project::config::GitConfig::default();
        let path = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
        let repo = GitRepo::open::<NativeSystem>(config, path).unwrap();
        let result = repo.dirty_files();
        assert!(result.is_ok(), "dirty_files should not error: {:?}", result);
    }
}