use anyhow::{Context as _, Result};
use sha2::{Digest, Sha256};
use std::path::Path;
use std::process::Command;
pub fn resolve_snapshot_sde(repo: &Path) -> Result<i64> {
if let Ok(v) = std::env::var("ANODIZE_SOURCE_DATE_EPOCH") {
let parsed = v.parse::<i64>().with_context(|| {
format!(
"ANODIZE_SOURCE_DATE_EPOCH is set but not a valid i64: {}",
v
)
})?;
return Ok(parsed);
}
let head_ts = head_commit_timestamp(repo)?;
let porcelain = git_status_porcelain_v2(repo)?;
if porcelain.is_empty() {
return Ok(head_ts);
}
let mut hasher = Sha256::new();
hasher.update(&porcelain);
let digest = hasher.finalize();
let dirty_offset = u32::from_le_bytes([digest[0], digest[1], digest[2], digest[3]]) as i64;
Ok(head_ts + dirty_offset)
}
fn head_commit_timestamp(repo: &Path) -> Result<i64> {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(["log", "-1", "--format=%ct"])
.output()
.context("failed to invoke git log -1 --format=%ct")?;
if !out.status.success() {
anyhow::bail!(
"git log -1 --format=%ct failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
let text = String::from_utf8(out.stdout)
.context("git log %ct produced non-utf8 output")?
.trim()
.to_string();
text.parse::<i64>()
.with_context(|| format!("git log %ct returned non-i64 timestamp: {}", text))
}
fn git_status_porcelain_v2(repo: &Path) -> Result<Vec<u8>> {
let out = Command::new("git")
.arg("-C")
.arg(repo)
.args(["status", "--porcelain=v2", "-z"])
.output()
.context("failed to invoke git status --porcelain=v2 -z")?;
if !out.status.success() {
anyhow::bail!(
"git status --porcelain=v2 -z failed: {}",
String::from_utf8_lossy(&out.stderr)
);
}
Ok(out.stdout)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use std::process::Command;
use tempfile::TempDir;
fn init_repo() -> TempDir {
let dir = tempfile::tempdir().unwrap();
Command::new("git")
.arg("-C")
.arg(dir.path())
.arg("init")
.output()
.unwrap();
Command::new("git")
.arg("-C")
.arg(dir.path())
.args(["config", "user.email", "test@example.com"])
.output()
.unwrap();
Command::new("git")
.arg("-C")
.arg(dir.path())
.args(["config", "user.name", "test"])
.output()
.unwrap();
fs::write(dir.path().join("a.txt"), "hello").unwrap();
Command::new("git")
.arg("-C")
.arg(dir.path())
.args(["add", "a.txt"])
.output()
.unwrap();
Command::new("git")
.arg("-C")
.arg(dir.path())
.env("GIT_AUTHOR_DATE", "1715000000 +0000")
.env("GIT_COMMITTER_DATE", "1715000000 +0000")
.args(["commit", "-m", "init"])
.output()
.unwrap();
dir
}
#[test]
#[serial_test::serial(snapshot_sde_env)]
fn snapshot_sde_uses_env_var_when_set() {
let dir = init_repo();
unsafe {
std::env::set_var("ANODIZE_SOURCE_DATE_EPOCH", "999999999");
}
let sde = resolve_snapshot_sde(dir.path()).unwrap();
assert_eq!(sde, 999_999_999);
unsafe {
std::env::remove_var("ANODIZE_SOURCE_DATE_EPOCH");
}
}
#[test]
#[serial_test::serial(snapshot_sde_env)]
fn snapshot_sde_uses_head_when_tree_clean() {
unsafe {
std::env::remove_var("ANODIZE_SOURCE_DATE_EPOCH");
}
let dir = init_repo();
let sde = resolve_snapshot_sde(dir.path()).unwrap();
assert_eq!(sde, 1_715_000_000);
}
#[test]
#[serial_test::serial(snapshot_sde_env)]
fn snapshot_sde_uses_dirty_tree_hash_when_tree_dirty() {
unsafe {
std::env::remove_var("ANODIZE_SOURCE_DATE_EPOCH");
}
let dir = init_repo();
fs::write(dir.path().join("b.txt"), "dirty").unwrap();
let sde = resolve_snapshot_sde(dir.path()).unwrap();
assert!(sde > 1_715_000_000);
assert!(sde - 1_715_000_000 <= u32::MAX as i64);
}
#[test]
#[serial_test::serial(snapshot_sde_env)]
fn snapshot_sde_is_stable_for_unchanged_dirty_tree() {
unsafe {
std::env::remove_var("ANODIZE_SOURCE_DATE_EPOCH");
}
let dir = init_repo();
fs::write(dir.path().join("b.txt"), "dirty").unwrap();
let sde1 = resolve_snapshot_sde(dir.path()).unwrap();
let sde2 = resolve_snapshot_sde(dir.path()).unwrap();
assert_eq!(sde1, sde2);
}
}