#!/usr/bin/env rust-script
use regex::Regex;
use std::env;
use std::fs;
use std::io::Write;
use std::process::Command;
fn exec(command: &str, args: &[&str]) -> String {
match Command::new(command).args(args).output() {
Ok(output) => {
if output.status.success() {
String::from_utf8_lossy(&output.stdout).trim().to_string()
} else {
eprintln!("Error executing {} {:?}", command, args);
eprintln!("{}", String::from_utf8_lossy(&output.stderr));
String::new()
}
}
Err(e) => {
eprintln!("Failed to execute {} {:?}: {}", command, args, e);
String::new()
}
}
}
fn set_output(name: &str, value: &str) {
if let Ok(output_file) = env::var("GITHUB_OUTPUT") {
if let Ok(mut file) = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&output_file)
{
let _ = writeln!(file, "{}={}", name, value);
}
}
println!("{}={}", name, value);
}
fn is_merge_commit() -> bool {
let output = exec("git", &["cat-file", "-p", "HEAD"]);
output
.lines()
.filter(|line| line.starts_with("parent "))
.count()
> 1
}
fn get_changed_files() -> Vec<String> {
if is_merge_commit() {
println!("Merge commit detected (pull_request event)");
println!("Comparing HEAD^2^ to HEAD^2 (per-commit diff of PR head)");
let output = exec("git", &["diff", "--name-only", "HEAD^2^", "HEAD^2"]);
if !output.is_empty() {
return output
.lines()
.filter(|s| !s.is_empty())
.map(String::from)
.collect();
}
println!("HEAD^2^ not available (first commit in PR), comparing HEAD^ to HEAD^2");
let output = exec("git", &["diff", "--name-only", "HEAD^", "HEAD^2"]);
if !output.is_empty() {
return output
.lines()
.filter(|s| !s.is_empty())
.map(String::from)
.collect();
}
}
println!("Comparing HEAD^ to HEAD");
let output = exec("git", &["diff", "--name-only", "HEAD^", "HEAD"]);
if output.is_empty() {
println!("HEAD^ not available, listing all files in HEAD");
let output = exec("git", &["ls-tree", "--name-only", "-r", "HEAD"]);
return output
.lines()
.filter(|s| !s.is_empty())
.map(String::from)
.collect();
}
output
.lines()
.filter(|s| !s.is_empty())
.map(String::from)
.collect()
}
fn is_excluded_from_code_changes(file_path: &str) -> bool {
if file_path.ends_with(".md") {
return true;
}
let excluded_folders = ["changelog.d/", "docs/", "experiments/", "examples/"];
for folder in &excluded_folders {
if file_path.starts_with(folder) {
return true;
}
}
false
}
fn main() {
println!("Detecting file changes for CI/CD...\n");
let changed_files = get_changed_files();
println!("Changed files:");
if changed_files.is_empty() {
println!(" (none)");
} else {
for file in &changed_files {
println!(" {}", file);
}
}
println!();
let rs_changed = changed_files.iter().any(|f| f.ends_with(".rs"));
set_output("rs-changed", if rs_changed { "true" } else { "false" });
let toml_changed = changed_files.iter().any(|f| f.ends_with(".toml"));
set_output("toml-changed", if toml_changed { "true" } else { "false" });
let mjs_changed = changed_files.iter().any(|f| f.ends_with(".mjs"));
set_output("mjs-changed", if mjs_changed { "true" } else { "false" });
let docs_changed = changed_files.iter().any(|f| f.ends_with(".md"));
set_output("docs-changed", if docs_changed { "true" } else { "false" });
let workflow_changed = changed_files
.iter()
.any(|f| f.starts_with(".github/workflows/"));
set_output(
"workflow-changed",
if workflow_changed { "true" } else { "false" },
);
let code_changed_files: Vec<&String> = changed_files
.iter()
.filter(|f| !is_excluded_from_code_changes(f))
.collect();
println!("\nFiles considered as code changes:");
if code_changed_files.is_empty() {
println!(" (none)");
} else {
for file in &code_changed_files {
println!(" {}", file);
}
}
println!();
let code_pattern = Regex::new(r"\.(rs|toml|mjs|js|yml|yaml)$|\.github/workflows/").unwrap();
let code_changed = code_changed_files.iter().any(|f| code_pattern.is_match(f));
set_output(
"any-code-changed",
if code_changed { "true" } else { "false" },
);
set_output(
"rust-code-changed",
if code_changed { "true" } else { "false" },
);
println!("\nChange detection completed.");
}