nixfleet_release/
git.rs

1//! Git plumbing - shells out to `git`, never embeds a library.
2
3use std::path::{Path, PathBuf};
4use std::process::Command;
5
6use anyhow::{Context, Result, bail};
7use chrono::{DateTime, Utc};
8
9use crate::{GitPushTarget, ReleaseConfig};
10
11pub(crate) fn git_head_sha(repo: &Path) -> Result<String> {
12    let output = Command::new("git")
13        .args(["rev-parse", "HEAD"])
14        .current_dir(repo)
15        .output()
16        .context("invoke `git rev-parse HEAD`")?;
17    if !output.status.success() {
18        bail!(
19            "git rev-parse HEAD: {}",
20            String::from_utf8_lossy(&output.stderr)
21        );
22    }
23    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
24}
25
26pub(crate) fn git_commit_release(
27    config: &ReleaseConfig,
28    files: &[PathBuf],
29    ci_commit: Option<&str>,
30    signed_at: DateTime<Utc>,
31) -> Result<bool> {
32    if let Some(name) = &config.git_user_name {
33        run_git(&config.flake_dir, &["config", "user.name", name])?;
34    }
35    if let Some(email) = &config.git_user_email {
36        run_git(&config.flake_dir, &["config", "user.email", email])?;
37    }
38    let mut add_args = vec!["add", "--"];
39    let file_strs: Vec<String> = files
40        .iter()
41        .map(|p| {
42            p.strip_prefix(&config.flake_dir)
43                .unwrap_or(p)
44                .to_string_lossy()
45                .into_owned()
46        })
47        .collect();
48    for f in &file_strs {
49        add_args.push(f);
50    }
51    run_git(&config.flake_dir, &add_args)?;
52
53    let cached_diff = Command::new("git")
54        .args(["diff", "--cached", "--quiet", "--"])
55        .args(&file_strs)
56        .current_dir(&config.flake_dir)
57        .status()
58        .context("invoke `git diff --cached --quiet`")?;
59    if cached_diff.success() {
60        tracing::info!("git: no release change");
61        return Ok(false);
62    }
63
64    let message = render_commit_message(
65        &config.commit_template,
66        ci_commit.unwrap_or("HEAD"),
67        signed_at,
68    );
69    run_git(&config.flake_dir, &["commit", "-m", &message])?;
70    tracing::info!(message = %message, "git commit");
71    Ok(true)
72}
73
74pub fn render_commit_message(template: &str, sha: &str, ts: DateTime<Utc>) -> String {
75    let short = if sha.len() >= 8 { &sha[..8] } else { sha };
76    template
77        .replace("{sha:0:8}", short)
78        .replace("{sha}", sha)
79        .replace("{ts}", &ts.to_rfc3339())
80}
81
82pub(crate) fn git_push_release(repo: &Path, target: &GitPushTarget) -> Result<()> {
83    let refspec = format!("HEAD:{}", target.branch);
84    run_git(repo, &["push", &target.remote, &refspec])?;
85    tracing::info!(
86        remote = %target.remote,
87        branch = %target.branch,
88        "git push",
89    );
90    Ok(())
91}
92
93fn run_git(repo: &Path, args: &[&str]) -> Result<()> {
94    let status = Command::new("git")
95        .args(args)
96        .current_dir(repo)
97        .status()
98        .with_context(|| format!("invoke git {args:?}"))?;
99    if !status.success() {
100        bail!("git {:?} exited {}", args, status.code().unwrap_or(-1));
101    }
102    Ok(())
103}